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
+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[];
}