import { BadRequestException, ConflictException, ForbiddenException, HttpException, Injectable, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { randomBytes } from 'crypto'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; import { SmsBillingService } from '../auth/sms-billing.service'; import { SmsService } from '../auth/sms.service'; import { buildDualDailyActivitySeries, startOfLocalDay, } from '../common/daily-activity'; import { normalizeBusinessSettings } from '../business-settings/business-settings.util'; import { PrismaService } from '../prisma/prisma.service'; import { buildCustomersExcel, parseCustomersExcel, } from './customers-excel'; import { CreateCustomerDto } from './dto/create-customer.dto'; import { ListCustomersDto } from './dto/list-customers.dto'; import { SearchCustomersDto } from './dto/search-customers.dto'; import { SendCustomerSmsDto } from './dto/send-customer-sms.dto'; import { SendCustomersBulkSmsDto } from './dto/send-customers-bulk-sms.dto'; import { UpdateCustomerDto } from './dto/update-customer.dto'; import { SmsTemplatesService } from '../sms-templates/sms-templates.service'; import { renderSmsTemplate, smsTemplateUsesVariables, } from '../sms-templates/sms-template-render'; type CustomerListRow = { id: bigint; cellNumber: string; firstName: string | null; lastName: string | null; firstNameEn: string | null; lastNameEn: string | null; email: string | null; createdAt: Date; isEnabled: boolean; businessMemberId: bigint | null; isBusinessOwner: boolean | null; teamRole: string | null; }; @Injectable() export class CustomersService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly sms: SmsService, private readonly smsBilling: SmsBillingService, private readonly smsTemplates: SmsTemplatesService, ) {} async list( businessIdRaw: string, query: ListCustomersDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'orders.read'); const page = query.page ?? 1; const pageSize = Math.min(Math.max(query.pageSize ?? 24, 1), 100); const skip = (page - 1) * pageSize; const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null; const cellLike = query.cellNumber?.trim() ? `%${query.cellNumber.trim()}%` : null; const access = query.access ?? 'all'; const accessFilter = access === 'customers' ? Prisma.sql`AND bc.id IS NOT NULL AND bu.id IS NULL` : access === 'managers' ? Prisma.sql`AND bu.id IS NOT NULL` : Prisma.sql`AND (bc.id IS NOT NULL OR bu.id IS NOT NULL)`; const where = Prisma.sql` WHERE 1=1 ${accessFilter} ${nameLike ? Prisma.sql` AND ( u.first_name ILIKE ${nameLike} OR u.last_name ILIKE ${nameLike} OR u.first_name_en ILIKE ${nameLike} OR u.last_name_en ILIKE ${nameLike} OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike} OR (COALESCE(u.first_name_en, '') || ' ' || COALESCE(u.last_name_en, '')) ILIKE ${nameLike} ) ` : Prisma.empty} ${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty} `; const [items, totalRow] = await Promise.all([ this.prisma.$queryRaw(Prisma.sql` SELECT u.id AS "id", u.cell_number AS "cellNumber", u.first_name AS "firstName", u.last_name AS "lastName", u.first_name_en AS "firstNameEn", u.last_name_en AS "lastNameEn", u.email AS "email", COALESCE(bc.created_at, bu.created_at) AS "createdAt", COALESCE(bc.is_enabled, TRUE) AS "isEnabled", bu.id AS "businessMemberId", bu.is_owner AS "isBusinessOwner", r.slug AS "teamRole" FROM users u LEFT JOIN business_customers bc ON bc.user_id = u.id AND bc.business_id = ${businessId} LEFT JOIN business_users bu ON bu.user_id = u.id AND bu.business_id = ${businessId} LEFT JOIN roles r ON r.id = bu.role_id ${where} ORDER BY COALESCE(bc.created_at, bu.created_at) DESC LIMIT ${pageSize} OFFSET ${skip} `), this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql` SELECT COUNT(*)::int AS "total" FROM users u LEFT JOIN business_customers bc ON bc.user_id = u.id AND bc.business_id = ${businessId} LEFT JOIN business_users bu ON bu.user_id = u.id AND bu.business_id = ${businessId} ${where} `), ]); return { items: items.map((user) => ({ id: user.id.toString(), cellNumber: user.cellNumber, firstName: user.firstName, lastName: user.lastName, firstNameEn: user.firstNameEn, lastNameEn: user.lastNameEn, email: user.email, label: this.formatLabel(user), createdAt: user.createdAt.toISOString(), isEnabled: user.isEnabled, businessMemberId: user.businessMemberId?.toString() ?? null, isBusinessOwner: user.isBusinessOwner ?? false, teamRole: user.isBusinessOwner ? 'business_owner' : user.teamRole ?? null, })), total: totalRow[0]?.total ?? 0, page, pageSize, }; } /** Lightweight daily registration + active-login counts for dashboard charts. */ async dailyActivity( businessIdRaw: string, daysRaw: number | undefined, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'orders.read'); const days = Math.min(Math.max(daysRaw ?? 30, 1), 366); const from = startOfLocalDay(days - 1); const [registeredRows, activeRows] = await Promise.all([ this.prisma.$queryRaw<{ day: Date; count: number }[]>( Prisma.sql` SELECT created_at::date AS day, COUNT(*)::int AS count FROM business_customers WHERE business_id = ${businessId} AND created_at >= ${from} GROUP BY 1 ORDER BY 1 `, ), this.prisma.$queryRaw<{ day: Date; count: number }[]>( Prisma.sql` SELECT u.last_login_at::date AS day, COUNT(*)::int AS count FROM business_customers bc JOIN users u ON u.id = bc.user_id WHERE bc.business_id = ${businessId} AND u.last_login_at IS NOT NULL AND u.last_login_at >= ${from} GROUP BY 1 ORDER BY 1 `, ), ]); return buildDualDailyActivitySeries(days, registeredRows, activeRows); } async search( businessIdRaw: string, query: SearchCustomersDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertCustomerSearchPermission(businessId, actor.id); const q = query.q.trim(); const limit = Math.min(Math.max(query.limit ?? 20, 1), 50); const like = `%${q}%`; const items = await this.prisma.$queryRaw< { id: bigint; cellNumber: string; firstName: string | null; lastName: string | null; email: string | null; }[] >(Prisma.sql` SELECT u.id AS "id", u.cell_number AS "cellNumber", u.first_name AS "firstName", u.last_name AS "lastName", u.email AS "email" FROM business_customers bc JOIN users u ON u.id = bc.user_id WHERE bc.business_id = ${businessId} AND bc.is_enabled = TRUE AND u.is_active = TRUE AND ( u.cell_number ILIKE ${like} OR u.first_name ILIKE ${like} OR u.last_name ILIKE ${like} OR u.email ILIKE ${like} OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${like} ) ORDER BY u.first_name ASC NULLS LAST, u.last_name ASC NULLS LAST LIMIT ${limit} `); return { items: items.map((user) => ({ id: user.id.toString(), cellNumber: user.cellNumber, firstName: user.firstName, lastName: user.lastName, email: user.email, label: this.formatLabel(user), })), }; } async exportExcel(businessIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'orders.read'); const rows = await this.prisma.$queryRaw< { cellNumber: string; firstName: string | null; lastName: string | null; }[] >(Prisma.sql` SELECT u.cell_number AS "cellNumber", u.first_name AS "firstName", u.last_name AS "lastName" FROM business_customers bc JOIN users u ON u.id = bc.user_id WHERE bc.business_id = ${businessId} ORDER BY bc.created_at DESC, u.id DESC `); return buildCustomersExcel( rows.map((row) => ({ phone: row.cellNumber, firstName: row.firstName?.trim() ?? '', lastName: row.lastName?.trim() ?? '', })), ); } async importExcel( businessIdRaw: string, file: Express.Multer.File | undefined, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'orders.create'); if (!file?.buffer?.length) { throw new BadRequestException('Excel file is required.'); } const name = file.originalname?.toLowerCase() ?? ''; if (name && !name.endsWith('.xlsx')) { throw new BadRequestException('Please upload an .xlsx Excel file.'); } const business = await this.prisma.business.findUnique({ where: { id: businessId }, }); if (!business?.isActive) { throw new NotFoundException('Business not found'); } const customerRole = await this.prisma.role.findUnique({ where: { slug: 'customer' }, }); if (!customerRole) { throw new Error('Customer role is missing. Run database migrations first.'); } const rows = await parseCustomersExcel(file.buffer); const phones = rows.map((row) => row.phone); const existingUsers = await this.prisma.user.findMany({ where: { cellNumber: { in: phones } }, select: { id: true, cellNumber: true, cellVerifiedAt: true, businessCustomers: { where: { businessId }, select: { id: true }, }, }, }); const userByPhone = new Map( existingUsers.map((user) => [user.cellNumber, user]), ); const existingUserIds = existingUsers.map((user) => user.id); const staffMemberships = existingUserIds.length === 0 ? [] : await this.prisma.businessUser.findMany({ where: { businessId, userId: { in: existingUserIds }, }, select: { userId: true }, }); const staffUserIds = new Set( staffMemberships.map((row) => row.userId.toString()), ); let created = 0; let linked = 0; let skipped = 0; const skipSamples: string[] = []; const noteSkip = (message: string) => { skipped += 1; if (skipSamples.length < 12) { skipSamples.push(message); } }; const placeholderPasswordHash = await bcrypt.hash( randomBytes(32).toString('hex'), 10, ); for (const row of rows) { const existing = userByPhone.get(row.phone); if (existing?.businessCustomers.length) { noteSkip(`Row ${row.rowNumber}: already a customer (${row.phone}).`); continue; } if (existing && staffUserIds.has(existing.id.toString())) { noteSkip( `Row ${row.rowNumber}: user is staff of this business (${row.phone}).`, ); continue; } const wasNew = !existing; await this.prisma.$transaction(async (tx) => { let userId = existing?.id; if (!userId) { const account = await tx.user.create({ data: { cellNumber: row.phone, passwordHash: placeholderPasswordHash, firstName: row.firstName, lastName: row.lastName, cellVerifiedAt: new Date(), }, }); userId = account.id; userByPhone.set(row.phone, { id: account.id, cellNumber: row.phone, cellVerifiedAt: account.cellVerifiedAt, businessCustomers: [{ id: BigInt(0) }], }); } else if (existing && !existing.cellVerifiedAt) { await tx.user.update({ where: { id: userId }, data: { cellVerifiedAt: new Date() }, }); } await tx.businessCustomer.create({ data: { businessId, userId, }, }); const hasCustomerRole = await tx.userRole.findUnique({ where: { userId_roleId: { userId, roleId: customerRole.id, }, }, }); if (!hasCustomerRole) { await tx.userRole.create({ data: { userId, roleId: customerRole.id, }, }); } const cached = userByPhone.get(row.phone); if (cached && cached.businessCustomers.length === 0) { cached.businessCustomers = [{ id: BigInt(0) }]; } }); if (wasNew) { created += 1; } else { linked += 1; } } return { message: 'Customers imported.', total: rows.length, created, linked, skipped, skipSamples, }; } async create( businessIdRaw: string, dto: CreateCustomerDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'orders.create'); const business = await this.prisma.business.findUnique({ where: { id: businessId }, }); if (!business?.isActive) { throw new NotFoundException('Business not found'); } const customerRole = await this.prisma.role.findUnique({ where: { slug: 'customer' }, }); if (!customerRole) { throw new Error('Customer role is missing. Run database migrations first.'); } const existingUser = await this.prisma.user.findUnique({ where: { cellNumber: dto.cellNumber }, include: { businessCustomers: { where: { businessId } }, }, }); if (existingUser?.businessCustomers.length) { throw new ConflictException('User is already a customer of this business'); } if (existingUser) { const staffMembership = await this.prisma.businessUser.findUnique({ where: { businessId_userId: { businessId, userId: existingUser.id }, }, }); if (staffMembership) { throw new ConflictException('User is already staff of this business'); } } const passwordHash = existingUser ? existingUser.passwordHash : await bcrypt.hash( // Password optional: random unusable hash when omitted (OTP login still works). dto.password?.trim() || randomBytes(32).toString('hex'), 10, ); const firstName = dto.firstName?.trim() || null; const lastName = dto.lastName?.trim() || null; if (!firstName && !lastName) { throw new BadRequestException('firstName or lastName is required'); } const membership = await this.prisma.$transaction(async (tx) => { const account = existingUser ?? (await tx.user.create({ data: { cellNumber: dto.cellNumber, passwordHash, email: dto.email, firstName, lastName, firstNameEn: dto.firstNameEn?.trim() || null, lastNameEn: dto.lastNameEn?.trim() || null, cellVerifiedAt: new Date(), }, })); if (existingUser && !existingUser.cellVerifiedAt) { await tx.user.update({ where: { id: account.id }, data: { cellVerifiedAt: new Date() }, }); } const link = await tx.businessCustomer.create({ data: { businessId, userId: account.id, }, }); const hasCustomerRole = await tx.userRole.findUnique({ where: { userId_roleId: { userId: account.id, roleId: customerRole.id, }, }, }); if (!hasCustomerRole) { await tx.userRole.create({ data: { userId: account.id, roleId: customerRole.id, }, }); } return { account, link }; }); const user = membership.account; const verifiedAt = user.cellVerifiedAt ?? new Date(); return { id: user.id.toString(), cellNumber: user.cellNumber, firstName: user.firstName, lastName: user.lastName, firstNameEn: user.firstNameEn, lastNameEn: user.lastNameEn, email: user.email, label: this.formatLabel(user), createdAt: membership.link.createdAt.toISOString(), isEnabled: membership.link.isEnabled, isVerified: verifiedAt !== null, role: 'customer', }; } async update( businessIdRaw: string, userIdRaw: string, dto: UpdateCustomerDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const userId = BigInt(userIdRaw); await this.assertPermission(businessId, actor.id, 'orders.update'); const membership = await this.prisma.businessCustomer.findUnique({ where: { businessId_userId: { businessId, userId }, }, include: { user: true }, }); if (!membership) { throw new NotFoundException('Customer not found for this business'); } if (dto.isEnabled !== undefined) { await this.prisma.businessCustomer.update({ where: { id: membership.id }, data: { isEnabled: dto.isEnabled }, }); } const hasProfileUpdate = dto.firstName !== undefined || dto.lastName !== undefined || dto.firstNameEn !== undefined || dto.lastNameEn !== undefined || dto.email !== undefined || dto.cellNumber !== undefined; if (hasProfileUpdate) { const user = membership.user; if (dto.cellNumber && dto.cellNumber !== user.cellNumber) { const existing = await this.prisma.user.findUnique({ where: { cellNumber: dto.cellNumber }, }); if (existing && existing.id !== userId) { throw new BadRequestException('Cell number is already in use'); } } await this.prisma.user.update({ where: { id: userId }, data: { firstName: dto.firstName?.trim(), lastName: dto.lastName?.trim(), firstNameEn: dto.firstNameEn !== undefined ? dto.firstNameEn.trim() || null : undefined, lastNameEn: dto.lastNameEn !== undefined ? dto.lastNameEn.trim() || null : undefined, email: dto.email !== undefined ? dto.email.trim() || null : undefined, cellNumber: dto.cellNumber?.trim(), }, }); } const refreshed = await this.prisma.businessCustomer.findUnique({ where: { id: membership.id }, include: { user: true }, }); if (!refreshed) { throw new NotFoundException('Customer not found for this business'); } const user = refreshed.user; return { id: user.id.toString(), cellNumber: user.cellNumber, firstName: user.firstName, lastName: user.lastName, firstNameEn: user.firstNameEn, lastNameEn: user.lastNameEn, email: user.email, label: this.formatLabel(user), isEnabled: refreshed.isEnabled, }; } async remove(businessIdRaw: string, userIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const userId = BigInt(userIdRaw); await this.assertPermission(businessId, actor.id, 'orders.update'); const membership = await this.prisma.businessCustomer.findUnique({ where: { businessId_userId: { businessId, userId }, }, }); if (!membership) { throw new NotFoundException('Customer not found for this business'); } await this.prisma.businessCustomer.delete({ where: { id: membership.id }, }); return { success: true }; } async sendSms( businessIdRaw: string, userIdRaw: string, dto: SendCustomerSmsDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const userId = BigInt(userIdRaw); await this.assertPermission(businessId, actor.id, 'orders.update'); const membership = await this.prisma.businessCustomer.findUnique({ where: { businessId_userId: { businessId, userId }, }, include: { user: { include: { title: true } }, business: { select: { settings: true, isActive: true } }, }, }); if (!membership || !membership.business.isActive) { throw new NotFoundException('Customer not found for this business'); } if (!this.sms.isEnabled()) { return { enabled: false, message: 'SMS is disabled. Message was not sent.', }; } const resolved = await this.resolveOutboundSms( businessId, membership.business.settings, dto.message, dto.templateId, { firstName: membership.user.firstName, lastName: membership.user.lastName, titleName: membership.user.title?.nameFa || membership.user.title?.nameEn, }, ); const charged = await this.smsBilling.charge( businessId, resolved.message, 1, 'singular', ); try { const result = await this.sms.sendMessage( membership.user.cellNumber, resolved.message, { source: resolved.source }, ); return { enabled: true, message: 'Message sent successfully', serverId: result.serverId, source: resolved.source, templateId: resolved.templateId, templateVerified: resolved.templateVerified, }; } catch (err) { await this.smsBilling.refund(businessId, charged, 1, 'singular'); if ( err instanceof BadRequestException || err instanceof ServiceUnavailableException ) { throw err; } throw new ServiceUnavailableException('Unable to send SMS'); } } /** * Bulk SMS. Empty/omitted userIds → all enabled business customers. * Always sends per-recipient so one failure does not abort the rest. * Returns a report with sent/failed counts and per-number errors. */ async sendBulkSms( businessIdRaw: string, dto: SendCustomersBulkSmsDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'orders.update'); const business = await this.prisma.business.findUnique({ where: { id: businessId }, select: { settings: true, isActive: true }, }); if (!business?.isActive) { throw new NotFoundException('Business not found'); } const selectedIds = (dto.userIds ?? []) .map((id) => id.trim()) .filter(Boolean); type Recipient = { cellNumber: string; firstName: string | null; lastName: string | null; titleName: string | null; }; let recipients: Recipient[]; if (selectedIds.length > 0) { const userIds = selectedIds.map((id) => { try { return BigInt(id); } catch { throw new BadRequestException('Invalid user id'); } }); const rows = await this.prisma.$queryRaw< { cellNumber: string; firstName: string | null; lastName: string | null; titleName: string | null; }[] >(Prisma.sql` SELECT DISTINCT ON (u.id) u.cell_number AS "cellNumber", u.first_name AS "firstName", u.last_name AS "lastName", COALESCE(ut.name_fa, ut.name_en) AS "titleName" FROM users u LEFT JOIN user_titles ut ON ut.id = u.title_id LEFT JOIN business_customers bc ON bc.user_id = u.id AND bc.business_id = ${businessId} LEFT JOIN business_users bu ON bu.user_id = u.id AND bu.business_id = ${businessId} WHERE u.id IN (${Prisma.join(userIds)}) AND (bc.id IS NOT NULL OR bu.id IS NOT NULL) `); recipients = rows; } else { const rows = await this.prisma.businessCustomer.findMany({ where: { businessId, isEnabled: true }, select: { user: { select: { cellNumber: true, firstName: true, lastName: true, title: { select: { nameFa: true, nameEn: true } }, }, }, }, }); recipients = rows.map((r) => ({ cellNumber: r.user.cellNumber, firstName: r.user.firstName, lastName: r.user.lastName, titleName: r.user.title?.nameFa || r.user.title?.nameEn || null, })); } if (recipients.length === 0) { throw new BadRequestException('No recipients found to send SMS'); } if (!this.sms.isEnabled()) { return { enabled: false, message: 'SMS is disabled. Message was not sent.', recipientCount: recipients.length, sentCount: 0, failedCount: 0, failures: [], }; } const payloads: Array<{ cellNumber: string; message: string; source: string; cost: number; templateId: string | null; templateVerified: boolean | null; }> = []; for (const recipient of recipients) { const resolved = await this.resolveOutboundSms( businessId, business.settings, dto.message, dto.templateId, { firstName: recipient.firstName, lastName: recipient.lastName, titleName: recipient.titleName, }, ); payloads.push({ cellNumber: recipient.cellNumber, message: resolved.message, source: resolved.source, cost: this.smsBilling.costForMessage(resolved.message), templateId: resolved.templateId, templateVerified: resolved.templateVerified, }); } const serverIds: string[] = []; const failures: Array<{ cellNumber: string; error: string }> = []; let sentCount = 0; let balanceExhausted = false; for (const payload of payloads) { if (balanceExhausted) { failures.push({ cellNumber: payload.cellNumber, error: 'Insufficient SMS balance. Contact Meshkee to top up.', }); continue; } let charged = 0; try { charged = await this.smsBilling.chargeAmount( businessId, payload.cost, 1, 'mass', ); } catch (err) { const message = this.smsFailureMessage(err); if (message.toLowerCase().includes('insufficient sms balance')) { balanceExhausted = true; } failures.push({ cellNumber: payload.cellNumber, error: message }); continue; } try { const result = await this.sms.sendMessage( payload.cellNumber, payload.message, { source: payload.source }, ); serverIds.push(result.serverId); sentCount += 1; } catch (err) { if (charged > 0) { await this.smsBilling.refund(businessId, charged, 1, 'mass'); } failures.push({ cellNumber: payload.cellNumber, error: this.smsFailureMessage(err), }); } } const sample = payloads[0]; const failedCount = failures.length; const message = failedCount === 0 ? 'Message sent successfully' : sentCount === 0 ? 'No messages were sent' : `Sent ${sentCount} of ${payloads.length}; ${failedCount} failed`; return { enabled: true, message, recipientCount: payloads.length, sentCount, failedCount, serverIds, failures, source: sample?.source, templateId: sample?.templateId, templateVerified: sample?.templateVerified, }; } private smsFailureMessage(err: unknown): string { if (err instanceof HttpException) { const response = err.getResponse(); if (typeof response === 'string' && response.trim()) { return response.trim(); } if (typeof response === 'object' && response !== null && 'message' in response) { const message = (response as { message?: string | string[] }).message; if (Array.isArray(message)) { return message.filter(Boolean).join(', ') || err.message; } if (typeof message === 'string' && message.trim()) { return message.trim(); } } return err.message || 'Unable to send SMS'; } if (err instanceof Error && err.message.trim()) { return err.message.trim(); } return 'Unable to send SMS'; } /** * Resolve message text + Gama shortcode for customer SMS. * Verified template → service line (fixed). Unverified template → advertising line. * Free text → business senderNumber or advertising (unchanged). */ private async resolveOutboundSms( businessId: bigint, businessSettings: unknown, messageRaw: string, templateIdRaw: string | undefined, vars: { firstName?: string | null; lastName?: string | null; titleName?: string | null; }, ) { const settings = normalizeBusinessSettings(businessSettings); let message = messageRaw.trim(); let templateId: string | null = null; let templateVerified: boolean | null = null; let source = settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource(); if (templateIdRaw?.trim()) { const template = await this.smsTemplates.getTemplateForSend( businessId, BigInt(templateIdRaw.trim()), ); templateId = template.id.toString(); templateVerified = template.isVerified; // Verified templates always use the approved body (sender is also fixed). const draft = template.isVerified ? template.body : message || template.body; message = renderSmsTemplate(draft, vars); source = template.isVerified ? this.sms.getServiceSource() : this.sms.getAdvertisingSource(); } else if (smsTemplateUsesVariables(message)) { message = renderSmsTemplate(message, vars); } if (!message.trim()) { throw new BadRequestException('Message is empty'); } return { message: message.trim(), source, templateId, templateVerified, }; } private formatLabel(user: { firstName: string | null; lastName: string | null; cellNumber: string; email: string | null; }) { const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); if (name) { return `${name} · ${user.cellNumber}`; } return user.email ? `${user.email} · ${user.cellNumber}` : user.cellNumber; } private async assertCustomerSearchPermission( businessId: bigint, userId: bigint, ) { const allowed = (await this.permissions.hasBusinessPermission( userId, businessId, 'orders.create', )) || (await this.permissions.hasBusinessPermission( userId, businessId, 'workshops.update', )); if (!allowed) { throw new ForbiddenException( 'Missing permission to search customers for this business', ); } } private async assertPermission( businessId: bigint, userId: bigint, permission: string, ) { const allowed = await this.permissions.hasBusinessPermission( userId, businessId, permission, ); if (!allowed) { throw new ForbiddenException( `Missing permission: ${permission} for this business`, ); } } }