fix(sms): send form confirmations to the lead number

Lead-only website forms had no person mobile, so the confirmation was marked sent without a number. Use the lead phone and send the full text.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-31 16:41:41 +03:30
co-authored by Cursor
parent 8d01bce0c2
commit 590f8114cc
5 changed files with 91 additions and 19 deletions
@@ -578,7 +578,7 @@ export class AutomationsService {
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
const wantApp = channel === 'app' || channel === 'both';
const wantSms = channel === 'sms' || channel === 'both';
const smsText = (config.body?.trim() || title).slice(0, 160);
const smsText = (config.body?.trim() || title).trim();
const link = entityLink(ctx);
if (config.notifyTarget === 'person') {
@@ -592,9 +592,16 @@ export class AutomationsService {
select: { mobile: true, phone: true },
})
: null;
const leadId = ctx.leadId || (ctx.entityType === 'lead' ? ctx.entityId : null);
const lead = !person?.mobile && !person?.phone && leadId
? await this.prisma.lead.findFirst({
where: { id: leadId, organizationId: ctx.organizationId },
select: { mobile: true, phone: true },
})
: null;
await this.sendAutomationSms(
ctx.organizationId,
person?.mobile || person?.phone,
person?.mobile || person?.phone || lead?.mobile || lead?.phone || ctx.mobile,
smsText,
);
}
@@ -56,7 +56,7 @@ function testTemplate() {
}) === 'سلام علی 0912',
'name phone vars',
);
assert(clipSms('الف'.repeat(200)).length === 160, 'sms clip');
assert(clipSms('الف'.repeat(200)).length === 200, 'sms is not clipped');
}
testDelay();
@@ -17,7 +17,20 @@ export const MESSAGE_AUTOMATION_TRIGGERS = [
] as const;
export const MAX_MESSAGE_REPEAT = 10;
export const MAX_MESSAGE_SMS = 160;
/** شمارهٔ مشتری: اول شخص، و اگر نبود شمارهٔ خود سرنخ. */
export function customerPhone(
person: { mobile?: string | null; phone?: string | null } | null | undefined,
lead: { mobile?: string | null; phone?: string | null } | null | undefined,
): string {
return (
person?.mobile?.trim() ||
person?.phone?.trim() ||
lead?.mobile?.trim() ||
lead?.phone?.trim() ||
''
);
}
export function delayMs(value: number, unit: MessageDelayUnit): number {
const n = Math.max(0, Math.trunc(Number(value) || 0));
@@ -92,10 +105,9 @@ export function applyMessageTemplate(
});
}
/** متن کامل پیامک؛ بخش‌بندی و هزینه در درگاه حساب می‌شود. */
export function clipSms(text: string): string {
const trimmed = text.trim();
if (trimmed.length <= MAX_MESSAGE_SMS) return trimmed;
return `${trimmed.slice(0, MAX_MESSAGE_SMS - 1)}…`;
return text.trim();
}
/** When this event happens, cancel leftover messages of these earlier triggers. */
@@ -21,6 +21,7 @@ import {
buildSendAts,
cancelTriggersFor,
clipSms,
customerPhone,
} from './message-automation.util';
export type MessageAutomationContext = {
@@ -354,13 +355,15 @@ export class MessageAutomationsService {
},
})
: null;
const lead = await this.lookupLead(job);
const personName = person
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
: '';
const phone = customerPhone(person, lead);
const vars = {
title: job.entityTitle,
name: personName || job.entityTitle,
phone: person?.mobile || person?.phone || '',
name: personName || lead?.contactName?.trim() || lead?.title || job.entityTitle,
phone,
date: formatJalaliDayFa(new Date()),
company: person?.company?.name ?? '',
};
@@ -405,26 +408,59 @@ export class MessageAutomationsService {
}
if (targetPerson && wantSms) {
await this.sendSms(person?.mobile || person?.phone, smsText, {
if (!phone) {
throw new Error('شماره مشتری یا سرنخ برای پیامک پیدا نشد');
}
const sent = await this.sendSms(phone, smsText, {
organizationId: job.organizationId,
personId: job.personId,
leadId: lead?.id ?? null,
ruleId: job.ruleId || job.rule.id || '',
});
if (!sent) {
throw new Error('ارسال پیامک به مشتری یا سرنخ ناموفق بود');
}
}
}
/** سرنخ همان پرونده، یا سرنخی که به این شخص تبدیل شده. */
private async lookupLead(job: {
organizationId: string;
entityId: string;
personId: string | null;
}) {
return this.prisma.lead.findFirst({
where: {
organizationId: job.organizationId,
OR: [
{ id: job.entityId },
...(job.personId ? [{ convertedPersonId: job.personId }] : []),
],
},
select: {
id: true,
mobile: true,
phone: true,
contactName: true,
title: true,
},
orderBy: { createdAt: 'desc' },
});
}
private async sendSms(
mobile: string | null | undefined,
text: string,
log: {
organizationId: string;
personId?: string | null;
leadId?: string | null;
ruleId: string;
},
) {
if (!mobile) {
): Promise<boolean> {
if (!mobile?.trim()) {
this.logger.warn('Message automation SMS skipped: no mobile');
return;
return false;
}
const result = await this.sms.send({
organizationId: log.organizationId,
@@ -432,10 +468,11 @@ export class MessageAutomationsService {
text,
source: 'automation',
});
if (log.personId) {
if (log.personId || log.leadId) {
await this.outboundSms.record({
organizationId: log.organizationId,
personId: log.personId,
leadId: log.leadId,
mobile,
body: text,
source: 'automation',
@@ -448,7 +485,9 @@ export class MessageAutomationsService {
this.logger.warn(
`Message automation SMS failed: ${result.reason ?? 'unknown'}`,
);
return false;
}
return true;
}
private toData(
+19 -5
View File
@@ -2,12 +2,12 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
MAX_MESSAGE_REPEAT,
MAX_MESSAGE_SMS,
addDelay,
applyMessageTemplate,
buildSendAts,
cancelTriggersFor,
clipSms,
customerPhone,
delayMs,
} from '../src/message-automations/message-automation.util';
@@ -40,14 +40,28 @@ describe('اتوماسیون پیام', () => {
assert.equal(applyMessageTemplate('{ name }', { name: 'x' }), 'x');
});
it('کوتاه کردن پیامک', () => {
it('متن پیامک بریده نمی‌شود', () => {
const long = 'ا'.repeat(200);
const clipped = clipSms(long);
assert.equal(clipped.length, MAX_MESSAGE_SMS);
assert.ok(clipped.endsWith('…'));
assert.equal(clipSms(long), long);
assert.equal(clipSms(' کوتاه '), 'کوتاه');
});
it('شماره سرنخ جای شماره مشتری می‌نشیند', () => {
assert.equal(
customerPhone(null, { mobile: '09120000000' }),
'09120000000',
);
assert.equal(
customerPhone({ mobile: '', phone: null }, { mobile: '09121111111', phone: '021' }),
'09121111111',
);
assert.equal(
customerPhone({ mobile: '09123333333' }, { mobile: '09124444444' }),
'09123333333',
);
assert.equal(customerPhone(null, null), '');
});
it('لغو پیام‌های قبلی', () => {
assert.deepEqual(cancelTriggersFor('invoice_paid'), ['invoice_issued']);
assert.deepEqual(cancelTriggersFor('deal_won'), ['deal_created', 'deal_stage_changed']);