Add customer Excel import/export and Gama bulk SMS sending.

Business SMS uses optional settings.sms.senderNumber (else advertising shortcode) with SendQuick for one recipient and SendBulk for quantity.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-08 14:12:48 +03:30
co-authored by Cursor
parent cf459807d8
commit b1dcecc468
13 changed files with 1109 additions and 12 deletions
+2 -1
View File
@@ -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=
+211 -7
View File
@@ -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<number | string> | null;
};
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
@@ -27,6 +50,14 @@ export class SmsService {
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
}
/** Advertising / customer-broadcast fallback shortcode. */
getAdvertisingSource(): string {
return (
this.config.get<string>('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<SmsSendResult> {
async sendMessage(
cellNumber: string,
message: string,
options?: SmsSendOptions,
): Promise<SmsSendResult> {
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<SmsBulkSendResult> {
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<string>();
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<SmsSendResult> {
private async sendViaProxy(
destination: string,
message: string,
source?: string,
): Promise<SmsSendResult> {
const url = this.config.get<string>('SMS_PROXY_URL')!.trim();
const apiKey = this.config.get<string>('SMS_PROXY_API_KEY')!.trim();
const domain = this.config.get<string>('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<SmsSendResult> {
private async sendQuick(
destination: string,
message: string,
sourceOverride?: string,
): Promise<SmsSendResult> {
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
const source = this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
const source =
sourceOverride?.trim() ||
this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
const baseUrl = (
this.config.get<string>('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<string[]> {
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
const source =
sourceOverride?.trim() ||
this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
const baseUrl = (
this.config.get<string>('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');
}
+37 -1
View File
@@ -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,
@@ -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;
}
@@ -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,
},
};
@@ -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,
},
};
}
+340
View File
@@ -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<string, HeaderKey> = {
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<Buffer> {
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<ParsedCustomerExcelRow[]> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as unknown as Parameters<ExcelJS.Xlsx['load']>[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<string>();
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<Record<HeaderKey, number>> {
const headerRow = sheet.getRow(1);
const map: Partial<Record<HeaderKey, number>> = {};
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('');
}
+65
View File
@@ -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(
+365 -1
View File
@@ -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;
@@ -0,0 +1,8 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class SendCustomerSmsDto {
@IsString()
@MinLength(1)
@MaxLength(700)
message!: string;
}
@@ -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[];
}
+9 -1
View File
@@ -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;
}
+3 -1
View File
@@ -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}`,
);