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:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user