2 Commits
Author SHA1 Message Date
MahdiandCursor 23f8da2c65 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>
2026-10-17 21:23:07 +03:30
MahdiandCursor 9325d879a5 docs: spec unified outbound SMS log in compose modal
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:16:08 +03:30
17 changed files with 544 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 },
+122 -1
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import axios from 'axios';
import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared';
import { MESSAGE_AUTOMATION_FIELDS, MESSAGE_AUTOMATION_SMS_SEGMENT } from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliDay, toFaDigits } from '@/lib/jalali';
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
const props = withDefaults(
defineProps<{
open: boolean;
endpoint: string;
historyEndpoint?: string;
name: string;
phone: string;
recordTitle?: string;
@@ -19,6 +21,7 @@ const props = withDefaults(
nest?: boolean;
}>(),
{
historyEndpoint: '',
recordTitle: '',
company: '',
title: 'ارسال پیامک',
@@ -38,6 +41,8 @@ const error = ref('');
const done = ref('');
const fieldsOpen = ref(false);
const bodyEl = ref<HTMLTextAreaElement | null>(null);
const history = ref<OutboundSmsLogDto[]>([]);
const historyLoading = ref(false);
const preview = computed(() => fill(text.value));
const count = computed(() => text.value.length);
@@ -52,9 +57,26 @@ watch(
done.value = '';
fieldsOpen.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) {
const map: Record<string, string> = {
نام: props.name,
@@ -108,12 +130,17 @@ async function send() {
await api.post(props.endpoint, { text: text.value });
done.value = 'پیامک ارسال شد.';
emit('sent');
void loadHistory();
} catch (e) {
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
} finally {
busy.value = false;
}
}
function statusLabel(status: OutboundSmsLogDto['status']) {
return status === 'failed' ? 'ناموفق' : 'ارسال‌شده';
}
</script>
<template>
@@ -147,6 +174,26 @@ async function send() {
<p>{{ preview || 'متن پیام هنوز نوشته نشده است.' }}</p>
</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="done" class="ok">{{ done }}</p>
@@ -271,6 +318,80 @@ textarea {
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,
.ok {
margin: 0.55rem 0 0;
+1
View File
@@ -788,6 +788,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
:open="smsOpen"
title="ارسال پیامک"
:endpoint="`/leads/${lead.id}/sms`"
:history-endpoint="`/leads/${lead.id}/sms-history`"
:name="lead.contactName || lead.title"
:phone="lead.mobile || lead.phone || ''"
:record-title="lead.title"
+1
View File
@@ -1105,6 +1105,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
:open="smsOpen"
title="ارسال پیامک"
:endpoint="`/people/${id}/sms`"
:history-endpoint="`/people/${id}/sms-history`"
:name="fullName"
:phone="phoneNumber"
: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
@@ -0,0 +1,87 @@
# لاگ یکپارچهٔ پیامک خروجی در دیالوگ ارسال
تاریخ: ۲۰۲۶-۱۰-۱۷
وضعیت: تأیید شده؛ آمادهٔ پلن پیاده‌سازی.
## هدف
در دیالوگ ارسال پیامک (لید و شخص)، لیست پیامک‌هایی که از این به بعد برای همان مخاطب رفته نمایش داده شود: دستی، کمپین گروهی، اتوماسیون.
## تصمیم‌ها
| موضوع | تصمیم |
| --- | --- |
| منبع حقیقت | جدول جدید `OutboundSmsLog` |
| تاریخچهٔ قبل از استقرار | مهم نیست؛ فقط از این به بعد |
| پوشش | manual + campaign + automation |
| UI | همان `SmsComposeModal` برای لید و شخص |
## مدل داده
`OutboundSmsLog`:
- `id`, `organizationId`
- `personId` اختیاری، `leadId` اختیاری (حداقل یکی در عمل پر می‌شود)
- `mobile`, `body` (متن نهایی پس از توکن/لینک)
- `source`: `manual` | `campaign` | `automation`
- `sourceId` اختیاری (شناسهٔ کمپین / قانون اتوماسیون)
- `status`: `sent` | `failed`
- `error` اختیاری
- `actorUserId` اختیاری (برای دستی)
- `createdAt`
- ایندکس: `(organizationId, personId, createdAt)`, `(organizationId, leadId, createdAt)`
## نقاط ثبت
1. `PeopleService.sendSms` — source=`manual`
2. `LeadsService.sendSms` — source=`manual` (LeadEvent فعلی می‌تواند بماند)
3. `SmsCampaignsService.processCampaign` پس از هر تلاش ارسال — source=`campaign`, `sourceId`=campaignId
4. ارسال SMS در message-automations — source=`automation`, `sourceId`=ruleId، با `personId` وقتی موجود است
ثبت نباید ارسال را بشکند: اگر لاگ fail شد، فقط log خطا؛ نتیجهٔ SMS حفظ شود.
## API
- `GET /people/:id/sms-history?limit=50`
- `GET /leads/:id/sms-history?limit=50`
`OutboundSmsHistoryResponse`:
```ts
{
items: Array<{
id: string;
body: string;
mobile: string;
source: 'manual' | 'campaign' | 'automation';
sourceLabel: string; // دستی | کمپین | اتوماسیون
status: 'sent' | 'failed';
createdAt: string;
}>;
}
```
مرتب‌سازی: جدیدترین اول. Scope دسترسی همان دسترسی به شخص/لید.
## UI
در `SmsComposeModal`:
- prop اختیاری `historyEndpoint`
- با `open` لود تاریخچه
- بلوک «پیامک‌های قبلی» زیر پیش‌نمایش؛ اسکرول؛ خالی = «هنوز پیامکی ثبت نشده»
- پس از ارسال موفق، رفرش لیست (یا prepend محلی)
`LeadDetailView` و `PersonProfileView` endpoint تاریخچه را پاس می‌دهند.
## خارج از محدوده
- بک‌فیل تاریخچهٔ قدیمی
- نمایش در صفحهٔ جدا / گزارش سازمانی کامل
- ویرایش یا حذف لاگ از UI
## معیار پذیرش
1. بعد از ارسال دستی به لید/شخص، همان متن در دیالوگ دیده شود.
2. پیامک کمپین و اتوماسیون (برای شخص) در تاریخچه ظاهر شود.
3. لید و شخص هر دو از همان مودال تاریخچه بگیرند.
+25
View File
@@ -4300,6 +4300,31 @@ export interface WebhookDeliveryDto {
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 =