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,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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user