Add SMS templates, prepaid IRT billing, and advertising shortcode 9000590009.
Charge ceil(len/64)*400 IRT per recipient across OTP, customer, and partner SMS; seed new businesses with 500k IRT; default partner sends to the advertising line. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4623061a4f
commit
4a1d60dbef
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { CustomersController } from './customers.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
import { SmsTemplatesModule } from '../sms-templates/sms-templates.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, SmsTemplatesModule],
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { SmsBillingService } from '../auth/sms-billing.service';
|
||||
import { SmsService } from '../auth/sms.service';
|
||||
import {
|
||||
buildDualDailyActivitySeries,
|
||||
@@ -21,6 +22,11 @@ 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';
|
||||
import { SmsTemplatesService } from '../sms-templates/sms-templates.service';
|
||||
import {
|
||||
renderSmsTemplate,
|
||||
smsTemplateUsesVariables,
|
||||
} from '../sms-templates/sms-template-render';
|
||||
|
||||
type CustomerListRow = {
|
||||
id: bigint;
|
||||
@@ -43,6 +49,8 @@ export class CustomersService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly smsBilling: SmsBillingService,
|
||||
private readonly smsTemplates: SmsTemplatesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -692,7 +700,7 @@ export class CustomersService {
|
||||
businessId_userId: { businessId, userId },
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
user: { include: { title: true } },
|
||||
business: { select: { settings: true, isActive: true } },
|
||||
},
|
||||
});
|
||||
@@ -708,23 +716,36 @@ export class CustomersService {
|
||||
};
|
||||
}
|
||||
|
||||
const settings = normalizeBusinessSettings(membership.business.settings);
|
||||
const source =
|
||||
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
|
||||
const resolved = await this.resolveOutboundSms(
|
||||
businessId,
|
||||
membership.business.settings,
|
||||
dto.message,
|
||||
dto.templateId,
|
||||
{
|
||||
firstName: membership.user.firstName,
|
||||
lastName: membership.user.lastName,
|
||||
titleName: membership.user.title?.nameFa || membership.user.title?.nameEn,
|
||||
},
|
||||
);
|
||||
|
||||
const charged = await this.smsBilling.charge(businessId, resolved.message);
|
||||
|
||||
try {
|
||||
const result = await this.sms.sendMessage(
|
||||
membership.user.cellNumber,
|
||||
dto.message.trim(),
|
||||
{ source },
|
||||
resolved.message,
|
||||
{ source: resolved.source },
|
||||
);
|
||||
return {
|
||||
enabled: true,
|
||||
message: 'Message sent successfully',
|
||||
serverId: result.serverId,
|
||||
source,
|
||||
source: resolved.source,
|
||||
templateId: resolved.templateId,
|
||||
templateVerified: resolved.templateVerified,
|
||||
};
|
||||
} catch (err) {
|
||||
await this.smsBilling.refund(businessId, charged);
|
||||
if (
|
||||
err instanceof BadRequestException ||
|
||||
err instanceof ServiceUnavailableException
|
||||
@@ -736,8 +757,8 @@ export class CustomersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk SMS (Gama SendBulk). Empty/omitted userIds → all enabled business customers.
|
||||
* Selected userIds may include team members shown on the customers list.
|
||||
* Bulk SMS. Empty/omitted userIds → all enabled business customers.
|
||||
* With a personalized template, messages are sent per-recipient (not one bulk payload).
|
||||
*/
|
||||
async sendBulkSms(
|
||||
businessIdRaw: string,
|
||||
@@ -759,7 +780,14 @@ export class CustomersService {
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let cellNumbers: string[];
|
||||
type Recipient = {
|
||||
cellNumber: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
titleName: string | null;
|
||||
};
|
||||
|
||||
let recipients: Recipient[];
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
const userIds = selectedIds.map((id) => {
|
||||
@@ -770,9 +798,21 @@ export class CustomersService {
|
||||
}
|
||||
});
|
||||
|
||||
const rows = await this.prisma.$queryRaw<{ cellNumber: string }[]>(Prisma.sql`
|
||||
SELECT DISTINCT u.cell_number AS "cellNumber"
|
||||
const rows = await this.prisma.$queryRaw<
|
||||
{
|
||||
cellNumber: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
titleName: string | null;
|
||||
}[]
|
||||
>(Prisma.sql`
|
||||
SELECT DISTINCT ON (u.id)
|
||||
u.cell_number AS "cellNumber",
|
||||
u.first_name AS "firstName",
|
||||
u.last_name AS "lastName",
|
||||
COALESCE(ut.name_fa, ut.name_en) AS "titleName"
|
||||
FROM users u
|
||||
LEFT JOIN user_titles ut ON ut.id = u.title_id
|
||||
LEFT JOIN business_customers bc
|
||||
ON bc.user_id = u.id AND bc.business_id = ${businessId}
|
||||
LEFT JOIN business_users bu
|
||||
@@ -780,16 +820,30 @@ export class CustomersService {
|
||||
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);
|
||||
recipients = rows;
|
||||
} else {
|
||||
const rows = await this.prisma.businessCustomer.findMany({
|
||||
where: { businessId, isEnabled: true },
|
||||
select: { user: { select: { cellNumber: true } } },
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
cellNumber: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
title: { select: { nameFa: true, nameEn: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cellNumbers = rows.map((r) => r.user.cellNumber);
|
||||
recipients = rows.map((r) => ({
|
||||
cellNumber: r.user.cellNumber,
|
||||
firstName: r.user.firstName,
|
||||
lastName: r.user.lastName,
|
||||
titleName: r.user.title?.nameFa || r.user.title?.nameEn || null,
|
||||
}));
|
||||
}
|
||||
|
||||
if (cellNumbers.length === 0) {
|
||||
if (recipients.length === 0) {
|
||||
throw new BadRequestException('No recipients found to send SMS');
|
||||
}
|
||||
|
||||
@@ -797,26 +851,117 @@ export class CustomersService {
|
||||
return {
|
||||
enabled: false,
|
||||
message: 'SMS is disabled. Message was not sent.',
|
||||
recipientCount: cellNumbers.length,
|
||||
recipientCount: recipients.length,
|
||||
};
|
||||
}
|
||||
|
||||
const settings = normalizeBusinessSettings(business.settings);
|
||||
const source =
|
||||
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
|
||||
const sample = await this.resolveOutboundSms(
|
||||
businessId,
|
||||
business.settings,
|
||||
dto.message,
|
||||
dto.templateId,
|
||||
{
|
||||
firstName: recipients[0].firstName,
|
||||
lastName: recipients[0].lastName,
|
||||
titleName: recipients[0].titleName,
|
||||
},
|
||||
);
|
||||
|
||||
const personalized =
|
||||
Boolean(dto.templateId) || smsTemplateUsesVariables(dto.message);
|
||||
|
||||
let charged = 0;
|
||||
let personalizedPayloads: Array<{
|
||||
cellNumber: string;
|
||||
message: string;
|
||||
source: string;
|
||||
cost: number;
|
||||
}> = [];
|
||||
|
||||
if (!personalized) {
|
||||
charged = await this.smsBilling.charge(
|
||||
businessId,
|
||||
sample.message,
|
||||
recipients.length,
|
||||
);
|
||||
} else {
|
||||
personalizedPayloads = [];
|
||||
let totalCost = 0;
|
||||
for (const recipient of recipients) {
|
||||
const resolved = await this.resolveOutboundSms(
|
||||
businessId,
|
||||
business.settings,
|
||||
dto.message,
|
||||
dto.templateId,
|
||||
{
|
||||
firstName: recipient.firstName,
|
||||
lastName: recipient.lastName,
|
||||
titleName: recipient.titleName,
|
||||
},
|
||||
);
|
||||
const cost = this.smsBilling.costForMessage(resolved.message);
|
||||
personalizedPayloads.push({
|
||||
cellNumber: recipient.cellNumber,
|
||||
message: resolved.message,
|
||||
source: resolved.source,
|
||||
cost,
|
||||
});
|
||||
totalCost += cost;
|
||||
}
|
||||
charged = await this.smsBilling.chargeAmount(businessId, totalCost);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.sms.sendBulkMessage(cellNumbers, dto.message.trim(), {
|
||||
source,
|
||||
});
|
||||
if (!personalized) {
|
||||
const result = await this.sms.sendBulkMessage(
|
||||
recipients.map((r) => r.cellNumber),
|
||||
sample.message,
|
||||
{ source: sample.source },
|
||||
);
|
||||
return {
|
||||
enabled: true,
|
||||
message: 'Message sent successfully',
|
||||
recipientCount: result.recipientCount,
|
||||
serverIds: result.serverIds,
|
||||
source: sample.source,
|
||||
templateId: sample.templateId,
|
||||
templateVerified: sample.templateVerified,
|
||||
};
|
||||
}
|
||||
|
||||
const serverIds: string[] = [];
|
||||
for (let i = 0; i < personalizedPayloads.length; i++) {
|
||||
const payload = personalizedPayloads[i];
|
||||
try {
|
||||
const result = await this.sms.sendMessage(
|
||||
payload.cellNumber,
|
||||
payload.message,
|
||||
{ source: payload.source },
|
||||
);
|
||||
serverIds.push(result.serverId);
|
||||
} catch (err) {
|
||||
const refundAmount = personalizedPayloads
|
||||
.slice(i)
|
||||
.reduce((sum, item) => sum + item.cost, 0);
|
||||
await this.smsBilling.refund(businessId, refundAmount);
|
||||
charged = 0;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
message: 'Message sent successfully',
|
||||
recipientCount: result.recipientCount,
|
||||
serverIds: result.serverIds,
|
||||
source,
|
||||
recipientCount: serverIds.length,
|
||||
serverIds,
|
||||
source: sample.source,
|
||||
templateId: sample.templateId,
|
||||
templateVerified: sample.templateVerified,
|
||||
};
|
||||
} catch (err) {
|
||||
if (charged > 0) {
|
||||
await this.smsBilling.refund(businessId, charged);
|
||||
}
|
||||
if (
|
||||
err instanceof BadRequestException ||
|
||||
err instanceof ServiceUnavailableException
|
||||
@@ -827,6 +972,60 @@ export class CustomersService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve message text + Gama shortcode for customer SMS.
|
||||
* Verified template → service line (fixed). Unverified template → advertising line.
|
||||
* Free text → business senderNumber or advertising (unchanged).
|
||||
*/
|
||||
private async resolveOutboundSms(
|
||||
businessId: bigint,
|
||||
businessSettings: unknown,
|
||||
messageRaw: string,
|
||||
templateIdRaw: string | undefined,
|
||||
vars: {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
titleName?: string | null;
|
||||
},
|
||||
) {
|
||||
const settings = normalizeBusinessSettings(businessSettings);
|
||||
let message = messageRaw.trim();
|
||||
let templateId: string | null = null;
|
||||
let templateVerified: boolean | null = null;
|
||||
let source =
|
||||
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
|
||||
|
||||
if (templateIdRaw?.trim()) {
|
||||
const template = await this.smsTemplates.getTemplateForSend(
|
||||
businessId,
|
||||
BigInt(templateIdRaw.trim()),
|
||||
);
|
||||
templateId = template.id.toString();
|
||||
templateVerified = template.isVerified;
|
||||
// Verified templates always use the approved body (sender is also fixed).
|
||||
const draft = template.isVerified
|
||||
? template.body
|
||||
: message || template.body;
|
||||
message = renderSmsTemplate(draft, vars);
|
||||
source = template.isVerified
|
||||
? this.sms.getServiceSource()
|
||||
: this.sms.getAdvertisingSource();
|
||||
} else if (smsTemplateUsesVariables(message)) {
|
||||
message = renderSmsTemplate(message, vars);
|
||||
}
|
||||
|
||||
if (!message.trim()) {
|
||||
throw new BadRequestException('Message is empty');
|
||||
}
|
||||
|
||||
return {
|
||||
message: message.trim(),
|
||||
source,
|
||||
templateId,
|
||||
templateVerified,
|
||||
};
|
||||
}
|
||||
|
||||
private formatLabel(user: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class SendCustomerSmsDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(700)
|
||||
message!: string;
|
||||
|
||||
/** Optional SMS template id — body may still be sent as the rendered text. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
templateId?: string;
|
||||
}
|
||||
|
||||
@@ -24,4 +24,8 @@ export class SendCustomersBulkSmsDto {
|
||||
@IsString({ each: true })
|
||||
@Type(() => String)
|
||||
userIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
templateId?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user