From 42f0573a5fdcce6bc6e07ee42a65dcfed09027be Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 13 Oct 2026 21:05:17 +0330 Subject: [PATCH] feat: send one SMS from a person or lead and keep the dashboard inside the phone frame Co-authored-by: Cursor --- apps/api/src/common/sms-text.ts | 35 ++ apps/api/src/contacts/people.controller.ts | 9 + apps/api/src/contacts/people.service.ts | 42 ++- apps/api/src/leads/dto.ts | 8 + apps/api/src/leads/leads.controller.ts | 10 + apps/api/src/leads/leads.module.ts | 3 +- apps/api/src/leads/leads.service.ts | 32 ++ apps/web/src/components/SmsComposeModal.vue | 287 ++++++++++++++++ .../dashboard/HomeBirthdayStrip.vue | 310 ++++++++++++------ .../web/src/components/dashboard/HomeHero.vue | 28 +- apps/web/src/views/HomeView.vue | 6 + apps/web/src/views/LeadDetailView.vue | 45 ++- apps/web/src/views/PersonProfileView.vue | 31 ++ 13 files changed, 733 insertions(+), 113 deletions(-) create mode 100644 apps/api/src/common/sms-text.ts create mode 100644 apps/web/src/components/SmsComposeModal.vue diff --git a/apps/api/src/common/sms-text.ts b/apps/api/src/common/sms-text.ts new file mode 100644 index 0000000..d2c4302 --- /dev/null +++ b/apps/api/src/common/sms-text.ts @@ -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(); +} diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index 3391297..7f72487 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -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, diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index b5b3b10..e7a0366 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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( diff --git a/apps/api/src/leads/dto.ts b/apps/api/src/leads/dto.ts index 194122a..8a80f28 100644 --- a/apps/api/src/leads/dto.ts +++ b/apps/api/src/leads/dto.ts @@ -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) diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts index a175a91..1bee8e4 100644 --- a/apps/api/src/leads/leads.controller.ts +++ b/apps/api/src/leads/leads.controller.ts @@ -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, diff --git a/apps/api/src/leads/leads.module.ts b/apps/api/src/leads/leads.module.ts index 4f6799b..683b8df 100644 --- a/apps/api/src/leads/leads.module.ts +++ b/apps/api/src/leads/leads.module.ts @@ -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 {} diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 34d1baf..3714bb5 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -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) { diff --git a/apps/web/src/components/SmsComposeModal.vue b/apps/web/src/components/SmsComposeModal.vue new file mode 100644 index 0000000..077b9a1 --- /dev/null +++ b/apps/web/src/components/SmsComposeModal.vue @@ -0,0 +1,287 @@ + + +