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:
co-authored by
Claude Opus 5
parent
f71fd5bfe1
commit
5ad1083aee
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user