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>
341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
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('');
|
|
}
|