diff --git a/apps/api/prisma/migrations/20261017180000_outbound_sms_log/migration.sql b/apps/api/prisma/migrations/20261017180000_outbound_sms_log/migration.sql new file mode 100644 index 0000000..00ea689 --- /dev/null +++ b/apps/api/prisma/migrations/20261017180000_outbound_sms_log/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "OutboundSmsLog" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "personId" TEXT, + "leadId" TEXT, + "mobile" TEXT NOT NULL, + "body" TEXT NOT NULL, + "source" TEXT NOT NULL, + "sourceId" TEXT, + "status" TEXT NOT NULL, + "error" TEXT, + "actorUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OutboundSmsLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "OutboundSmsLog_organizationId_personId_createdAt_idx" ON "OutboundSmsLog"("organizationId", "personId", "createdAt"); + +-- CreateIndex +CREATE INDEX "OutboundSmsLog_organizationId_leadId_createdAt_idx" ON "OutboundSmsLog"("organizationId", "leadId", "createdAt"); + +-- CreateIndex +CREATE INDEX "OutboundSmsLog_organizationId_createdAt_idx" ON "OutboundSmsLog"("organizationId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "OutboundSmsLog" ADD CONSTRAINT "OutboundSmsLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OutboundSmsLog" ADD CONSTRAINT "OutboundSmsLog_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OutboundSmsLog" ADD CONSTRAINT "OutboundSmsLog_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3dc1e45..2fa27e4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -202,6 +202,7 @@ model Organization { apiKeys OrganizationApiKey[] webhookEndpoints WebhookEndpoint[] smsCampaigns SmsCampaign[] + outboundSmsLogs OutboundSmsLog[] wooCommerceConnections WooCommerceConnection[] montifyConnections MontifyConnection[] voipConnections VoipConnection[] @@ -541,6 +542,7 @@ model Person { trackingLinks TrackingLink[] trackingEvents TrackingEvent[] routineTasks RoutineTask[] + outboundSmsLogs OutboundSmsLog[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1989,6 +1991,31 @@ model WebhookDelivery { @@index([endpointId, createdAt]) } +/// لاگ یکپارچهٔ پیامک خروجی (دستی / کمپین / اتوماسیون) +model OutboundSmsLog { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + leadId String? + lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull) + mobile String + body String + /// manual | campaign | automation + source String + sourceId String? + /// sent | failed + status String + error String? + actorUserId String? + createdAt DateTime @default(now()) + + @@index([organizationId, personId, createdAt]) + @@index([organizationId, leadId, createdAt]) + @@index([organizationId, createdAt]) +} + /// کمپین پیامک گروهی model SmsCampaign { id String @id @default(cuid()) @@ -2459,6 +2486,7 @@ model Lead { trackingEvents TrackingEvent[] routineTasks RoutineTask[] activities Activity[] + outboundSmsLogs OutboundSmsLog[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b66bd0d..3991eb7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -50,6 +50,7 @@ import { ConversationsModule } from './conversations/conversations.module'; import { RoutinesModule } from './routines/routines.module'; import { GamificationModule } from './gamification/gamification.module'; import { DeletionsModule } from './deletions/deletions.module'; +import { OutboundSmsModule } from './outbound-sms/outbound-sms.module'; @Module({ imports: [ @@ -60,6 +61,7 @@ import { DeletionsModule } from './deletions/deletions.module'; ScheduleModule.forRoot(), AiModule, PrismaModule, + OutboundSmsModule, StorageModule, HealthModule, BillingModule, diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index d82b880..869135f 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -132,6 +132,19 @@ export class PeopleController { return this.peopleService.birthdaysThisMonth(user); } + @Get(':id/sms-history') + smsHistory( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Query('limit') limit?: string, + ) { + return this.peopleService.listSmsHistory( + user, + id, + limit ? Number(limit) : 50, + ); + } + @Post(':id/sms') sms( @CurrentUser() user: Actor, diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index c3bad7e..b0c2036 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -20,6 +20,7 @@ import type { import { FILTERED_IDS_MAX } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { CompaniesService } from './companies.service'; import { emptyToNull, normalizeEmail, normalizePhone } from './normalize'; import { @@ -111,6 +112,7 @@ export class PeopleService implements OnModuleInit { private readonly webhooks: WebhooksService, private readonly game: GamificationService, private readonly sms: SmsService, + private readonly outboundSms: OutboundSmsLogService, ) {} private personListWhere( @@ -633,6 +635,16 @@ export class PeopleService implements OnModuleInit { }); if (!message) throw new BadRequestException('متن پیامک خالی است.'); const result = await this.sms.sendSmsDetailed(mobile, message); + await this.outboundSms.record({ + organizationId: actor.organizationId, + personId: person.id, + mobile, + body: message, + source: 'manual', + status: result.ok ? 'sent' : 'failed', + error: result.ok ? null : result.reason, + actorUserId: actor.userId, + }); if (!result.ok) { throw new ServiceUnavailableException( result.reason === 'SMS not configured' @@ -659,6 +671,16 @@ export class PeopleService implements OnModuleInit { }); if (!message) throw new BadRequestException('متن پیامک خالی است.'); const result = await this.sms.sendSmsDetailed(mobile, message); + await this.outboundSms.record({ + organizationId: actor.organizationId, + personId: person.id, + mobile, + body: message, + source: 'manual', + status: result.ok ? 'sent' : 'failed', + error: result.ok ? null : result.reason, + actorUserId: actor.userId, + }); if (!result.ok) { throw new ServiceUnavailableException( result.reason === 'SMS not configured' @@ -669,6 +691,12 @@ export class PeopleService implements OnModuleInit { return { ok: true }; } + listSmsHistory(actor: Actor, personId: string, limit?: number) { + return this.get(actor, personId).then(() => + this.outboundSms.historyForPerson(actor.organizationId, personId, limit), + ); + } + private toDto(row: PersonRow): PersonDto { const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim(); const importSource = row.importSource || 'manual'; diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts index c0fbf2a..8077c29 100644 --- a/apps/api/src/leads/leads.controller.ts +++ b/apps/api/src/leads/leads.controller.ts @@ -379,6 +379,15 @@ export class LeadsController { return this.leads.move(user, id, dto.stageId, dto.position); } + @Get(':id/sms-history') + smsHistory( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Query('limit') limit?: string, + ) { + return this.leads.listSmsHistory(user, id, limit ? Number(limit) : 50); + } + @Post(':id/sms') sms( @CurrentUser() user: Actor, diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index f620aa4..57de40b 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -23,6 +23,7 @@ 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'; +import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { type Actor, elevated, @@ -87,6 +88,7 @@ export class LeadsService { private readonly files: LocalFileStorage, private readonly game: GamificationService, private readonly sms: SmsService, + private readonly outboundSms: OutboundSmsLogService, ) {} private gameEvent( @@ -1290,6 +1292,16 @@ export class LeadsService { }); if (!message) throw new BadRequestException('متن پیامک خالی است.'); const result = await this.sms.sendSmsDetailed(mobile, message); + await this.outboundSms.record({ + organizationId: actor.organizationId, + leadId: id, + mobile, + body: message, + source: 'manual', + status: result.ok ? 'sent' : 'failed', + error: result.ok ? null : result.reason, + actorUserId: actor.userId, + }); if (!result.ok) { throw new ServiceUnavailableException( result.reason === 'SMS not configured' @@ -1301,6 +1313,11 @@ export class LeadsService { return { ok: true }; } + async listSmsHistory(actor: Actor, id: string, limit?: number) { + await this.findAccessible(actor, id); + return this.outboundSms.historyForLead(actor.organizationId, id, limit); + } + async openLogo(actor: Actor, id: string) { const existing = await this.findAccessible(actor, id); if (!existing.logoStorageKey) { diff --git a/apps/api/src/message-automations/message-automations.service.ts b/apps/api/src/message-automations/message-automations.service.ts index 151587c..e4ff3bd 100644 --- a/apps/api/src/message-automations/message-automations.service.ts +++ b/apps/api/src/message-automations/message-automations.service.ts @@ -15,6 +15,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SmsService } from '../common/sms.service'; import { dateToJalali, jalaliMonthName } from '../common/jalali'; +import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { applyMessageTemplate, buildSendAts, @@ -47,6 +48,7 @@ export class MessageAutomationsService { private readonly prisma: PrismaService, private readonly notifications: NotificationsService, private readonly sms: SmsService, + private readonly outboundSms: OutboundSmsLogService, ) {} async list(organizationId: string): Promise { @@ -328,7 +330,9 @@ export class MessageAutomationsService { personId: string | null; ownerId: string | null; assigneeId: string | null; + ruleId: string; rule: { + id?: string; title: string; body: string; channel: string; @@ -398,11 +402,23 @@ export class MessageAutomationsService { } if (targetPerson && wantSms) { - await this.sendSms(person?.mobile || person?.phone, smsText); + await this.sendSms(person?.mobile || person?.phone, smsText, { + organizationId: job.organizationId, + personId: job.personId, + ruleId: job.ruleId || job.rule.id || '', + }); } } - private async sendSms(mobile: string | null | undefined, text: string) { + private async sendSms( + mobile: string | null | undefined, + text: string, + log?: { + organizationId: string; + personId?: string | null; + ruleId: string; + }, + ) { if (!mobile) { this.logger.warn('Message automation SMS skipped: no mobile'); return; @@ -412,6 +428,18 @@ export class MessageAutomationsService { return; } const result = await this.sms.sendSmsDetailed(mobile, text); + if (log?.personId) { + await this.outboundSms.record({ + organizationId: log.organizationId, + personId: log.personId, + mobile, + body: text, + source: 'automation', + sourceId: log.ruleId, + status: result.ok ? 'sent' : 'failed', + error: result.ok ? null : result.reason, + }); + } if (!result.ok) { this.logger.warn( `Message automation SMS failed: ${result.reason ?? 'unknown'}`, diff --git a/apps/api/src/outbound-sms/outbound-sms-log.service.ts b/apps/api/src/outbound-sms/outbound-sms-log.service.ts new file mode 100644 index 0000000..24fc9fd --- /dev/null +++ b/apps/api/src/outbound-sms/outbound-sms-log.service.ts @@ -0,0 +1,108 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { + OutboundSmsHistoryResponse, + OutboundSmsLogDto, + OutboundSmsSource, + OutboundSmsStatus, +} from '@mento/shared'; +import { OUTBOUND_SMS_SOURCE_LABELS } from '@mento/shared'; +import { PrismaService } from '../prisma/prisma.service'; + +export type RecordOutboundSmsInput = { + organizationId: string; + personId?: string | null; + leadId?: string | null; + mobile: string; + body: string; + source: OutboundSmsSource; + sourceId?: string | null; + status: OutboundSmsStatus; + error?: string | null; + actorUserId?: string | null; +}; + +@Injectable() +export class OutboundSmsLogService { + private readonly logger = new Logger(OutboundSmsLogService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** ثبت لاگ؛ خطا را می‌بلعد تا ارسال اصلی قطع نشود. */ + async record(input: RecordOutboundSmsInput): Promise { + try { + if (!input.mobile?.trim() || !input.body?.trim()) return; + if (!input.personId && !input.leadId) return; + await this.prisma.outboundSmsLog.create({ + data: { + organizationId: input.organizationId, + personId: input.personId ?? null, + leadId: input.leadId ?? null, + mobile: input.mobile.trim(), + body: input.body.trim().slice(0, 2000), + source: input.source, + sourceId: input.sourceId ?? null, + status: input.status, + error: input.error?.slice(0, 500) ?? null, + actorUserId: input.actorUserId ?? null, + }, + }); + } catch (e) { + this.logger.warn( + `OutboundSmsLog record failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + async historyForPerson( + organizationId: string, + personId: string, + limit = 50, + ): Promise { + const take = Math.min(100, Math.max(1, limit)); + const rows = await this.prisma.outboundSmsLog.findMany({ + where: { organizationId, personId }, + orderBy: { createdAt: 'desc' }, + take, + }); + return { items: rows.map((r) => this.toDto(r)) }; + } + + async historyForLead( + organizationId: string, + leadId: string, + limit = 50, + ): Promise { + const take = Math.min(100, Math.max(1, limit)); + const rows = await this.prisma.outboundSmsLog.findMany({ + where: { organizationId, leadId }, + orderBy: { createdAt: 'desc' }, + take, + }); + return { items: rows.map((r) => this.toDto(r)) }; + } + + private toDto(row: { + id: string; + body: string; + mobile: string; + source: string; + status: string; + createdAt: Date; + }): OutboundSmsLogDto { + const source = ( + row.source === 'campaign' || row.source === 'automation' + ? row.source + : 'manual' + ) as OutboundSmsSource; + const status = (row.status === 'failed' ? 'failed' : 'sent') as OutboundSmsStatus; + return { + id: row.id, + body: row.body, + mobile: row.mobile, + source, + sourceLabel: OUTBOUND_SMS_SOURCE_LABELS[source], + status, + createdAt: row.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/src/outbound-sms/outbound-sms.module.ts b/apps/api/src/outbound-sms/outbound-sms.module.ts new file mode 100644 index 0000000..760598b --- /dev/null +++ b/apps/api/src/outbound-sms/outbound-sms.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { OutboundSmsLogService } from './outbound-sms-log.service'; + +@Global() +@Module({ + providers: [OutboundSmsLogService], + exports: [OutboundSmsLogService], +}) +export class OutboundSmsModule {} diff --git a/apps/api/src/sms-campaigns/sms-campaigns.service.ts b/apps/api/src/sms-campaigns/sms-campaigns.service.ts index dcc9b60..36cead6 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.service.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.service.ts @@ -24,6 +24,7 @@ import { ownerWhere, } from '../common/record-scope'; import { TrackingService } from '../tracking/tracking.service'; +import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import type { Prisma } from '@prisma/client'; function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput { @@ -46,6 +47,7 @@ export class SmsCampaignsService { private readonly prisma: PrismaService, private readonly sms: SmsService, private readonly tracking: TrackingService, + private readonly outboundSms: OutboundSmsLogService, ) {} async list( @@ -585,6 +587,17 @@ export class SmsCampaignsService { recipient.mobile, personalized.body, ); + await this.outboundSms.record({ + organizationId: campaign.organizationId, + personId: recipient.personId, + leadId: recipient.leadId, + mobile: recipient.mobile, + body: personalized.body, + source: 'campaign', + sourceId: campaign.id, + status: result.ok ? 'sent' : 'failed', + error: result.ok ? null : result.reason, + }); if (result.ok) { await this.prisma.smsCampaignRecipient.update({ where: { id: recipient.id }, diff --git a/apps/web/src/components/SmsComposeModal.vue b/apps/web/src/components/SmsComposeModal.vue index 077b9a1..3688336 100644 --- a/apps/web/src/components/SmsComposeModal.vue +++ b/apps/web/src/components/SmsComposeModal.vue @@ -1,15 +1,17 @@