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:
co-authored by
Cursor
parent
cf459807d8
commit
b1dcecc468
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user