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