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[]
|
apiKeys OrganizationApiKey[]
|
||||||
webhookEndpoints WebhookEndpoint[]
|
webhookEndpoints WebhookEndpoint[]
|
||||||
smsCampaigns SmsCampaign[]
|
smsCampaigns SmsCampaign[]
|
||||||
|
outboundSmsLogs OutboundSmsLog[]
|
||||||
wooCommerceConnections WooCommerceConnection[]
|
wooCommerceConnections WooCommerceConnection[]
|
||||||
montifyConnections MontifyConnection[]
|
montifyConnections MontifyConnection[]
|
||||||
voipConnections VoipConnection[]
|
voipConnections VoipConnection[]
|
||||||
@@ -541,6 +542,7 @@ model Person {
|
|||||||
trackingLinks TrackingLink[]
|
trackingLinks TrackingLink[]
|
||||||
trackingEvents TrackingEvent[]
|
trackingEvents TrackingEvent[]
|
||||||
routineTasks RoutineTask[]
|
routineTasks RoutineTask[]
|
||||||
|
outboundSmsLogs OutboundSmsLog[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1989,6 +1991,31 @@ model WebhookDelivery {
|
|||||||
@@index([endpointId, createdAt])
|
@@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 {
|
model SmsCampaign {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -2459,6 +2486,7 @@ model Lead {
|
|||||||
trackingEvents TrackingEvent[]
|
trackingEvents TrackingEvent[]
|
||||||
routineTasks RoutineTask[]
|
routineTasks RoutineTask[]
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
|
outboundSmsLogs OutboundSmsLog[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { ConversationsModule } from './conversations/conversations.module';
|
|||||||
import { RoutinesModule } from './routines/routines.module';
|
import { RoutinesModule } from './routines/routines.module';
|
||||||
import { GamificationModule } from './gamification/gamification.module';
|
import { GamificationModule } from './gamification/gamification.module';
|
||||||
import { DeletionsModule } from './deletions/deletions.module';
|
import { DeletionsModule } from './deletions/deletions.module';
|
||||||
|
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -60,6 +61,7 @@ import { DeletionsModule } from './deletions/deletions.module';
|
|||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
AiModule,
|
AiModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
OutboundSmsModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
|||||||
@@ -132,6 +132,19 @@ export class PeopleController {
|
|||||||
return this.peopleService.birthdaysThisMonth(user);
|
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')
|
@Post(':id/sms')
|
||||||
sms(
|
sms(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
import { FILTERED_IDS_MAX } from '@mento/shared';
|
import { FILTERED_IDS_MAX } from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
||||||
import {
|
import {
|
||||||
@@ -111,6 +112,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private personListWhere(
|
private personListWhere(
|
||||||
@@ -633,6 +635,16 @@ export class PeopleService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
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) {
|
if (!result.ok) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
result.reason === 'SMS not configured'
|
result.reason === 'SMS not configured'
|
||||||
@@ -659,6 +671,16 @@ export class PeopleService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
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) {
|
if (!result.ok) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
result.reason === 'SMS not configured'
|
result.reason === 'SMS not configured'
|
||||||
@@ -669,6 +691,12 @@ export class PeopleService implements OnModuleInit {
|
|||||||
return { ok: true };
|
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 {
|
private toDto(row: PersonRow): PersonDto {
|
||||||
const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
|
const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
|
||||||
const importSource = row.importSource || 'manual';
|
const importSource = row.importSource || 'manual';
|
||||||
|
|||||||
@@ -379,6 +379,15 @@ export class LeadsController {
|
|||||||
return this.leads.move(user, id, dto.stageId, dto.position);
|
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')
|
@Post(':id/sms')
|
||||||
sms(
|
sms(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FILTERED_IDS_MAX } from '@mento/shared';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsService } from '../common/sms.service';
|
||||||
import { renderSmsText } from '../common/sms-text';
|
import { renderSmsText } from '../common/sms-text';
|
||||||
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
elevated,
|
elevated,
|
||||||
@@ -87,6 +88,7 @@ export class LeadsService {
|
|||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private gameEvent(
|
private gameEvent(
|
||||||
@@ -1290,6 +1292,16 @@ export class LeadsService {
|
|||||||
});
|
});
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
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) {
|
if (!result.ok) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
result.reason === 'SMS not configured'
|
result.reason === 'SMS not configured'
|
||||||
@@ -1301,6 +1313,11 @@ export class LeadsService {
|
|||||||
return { ok: true };
|
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) {
|
async openLogo(actor: Actor, id: string) {
|
||||||
const existing = await this.findAccessible(actor, id);
|
const existing = await this.findAccessible(actor, id);
|
||||||
if (!existing.logoStorageKey) {
|
if (!existing.logoStorageKey) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsService } from '../common/sms.service';
|
||||||
import { dateToJalali, jalaliMonthName } from '../common/jalali';
|
import { dateToJalali, jalaliMonthName } from '../common/jalali';
|
||||||
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||||
import {
|
import {
|
||||||
applyMessageTemplate,
|
applyMessageTemplate,
|
||||||
buildSendAts,
|
buildSendAts,
|
||||||
@@ -47,6 +48,7 @@ export class MessageAutomationsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(organizationId: string): Promise<MessageAutomationRuleDto[]> {
|
async list(organizationId: string): Promise<MessageAutomationRuleDto[]> {
|
||||||
@@ -328,7 +330,9 @@ export class MessageAutomationsService {
|
|||||||
personId: string | null;
|
personId: string | null;
|
||||||
ownerId: string | null;
|
ownerId: string | null;
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
|
ruleId: string;
|
||||||
rule: {
|
rule: {
|
||||||
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
@@ -398,11 +402,23 @@ export class MessageAutomationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetPerson && wantSms) {
|
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) {
|
if (!mobile) {
|
||||||
this.logger.warn('Message automation SMS skipped: no mobile');
|
this.logger.warn('Message automation SMS skipped: no mobile');
|
||||||
return;
|
return;
|
||||||
@@ -412,6 +428,18 @@ export class MessageAutomationsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, text);
|
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) {
|
if (!result.ok) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Message automation SMS failed: ${result.reason ?? 'unknown'}`,
|
`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,
|
ownerWhere,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
import { TrackingService } from '../tracking/tracking.service';
|
import { TrackingService } from '../tracking/tracking.service';
|
||||||
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
|
function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
|
||||||
@@ -46,6 +47,7 @@ export class SmsCampaignsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
private readonly tracking: TrackingService,
|
private readonly tracking: TrackingService,
|
||||||
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -585,6 +587,17 @@ export class SmsCampaignsService {
|
|||||||
recipient.mobile,
|
recipient.mobile,
|
||||||
personalized.body,
|
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) {
|
if (result.ok) {
|
||||||
await this.prisma.smsCampaignRecipient.update({
|
await this.prisma.smsCampaignRecipient.update({
|
||||||
where: { id: recipient.id },
|
where: { id: recipient.id },
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared';
|
||||||
import { MESSAGE_AUTOMATION_FIELDS, MESSAGE_AUTOMATION_SMS_SEGMENT } from '@mento/shared';
|
import { MESSAGE_AUTOMATION_FIELDS, MESSAGE_AUTOMATION_SMS_SEGMENT } from '@mento/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { formatJalaliDay, toFaDigits } from '@/lib/jalali';
|
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
import { UiButton, UiModal } from '@/components/ui';
|
import { UiButton, UiModal } from '@/components/ui';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
historyEndpoint?: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
recordTitle?: string;
|
recordTitle?: string;
|
||||||
@@ -19,6 +21,7 @@ const props = withDefaults(
|
|||||||
nest?: boolean;
|
nest?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
|
historyEndpoint: '',
|
||||||
recordTitle: '',
|
recordTitle: '',
|
||||||
company: '',
|
company: '',
|
||||||
title: 'ارسال پیامک',
|
title: 'ارسال پیامک',
|
||||||
@@ -38,6 +41,8 @@ const error = ref('');
|
|||||||
const done = ref('');
|
const done = ref('');
|
||||||
const fieldsOpen = ref(false);
|
const fieldsOpen = ref(false);
|
||||||
const bodyEl = ref<HTMLTextAreaElement | null>(null);
|
const bodyEl = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const history = ref<OutboundSmsLogDto[]>([]);
|
||||||
|
const historyLoading = ref(false);
|
||||||
|
|
||||||
const preview = computed(() => fill(text.value));
|
const preview = computed(() => fill(text.value));
|
||||||
const count = computed(() => text.value.length);
|
const count = computed(() => text.value.length);
|
||||||
@@ -52,9 +57,26 @@ watch(
|
|||||||
done.value = '';
|
done.value = '';
|
||||||
fieldsOpen.value = false;
|
fieldsOpen.value = false;
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
|
void loadHistory();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
history.value = [];
|
||||||
|
if (!props.historyEndpoint) return;
|
||||||
|
historyLoading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<OutboundSmsHistoryResponse>(props.historyEndpoint, {
|
||||||
|
params: { limit: 50 },
|
||||||
|
});
|
||||||
|
history.value = data.items ?? [];
|
||||||
|
} catch {
|
||||||
|
history.value = [];
|
||||||
|
} finally {
|
||||||
|
historyLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fill(source: string) {
|
function fill(source: string) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
نام: props.name,
|
نام: props.name,
|
||||||
@@ -108,12 +130,17 @@ async function send() {
|
|||||||
await api.post(props.endpoint, { text: text.value });
|
await api.post(props.endpoint, { text: text.value });
|
||||||
done.value = 'پیامک ارسال شد.';
|
done.value = 'پیامک ارسال شد.';
|
||||||
emit('sent');
|
emit('sent');
|
||||||
|
void loadHistory();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
|
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: OutboundSmsLogDto['status']) {
|
||||||
|
return status === 'failed' ? 'ناموفق' : 'ارسالشده';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -147,6 +174,26 @@ async function send() {
|
|||||||
<p>{{ preview || 'متن پیام هنوز نوشته نشده است.' }}</p>
|
<p>{{ preview || 'متن پیام هنوز نوشته نشده است.' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="historyEndpoint" class="history">
|
||||||
|
<header>
|
||||||
|
<strong>پیامکهای قبلی</strong>
|
||||||
|
<span v-if="historyLoading" class="muted">در حال بارگذاری…</span>
|
||||||
|
</header>
|
||||||
|
<p v-if="!historyLoading && !history.length" class="muted empty">
|
||||||
|
هنوز پیامکی ثبت نشده است.
|
||||||
|
</p>
|
||||||
|
<ul v-else class="history-list">
|
||||||
|
<li v-for="item in history" :key="item.id">
|
||||||
|
<div class="meta">
|
||||||
|
<span class="src">{{ item.sourceLabel }}</span>
|
||||||
|
<span :class="['st', item.status]">{{ statusLabel(item.status) }}</span>
|
||||||
|
<time>{{ formatJalaliNumeric(new Date(item.createdAt)) }}</time>
|
||||||
|
</div>
|
||||||
|
<p>{{ item.body }}</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="err">{{ error }}</p>
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
<p v-if="done" class="ok">{{ done }}</p>
|
<p v-if="done" class="ok">{{ done }}</p>
|
||||||
|
|
||||||
@@ -271,6 +318,80 @@ textarea {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
border-top: 1px solid #eef2f7;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history header strong {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list li {
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list .meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 0.65rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list .src {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list .st.failed {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list .st.sent {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list p {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted,
|
||||||
|
.empty {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.err,
|
.err,
|
||||||
.ok {
|
.ok {
|
||||||
margin: 0.55rem 0 0;
|
margin: 0.55rem 0 0;
|
||||||
|
|||||||
@@ -788,6 +788,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
|
|||||||
:open="smsOpen"
|
:open="smsOpen"
|
||||||
title="ارسال پیامک"
|
title="ارسال پیامک"
|
||||||
:endpoint="`/leads/${lead.id}/sms`"
|
:endpoint="`/leads/${lead.id}/sms`"
|
||||||
|
:history-endpoint="`/leads/${lead.id}/sms-history`"
|
||||||
:name="lead.contactName || lead.title"
|
:name="lead.contactName || lead.title"
|
||||||
:phone="lead.mobile || lead.phone || ''"
|
:phone="lead.mobile || lead.phone || ''"
|
||||||
:record-title="lead.title"
|
:record-title="lead.title"
|
||||||
|
|||||||
@@ -1105,6 +1105,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
|||||||
:open="smsOpen"
|
:open="smsOpen"
|
||||||
title="ارسال پیامک"
|
title="ارسال پیامک"
|
||||||
:endpoint="`/people/${id}/sms`"
|
:endpoint="`/people/${id}/sms`"
|
||||||
|
:history-endpoint="`/people/${id}/sms-history`"
|
||||||
:name="fullName"
|
:name="fullName"
|
||||||
:phone="phoneNumber"
|
:phone="phoneNumber"
|
||||||
:record-title="fullName"
|
:record-title="fullName"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Outbound SMS Log Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Use executing-plans / implement task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Unified `OutboundSmsLog` from all SMS send paths; show history in `SmsComposeModal`.
|
||||||
|
|
||||||
|
**Architecture:** Prisma model + small logger service; write on send (manual/campaign/automation); GET history on people/leads; modal loads `historyEndpoint`.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Prisma `OutboundSmsLog` + migrate
|
||||||
|
- [ ] Shared DTOs
|
||||||
|
- [ ] `OutboundSmsLogService` + wire send paths
|
||||||
|
- [ ] GET sms-history endpoints
|
||||||
|
- [ ] SmsComposeModal + parent props
|
||||||
|
- [ ] Verify typecheck
|
||||||
@@ -4300,6 +4300,31 @@ export interface WebhookDeliveryDto {
|
|||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ——— لاگ پیامک خروجی ——— */
|
||||||
|
|
||||||
|
export type OutboundSmsSource = "manual" | "campaign" | "automation";
|
||||||
|
export type OutboundSmsStatus = "sent" | "failed";
|
||||||
|
|
||||||
|
export const OUTBOUND_SMS_SOURCE_LABELS: Record<OutboundSmsSource, string> = {
|
||||||
|
manual: "دستی",
|
||||||
|
campaign: "کمپین",
|
||||||
|
automation: "اتوماسیون",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OutboundSmsLogDto {
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
mobile: string;
|
||||||
|
source: OutboundSmsSource;
|
||||||
|
sourceLabel: string;
|
||||||
|
status: OutboundSmsStatus;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutboundSmsHistoryResponse {
|
||||||
|
items: OutboundSmsLogDto[];
|
||||||
|
}
|
||||||
|
|
||||||
/* ——— پیامک گروهی ——— */
|
/* ——— پیامک گروهی ——— */
|
||||||
|
|
||||||
export type SmsCampaignStatus =
|
export type SmsCampaignStatus =
|
||||||
|
|||||||
Reference in New Issue
Block a user