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,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 {}