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
+308
View File
@@ -0,0 +1,308 @@
/**
* "تکمیل پروفایل" — POST customer/me/complete-profile and its uploads.
*
* Ports henza_server Major/CustomerController::complete
* (app/Http/Controllers/Major/CustomerController.php:73-108).
*
* Covers what the owner asked for per page — opening, the empty state, the
* error state and the main flow — plus the boundaries that matter here:
*
* 1. the main flow: three uploads, then complete, then VERIFIED
* 2. empty/validation: each id missing in turn is a 400/422
* 3. error: an id that names no file is refused, and nothing is written
* 4. the customer comes from the TOKEN — no id is accepted from the body
* 5. no token at all is a 401 on both routes
* 6. an expert can complete their own profile too (D21: every customer
* route works for an expert unchanged)
* 7. SEC-14: extra body fields are IGNORED, not mass-assigned. The
* reference did `$customer->update($request->all())`, which let the app
* write gram_level/percent/active onto itself.
*
* Every Persian string is a literal here and travels as UTF-8 JSON (D15).
*
* Requires the API running WITH OTP_MASTER_CODE set.
* Usage: node scripts/test-complete-profile.cjs [apiBaseUrl] [masterCode]
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
const MASTER = process.argv[3] || '1379';
let pass = 0, fail = 0;
const failures = [];
const chk = (c, m) => {
if (c) { pass++; console.log(' PASS ' + m); }
else { fail++; failures.push(m); console.log(' FAIL ' + m); }
};
const step = (t) => console.log('\n' + t);
const call = async (method, p, body, token) => {
const res = await fetch(`${API}/${p}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let payload = null;
try { payload = JSON.parse(text); } catch { /* empty */ }
return { status: res.status, ok: res.ok, body: payload, raw: text };
};
/** A one-pixel PNG, so the upload exercises the real MIME whitelist. */
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const upload = async (token, purpose) => {
const form = new FormData();
form.append('file', new Blob([PNG], { type: 'image/png' }), `${purpose}.png`);
form.append('purpose', purpose);
const res = await fetch(`${API}/customer/me/documents`, {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: form,
});
const text = await res.text();
let payload = null;
try { payload = JSON.parse(text); } catch { /* empty */ }
return { status: res.status, ok: res.ok, body: payload, raw: text };
};
const signIn = (mobile) =>
call('POST', 'customer/auth/verify-otp', { username: mobile, password: MASTER });
/** Everything this run creates, removed at the end. */
const made = { admins: [], customers: [], files: [] };
const uniq = String(Date.now()).slice(-7);
(async () => {
step('SETUP — an unverified customer, and a sales expert');
const mkCustomer = async (suffix, name) => {
const c = await prisma.customer.create({
data: {
fullName: name,
firstName: name.split(' ')[0],
lastName: name.split(' ').slice(1).join(' '),
mobile: `0917${uniq.slice(0, 3)}${suffix}`,
nationalCode: `nc${uniq}${suffix}`,
passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true,
goldCreditLimitGrams: '100',
verificationStep: 'UNVERIFIED',
},
});
made.customers.push(c.id);
return c;
};
const cust = await mkCustomer('11', 'مشتری مدارک');
console.log(` customer: ${cust.fullName} (${cust.mobile}) step=${cust.verificationStep}`);
const expertAdmin = await prisma.adminUser.create({
data: {
fullName: 'کارشناس مدارک', firstName: 'کارشناس', lastName: 'مدارک',
mobile: `0918${uniq.slice(0, 3)}22`,
passwordHash: '', role: 'SALES_EXPERT', isActive: true, wagePercent: '3',
},
});
await prisma.salesExpertProfile.create({
data: { adminUserId: expertAdmin.id, gramLevel: '120' },
});
made.admins.push(expertAdmin.id);
// ── 5. NO TOKEN ─────────────────────────────────────────────────────────
step('5 — both routes require a session');
const anonComplete = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: 'x', shop_image_id: 'x', ncode_image_id: 'x',
});
chk(anonComplete.status === 401, `complete-profile with no token -> ${anonComplete.status} (expected 401)`);
const anonUpload = await upload(null, 'business-license');
chk(anonUpload.status === 401, `documents with no token -> ${anonUpload.status} (expected 401)`);
// ── SIGN IN ─────────────────────────────────────────────────────────────
step('SIGN IN — the customer, through the ordinary app flow');
await call('POST', 'customer/auth/request-otp', { username: cust.mobile });
const login = await signIn(cust.mobile);
chk(login.ok, `verify-otp -> ${login.status}`);
const token = login.body?.token;
chk(Boolean(token), 'an app token came back');
// The page opens against /customer/me, which is what the router guard reads.
const me = await call('GET', 'customer/me', undefined, token);
chk(me.ok, `GET customer/me -> ${me.status}`);
chk(me.body?.verificationStep === 'UNVERIFIED',
`the page is reachable: verificationStep is ${me.body?.verificationStep}`);
// ── 2. VALIDATION — the empty state ─────────────────────────────────────
step('2 — EMPTY STATE: each document is required');
const ids = { business_license_image_id: 'a', shop_image_id: 'b', ncode_image_id: 'c' };
for (const key of Object.keys(ids)) {
const partial = { ...ids };
delete partial[key];
const r = await call('POST', 'customer/me/complete-profile', partial, token);
chk(r.status === 400 || r.status === 422, `missing ${key} -> ${r.status} (expected 400/422)`);
}
const allEmpty = await call('POST', 'customer/me/complete-profile', {}, token);
chk(allEmpty.status === 400 || allEmpty.status === 422,
`an empty body -> ${allEmpty.status} (expected 400/422)`);
const stillUnverified = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(stillUnverified.verificationStep === 'UNVERIFIED',
'a rejected submission left the verification step untouched');
// ── 3. ERROR STATE — an id that names no file ───────────────────────────
step('3 — ERROR STATE: an unknown document id is refused');
const bogus = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: 'no-such-file-id',
shop_image_id: 'no-such-file-id',
ncode_image_id: 'no-such-file-id',
}, token);
chk(bogus.status === 400, `unknown ids -> ${bogus.status} (expected 400)`);
const afterBogus = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(afterBogus.verificationStep === 'UNVERIFIED',
'and nothing was written: still UNVERIFIED');
chk(afterBogus.businessLicenseUrl === null,
'and no document URL was stored');
// ── 1. MAIN FLOW ────────────────────────────────────────────────────────
step('1 — MAIN FLOW: upload three documents, then complete');
const licence = await upload(token, 'business-license');
chk(licence.ok, `upload the business licence -> ${licence.status}`);
const shop = await upload(token, 'shop-image');
chk(shop.ok, `upload the shop front -> ${shop.status}`);
const ncode = await upload(token, 'national-card');
chk(ncode.ok, `upload the national card -> ${ncode.status}`);
for (const u of [licence, shop, ncode]) if (u.body?.id) made.files.push(u.body.id);
chk(Boolean(licence.body?.id && licence.body?.url),
'each upload answers with an id and a stored url');
chk(licence.body?.uploadedByAdminUserId === null,
'a customer upload is not attributed to an admin user');
const done = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: licence.body.id,
shop_image_id: shop.body.id,
ncode_image_id: ncode.body.id,
}, token);
chk(done.ok, `complete-profile -> ${done.status}`);
const after = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(after.verificationStep === 'VERIFIED',
`the step moved to the reference's live value (VERIFIED), got ${after.verificationStep}`);
chk(after.businessLicenseUrl === licence.body.url, 'the business licence url was stored');
chk(after.shopImageUrl === shop.body.url, 'the shop front url was stored');
chk(after.nationalIdImageUrl === ncode.body.url, 'the national card url was stored');
// The router guard must now keep the page shut.
const meAfter = await call('GET', 'customer/me', undefined, token);
chk(meAfter.body?.verificationStep === 'VERIFIED',
'the app now reads VERIFIED, so the guard redirects the page home');
// ── 7. SEC-14 — extra fields are ignored, not mass-assigned ─────────────
step('7 — SEC-14: extra body fields are IGNORED, not written to the row');
const before = await prisma.customer.findUnique({ where: { id: cust.id } });
const inject = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: licence.body.id,
shop_image_id: shop.body.id,
ncode_image_id: ncode.body.id,
// Every one of these was writable through the reference's $request->all().
goldCreditLimitGrams: '999999',
gram_level: '999999',
goldDebtGrams: '-500',
makingChargeOffsetPercent: '0',
isActive: false,
mobile: '09000000000',
}, token);
chk(inject.status === 400 || inject.ok,
`a body with extra fields -> ${inject.status}`);
const poked = await prisma.customer.findUnique({ where: { id: cust.id } });
chk(String(poked.goldCreditLimitGrams) === String(before.goldCreditLimitGrams),
`the credit limit is unchanged (${poked.goldCreditLimitGrams})`);
chk(String(poked.goldDebtGrams) === String(before.goldDebtGrams),
`the gold debt is unchanged (${poked.goldDebtGrams})`);
chk(String(poked.makingChargeOffsetPercent) === String(before.makingChargeOffsetPercent),
`the making charge is unchanged (${poked.makingChargeOffsetPercent})`);
chk(poked.isActive === before.isActive, 'isActive is unchanged');
chk(poked.mobile === before.mobile, `the mobile is unchanged (${poked.mobile})`);
// ── 4. THE SUBJECT COMES FROM THE TOKEN ─────────────────────────────────
step('4 — the customer comes from the TOKEN, never from the body');
const victim = await mkCustomer('44', 'مشتری دیگر');
const victimBefore = await prisma.customer.findUnique({ where: { id: victim.id } });
chk(victimBefore.verificationStep === 'UNVERIFIED', 'the second customer starts UNVERIFIED');
await call('POST', 'customer/me/complete-profile', {
business_license_image_id: licence.body.id,
shop_image_id: shop.body.id,
ncode_image_id: ncode.body.id,
// Both spellings, in case either were read.
customerId: victim.id,
customer_id: victim.id,
id: victim.id,
}, token);
const victimAfter = await prisma.customer.findUnique({ where: { id: victim.id } });
chk(victimAfter.verificationStep === 'UNVERIFIED',
"naming another customer in the body did NOT touch their row");
chk(victimAfter.businessLicenseUrl === null,
'and stored no document against them');
// ── 6. AN EXPERT CAN COMPLETE THEIR OWN PROFILE ─────────────────────────
step('6 — D21: the same page works for a sales expert');
await call('POST', 'customer/auth/request-otp', { username: expertAdmin.mobile });
const expLogin = await signIn(expertAdmin.mobile);
chk(expLogin.ok, `the expert signs in -> ${expLogin.status}`);
const expToken = expLogin.body?.token;
const prof = await prisma.salesExpertProfile.findUnique({ where: { adminUserId: expertAdmin.id } });
if (prof?.tradingCustomerId) made.customers.push(prof.tradingCustomerId);
const expUpload = await upload(expToken, 'business-license');
chk(expUpload.ok, `the expert uploads a document -> ${expUpload.status}`);
if (expUpload.body?.id) made.files.push(expUpload.body.id);
const expDone = await call('POST', 'customer/me/complete-profile', {
business_license_image_id: expUpload.body.id,
shop_image_id: expUpload.body.id,
ncode_image_id: expUpload.body.id,
}, expToken);
chk(expDone.ok, `the expert completes their profile -> ${expDone.status}`);
const expTrading = prof?.tradingCustomerId
? await prisma.customer.findUnique({ where: { id: prof.tradingCustomerId } })
: null;
chk(expTrading?.verificationStep === 'VERIFIED',
'it landed on the expert\'s own trading account, not on anyone else');
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP');
await prisma.uploadedFile.deleteMany({ where: { id: { in: made.files } } });
await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } });
await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: made.admins } } });
await prisma.customer.deleteMany({ where: { id: { in: made.customers } } });
await prisma.adminUser.deleteMany({ where: { id: { in: made.admins } } });
console.log(
` deleted ${made.files.length} uploaded-file row(s), ${made.customers.length} customer(s) ` +
`(including the expert trading account this run created) and ${made.admins.length} admin user(s). ` +
'All were created by this run; no pre-existing data was touched. ' +
'The uploaded blobs themselves are left on disk, as every other test does.',
);
console.log(`\n${'='.repeat(60)}`);
console.log(`COMPLETE PROFILE: passed ${pass} failed ${fail}`);
if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); }
console.log('='.repeat(60));
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => {
console.error('\nABORTED:', e);
await prisma.$disconnect();
process.exit(1);
});
+2
View File
@@ -4,6 +4,7 @@ import { CatalogModule } from './modules/catalog/catalog.module';
import { CustomersModule } from './modules/customers/customers.module';
import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module';
import { SalesExpertAppModule } from './modules/sales-expert-app/sales-expert-app.module';
import { CustomerProfileModule } from './modules/customer-profile/customer-profile.module';
import { SmsModule } from './modules/sms/sms.module';
import { OrdersModule } from './modules/orders/orders.module';
import { AccountingModule } from './modules/accounting/accounting.module';
@@ -54,6 +55,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
SmsModule,
CustomerAuthModule,
SalesExpertAppModule,
CustomerProfileModule,
SalesAgentModule,
OrdersModule,
AccountingModule,
+27
View File
@@ -22,6 +22,33 @@ async function bootstrap() {
// no longer stops the boot; messages are logged instead of sent.
assertSmsConfigured();
// A missing JWT_CUSTOMER_SECRET is not a cosmetic default: customerJwtSecret()
// falls back to JWT_SECRET, so admin and app tokens end up signed with the
// SAME key. The `typ` check in JwtStrategy is then the only thing keeping an
// app token off the admin routes — one guard instead of two independent ones,
// and a sales expert's app token carries their AdminUser id in `sub`. Warn as
// loudly as the master-code banner rather than fail the boot, so a running
// deployment that has not set it yet still starts.
if (!process.env.JWT_CUSTOMER_SECRET?.trim()) {
const sharedWithAdmin = Boolean(process.env.JWT_SECRET?.trim());
// eslint-disable-next-line no-console
console.warn(
'\n' +
'='.repeat(72) +
'\n ⚠ JWT_CUSTOMER_SECRET تنظیم نشده است\n' +
' ⚠ JWT_CUSTOMER_SECRET IS NOT SET.\n' +
(sharedWithAdmin
? ' ⚠ Customer/expert app tokens are being signed with JWT_SECRET,\n' +
' ⚠ the SAME key as admin tokens. The two token realms are no\n' +
' ⚠ longer cryptographically separated.\n'
: ' ⚠ Neither JWT_SECRET nor JWT_CUSTOMER_SECRET is set, so app\n' +
' ⚠ tokens are signed with a well-known development string.\n') +
' ⚠ Set JWT_CUSTOMER_SECRET to a distinct random value before release.\n' +
'='.repeat(72) +
'\n',
);
}
// D20 — a loud, unmissable banner whenever the delivery bypass is armed, so
// it cannot reach the real release unnoticed.
if (masterOtpEnabled()) {
@@ -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;
}