diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index bab8c67..c671225 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -63,6 +63,23 @@ export class ListQueryDto { @IsString() @MaxLength(40) importSource?: string; + + /** استان شرکت مرتبط */ + @IsOptional() + @IsString() + @MaxLength(80) + province?: string; + + /** شهر/شهرستان شرکت مرتبط */ + @IsOptional() + @IsString() + @MaxLength(80) + city?: string; + + /** فقط کسانی که موبایل دارند */ + @IsOptional() + @IsIn(['1', 'true', '0', 'false']) + hasMobile?: string; } class CompanyExtraFieldsDto { diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index 7f72487..d82b880 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -43,6 +43,10 @@ export class PeopleController { @Get() list(@CurrentUser() user: Actor, @Query() query: ListQueryDto) { + const hasMobile = + query.hasMobile === '1' || query.hasMobile === 'true' + ? true + : undefined; return this.peopleService.list( user, query.q, @@ -52,10 +56,28 @@ export class PeopleController { mood: query.mood, sort: query.sort, importSource: query.importSource, + province: query.province, + city: query.city, + hasMobile, }, ); } + @Get('ids') + listIds(@CurrentUser() user: Actor, @Query() query: ListQueryDto) { + const hasMobile = + query.hasMobile === '1' || query.hasMobile === 'true' + ? true + : undefined; + return this.peopleService.listIds(user, query.q, { + mood: query.mood, + importSource: query.importSource, + province: query.province, + city: query.city, + hasMobile, + }); + } + @Get('source-stats') sourceStats(@CurrentUser() user: Actor) { return this.peopleService.sourceStats(user); diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index e7a0366..c3bad7e 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -10,12 +10,14 @@ import { } from '@nestjs/common'; import type { CreatePersonRequest, + FilteredIdsResponse, PaginatedResponse, PersonDto, PersonImportSourceStatDto, PersonMood, UpdatePersonRequest, } from '@mento/shared'; +import { FILTERED_IDS_MAX } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CompaniesService } from './companies.service'; @@ -49,7 +51,7 @@ import { } from './person-source'; const personInclude = { - company: { select: { id: true, name: true } }, + company: { select: { id: true, name: true, province: true, city: true } }, owner: { select: { id: true, fullName: true } }, loyaltyTier: { select: { id: true, name: true, minPoints: true, color: true }, @@ -82,7 +84,12 @@ type PersonRow = { loyaltyPoints: number; createdAt: Date; updatedAt: Date; - company: { id: string; name: string } | null; + company: { + id: string; + name: string; + province: string | null; + city: string | null; + } | null; owner: { id: string; fullName: string } | null; loyaltyTier: { id: string; @@ -106,17 +113,17 @@ export class PeopleService implements OnModuleInit { private readonly sms: SmsService, ) {} - async list( + private personListWhere( actor: Actor, q?: string, - page = 1, - limit = 20, opts?: { mood?: PersonMood; - sort?: 'name' | 'newest'; importSource?: string; + province?: string; + city?: string; + hasMobile?: boolean; }, - ): Promise> { + ): Prisma.PersonWhereInput { const sourceFilter = opts?.importSource?.trim() ? normalizePersonImportSource(opts.importSource) : undefined; @@ -131,14 +138,56 @@ export class PeopleService implements OnModuleInit { { sourceRef: ci(t) }, { previousSourceRef: ci(t) }, { company: { name: ci(t) } }, + { company: { province: ci(t) } }, + { company: { city: ci(t) } }, ]); - const where: Prisma.PersonWhereInput = { + const province = opts?.province?.trim(); + const city = opts?.city?.trim(); + const and: Prisma.PersonWhereInput[] = []; + if (search) and.push(search); + if (province) { + and.push({ + OR: [ + { company: { province: { contains: province, mode: 'insensitive' } } }, + { company: { city: { contains: province, mode: 'insensitive' } } }, + ], + }); + } + if (city) { + and.push({ + OR: [ + { company: { city: { contains: city, mode: 'insensitive' } } }, + { company: { province: { contains: city, mode: 'insensitive' } } }, + ], + }); + } + if (opts?.hasMobile) { + and.push({ mobile: { not: null } }, { NOT: { mobile: '' } }); + } + return { organizationId: actor.organizationId, ...ownerWhere(actor), ...(opts?.mood ? { mood: opts.mood } : {}), ...(sourceFilter ? { importSource: sourceFilter } : {}), - ...(search ? { AND: [search] } : {}), + ...(and.length ? { AND: and } : {}), }; + } + + async list( + actor: Actor, + q?: string, + page = 1, + limit = 20, + opts?: { + mood?: PersonMood; + sort?: 'name' | 'newest'; + importSource?: string; + province?: string; + city?: string; + hasMobile?: boolean; + }, + ): Promise> { + const where = this.personListWhere(actor, q, opts); const [rows, total] = await this.prisma.$transaction([ this.prisma.person.findMany({ @@ -162,6 +211,34 @@ export class PeopleService implements OnModuleInit { }; } + async listIds( + actor: Actor, + q?: string, + opts?: { + mood?: PersonMood; + importSource?: string; + province?: string; + city?: string; + hasMobile?: boolean; + }, + max = FILTERED_IDS_MAX, + ): Promise { + const where = this.personListWhere(actor, q, opts); + const total = await this.prisma.person.count({ where }); + if (total > max) { + throw new BadRequestException( + `تعداد نتایج (${total}) از سقف مجاز بیشتر است؛ فیلتر را محدودتر کنید.`, + ); + } + const rows = await this.prisma.person.findMany({ + where, + select: { id: true }, + orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], + take: max, + }); + return { ids: rows.map((r) => r.id), total }; + } + async sourceStats(actor: Actor): Promise { const rows = await this.prisma.person.groupBy({ by: ['importSource'], diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts index 1bee8e4..c0fbf2a 100644 --- a/apps/api/src/leads/leads.controller.ts +++ b/apps/api/src/leads/leads.controller.ts @@ -231,6 +231,34 @@ export class LeadsController { return new StreamableFile(buffer); } + @Get('ids') + listIds( + @CurrentUser() user: Actor, + @Query('q') q?: string, + @Query('stageId') stageId?: string, + @Query('assigneeId') assigneeId?: string, + @Query('city') city?: string, + @Query('region') region?: string, + @Query('trade') trade?: string, + @Query('source') source?: string, + @Query('unassigned') unassigned?: string, + @Query('archived') archived?: string, + @Query('hasMobile') hasMobile?: string, + ) { + return this.leads.listIds(user, { + q, + stageId, + assigneeId, + city, + region, + trade, + source, + unassigned: unassigned === '1' || unassigned === 'true', + archived: archived === '1' || archived === 'true', + hasMobile: hasMobile === '1' || hasMobile === 'true', + }); + } + @Get() list( @CurrentUser() user: Actor, @@ -240,11 +268,13 @@ export class LeadsController { @Query('stageId') stageId?: string, @Query('assigneeId') assigneeId?: string, @Query('city') city?: string, + @Query('region') region?: string, @Query('trade') trade?: string, @Query('source') source?: string, @Query('unassigned') unassigned?: string, @Query('archived') archived?: string, @Query('sort') sort?: string, + @Query('hasMobile') hasMobile?: string, ) { return this.leads.list(user, { q, @@ -253,11 +283,13 @@ export class LeadsController { stageId, assigneeId, city, + region, trade, source, unassigned: unassigned === '1' || unassigned === 'true', archived: archived === '1' || archived === 'true', sort, + hasMobile: hasMobile === '1' || hasMobile === 'true', }); } diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index c0f70b1..f620aa4 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -11,6 +11,7 @@ import * as path from 'node:path'; import { randomUUID } from 'node:crypto'; import type { ConvertLeadResult, + FilteredIdsResponse, LeadBoardColumnDto, LeadDto, LeadDuplicateCheckResult, @@ -18,6 +19,7 @@ import type { LeadStatsDto, PaginatedResponse, } from '@mento/shared'; +import { FILTERED_IDS_MAX } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { SmsService } from '../common/sms.service'; import { renderSmsText } from '../common/sms-text'; @@ -105,25 +107,21 @@ export class LeadsService { }); } - async list( + private leadListWhere( actor: Actor, opts: { q?: string; - page?: number; - limit?: number; stageId?: string; assigneeId?: string; city?: string; + region?: string; trade?: string; source?: string; unassigned?: boolean; archived?: boolean; - sort?: string; + hasMobile?: boolean; }, - ): Promise> { - await this.stages.ensureDefaults(actor.organizationId); - const page = Math.max(1, opts.page ?? 1); - const limit = Math.min(100, Math.max(1, opts.limit ?? 20)); + ): Prisma.LeadWhereInput { const search = buildSearchWhere(opts.q, (t) => [ { title: ci(t) }, { contactName: ci(t) }, @@ -135,8 +133,12 @@ export class LeadsService { { instagram: ci(t) }, { source: ci(t) }, ]); - - const where: Prisma.LeadWhereInput = { + const and: Prisma.LeadWhereInput[] = []; + if (search) and.push(search); + if (opts.hasMobile) { + and.push({ mobile: { not: '' } }); + } + return { organizationId: actor.organizationId, ...leadAccessWhere(actor), archivedAt: opts.archived ? { not: null } : null, @@ -144,12 +146,39 @@ export class LeadsService { ...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}), ...(opts.unassigned ? { assigneeId: null } : {}), ...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}), + ...(opts.region + ? { region: { contains: opts.region, mode: 'insensitive' } } + : {}), ...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}), ...(opts.source ? { source: { contains: opts.source, mode: 'insensitive' } } : {}), - ...(search ? { AND: [search] } : {}), + ...(and.length ? { AND: and } : {}), }; + } + + async list( + actor: Actor, + opts: { + q?: string; + page?: number; + limit?: number; + stageId?: string; + assigneeId?: string; + city?: string; + region?: string; + trade?: string; + source?: string; + unassigned?: boolean; + archived?: boolean; + sort?: string; + hasMobile?: boolean; + }, + ): Promise> { + await this.stages.ensureDefaults(actor.organizationId); + const page = Math.max(1, opts.page ?? 1); + const limit = Math.min(100, Math.max(1, opts.limit ?? 20)); + const where = this.leadListWhere(actor, opts); const orderBy: Prisma.LeadOrderByWithRelationInput = opts.sort === 'follow_up' @@ -179,6 +208,39 @@ export class LeadsService { }; } + async listIds( + actor: Actor, + opts: { + q?: string; + stageId?: string; + assigneeId?: string; + city?: string; + region?: string; + trade?: string; + source?: string; + unassigned?: boolean; + archived?: boolean; + hasMobile?: boolean; + }, + max = FILTERED_IDS_MAX, + ): Promise { + await this.stages.ensureDefaults(actor.organizationId); + const where = this.leadListWhere(actor, opts); + const total = await this.prisma.lead.count({ where }); + if (total > max) { + throw new BadRequestException( + `تعداد نتایج (${total}) از سقف مجاز بیشتر است؛ فیلتر را محدودتر کنید.`, + ); + } + const rows = await this.prisma.lead.findMany({ + where, + select: { id: true }, + orderBy: { updatedAt: 'desc' }, + take: max, + }); + return { ids: rows.map((r) => r.id), total }; + } + async board(actor: Actor): Promise { const stages = await this.stages.list(actor.organizationId); const boardStages = stages.filter((s) => s.showOnBoard); diff --git a/apps/api/src/sms-campaigns/sms-campaigns.controller.ts b/apps/api/src/sms-campaigns/sms-campaigns.controller.ts index ebb4edb..cf336bd 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.controller.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.controller.ts @@ -8,6 +8,7 @@ import { UseGuards, } from '@nestjs/common'; import { + ArrayMaxSize, IsArray, IsBoolean, IsDateString, @@ -21,12 +22,14 @@ import { ValidateIf, } from 'class-validator'; import { Type } from 'class-transformer'; +import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; import { OrgModuleGuard } from '../billing/org-module.guard'; import { RequireOrgModule } from '../billing/org-module.decorator'; +import type { Actor } from '../common/record-scope'; import { SmsCampaignsService } from './sms-campaigns.service'; class CreateSmsCampaignDto { @@ -40,9 +43,17 @@ class CreateSmsCampaignDto { @MaxLength(900) body!: string; + @IsOptional() @IsArray() + @ArrayMaxSize(SMS_CAMPAIGN_CHUNK_SIZE) @IsString({ each: true }) - personIds!: string[]; + personIds?: string[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(SMS_CAMPAIGN_CHUNK_SIZE) + @IsString({ each: true }) + leadIds?: string[]; @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @@ -54,6 +65,20 @@ class CreateSmsCampaignDto { queueNow?: boolean; } +class AddRecipientsDto { + @IsOptional() + @IsArray() + @ArrayMaxSize(SMS_CAMPAIGN_CHUNK_SIZE) + @IsString({ each: true }) + personIds?: string[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(SMS_CAMPAIGN_CHUNK_SIZE) + @IsString({ each: true }) + leadIds?: string[]; +} + class ListQueryDto { @IsOptional() @Type(() => Number) @@ -105,19 +130,17 @@ export class SmsCampaignsController { } @Post() - create( - @CurrentUser() - user: { organizationId: string; userId: string; role: string }, - @Body() dto: CreateSmsCampaignDto, + create(@CurrentUser() user: Actor, @Body() dto: CreateSmsCampaignDto) { + return this.campaigns.create(user, dto); + } + + @Post(':id/recipients') + addRecipients( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: AddRecipientsDto, ) { - return this.campaigns.create( - { - organizationId: user.organizationId, - userId: user.userId, - role: user.role as 'owner' | 'admin' | 'sales' | 'support', - }, - dto, - ); + return this.campaigns.addRecipients(user, id, dto); } @Post(':id/queue') diff --git a/apps/api/src/sms-campaigns/sms-campaigns.service.ts b/apps/api/src/sms-campaigns/sms-campaigns.service.ts index 315209e..dcc9b60 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.service.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import type { + AddSmsCampaignRecipientsRequest, CreateSmsCampaignRequest, SmsCampaignClickerDto, SmsCampaignDto, @@ -12,10 +13,32 @@ import type { SmsCampaignReportDto, SmsCampaignStatus, } from '@mento/shared'; +import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { SmsService } from '../common/sms.service'; -import { type Actor, ownerWhere } from '../common/record-scope'; +import { renderSmsText } from '../common/sms-text'; +import { + type Actor, + elevated, + forScope, + ownerWhere, +} from '../common/record-scope'; import { TrackingService } from '../tracking/tracking.service'; +import type { Prisma } from '@prisma/client'; + +function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput { + const actor = forScope(baseActor, 'leads'); + if (elevated(actor.role)) return {}; + if (actor.dataVisibility === 'org') return {}; + if ( + actor.dataVisibility === 'team' && + actor.visibleUserIds && + actor.visibleUserIds.length + ) { + return { assigneeId: { in: actor.visibleUserIds } }; + } + return { assigneeId: actor.userId }; +} @Injectable() export class SmsCampaignsService { @@ -230,19 +253,24 @@ export class SmsCampaignsService { throw new BadRequestException('متن پیامک کوتاه است'); } const personIds = [...new Set((dto.personIds ?? []).filter(Boolean))]; - if (!personIds.length) { + const leadIds = [...new Set((dto.leadIds ?? []).filter(Boolean))]; + if (personIds.length && leadIds.length) { + throw new BadRequestException('کمپین نمی‌تواند همزمان شخص و لید داشته باشد.'); + } + if (!personIds.length && !leadIds.length) { throw new BadRequestException('حداقل یک مخاطب انتخاب کنید'); } + if (personIds.length > SMS_CAMPAIGN_CHUNK_SIZE || leadIds.length > SMS_CAMPAIGN_CHUNK_SIZE) { + throw new BadRequestException( + `حداکثر ${SMS_CAMPAIGN_CHUNK_SIZE} مخاطب در هر درخواست مجاز است.`, + ); + } - const people = await this.prisma.person.findMany({ - where: { - id: { in: personIds }, - organizationId: actor.organizationId, - ...ownerWhere(actor), - }, - select: { id: true, mobile: true }, - }); - if (!people.length) throw new BadRequestException('مخاطب معتبری پیدا نشد'); + const recipients = + personIds.length > 0 + ? await this.resolvePersonRecipients(actor, personIds) + : await this.resolveLeadRecipients(actor, leadIds); + if (!recipients.length) throw new BadRequestException('مخاطب معتبری پیدا نشد'); const queueNow = Boolean(dto.queueNow); const scheduledAt = dto.scheduledAt ? new Date(dto.scheduledAt) : null; @@ -252,6 +280,7 @@ export class SmsCampaignsService { const status: SmsCampaignStatus = queueNow || scheduledAt ? 'queued' : 'draft'; + const skipped = recipients.filter((r) => !r.mobile).length; const campaign = await this.prisma.smsCampaign.create({ data: { @@ -260,27 +289,143 @@ export class SmsCampaignsService { body, status, scheduledAt: queueNow ? new Date() : scheduledAt, - totalCount: people.length, + totalCount: recipients.length, + skippedCount: skipped, createdById: actor.userId, recipients: { - create: people.map((p) => ({ - personId: p.id, - mobile: p.mobile, - status: p.mobile ? 'pending' : 'skipped', + create: recipients.map((r) => ({ + personId: r.personId, + leadId: r.leadId, + mobile: r.mobile, + status: r.mobile ? 'pending' : 'skipped', })), }, }, }); - const skipped = people.filter((p) => !p.mobile).length; - if (skipped) { - await this.prisma.smsCampaign.update({ - where: { id: campaign.id }, - data: { skippedCount: skipped }, - }); + return this.get(actor.organizationId, campaign.id); + } + + async addRecipients( + actor: Actor, + campaignId: string, + dto: AddSmsCampaignRecipientsRequest, + ): Promise { + const campaign = await this.prisma.smsCampaign.findFirst({ + where: { id: campaignId, organizationId: actor.organizationId }, + }); + if (!campaign) throw new NotFoundException('کمپین پیدا نشد'); + if (campaign.status !== 'draft' && campaign.status !== 'cancelled') { + throw new BadRequestException('فقط به کمپین پیش‌نویس می‌توان مخاطب افزود.'); } - return this.get(actor.organizationId, campaign.id); + const personIds = [...new Set((dto.personIds ?? []).filter(Boolean))]; + const leadIds = [...new Set((dto.leadIds ?? []).filter(Boolean))]; + if (personIds.length && leadIds.length) { + throw new BadRequestException('در هر درخواست فقط شخص یا فقط لید بفرستید.'); + } + if (!personIds.length && !leadIds.length) { + throw new BadRequestException('حداقل یک مخاطب انتخاب کنید'); + } + if (personIds.length > SMS_CAMPAIGN_CHUNK_SIZE || leadIds.length > SMS_CAMPAIGN_CHUNK_SIZE) { + throw new BadRequestException( + `حداکثر ${SMS_CAMPAIGN_CHUNK_SIZE} مخاطب در هر درخواست مجاز است.`, + ); + } + + const existingSample = await this.prisma.smsCampaignRecipient.findFirst({ + where: { campaignId }, + select: { personId: true, leadId: true }, + }); + if (existingSample?.personId && leadIds.length) { + throw new BadRequestException('این کمپین فقط برای اشخاص است.'); + } + if (existingSample?.leadId && personIds.length) { + throw new BadRequestException('این کمپین فقط برای لیدها است.'); + } + + const resolved = + personIds.length > 0 + ? await this.resolvePersonRecipients(actor, personIds) + : await this.resolveLeadRecipients(actor, leadIds); + + const existingIds = new Set( + ( + await this.prisma.smsCampaignRecipient.findMany({ + where: { campaignId }, + select: { personId: true, leadId: true }, + }) + ).map((r) => r.personId ?? r.leadId ?? ''), + ); + const fresh = resolved.filter( + (r) => !existingIds.has(r.personId ?? r.leadId ?? ''), + ); + if (!fresh.length) { + return this.get(actor.organizationId, campaignId); + } + + const skipped = fresh.filter((r) => !r.mobile).length; + await this.prisma.smsCampaignRecipient.createMany({ + data: fresh.map((r) => ({ + campaignId, + personId: r.personId, + leadId: r.leadId, + mobile: r.mobile, + status: r.mobile ? 'pending' : 'skipped', + })), + }); + await this.prisma.smsCampaign.update({ + where: { id: campaignId }, + data: { + totalCount: { increment: fresh.length }, + skippedCount: { increment: skipped }, + status: 'draft', + }, + }); + return this.get(actor.organizationId, campaignId); + } + + private async resolvePersonRecipients( + actor: Actor, + personIds: string[], + ): Promise< + Array<{ personId: string; leadId: null; mobile: string | null }> + > { + const people = await this.prisma.person.findMany({ + where: { + id: { in: personIds }, + organizationId: actor.organizationId, + ...ownerWhere(actor), + }, + select: { id: true, mobile: true }, + }); + return people.map((p) => ({ + personId: p.id, + leadId: null, + mobile: p.mobile?.trim() || null, + })); + } + + private async resolveLeadRecipients( + actor: Actor, + leadIds: string[], + ): Promise< + Array<{ personId: null; leadId: string; mobile: string | null }> + > { + const leads = await this.prisma.lead.findMany({ + where: { + id: { in: leadIds }, + organizationId: actor.organizationId, + archivedAt: null, + ...leadAccessWhere(actor), + }, + select: { id: true, mobile: true }, + }); + return leads.map((l) => ({ + personId: null, + leadId: l.id, + mobile: l.mobile?.trim() || null, + })); } async queue(organizationId: string, id: string): Promise { @@ -350,10 +495,44 @@ export class SmsCampaignsService { const batch = await this.prisma.smsCampaignRecipient.findMany({ where: { campaignId, status: 'pending' }, - take: 40, + take: SMS_CAMPAIGN_CHUNK_SIZE, orderBy: { createdAt: 'asc' }, }); + const personIds = batch + .map((r) => r.personId) + .filter((id): id is string => Boolean(id)); + const leadIds = batch + .map((r) => r.leadId) + .filter((id): id is string => Boolean(id)); + const [people, leads] = await Promise.all([ + personIds.length + ? this.prisma.person.findMany({ + where: { id: { in: personIds } }, + select: { + id: true, + firstName: true, + lastName: true, + mobile: true, + company: { select: { name: true } }, + }, + }) + : Promise.resolve([]), + leadIds.length + ? this.prisma.lead.findMany({ + where: { id: { in: leadIds } }, + select: { + id: true, + title: true, + contactName: true, + mobile: true, + }, + }) + : Promise.resolve([]), + ]); + const personMap = new Map(people.map((p) => [p.id, p])); + const leadMap = new Map(leads.map((l) => [l.id, l])); + let sent = 0; let failed = 0; let skipped = 0; @@ -368,6 +547,27 @@ export class SmsCampaignsService { continue; } + let name = ''; + let title = ''; + let company = ''; + if (recipient.personId) { + const p = personMap.get(recipient.personId); + name = [p?.firstName, p?.lastName].filter(Boolean).join(' ').trim(); + title = name; + company = p?.company?.name ?? ''; + } else if (recipient.leadId) { + const l = leadMap.get(recipient.leadId); + name = (l?.contactName || l?.title || '').trim(); + title = (l?.title || name).trim(); + } + + const withTokens = renderSmsText(campaign.body, { + name, + phone: recipient.mobile, + title, + company, + }); + const personalized = await this.tracking.personalizeSmsBody({ organizationId: campaign.organizationId, channel: 'sms', @@ -378,7 +578,7 @@ export class SmsCampaignsService { smsRecipientId: recipient.id, personId: recipient.personId, leadId: recipient.leadId, - body: campaign.body, + body: withTokens, }); const result = await this.sms.sendSmsDetailed( diff --git a/apps/web/src/views/SmsCampaignsView.vue b/apps/web/src/views/SmsCampaignsView.vue index 14e40f4..f890d21 100644 --- a/apps/web/src/views/SmsCampaignsView.vue +++ b/apps/web/src/views/SmsCampaignsView.vue @@ -1,14 +1,21 @@