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
+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;