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:
@@ -578,7 +578,7 @@ export class AutomationsService {
|
|||||||
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
|
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
|
||||||
const wantApp = channel === 'app' || channel === 'both';
|
const wantApp = channel === 'app' || channel === 'both';
|
||||||
const wantSms = channel === 'sms' || 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);
|
const link = entityLink(ctx);
|
||||||
|
|
||||||
if (config.notifyTarget === 'person') {
|
if (config.notifyTarget === 'person') {
|
||||||
@@ -592,9 +592,16 @@ export class AutomationsService {
|
|||||||
select: { mobile: true, phone: true },
|
select: { mobile: true, phone: true },
|
||||||
})
|
})
|
||||||
: null;
|
: 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(
|
await this.sendAutomationSms(
|
||||||
ctx.organizationId,
|
ctx.organizationId,
|
||||||
person?.mobile || person?.phone,
|
person?.mobile || person?.phone || lead?.mobile || lead?.phone || ctx.mobile,
|
||||||
smsText,
|
smsText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function testTemplate() {
|
|||||||
}) === 'سلام علی 0912',
|
}) === 'سلام علی 0912',
|
||||||
'name phone vars',
|
'name phone vars',
|
||||||
);
|
);
|
||||||
assert(clipSms('الف'.repeat(200)).length === 160, 'sms clip');
|
assert(clipSms('الف'.repeat(200)).length === 200, 'sms is not clipped');
|
||||||
}
|
}
|
||||||
|
|
||||||
testDelay();
|
testDelay();
|
||||||
|
|||||||
@@ -17,7 +17,20 @@ export const MESSAGE_AUTOMATION_TRIGGERS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const MAX_MESSAGE_REPEAT = 10;
|
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 {
|
export function delayMs(value: number, unit: MessageDelayUnit): number {
|
||||||
const n = Math.max(0, Math.trunc(Number(value) || 0));
|
const n = Math.max(0, Math.trunc(Number(value) || 0));
|
||||||
@@ -92,10 +105,9 @@ export function applyMessageTemplate(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** متن کامل پیامک؛ بخشبندی و هزینه در درگاه حساب میشود. */
|
||||||
export function clipSms(text: string): string {
|
export function clipSms(text: string): string {
|
||||||
const trimmed = text.trim();
|
return text.trim();
|
||||||
if (trimmed.length <= MAX_MESSAGE_SMS) return trimmed;
|
|
||||||
return `${trimmed.slice(0, MAX_MESSAGE_SMS - 1)}…`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** When this event happens, cancel leftover messages of these earlier triggers. */
|
/** When this event happens, cancel leftover messages of these earlier triggers. */
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
buildSendAts,
|
buildSendAts,
|
||||||
cancelTriggersFor,
|
cancelTriggersFor,
|
||||||
clipSms,
|
clipSms,
|
||||||
|
customerPhone,
|
||||||
} from './message-automation.util';
|
} from './message-automation.util';
|
||||||
|
|
||||||
export type MessageAutomationContext = {
|
export type MessageAutomationContext = {
|
||||||
@@ -354,13 +355,15 @@ export class MessageAutomationsService {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
const lead = await this.lookupLead(job);
|
||||||
const personName = person
|
const personName = person
|
||||||
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
|
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
|
||||||
: '';
|
: '';
|
||||||
|
const phone = customerPhone(person, lead);
|
||||||
const vars = {
|
const vars = {
|
||||||
title: job.entityTitle,
|
title: job.entityTitle,
|
||||||
name: personName || job.entityTitle,
|
name: personName || lead?.contactName?.trim() || lead?.title || job.entityTitle,
|
||||||
phone: person?.mobile || person?.phone || '',
|
phone,
|
||||||
date: formatJalaliDayFa(new Date()),
|
date: formatJalaliDayFa(new Date()),
|
||||||
company: person?.company?.name ?? '',
|
company: person?.company?.name ?? '',
|
||||||
};
|
};
|
||||||
@@ -405,26 +408,59 @@ export class MessageAutomationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetPerson && wantSms) {
|
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,
|
organizationId: job.organizationId,
|
||||||
personId: job.personId,
|
personId: job.personId,
|
||||||
|
leadId: lead?.id ?? null,
|
||||||
ruleId: job.ruleId || job.rule.id || '',
|
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(
|
private async sendSms(
|
||||||
mobile: string | null | undefined,
|
mobile: string | null | undefined,
|
||||||
text: string,
|
text: string,
|
||||||
log: {
|
log: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
personId?: string | null;
|
personId?: string | null;
|
||||||
|
leadId?: string | null;
|
||||||
ruleId: string;
|
ruleId: string;
|
||||||
},
|
},
|
||||||
) {
|
): Promise<boolean> {
|
||||||
if (!mobile) {
|
if (!mobile?.trim()) {
|
||||||
this.logger.warn('Message automation SMS skipped: no mobile');
|
this.logger.warn('Message automation SMS skipped: no mobile');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const result = await this.sms.send({
|
const result = await this.sms.send({
|
||||||
organizationId: log.organizationId,
|
organizationId: log.organizationId,
|
||||||
@@ -432,10 +468,11 @@ export class MessageAutomationsService {
|
|||||||
text,
|
text,
|
||||||
source: 'automation',
|
source: 'automation',
|
||||||
});
|
});
|
||||||
if (log.personId) {
|
if (log.personId || log.leadId) {
|
||||||
await this.outboundSms.record({
|
await this.outboundSms.record({
|
||||||
organizationId: log.organizationId,
|
organizationId: log.organizationId,
|
||||||
personId: log.personId,
|
personId: log.personId,
|
||||||
|
leadId: log.leadId,
|
||||||
mobile,
|
mobile,
|
||||||
body: text,
|
body: text,
|
||||||
source: 'automation',
|
source: 'automation',
|
||||||
@@ -448,7 +485,9 @@ export class MessageAutomationsService {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Message automation SMS failed: ${result.reason ?? 'unknown'}`,
|
`Message automation SMS failed: ${result.reason ?? 'unknown'}`,
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toData(
|
private toData(
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { describe, it } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
MAX_MESSAGE_REPEAT,
|
MAX_MESSAGE_REPEAT,
|
||||||
MAX_MESSAGE_SMS,
|
|
||||||
addDelay,
|
addDelay,
|
||||||
applyMessageTemplate,
|
applyMessageTemplate,
|
||||||
buildSendAts,
|
buildSendAts,
|
||||||
cancelTriggersFor,
|
cancelTriggersFor,
|
||||||
clipSms,
|
clipSms,
|
||||||
|
customerPhone,
|
||||||
delayMs,
|
delayMs,
|
||||||
} from '../src/message-automations/message-automation.util';
|
} from '../src/message-automations/message-automation.util';
|
||||||
|
|
||||||
@@ -40,14 +40,28 @@ describe('اتوماسیون پیام', () => {
|
|||||||
assert.equal(applyMessageTemplate('{ name }', { name: 'x' }), 'x');
|
assert.equal(applyMessageTemplate('{ name }', { name: 'x' }), 'x');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('کوتاه کردن پیامک', () => {
|
it('متن پیامک بریده نمیشود', () => {
|
||||||
const long = 'ا'.repeat(200);
|
const long = 'ا'.repeat(200);
|
||||||
const clipped = clipSms(long);
|
assert.equal(clipSms(long), long);
|
||||||
assert.equal(clipped.length, MAX_MESSAGE_SMS);
|
|
||||||
assert.ok(clipped.endsWith('…'));
|
|
||||||
assert.equal(clipSms(' کوتاه '), 'کوتاه');
|
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('لغو پیامهای قبلی', () => {
|
it('لغو پیامهای قبلی', () => {
|
||||||
assert.deepEqual(cancelTriggersFor('invoice_paid'), ['invoice_issued']);
|
assert.deepEqual(cancelTriggersFor('invoice_paid'), ['invoice_issued']);
|
||||||
assert.deepEqual(cancelTriggersFor('deal_won'), ['deal_created', 'deal_stage_changed']);
|
assert.deepEqual(cancelTriggersFor('deal_won'), ['deal_created', 'deal_stage_changed']);
|
||||||
|
|||||||
Reference in New Issue
Block a user