diff --git a/.env.example b/.env.example index 5cbc382..eb2bf94 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,8 @@ SMS_GAMA_BASE_URL=https://sms.igama.ir/api/v1 SMS_GAMA_USERNAME= SMS_GAMA_PASSWORD= SMS_GAMA_SOURCE_SERVICE=5000110005 -# Optional later: SMS_GAMA_SOURCE_ADVERTISE=500099000005 +# Optional later: SMS_GAMA_SOURCE_ADVERTISE=5000990009 +SMS_GAMA_SOURCE_ADVERTISE=5000990009 # Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped) # Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret SMS_PARTNERS= diff --git a/src/auth/sms.service.ts b/src/auth/sms.service.ts index 376f22a..0729ef8 100644 --- a/src/auth/sms.service.ts +++ b/src/auth/sms.service.ts @@ -10,6 +10,22 @@ export type SmsSendResult = { serverId: string; }; +export type SmsBulkSendResult = { + serverIds: string[]; + recipientCount: number; +}; + +export type SmsSendOptions = { + /** Gama shortcode override. When omitted, uses SMS_GAMA_SOURCE_SERVICE. */ + source?: string; +}; + +/** Default Meshkee advertising shortcode when a business has no own SMS number. */ +export const DEFAULT_ADVERTISING_SMS_SOURCE = '5000990009'; + +/** Gama batch size limit is undocumented; keep chunks conservative. */ +const GAMA_BULK_CHUNK_SIZE = 100; + type GamaQuickResponse = { success?: boolean; message?: string | null; @@ -17,6 +33,13 @@ type GamaQuickResponse = { body?: number | string | null; }; +type GamaBulkResponse = { + success?: boolean; + message?: string | null; + errors?: unknown; + body?: Array | null; +}; + @Injectable() export class SmsService { private readonly logger = new Logger(SmsService.name); @@ -27,6 +50,14 @@ export class SmsService { return this.config.get('SMS_ENABLED', 'false') === 'true'; } + /** Advertising / customer-broadcast fallback shortcode. */ + getAdvertisingSource(): string { + return ( + this.config.get('SMS_GAMA_SOURCE_ADVERTISE')?.trim() || + DEFAULT_ADVERTISING_SMS_SOURCE + ); + } + async sendVerificationCode( cellNumber: string, code: string, @@ -46,7 +77,11 @@ export class SmsService { return this.sendMessage(cellNumber, message); } - async sendMessage(cellNumber: string, message: string): Promise { + async sendMessage( + cellNumber: string, + message: string, + options?: SmsSendOptions, + ): Promise { if (!this.isEnabled()) { this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`); return { serverId: 'disabled' }; @@ -65,11 +100,78 @@ export class SmsService { throw new BadRequestException('Invalid cellphone number'); } + const sourceOverride = options?.source?.trim() || undefined; + + // Local proxy path has no per-sender shortcode unless we pass source through. if (this.isProxyConfigured()) { - return this.sendViaProxy(destination, text); + return this.sendViaProxy(destination, text, sourceOverride); } - return this.sendQuick(destination, text); + return this.sendQuick(destination, text, sourceOverride); + } + + /** + * One message → many destinations via Gama SendBulk (`/send/bulk`). + * Falls back to sequential SendQuick / proxy when only one number or proxy is configured. + */ + async sendBulkMessage( + cellNumbers: string[], + message: string, + options?: SmsSendOptions, + ): Promise { + if (!this.isEnabled()) { + this.logger.warn( + `SMS disabled — bulk message for ${cellNumbers.length} recipients not sent: ${message}`, + ); + return { serverIds: ['disabled'], recipientCount: cellNumbers.length }; + } + + const text = message.trim(); + if (!text) { + throw new BadRequestException('Message is empty'); + } + if (text.length > 700) { + throw new BadRequestException('Message is too long (max 700 characters)'); + } + + const destinations: string[] = []; + const seen = new Set(); + for (const raw of cellNumbers) { + const msisdn = toGamaMsisdn(raw); + if (!msisdn || seen.has(msisdn)) continue; + seen.add(msisdn); + destinations.push(msisdn); + } + + if (destinations.length === 0) { + throw new BadRequestException('No valid cellphone numbers to send to'); + } + + const sourceOverride = options?.source?.trim() || undefined; + + if (destinations.length === 1) { + const single = await this.sendMessage(destinations[0], text, options); + return { serverIds: [single.serverId], recipientCount: 1 }; + } + + // Local proxy only supports single destination — send sequentially. + if (this.isProxyConfigured()) { + const serverIds: string[] = []; + for (const destination of destinations) { + const result = await this.sendViaProxy(destination, text, sourceOverride); + serverIds.push(result.serverId); + } + return { serverIds, recipientCount: destinations.length }; + } + + const serverIds: string[] = []; + for (let i = 0; i < destinations.length; i += GAMA_BULK_CHUNK_SIZE) { + const chunk = destinations.slice(i, i + GAMA_BULK_CHUNK_SIZE); + const chunkIds = await this.sendBulk(chunk, text, sourceOverride); + serverIds.push(...chunkIds); + } + + return { serverIds, recipientCount: destinations.length }; } private isProxyConfigured(): boolean { @@ -80,7 +182,11 @@ export class SmsService { } /** Local-dev path: deliver via remote Meshkee partner SMS gateway (production reaches Gama). */ - private async sendViaProxy(destination: string, message: string): Promise { + private async sendViaProxy( + destination: string, + message: string, + source?: string, + ): Promise { const url = this.config.get('SMS_PROXY_URL')!.trim(); const apiKey = this.config.get('SMS_PROXY_API_KEY')!.trim(); const domain = this.config.get('SMS_PROXY_DOMAIN')!.trim(); @@ -99,6 +205,7 @@ export class SmsService { domain, to: destination, message, + ...(source ? { source } : {}), }), signal: AbortSignal.timeout(20_000), }); @@ -144,10 +251,16 @@ export class SmsService { return { serverId }; } - private async sendQuick(destination: string, message: string): Promise { + private async sendQuick( + destination: string, + message: string, + sourceOverride?: string, + ): Promise { const username = this.config.get('SMS_GAMA_USERNAME')?.trim(); const password = this.config.get('SMS_GAMA_PASSWORD')?.trim(); - const source = this.config.get('SMS_GAMA_SOURCE_SERVICE')?.trim(); + const source = + sourceOverride?.trim() || + this.config.get('SMS_GAMA_SOURCE_SERVICE')?.trim(); const baseUrl = ( this.config.get('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1' ).replace(/\/$/, ''); @@ -209,9 +322,97 @@ export class SmsService { throw new ServiceUnavailableException('SMS provider returned an empty server id'); } - this.logger.log(`SMS sent to ${masked} via Gama (serverId=${serverId})`); + this.logger.log( + `SMS sent to ${masked} via Gama source=${source} (serverId=${serverId})`, + ); return { serverId }; } + + /** Gama SendBulk — one message, many MSISDNs (`destinations` array). */ + private async sendBulk( + destinations: string[], + message: string, + sourceOverride?: string, + ): Promise { + const username = this.config.get('SMS_GAMA_USERNAME')?.trim(); + const password = this.config.get('SMS_GAMA_PASSWORD')?.trim(); + const source = + sourceOverride?.trim() || + this.config.get('SMS_GAMA_SOURCE_SERVICE')?.trim(); + const baseUrl = ( + this.config.get('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1' + ).replace(/\/$/, ''); + + if (!username || !password || !source) { + this.logger.error('SMS provider credentials are not configured'); + throw new ServiceUnavailableException('SMS provider is not configured yet'); + } + + const url = `${baseUrl}/send/bulk`; + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password, + message, + source, + destinations, + delay: 0, + expire: null, + }), + signal: AbortSignal.timeout(30_000), + }); + } catch (err) { + this.logger.error( + `Gama SendBulk network error for ${destinations.length} recipients: ${ + err instanceof Error ? err.message : err + }`, + ); + throw new ServiceUnavailableException('SMS provider is unreachable'); + } + + let payload: GamaBulkResponse; + try { + payload = (await response.json()) as GamaBulkResponse; + } catch { + this.logger.error( + `Gama SendBulk returned non-JSON for ${destinations.length} recipients (HTTP ${response.status})`, + ); + throw new ServiceUnavailableException('SMS provider returned an invalid response'); + } + + if (!response.ok || !payload.success) { + const providerMessage = + typeof payload.message === 'string' && payload.message.trim() + ? payload.message.trim() + : `HTTP ${response.status}`; + this.logger.warn( + `Gama SendBulk failed for ${destinations.length} recipients: ${providerMessage}`, + ); + throw mapGamaError(providerMessage); + } + + const body = Array.isArray(payload.body) ? payload.body : []; + const serverIds = body.map((id) => String(id)).filter(Boolean); + if (serverIds.length === 0) { + this.logger.warn( + `Gama SendBulk succeeded without serverIds for ${destinations.length} recipients`, + ); + throw new ServiceUnavailableException('SMS provider returned an empty server id'); + } + + this.logger.log( + `SMS bulk sent to ${destinations.length} via Gama source=${source} (ids=${serverIds.length})`, + ); + return serverIds; + } } /** Accept 09… / 9… / +989… / 989… → Gama MSISDN `989…` (no plus). */ @@ -273,5 +474,8 @@ function mapGamaError(message: string): Error { if (lower.includes('message is empty')) { return new BadRequestException('Message is empty'); } + if (lower.includes('maximum amount') || lower.includes('batch')) { + return new BadRequestException('Too many SMS destinations in one batch'); + } return new ServiceUnavailableException('SMS provider rejected the request'); } diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 002427f..ded3bd5 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -26,10 +26,13 @@ import { LegacyPurgeService } from './legacy-purge.service'; import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors'; import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors'; import { + mergeBusinessSettings, + normalizeBusinessSettings, normalizeDashboardLocale, normalizeDashboardThemeMode, normalizeEnabledBusinessModules, normalizeHomeCharts, + normalizeSmsSenderNumber, toPrismaJson, } from '../business-settings/business-settings.util'; import type { @@ -77,6 +80,7 @@ type BusinessRow = { themeMode: string | null; enabledModules: unknown; homeCharts: unknown; + smsSenderNumber: string | null; }; function slugify(value: string) { @@ -161,7 +165,8 @@ export class BusinessAdminService { b.settings->'branding'->>'defaultLocale' AS "defaultLocale", b.settings->'branding'->>'themeMode' AS "themeMode", b.settings->'modules'->'enabled' AS "enabledModules", - b.settings->'modules'->'charts' AS "homeCharts" + b.settings->'modules'->'charts' AS "homeCharts", + b.settings->'sms'->>'senderNumber' AS "smsSenderNumber" FROM businesses b LEFT JOIN LATERAL ( SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug @@ -222,6 +227,7 @@ export class BusinessAdminService { enabledModules, moduleCount: enabledModules.length, homeCharts, + smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber), }; }), total: totalRow[0]?.total ?? 0, @@ -448,7 +454,33 @@ export class BusinessAdminService { await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId), businessId); } + if (dto.smsSenderNumber !== undefined) { + if ( + dto.smsSenderNumber !== null && + dto.smsSenderNumber !== '' && + !normalizeSmsSenderNumber(dto.smsSenderNumber) + ) { + throw new BadRequestException( + 'smsSenderNumber must be an 8–16 digit shortcode', + ); + } + } + await this.prisma.$transaction(async (tx) => { + const nextSettings = + dto.smsSenderNumber !== undefined + ? toPrismaJson( + mergeBusinessSettings(normalizeBusinessSettings(business.settings), { + sms: { + senderNumber: + dto.smsSenderNumber === null || dto.smsSenderNumber === '' + ? null + : normalizeSmsSenderNumber(dto.smsSenderNumber), + }, + }), + ) + : undefined; + await tx.business.update({ where: { id: businessId }, data: { @@ -470,6 +502,7 @@ export class BusinessAdminService { : new Date(dto.annualRenewalAt), } : {}), + ...(nextSettings !== undefined ? { settings: nextSettings } : {}), }, }); @@ -1238,6 +1271,7 @@ export class BusinessAdminService { annualRenewalAt: Date | null; createdAt: Date; updatedAt: Date; + settings: unknown; categoryAssignments: { category: { id: bigint; @@ -1267,6 +1301,7 @@ export class BusinessAdminService { }, ) { const owner = business.businessUsers[0]?.user ?? null; + const settings = normalizeBusinessSettings(business.settings); return { id: business.id, @@ -1280,6 +1315,7 @@ export class BusinessAdminService { annualRenewalAt: business.annualRenewalAt, createdAt: business.createdAt, updatedAt: business.updatedAt, + smsSenderNumber: settings.sms.senderNumber, categories: business.categoryAssignments.map((a) => ({ id: a.category.id, name: a.category.name, diff --git a/src/business-admin/dto/update-business.dto.ts b/src/business-admin/dto/update-business.dto.ts index a736818..44ee4ad 100644 --- a/src/business-admin/dto/update-business.dto.ts +++ b/src/business-admin/dto/update-business.dto.ts @@ -59,4 +59,16 @@ export class UpdateBusinessDto { @ValidateIf((_, value) => value !== null) @IsDateString() annualRenewalAt?: string | null; + + /** + * Business SMS shortcode for customer messages (e.g. 5000123456). + * Empty string or null clears it (falls back to Meshkee advertising number). + */ + @IsOptional() + @ValidateIf((_, value) => value !== null && value !== '') + @IsString() + @Matches(/^\d{8,16}$/, { + message: 'smsSenderNumber must be an 8–16 digit shortcode', + }) + smsSenderNumber?: string | null; } diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index ab9d55d..697a2aa 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -195,6 +195,12 @@ export type ModulesSettings = { charts: [HomeChartId, HomeChartId]; }; +/** Per-business SMS sender (Gama shortcode). Empty → Meshkee advertising number. */ +export type SmsSettings = { + /** Outbound shortcode for customer SMS, e.g. "5000123456". */ + senderNumber: string | null; +}; + /** Top-level business settings stored in `businesses.settings` JSONB. */ export type BusinessSettings = { branding: BrandingSettings; @@ -202,6 +208,7 @@ export type BusinessSettings = { store: StoreSettings; modules: ModulesSettings; website: WebsiteSettings; + sms: SmsSettings; }; export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [ @@ -316,4 +323,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = { website: { specialProductsSource: 'store_item', }, + sms: { + senderNumber: null, + }, }; diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index 19cbd79..904cffb 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -121,6 +121,16 @@ function readString(value: unknown, fallback = '') { return typeof value === 'string' ? value.trim() : fallback; } +/** Gama / Iranian service shortcodes are numeric (e.g. 5000123456). */ +export function normalizeSmsSenderNumber(value: unknown): string | null { + if (value == null) return null; + if (typeof value !== 'string' && typeof value !== 'number') return null; + const digits = String(value).replace(/\D/g, ''); + if (!digits) return null; + if (digits.length < 8 || digits.length > 16) return null; + return digits; +} + function isPaymentGatewayId(value: unknown): value is PaymentGatewayId { return ( typeof value === 'string' && @@ -380,6 +390,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings { const store = isRecord(source.store) ? source.store : {}; const modules = isRecord(source.modules) ? source.modules : {}; const website = isRecord(source.website) ? source.website : {}; + const sms = isRecord(source.sms) ? source.sms : {}; const hasModulesKey = Object.prototype.hasOwnProperty.call(source, 'modules'); const enabledModules = hasModulesKey ? Array.isArray(modules.enabled) @@ -431,6 +442,9 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings { ), sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig), }, + sms: { + senderNumber: normalizeSmsSenderNumber(sms.senderNumber), + }, }; } @@ -482,6 +496,12 @@ export function mergeBusinessSettings( ? patch.website.sitemapConfig : current.website.sitemapConfig, }, + sms: { + senderNumber: + patch.sms?.senderNumber !== undefined + ? patch.sms.senderNumber + : current.sms.senderNumber, + }, }; } diff --git a/src/customers/customers-excel.ts b/src/customers/customers-excel.ts new file mode 100644 index 0000000..8b67c34 --- /dev/null +++ b/src/customers/customers-excel.ts @@ -0,0 +1,340 @@ +import { BadRequestException } from '@nestjs/common'; +import ExcelJS from 'exceljs'; + +export const CUSTOMERS_SHEET_NAME = 'Customers'; + +export const CUSTOMER_EXCEL_HEADERS = { + phone: 'phone', + firstName: 'first_name', + lastName: 'last_name', +} as const; + +type HeaderKey = keyof typeof CUSTOMER_EXCEL_HEADERS; + +const HEADER_ALIASES: Record = { + phone: 'phone', + cell: 'phone', + cell_number: 'phone', + cellnumber: 'phone', + mobile: 'phone', + 'mobile number': 'phone', + 'phone number': 'phone', + شماره: 'phone', + 'شماره موبایل': 'phone', + موبایل: 'phone', + first_name: 'firstName', + firstname: 'firstName', + 'first name': 'firstName', + name: 'firstName', + نام: 'firstName', + last_name: 'lastName', + lastname: 'lastName', + 'last name': 'lastName', + 'نام خانوادگی': 'lastName', +}; + +const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'; +const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩'; +const E164_RE = /^\+[1-9]\d{6,14}$/; + +export interface CustomerExcelRow { + phone: string; + firstName: string; + lastName: string; +} + +export interface ParsedCustomerExcelRow { + rowNumber: number; + phone: string; + firstName: string | null; + lastName: string | null; +} + +export async function buildCustomersExcel(rows: CustomerExcelRow[]): Promise { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Meshkee'; + workbook.created = new Date(); + + const sheet = workbook.addWorksheet(CUSTOMERS_SHEET_NAME); + sheet.columns = [ + { header: CUSTOMER_EXCEL_HEADERS.phone, key: 'phone', width: 16 }, + { header: CUSTOMER_EXCEL_HEADERS.firstName, key: 'firstName', width: 18 }, + { header: CUSTOMER_EXCEL_HEADERS.lastName, key: 'lastName', width: 20 }, + { width: 3 }, + { header: 'دستورالعمل', width: 48 }, + { header: 'Instructions', width: 48 }, + ]; + + const header = sheet.getRow(1); + header.height = 22; + header.font = { bold: true, size: 11 }; + header.alignment = { vertical: 'middle' }; + for (let col = 1; col <= 3; col += 1) { + const cell = header.getCell(col); + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE8EEF7' }, + }; + cell.font = { bold: true, size: 11 }; + cell.alignment = { vertical: 'middle' }; + } + for (let col = 5; col <= 6; col += 1) { + const cell = header.getCell(col); + cell.font = { bold: true, size: 11 }; + cell.alignment = { vertical: 'middle' }; + } + + for (const row of rows) { + const added = sheet.addRow({ + phone: formatPhoneForExcel(row.phone), + firstName: row.firstName, + lastName: row.lastName, + }); + added.height = 20; + added.font = { size: 11 }; + + const phoneCell = added.getCell('phone'); + phoneCell.numFmt = '@'; + phoneCell.alignment = { vertical: 'middle', readingOrder: 'ltr', horizontal: 'left' }; + + const firstNameCell = added.getCell('firstName'); + firstNameCell.alignment = { + vertical: 'middle', + readingOrder: looksPersian(row.firstName) ? 'rtl' : 'ltr', + }; + + const lastNameCell = added.getCell('lastName'); + lastNameCell.alignment = { + vertical: 'middle', + readingOrder: looksPersian(row.lastName) ? 'rtl' : 'ltr', + }; + } + + writeInstructionColumns(sheet); + sheet.views = [{ state: 'frozen', ySplit: 1, rightToLeft: false }]; + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +} + +export async function parseCustomersExcel(buffer: Buffer): Promise { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer as unknown as Parameters[0]); + + const sheet = + workbook.getWorksheet(CUSTOMERS_SHEET_NAME) ?? workbook.worksheets[0]; + + if (!sheet) { + throw new BadRequestException('Excel file has no worksheet.'); + } + + const headerMap = readHeaderMap(sheet); + const phoneCol = headerMap.phone; + if (!phoneCol) { + throw new BadRequestException( + 'Excel is missing required column: phone (or cell / mobile / شماره).', + ); + } + + const firstNameCol = headerMap.firstName; + const lastNameCol = headerMap.lastName; + const rows: ParsedCustomerExcelRow[] = []; + const seenPhones = new Set(); + + sheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; + + const rawPhone = cellText(row.getCell(phoneCol).value); + const firstName = firstNameCol + ? optionalName(cellText(row.getCell(firstNameCol).value)) + : null; + const lastName = lastNameCol + ? optionalName(cellText(row.getCell(lastNameCol).value)) + : null; + + if (!rawPhone && !firstName && !lastName) return; + + if (!rawPhone) { + throw new BadRequestException(`Row ${rowNumber}: phone is required.`); + } + + const phone = normalizeCellToE164(rawPhone); + if (!phone) { + throw new BadRequestException( + `Row ${rowNumber}: phone "${rawPhone}" is not a valid mobile number.`, + ); + } + + if (seenPhones.has(phone)) { + throw new BadRequestException( + `Row ${rowNumber}: duplicate phone "${phone}".`, + ); + } + seenPhones.add(phone); + + rows.push({ rowNumber, phone, firstName, lastName }); + }); + + if (rows.length === 0) { + throw new BadRequestException('Excel has no customer rows to import.'); + } + + if (rows.length > 10_000) { + throw new BadRequestException('Excel has too many rows (max 10,000).'); + } + + return rows; +} + +export function customersExcelFilename(now = new Date()) { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Tehran', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(now); + + const pick = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? ''; + + const date = `${pick('year')}-${pick('month')}-${pick('day')}`; + const time = `${pick('hour')}-${pick('minute')}`; + return `customers - ${date} - ${time}.xlsx`; +} + +/** Normalize Iranian/local input to E.164 (e.g. +989121111111). */ +export function normalizeCellToE164(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + if (E164_RE.test(trimmed)) return trimmed; + + const digits = toLatinDigits(trimmed).replace(/\D/g, ''); + if (!digits) return null; + + if (digits.startsWith('98') && digits.length >= 12) { + const value = `+${digits}`; + return E164_RE.test(value) ? value : null; + } + + if (digits.startsWith('0') && digits.length >= 11) { + const value = `+98${digits.slice(1)}`; + return E164_RE.test(value) ? value : null; + } + + if (digits.length === 10 && digits.startsWith('9')) { + const value = `+98${digits}`; + return E164_RE.test(value) ? value : null; + } + + if (/^[1-9]\d{6,14}$/.test(digits)) { + const value = `+${digits}`; + return E164_RE.test(value) ? value : null; + } + + return null; +} + +function writeInstructionColumns(sheet: ExcelJS.Worksheet) { + const faLines = [ + 'فقط ستون phone الزامی است.', + 'نام و نام خانوادگی اختیاری‌اند (برای شخصی‌سازی پیامک).', + 'رمز عبور لازم نیست — حساب‌های جدید با رمز تصادفی ساخته می‌شوند.', + 'فرمت شماره: 0912… یا +98912…', + 'شماره‌های تکراری در فایل یا مشتری‌های موجود نادیده گرفته می‌شوند.', + 'این فایل برای افزودن مشتری جهت ارسال پیامک است.', + ]; + const enLines = [ + 'Only the phone column is required.', + 'first_name / last_name are optional (for SMS personalization).', + 'No password column — new accounts get a random unusable password.', + 'Phone format: 0912… or +98912…', + 'Duplicates in the file or already-linked customers are skipped.', + 'Use this file to add customers for SMS sending.', + ]; + + faLines.forEach((line, index) => { + const row = sheet.getRow(index + 2); + if (!row.height || row.height < 20) { + row.height = 20; + } + + const faCell = row.getCell(5); + faCell.value = line; + faCell.font = { size: 11 }; + faCell.alignment = { wrapText: true, vertical: 'top', readingOrder: 'rtl' }; + + const enCell = row.getCell(6); + enCell.value = enLines[index] ?? ''; + enCell.font = { size: 11 }; + enCell.alignment = { wrapText: true, vertical: 'top', readingOrder: 'ltr' }; + }); +} + +/** Prefer local 09… display for Iranian numbers in the spreadsheet. */ +function formatPhoneForExcel(phone: string) { + if (phone.startsWith('+98') && phone.length === 13) { + return `0${phone.slice(3)}`; + } + return phone; +} + +function looksPersian(value: string) { + return /[\u0600-\u06FF]/.test(value); +} + +function readHeaderMap(sheet: ExcelJS.Worksheet): Partial> { + const headerRow = sheet.getRow(1); + const map: Partial> = {}; + + headerRow.eachCell((cell, colNumber) => { + const key = HEADER_ALIASES[normalizeHeader(cellText(cell.value))]; + if (key && !map[key]) { + map[key] = colNumber; + } + }); + + return map; +} + +function normalizeHeader(value: string) { + return value.trim().toLowerCase().replace(/\s+/g, ' '); +} + +function optionalName(value: string): string | null { + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, 100) : null; +} + +function cellText(value: ExcelJS.CellValue): string { + if (value == null || value === '') return ''; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value).trim(); + } + if (value instanceof Date) return ''; + if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') { + return value.text.trim(); + } + if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) { + return value.richText.map((part) => part.text).join('').trim(); + } + if (typeof value === 'object' && 'result' in value) { + return cellText(value.result as ExcelJS.CellValue); + } + return String(value).trim(); +} + +function toLatinDigits(value: string) { + return [...value] + .map((char) => { + const persian = PERSIAN_DIGITS.indexOf(char); + if (persian >= 0) return String(persian); + const arabic = ARABIC_DIGITS.indexOf(char); + if (arabic >= 0) return String(arabic); + return char; + }) + .join(''); +} diff --git a/src/customers/customers.controller.ts b/src/customers/customers.controller.ts index e84db56..4436a21 100644 --- a/src/customers/customers.controller.ts +++ b/src/customers/customers.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -7,8 +8,13 @@ import { Patch, Post, Query, + StreamableFile, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; import { AuthUser } from '../auth/auth.types'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; @@ -16,9 +22,12 @@ import { BusinessPermissionGuard } from '../auth/guards/business-permission.guar import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { DailyActivityQueryDto } from '../common/dto/daily-activity-query.dto'; import { CustomersService } from './customers.service'; +import { customersExcelFilename } 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'; @Controller('businesses/:businessId/customers') @@ -56,6 +65,40 @@ export class CustomersController { return this.service.search(businessId, query, user); } + @Get('export') + @RequireBusinessPermission('orders.read') + async exportExcel( + @Param('businessId') businessId: string, + @CurrentUser() user: AuthUser, + ) { + const buffer = await this.service.exportExcel(businessId, user); + const filename = customersExcelFilename(); + return new StreamableFile(buffer, { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + disposition: `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`, + length: buffer.length, + }); + } + + @Post('import') + @RequireBusinessPermission('orders.create') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 8 * 1024 * 1024 }, + }), + ) + importExcel( + @Param('businessId') businessId: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUser, + ) { + if (!file) { + throw new BadRequestException('Excel file is required.'); + } + return this.service.importExcel(businessId, file, user); + } + @Post() @RequireBusinessPermission('orders.create') create( @@ -66,6 +109,28 @@ export class CustomersController { return this.service.create(businessId, body, user); } + /** Bulk / quantity SMS via Gama SendBulk. Empty userIds → all enabled customers. */ + @Post('sms') + @RequireBusinessPermission('orders.update') + sendBulkSms( + @Param('businessId') businessId: string, + @Body() body: SendCustomersBulkSmsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.sendBulkSms(businessId, body, user); + } + + @Post(':userId/sms') + @RequireBusinessPermission('orders.update') + sendSms( + @Param('businessId') businessId: string, + @Param('userId') userId: string, + @Body() body: SendCustomerSmsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.sendSms(businessId, userId, body, user); + } + @Patch(':userId') @RequireBusinessPermission('orders.update') update( diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index 487d702..1d4d724 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -1,16 +1,25 @@ -import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, ForbiddenException, 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 { 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'; type CustomerListRow = { @@ -33,6 +42,7 @@ export class CustomersService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, + private readonly sms: SmsService, ) {} async list( @@ -231,6 +241,210 @@ export class CustomersService { }; } + 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, @@ -463,6 +677,156 @@ export class CustomersService { 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: 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 settings = normalizeBusinessSettings(membership.business.settings); + const source = + settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource(); + + try { + const result = await this.sms.sendMessage( + membership.user.cellNumber, + dto.message.trim(), + { source }, + ); + return { + enabled: true, + message: 'Message sent successfully', + serverId: result.serverId, + source, + }; + } catch (err) { + if ( + err instanceof BadRequestException || + err instanceof ServiceUnavailableException + ) { + throw err; + } + throw new ServiceUnavailableException('Unable to send SMS'); + } + } + + /** + * Bulk SMS (Gama SendBulk). Empty/omitted userIds → all enabled business customers. + * Selected userIds may include team members shown on the customers list. + */ + 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); + + let cellNumbers: string[]; + + 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 }[]>(Prisma.sql` + SELECT DISTINCT u.cell_number AS "cellNumber" + 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 u.id IN (${Prisma.join(userIds)}) + AND (bc.id IS NOT NULL OR bu.id IS NOT NULL) + `); + cellNumbers = rows.map((r) => r.cellNumber); + } else { + const rows = await this.prisma.businessCustomer.findMany({ + where: { businessId, isEnabled: true }, + select: { user: { select: { cellNumber: true } } }, + }); + cellNumbers = rows.map((r) => r.user.cellNumber); + } + + if (cellNumbers.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: cellNumbers.length, + }; + } + + const settings = normalizeBusinessSettings(business.settings); + const source = + settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource(); + + try { + const result = await this.sms.sendBulkMessage(cellNumbers, dto.message.trim(), { + source, + }); + return { + enabled: true, + message: 'Message sent successfully', + recipientCount: result.recipientCount, + serverIds: result.serverIds, + source, + }; + } catch (err) { + if ( + err instanceof BadRequestException || + err instanceof ServiceUnavailableException + ) { + throw err; + } + throw new ServiceUnavailableException('Unable to send SMS'); + } + } + private formatLabel(user: { firstName: string | null; lastName: string | null; diff --git a/src/customers/dto/send-customer-sms.dto.ts b/src/customers/dto/send-customer-sms.dto.ts new file mode 100644 index 0000000..2f9f417 --- /dev/null +++ b/src/customers/dto/send-customer-sms.dto.ts @@ -0,0 +1,8 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class SendCustomerSmsDto { + @IsString() + @MinLength(1) + @MaxLength(700) + message!: string; +} diff --git a/src/customers/dto/send-customers-bulk-sms.dto.ts b/src/customers/dto/send-customers-bulk-sms.dto.ts new file mode 100644 index 0000000..eafd63b --- /dev/null +++ b/src/customers/dto/send-customers-bulk-sms.dto.ts @@ -0,0 +1,27 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +export class SendCustomersBulkSmsDto { + @IsString() + @MinLength(1) + @MaxLength(700) + message!: string; + + /** + * When omitted or empty → send to all enabled business customers. + * When provided → only those user ids that belong to this business. + */ + @IsOptional() + @IsArray() + @ArrayMaxSize(5000) + @IsString({ each: true }) + @Type(() => String) + userIds?: string[]; +} diff --git a/src/public-sms/dto/send-public-sms.dto.ts b/src/public-sms/dto/send-public-sms.dto.ts index ba978fc..91646ab 100644 --- a/src/public-sms/dto/send-public-sms.dto.ts +++ b/src/public-sms/dto/send-public-sms.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator'; export class SendPublicSmsDto { @IsString() @@ -16,4 +16,12 @@ export class SendPublicSmsDto { @MinLength(1) @MaxLength(700) message!: string; + + /** Optional Gama shortcode override (digits only). */ + @IsOptional() + @IsString() + @Matches(/^\d{8,16}$/, { + message: 'source must be an 8–16 digit shortcode', + }) + source?: string; } diff --git a/src/public-sms/public-sms.service.ts b/src/public-sms/public-sms.service.ts index 1645bb4..3d291ab 100644 --- a/src/public-sms/public-sms.service.ts +++ b/src/public-sms/public-sms.service.ts @@ -34,7 +34,9 @@ export class PublicSmsService { await this.assertRateLimits(partnerDomain, msisdn); - const result = await this.sms.sendMessage(msisdn, dto.message); + const result = await this.sms.sendMessage(msisdn, dto.message, { + source: dto.source?.trim() || undefined, + }); this.logger.log( `Partner SMS accepted domain=${partnerDomain} serverId=${result.serverId}`, );