feat: send one SMS from a person or lead and keep the dashboard inside the phone frame
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { jalaliMonthName, toJalali } from './jalali';
|
||||
import { applyMessageTemplate } from '../message-automations/message-automation.util';
|
||||
|
||||
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
|
||||
function toFaDigits(value: string | number) {
|
||||
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d);
|
||||
}
|
||||
|
||||
function tehranJalaliLong(d: Date) {
|
||||
const [gy, gm, gd] = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.format(d)
|
||||
.split('-')
|
||||
.map(Number);
|
||||
const j = toJalali(gy, gm, gd);
|
||||
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
|
||||
}
|
||||
|
||||
export function renderSmsText(
|
||||
text: string,
|
||||
vars: { name?: string; phone?: string; title?: string; company?: string },
|
||||
) {
|
||||
return applyMessageTemplate(text, {
|
||||
name: vars.name,
|
||||
phone: vars.phone,
|
||||
title: vars.title,
|
||||
company: vars.company,
|
||||
date: tehranJalaliLong(new Date()),
|
||||
}).trim();
|
||||
}
|
||||
@@ -110,6 +110,15 @@ export class PeopleController {
|
||||
return this.peopleService.birthdaysThisMonth(user);
|
||||
}
|
||||
|
||||
@Post(':id/sms')
|
||||
sms(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: BirthdaySmsDto,
|
||||
) {
|
||||
return this.peopleService.sendSms(user, id, dto.text);
|
||||
}
|
||||
|
||||
@Post(':id/birthday-sms')
|
||||
birthdaySms(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -39,7 +39,8 @@ import { WebhooksService } from '../public-api/webhooks.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { parseBirthDate } from '../common/excel';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { fillBirthdaySmsText, selectMonthBirthdays } from './birthday-month';
|
||||
import { selectMonthBirthdays } from './birthday-month';
|
||||
import { renderSmsText } from '../common/sms-text';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import {
|
||||
@@ -467,6 +468,7 @@ export class PeopleService implements OnModuleInit {
|
||||
mobile: true,
|
||||
phone: true,
|
||||
birthDate: true,
|
||||
company: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const selected = selectMonthBirthdays(
|
||||
@@ -484,12 +486,14 @@ export class PeopleService implements OnModuleInit {
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const companyById = new Map(rows.map((row) => [row.id, row.company?.name ?? '']));
|
||||
const toItem = (row: (typeof selected.items)[number]) => ({
|
||||
id: row.id,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
||||
mobile: row.mobile,
|
||||
company: companyById.get(row.id) ?? '',
|
||||
day: row.day,
|
||||
});
|
||||
return {
|
||||
@@ -517,6 +521,7 @@ export class PeopleService implements OnModuleInit {
|
||||
phone: true,
|
||||
ownerId: true,
|
||||
birthDate: true,
|
||||
company: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (!person) throw new NotFoundException('مشتری پیدا نشد');
|
||||
@@ -542,7 +547,40 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
const mobile = person.mobile || person.phone;
|
||||
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
||||
const message = fillBirthdaySmsText(body, person.firstName, person.lastName);
|
||||
const fullName = `${person.firstName} ${person.lastName}`.trim();
|
||||
const message = renderSmsText(body, {
|
||||
name: fullName,
|
||||
phone: mobile,
|
||||
title: fullName,
|
||||
company: person.company?.name ?? '',
|
||||
});
|
||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
||||
if (!result.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
result.reason === 'SMS not configured'
|
||||
? 'ارسال پیامک روی سرور فعال نیست.'
|
||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async sendSms(actor: Actor, personId: string, text: string) {
|
||||
const body = text.trim();
|
||||
if (!body || body.length > 500) {
|
||||
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
||||
}
|
||||
const person = await this.get(actor, personId);
|
||||
const mobile = person.mobile || person.phone;
|
||||
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
||||
const message = renderSmsText(body, {
|
||||
name: person.fullName,
|
||||
phone: mobile,
|
||||
title: person.fullName,
|
||||
company: person.company?.name ?? '',
|
||||
});
|
||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
||||
if (!result.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
|
||||
@@ -5,9 +5,17 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class LeadSmsDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(500)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class CreateLeadDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
LeadCallLogDto,
|
||||
LeadFollowUpDto,
|
||||
LeadNoteDto,
|
||||
LeadSmsDto,
|
||||
MoveLeadDto,
|
||||
ReorderLeadStagesDto,
|
||||
UpdateLeadDto,
|
||||
@@ -346,6 +347,15 @@ export class LeadsController {
|
||||
return this.leads.move(user, id, dto.stageId, dto.position);
|
||||
}
|
||||
|
||||
@Post(':id/sms')
|
||||
sms(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: LeadSmsDto,
|
||||
) {
|
||||
return this.leads.sendSms(user, id, dto.text);
|
||||
}
|
||||
|
||||
@Post(':id/call')
|
||||
logCall(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -5,11 +5,12 @@ import { LeadStagesService } from './lead-stages.service';
|
||||
import { LeadsController } from './leads.controller';
|
||||
import { LeadsImportService } from './leads-import.service';
|
||||
import { LeadsService } from './leads.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
controllers: [LeadsController],
|
||||
providers: [LeadsService, LeadStagesService, LeadsImportService],
|
||||
providers: [LeadsService, LeadStagesService, LeadsImportService, SmsService],
|
||||
exports: [LeadsService, LeadStagesService],
|
||||
})
|
||||
export class LeadsModule {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { parseBirthDate } from '../common/excel';
|
||||
@@ -18,6 +19,8 @@ import type {
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { renderSmsText } from '../common/sms-text';
|
||||
import {
|
||||
type Actor,
|
||||
elevated,
|
||||
@@ -81,6 +84,7 @@ export class LeadsService {
|
||||
private readonly stages: LeadStagesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly game: GamificationService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
private gameEvent(
|
||||
@@ -1203,6 +1207,34 @@ export class LeadsService {
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async sendSms(actor: Actor, id: string, text: string) {
|
||||
const body = text.trim();
|
||||
if (!body || body.length > 500) {
|
||||
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
||||
}
|
||||
const lead = await this.findAccessible(actor, id);
|
||||
const mobile = lead.mobile || lead.phone;
|
||||
if (!mobile) throw new BadRequestException('برای این لید شماره موبایل ثبت نشده است.');
|
||||
const name = (lead.contactName || lead.title).trim();
|
||||
const message = renderSmsText(body, {
|
||||
name,
|
||||
phone: mobile,
|
||||
title: lead.title,
|
||||
company: lead.contactName ? lead.title : '',
|
||||
});
|
||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
||||
if (!result.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
result.reason === 'SMS not configured'
|
||||
? 'ارسال پیامک روی سرور فعال نیست.'
|
||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
await this.addEvent(actor, id, 'note', 'پیامک ارسال شد', message);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async openLogo(actor: Actor, id: string) {
|
||||
const existing = await this.findAccessible(actor, id);
|
||||
if (!existing.logoStorageKey) {
|
||||
|
||||
Reference in New Issue
Block a user