feat: log outbound SMS and show history in compose modal

Persist manual, campaign, and automation SMS; expose history APIs for people and leads.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 21:23:07 +03:30
co-authored by Cursor
parent 9325d879a5
commit 23f8da2c65
16 changed files with 457 additions and 3 deletions
@@ -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;
+28
View File
@@ -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
+2
View File
@@ -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,
@@ -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,
+28
View File
@@ -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';
+9
View File
@@ -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,
+17
View File
@@ -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) {
@@ -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<MessageAutomationRuleDto[]> {
@@ -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'}`,
@@ -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<void> {
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<OutboundSmsHistoryResponse> {
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<OutboundSmsHistoryResponse> {
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(),
};
}
}
@@ -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 {}
@@ -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 },