parity(apps+api): complete_profile in both apps [ref: henza_server Major/CustomerController::complete]

Ports "تکمیل پروفایل" end to end (D22).

API
- POST customer/me/complete-profile — three required document ids, the
  customer taken from the token, verification step set to the reference's
  LIVE value (approved outright; its `= 1` line is commented out at :83 in
  favour of `= 2` at :85). Both SMS messages byte-identical and non-blocking
  per D20.
- POST customer/me/documents — the reference let the app upload through the
  same file/create the admin used, inside its auth:api-customer group
  (routes/api.php:95); the target's copy is admin-guarded, so the page could
  not have uploaded at all. Reuses AdminUploadsService unchanged, so both
  paths share one size limit, MIME whitelist and naming scheme (UPL-01).
- PROFILE_ADMIN_MOBILES replaces the reference's four hardcoded personal
  numbers; blank means nobody is notified and the upload still succeeds.

Three reference behaviours deliberately NOT ported, all in ISSUES.md:
- SEC-14: `$customer->update($request->all())` mass-assigned the whole request
  body, letting the app write its own credit limit, making charge, gold debt,
  active flag or mobile. Replaced with a three-field whitelist.
- SEC-15: four real personal mobiles hardcoded in the source.
- APP-03: refresh()'s unterminated setInterval page reload.

Apps (both, template+script byte-identical, per-app styling only)
- CompleteProfileView + SingleImageUploader
- route /complete_profile, redirecting home once the documents are in, as the
  reference's guard did (router/index.ts:266)
- the entry point on the profile screen, shown only while they are missing
  (profile.vue:96-102)

Also (D23): JWT_CUSTOMER_SECRET now warns loudly at boot when unset. It falls
back to JWT_SECRET, which signs app and admin tokens with the same key and
leaves the `typ` check as the only barrier — and an expert's app token carries
their AdminUser id in `sub`. Warn rather than refuse to boot (D19, lowest
risk); DELIVERY.md lists it as the first pre-release step (REL-03).

Verified: build 5/5 · jest 45/45 · complete-profile 38/38 (new) ·
expert login 36/36 · apps smoke 43/43 · admin smoke 43/43 ·
B2B scan 34 ok/0 broken · typecheck clean on all four apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-23 01:42:07 +03:30
co-authored by Claude Opus 5
parent f71fd5bfe1
commit 5ad1083aee
115 changed files with 1746 additions and 99 deletions
@@ -0,0 +1,126 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import { CompleteProfileDto } from './dto/complete-profile.dto';
/**
* "تکمیل پروفایل" — the customer uploads their three documents.
*
* Ports henza_server Major/CustomerController::complete
* (app/Http/Controllers/Major/CustomerController.php:73-108), reached from the
* PWA at POST /major/complete (completeProfile.vue:193).
*
* Reference behaviour kept:
* - all three document ids are required (:75-79); a miss is a 422
* - the customer is taken from the session (`auth('api-customer')->id()`, :86)
* - the verification step is set to the reference's post-change value 2,
* i.e. approved outright, NOT "waiting for an expert" — the reference's
* own `$input['sts_verify_step_two'] = 1;` line is commented out at :83
* in favour of `= 2` at :85. Here that is VerificationStep.VERIFIED.
* - the customer SMS and the admin SMS, both byte-identical (:97-105)
*
* Reference behaviour deliberately NOT ported, both recorded in ISSUES.md:
* - SEC-14: `$customer->update($request->all())` mass-assigned the entire
* request body onto the customer row, so the app could set `gram_level`,
* `percent`, `active` or `remain` on itself. Only the three document ids
* are read here.
* - SEC-15: the four admin mobiles were hardcoded in the source
* (sendSmsToAdmin, :110-112). They come from PROFILE_ADMIN_MOBILES here,
* so no personal number is committed to this repository.
*/
@Injectable()
export class CustomerProfileService {
private readonly logger = new Logger(CustomerProfileService.name);
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsService,
) {}
/**
* The admins who are told a customer uploaded their documents.
*
* The reference hardcoded four numbers; this reads a comma-separated env var
* instead. Unset means nobody is notified, and the customer's own SMS and the
* upload itself still go through — consistent with D20, where SMS never
* blocks an operation.
*/
private adminMobiles(): string[] {
return (process.env.PROFILE_ADMIN_MOBILES ?? '')
.split(',')
.map((m) => m.trim())
.filter(Boolean);
}
/**
* Resolves an uploaded-file id to its stored URL.
*
* The reference stored the raw id in the customer row and joined to `files`
* on read; the target's Customer columns hold URLs
* (`nationalIdImageUrl`, `businessLicenseUrl`, `shopImageUrl`), so the id is
* resolved here. An id that names no file is a 400 rather than a silent
* null, so a half-completed profile can never be marked verified.
*/
private async urlOf(id: string, label: string): Promise<string> {
const file = await this.prisma.uploadedFile.findUnique({ where: { id } });
if (!file) throw new BadRequestException(`${label} یافت نشد`);
return file.url;
}
async complete(customerId: string, dto: CompleteProfileDto) {
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NotFoundException('مشتری یافت نشد');
// Resolved before the write, so a bad id fails the whole call.
const [businessLicenseUrl, shopImageUrl, nationalIdImageUrl] = await Promise.all([
this.urlOf(dto.business_license_image_id, 'تصویر جواز کسب'),
this.urlOf(dto.shop_image_id, 'تصویر سردر مغازه'),
this.urlOf(dto.ncode_image_id, 'تصویر کارت ملی'),
]);
const updated = await this.prisma.customer.update({
where: { id: customerId },
data: {
businessLicenseUrl,
shopImageUrl,
nationalIdImageUrl,
// The reference's live value: 2, approved outright (:85).
verificationStep: 'VERIFIED',
},
});
this.notify(updated.mobile, updated.firstName ?? '', updated.lastName ?? '');
// The reference answered a bare `true` (:107); the app only checks that the
// call succeeded (completeProfile.vue:193-194).
return { ok: true };
}
/**
* Both messages, fire-and-forget.
*
* D20: a failed or unconfigured SMS must never fail the upload, so nothing
* here is awaited and every rejection is swallowed into the log. The
* reference likewise queued them (SendSmsAfterCreate::dispatch, :100).
*/
private notify(mobile: string, firstName: string, lastName: string) {
// Byte-identical to Major/CustomerController::complete:97-99.
const message =
'هنزا گلد\n' +
`همکار گرامی ${firstName} ${lastName}\n` +
'تکمیل پروفایل شما با موفقیت انجام شد.';
// Byte-identical to :103-105.
const adminMessage =
'هنزا گلد\n' + `مشتری با نام ${firstName} ${lastName}` + ' مدارک خود را بارگذاری کرد.';
const fire = (to: string, body: string) => {
void this.sms.send(to, body).catch((e: unknown) => {
this.logger.error(`Complete-profile SMS to ${to} failed: ${String(e)}`);
});
};
fire(mobile, message);
for (const admin of this.adminMobiles()) fire(admin, adminMessage);
}
}