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