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,79 @@
import {
Controller,
HttpCode,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { Body } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CustomerProfileService } from './customer-profile.service';
import { AdminUploadsService } from '../admin-uploads/admin-uploads.service';
import { CompleteProfileDto } from './dto/complete-profile.dto';
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
/** See AdminUploadsController — the same locally-declared multer subset. */
interface UploadedMulterFile {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
/**
* The customer's own profile writes.
*
* Legacy endpoints:
* POST file/create -> the PWA's SingleImageUploader posted here with the
* customer token (routes/api.php:95, inside the
* `auth:api-customer` group)
* POST major/complete -> Major\CustomerController::complete
* (routes/api.php:114)
*
* Both are mounted under /customer/me here so the customer surface stays in one
* place. The subject always comes from the token — no id is accepted from the
* client, per D21.
*/
@Controller('customer/me')
export class CustomerProfileController {
constructor(
private readonly profile: CustomerProfileService,
private readonly uploads: AdminUploadsService,
) {}
/**
* A customer uploading one of their own documents.
*
* The reference let the app upload through the very same `file/create` the
* admin panel used, behind the customer guard (routes/api.php:95). The
* target's copy of that route sits behind an ADMIN guard
* (AdminUploadsController), which no app token can pass — so without this
* route the page could not upload at all.
*
* It reuses AdminUploadsService unchanged, so the size limit, the MIME
* whitelist and the stored-name scheme are identical on both paths. The
* uploader is recorded as null rather than as an admin, because
* `UploadedFile.uploadedByAdminUserId` is an AdminUser FK and this caller is
* a customer; `purpose` carries the document kind instead.
*/
@CustomerAuth()
@Post('documents')
@HttpCode(200)
@UseInterceptors(FileInterceptor('file'))
upload(
@UploadedFile() file: UploadedMulterFile | undefined,
@Body('purpose') purpose: string | undefined,
@CurrentCustomer() _me: AuthenticatedCustomer,
) {
return this.uploads.store(file, purpose ?? 'customer-document', null);
}
/** Ports POST major/complete. The customer is the token's. */
@CustomerAuth()
@Post('complete-profile')
@HttpCode(200)
complete(@Body() dto: CompleteProfileDto, @CurrentCustomer() me: AuthenticatedCustomer) {
return this.profile.complete(me.id, dto);
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { SmsModule } from '../sms/sms.module';
import { AdminUploadsModule } from '../admin-uploads/admin-uploads.module';
import { CustomerProfileController } from './customer-profile.controller';
import { CustomerProfileService } from './customer-profile.service';
/**
* The customer's own profile writes ("تکمیل پروفایل").
*
* AdminUploadsModule is imported for its service only — the controller reuses
* it so both upload paths share one implementation, rather than for its
* admin-guarded routes.
*/
@Module({
imports: [PrismaModule, SmsModule, AdminUploadsModule],
controllers: [CustomerProfileController],
providers: [CustomerProfileService],
})
export class CustomerProfileModule {}
@@ -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);
}
}
@@ -0,0 +1,39 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/**
* Body of POST customer/me/complete-profile.
*
* The three ids are the reference's exactly
* (henza_server Major/CustomerController::complete:73-79):
*
* 'business_license_image_id' => 'required',
* 'shop_image_id' => 'required',
* 'ncode_image_id' => 'required',
*
* and the PWA sends those same three keys (completeProfile.vue:136-140, :193).
* They are kept verbatim rather than camelCased so the app's payload is
* byte-identical to the reference's.
*
* Deliberately NOT accepted: anything else. The reference did
* `$customer->update($request->all())` (:87), so ANY column the caller named —
* `gram_level`, `percent`, `active`, `remain` — was written straight onto the
* customer row from the app. That is a privilege-escalation and money hole, not
* a feature, so only these three ids are read (whitelist, not mass-assign).
* Recorded in ISSUES.md as SEC-14.
*/
export class CompleteProfileDto {
@IsString()
@IsNotEmpty({ message: 'تصویر جواز کسب الزامی است' })
@MaxLength(64)
business_license_image_id!: string;
@IsString()
@IsNotEmpty({ message: 'تصویر سردر مغازه الزامی است' })
@MaxLength(64)
shop_image_id!: string;
@IsString()
@IsNotEmpty({ message: 'تصویر کارت ملی الزامی است' })
@MaxLength(64)
ncode_image_id!: string;
}