اپ: نام برند در همهی صفحهها و پیامها یکسان شده و از یک تنظیم مرکزی خوانده میشود. اپ: کارت حسابداری در صفحهی مشتری بازطراحی شد؛ ماندهها و وضعیت لینک واضحتر نمایش داده میشوند. پنل: کارت حسابداری در پروندهی مشتری بازطراحی شد؛ ماندهها، آخرین ارسال و فرم لینک دستی واضحتر شدهاند. پنل: ستون «کد» در جدولها بهطور پیشفرض پنهان است و از انتخاب ستونها قابل نمایش است. پنل: تغییر نمایش ستونها در جدولها بلافاصله اعمال میشود. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
128 lines
5.3 KiB
TypeScript
128 lines
5.3 KiB
TypeScript
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';
|
|
import { brandName } from '../../common/brand';
|
|
|
|
/**
|
|
* "تکمیل پروفایل" — 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) {
|
|
// Major/CustomerController::complete:97-99, brand read from BRAND_NAME.
|
|
const message =
|
|
`${brandName()}\n` +
|
|
`همکار گرامی ${firstName} ${lastName}\n` +
|
|
'تکمیل پروفایل شما با موفقیت انجام شد.';
|
|
|
|
// :103-105, brand read from BRAND_NAME.
|
|
const adminMessage =
|
|
`${brandName()}\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);
|
|
}
|
|
}
|