Files
hedena_gold/apps/api/src/modules/customer-profile/customer-profile.service.ts
T
s.esmaeiliandClaude Haiku 5.5 532ceee189 feat: move brand name to one config and redesign the accounting card
اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-14 13:31:20 +03:30

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);
}
}