Implementing a link tracking and user click logging system.Implementing a link tracking and user click logging system.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
-- Extend AutomationTrigger
|
||||
CREATE TYPE "AutomationTrigger_new" AS ENUM (
|
||||
'person_created',
|
||||
'lead_submitted',
|
||||
'deal_created',
|
||||
'deal_stage_changed',
|
||||
'deal_won',
|
||||
'deal_lost',
|
||||
'ticket_created',
|
||||
'ticket_resolved',
|
||||
'invoice_issued',
|
||||
'invoice_paid',
|
||||
'activity_done',
|
||||
'sms_link_clicked'
|
||||
);
|
||||
|
||||
ALTER TABLE "AutomationRule"
|
||||
ALTER COLUMN "trigger" TYPE "AutomationTrigger_new"
|
||||
USING ("trigger"::text::"AutomationTrigger_new");
|
||||
|
||||
ALTER TYPE "AutomationTrigger" RENAME TO "AutomationTrigger_old";
|
||||
ALTER TYPE "AutomationTrigger_new" RENAME TO "AutomationTrigger";
|
||||
DROP TYPE "AutomationTrigger_old";
|
||||
|
||||
-- Extend MessageAutomationTrigger
|
||||
CREATE TYPE "MessageAutomationTrigger_new" AS ENUM (
|
||||
'person_created',
|
||||
'lead_submitted',
|
||||
'deal_created',
|
||||
'deal_stage_changed',
|
||||
'deal_won',
|
||||
'deal_lost',
|
||||
'ticket_created',
|
||||
'ticket_resolved',
|
||||
'invoice_issued',
|
||||
'invoice_paid',
|
||||
'activity_created',
|
||||
'activity_done',
|
||||
'sms_link_clicked'
|
||||
);
|
||||
|
||||
ALTER TABLE "MessageAutomationRule"
|
||||
ALTER COLUMN "trigger" TYPE "MessageAutomationTrigger_new"
|
||||
USING ("trigger"::text::"MessageAutomationTrigger_new");
|
||||
|
||||
ALTER TABLE "MessageAutomationJob"
|
||||
ALTER COLUMN "trigger" TYPE "MessageAutomationTrigger_new"
|
||||
USING ("trigger"::text::"MessageAutomationTrigger_new");
|
||||
|
||||
ALTER TYPE "MessageAutomationTrigger" RENAME TO "MessageAutomationTrigger_old";
|
||||
ALTER TYPE "MessageAutomationTrigger_new" RENAME TO "MessageAutomationTrigger";
|
||||
DROP TYPE "MessageAutomationTrigger_old";
|
||||
|
||||
-- Recipient: optional leadId for future Lead campaigns
|
||||
ALTER TABLE "SmsCampaignRecipient" ADD COLUMN "leadId" TEXT;
|
||||
CREATE INDEX "SmsCampaignRecipient_personId_idx" ON "SmsCampaignRecipient"("personId");
|
||||
CREATE INDEX "SmsCampaignRecipient_leadId_idx" ON "SmsCampaignRecipient"("leadId");
|
||||
|
||||
-- TrackingLink
|
||||
CREATE TABLE "TrackingLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"originalUrl" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL DEFAULT 'sms',
|
||||
"sourceType" TEXT NOT NULL,
|
||||
"sourceId" TEXT NOT NULL,
|
||||
"recipientType" TEXT,
|
||||
"recipientId" TEXT,
|
||||
"smsRecipientId" TEXT,
|
||||
"personId" TEXT,
|
||||
"leadId" TEXT,
|
||||
"clickCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"firstClickedAt" TIMESTAMP(3),
|
||||
"lastClickedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TrackingLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "TrackingLink_token_key" ON "TrackingLink"("token");
|
||||
CREATE INDEX "TrackingLink_organizationId_sourceType_sourceId_idx" ON "TrackingLink"("organizationId", "sourceType", "sourceId");
|
||||
CREATE INDEX "TrackingLink_organizationId_personId_idx" ON "TrackingLink"("organizationId", "personId");
|
||||
CREATE INDEX "TrackingLink_organizationId_leadId_idx" ON "TrackingLink"("organizationId", "leadId");
|
||||
CREATE INDEX "TrackingLink_smsRecipientId_idx" ON "TrackingLink"("smsRecipientId");
|
||||
CREATE INDEX "TrackingLink_token_idx" ON "TrackingLink"("token");
|
||||
|
||||
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_smsRecipientId_fkey" FOREIGN KEY ("smsRecipientId") REFERENCES "SmsCampaignRecipient"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- TrackingEvent
|
||||
CREATE TABLE "TrackingEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"trackingLinkId" TEXT,
|
||||
"eventType" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL DEFAULT 'sms',
|
||||
"sourceType" TEXT,
|
||||
"sourceId" TEXT,
|
||||
"recipientType" TEXT,
|
||||
"recipientId" TEXT,
|
||||
"personId" TEXT,
|
||||
"leadId" TEXT,
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TrackingEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "TrackingEvent_organizationId_eventType_occurredAt_idx" ON "TrackingEvent"("organizationId", "eventType", "occurredAt");
|
||||
CREATE INDEX "TrackingEvent_organizationId_sourceType_sourceId_eventType_idx" ON "TrackingEvent"("organizationId", "sourceType", "sourceId", "eventType");
|
||||
CREATE INDEX "TrackingEvent_organizationId_personId_eventType_idx" ON "TrackingEvent"("organizationId", "personId", "eventType");
|
||||
CREATE INDEX "TrackingEvent_organizationId_leadId_eventType_idx" ON "TrackingEvent"("organizationId", "leadId", "eventType");
|
||||
CREATE INDEX "TrackingEvent_trackingLinkId_occurredAt_idx" ON "TrackingEvent"("trackingLinkId", "occurredAt");
|
||||
|
||||
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_trackingLinkId_fkey" FOREIGN KEY ("trackingLinkId") REFERENCES "TrackingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -119,6 +119,7 @@ enum AutomationTrigger {
|
||||
invoice_issued
|
||||
invoice_paid
|
||||
activity_done
|
||||
sms_link_clicked
|
||||
}
|
||||
|
||||
enum AutomationAction {
|
||||
@@ -215,6 +216,8 @@ model Organization {
|
||||
conversationRoomMembers ConversationRoomMember[]
|
||||
conversationRoomUserMutes ConversationRoomUserMute[]
|
||||
conversationMessageReactions ConversationMessageReaction[]
|
||||
trackingLinks TrackingLink[]
|
||||
trackingEvents TrackingEvent[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -455,6 +458,8 @@ model Person {
|
||||
accountingDocuments AccountingDocumentLog[]
|
||||
openingBalances PartyOpeningBalance[]
|
||||
cheques Cheque[]
|
||||
trackingLinks TrackingLink[]
|
||||
trackingEvents TrackingEvent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1306,6 +1311,7 @@ enum MessageAutomationTrigger {
|
||||
invoice_paid
|
||||
activity_created
|
||||
activity_done
|
||||
sms_link_clicked
|
||||
}
|
||||
|
||||
enum MessageAutomationDelayUnit {
|
||||
@@ -1787,14 +1793,84 @@ model SmsCampaignRecipient {
|
||||
campaignId String
|
||||
campaign SmsCampaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
personId String?
|
||||
/// برای پشتیبانی آینده از Lead (فعلاً کمپین روی Person است)
|
||||
leadId String?
|
||||
mobile String?
|
||||
/// pending | sent | failed | skipped
|
||||
status String @default("pending")
|
||||
error String?
|
||||
sentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
trackingLinks TrackingLink[]
|
||||
|
||||
@@index([campaignId, status])
|
||||
@@index([personId])
|
||||
@@index([leadId])
|
||||
}
|
||||
|
||||
/// لینک ردیابی یکتا — کانالخنثی (SMS / Email / …)
|
||||
model TrackingLink {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
/// توکن امن و غیرقابلحدس (بدون PII)
|
||||
token String @unique
|
||||
originalUrl String
|
||||
/// sms | email | whatsapp | notification | other
|
||||
channel String @default("sms")
|
||||
/// sms_campaign | …
|
||||
sourceType String
|
||||
sourceId String
|
||||
/// sms_campaign_recipient | …
|
||||
recipientType String?
|
||||
recipientId String?
|
||||
smsRecipientId String?
|
||||
smsRecipient SmsCampaignRecipient? @relation(fields: [smsRecipientId], references: [id], onDelete: SetNull)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
leadId String?
|
||||
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
||||
clickCount Int @default(0)
|
||||
firstClickedAt DateTime?
|
||||
lastClickedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
events TrackingEvent[]
|
||||
|
||||
@@index([organizationId, sourceType, sourceId])
|
||||
@@index([organizationId, personId])
|
||||
@@index([organizationId, leadId])
|
||||
@@index([smsRecipientId])
|
||||
@@index([token])
|
||||
}
|
||||
|
||||
/// رویداد رفتاری عمومی — آمادهٔ Automation
|
||||
model TrackingEvent {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
trackingLinkId String?
|
||||
trackingLink TrackingLink? @relation(fields: [trackingLinkId], references: [id], onDelete: SetNull)
|
||||
/// مثلاً sms_link_clicked | sms_delivered | sms_failed | …
|
||||
eventType String
|
||||
channel String @default("sms")
|
||||
sourceType String?
|
||||
sourceId String?
|
||||
recipientType String?
|
||||
recipientId String?
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
leadId String?
|
||||
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
||||
occurredAt DateTime @default(now())
|
||||
/// ua / referrer / ipHash (اختیاری، بدون PII خام)
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([organizationId, eventType, occurredAt])
|
||||
@@index([organizationId, sourceType, sourceId, eventType])
|
||||
@@index([organizationId, personId, eventType])
|
||||
@@index([organizationId, leadId, eventType])
|
||||
@@index([trackingLinkId, occurredAt])
|
||||
}
|
||||
|
||||
/// اتصال فروشگاه ووکامرس
|
||||
@@ -2009,6 +2085,8 @@ model Lead {
|
||||
convertedAt DateTime?
|
||||
archivedAt DateTime?
|
||||
events LeadEvent[]
|
||||
trackingLinks TrackingLink[]
|
||||
trackingEvents TrackingEvent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { LoyaltyModule } from './loyalty/loyalty.module';
|
||||
import { AutomationsModule } from './automations/automations.module';
|
||||
import { MessageAutomationsModule } from './message-automations/message-automations.module';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { TrackingModule } from './tracking/tracking.module';
|
||||
import { MemoryModule } from './memory/memory.module';
|
||||
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||
import { InventoryModule } from './inventory/inventory.module';
|
||||
@@ -90,6 +91,7 @@ import { ConversationsModule } from './conversations/conversations.module';
|
||||
SavedViewsModule,
|
||||
PublicApiModule,
|
||||
SmsCampaignsModule,
|
||||
TrackingModule,
|
||||
TeamsModule,
|
||||
WooCommerceModule,
|
||||
VoipModule,
|
||||
|
||||
@@ -40,6 +40,10 @@ export type AutomationContext = {
|
||||
dealId?: string | null;
|
||||
ownerId?: string | null;
|
||||
assigneeId?: string | null;
|
||||
/** مثلاً id کمپین SMS برای فیلتر قانون */
|
||||
sourceId?: string | null;
|
||||
sourceType?: string | null;
|
||||
clickCount?: number | null;
|
||||
};
|
||||
|
||||
type AutomationFacts = {
|
||||
@@ -151,6 +155,14 @@ export class AutomationsService {
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
const config = asConfig(rule.config);
|
||||
if (
|
||||
config.sourceId &&
|
||||
ctx.sourceId &&
|
||||
config.sourceId !== ctx.sourceId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (config.sourceId && !ctx.sourceId) continue;
|
||||
const steps = normalizeAutomationSteps(
|
||||
rule.action as AutomationAction,
|
||||
config,
|
||||
|
||||
@@ -24,6 +24,7 @@ const TRIGGERS = [
|
||||
'invoice_issued',
|
||||
'invoice_paid',
|
||||
'activity_done',
|
||||
'sms_link_clicked',
|
||||
] as const;
|
||||
const ACTIONS = [
|
||||
'create_activity',
|
||||
|
||||
@@ -13,6 +13,7 @@ export const MESSAGE_AUTOMATION_TRIGGERS = [
|
||||
'invoice_paid',
|
||||
'activity_created',
|
||||
'activity_done',
|
||||
'sms_link_clicked',
|
||||
] as const;
|
||||
|
||||
export const MAX_MESSAGE_REPEAT = 10;
|
||||
|
||||
@@ -32,6 +32,9 @@ export type MessageAutomationContext = {
|
||||
dealId?: string | null;
|
||||
ownerId?: string | null;
|
||||
assigneeId?: string | null;
|
||||
sourceId?: string | null;
|
||||
sourceType?: string | null;
|
||||
clickCount?: number | null;
|
||||
};
|
||||
|
||||
type Trigger = MessageAutomationTrigger;
|
||||
|
||||
@@ -88,6 +88,14 @@ export class SmsCampaignsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/report')
|
||||
report(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.campaigns.report(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { TrackingModule } from '../tracking/tracking.module';
|
||||
import { SmsCampaignsController } from './sms-campaigns.controller';
|
||||
import { SmsCampaignsService } from './sms-campaigns.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
imports: [AuthModule, BillingModule, TrackingModule],
|
||||
controllers: [SmsCampaignsController],
|
||||
providers: [SmsCampaignsService, SmsService],
|
||||
exports: [SmsCampaignsService],
|
||||
|
||||
@@ -6,19 +6,23 @@ import {
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import type {
|
||||
CreateSmsCampaignRequest,
|
||||
SmsCampaignClickerDto,
|
||||
SmsCampaignDto,
|
||||
SmsCampaignListResponse,
|
||||
SmsCampaignReportDto,
|
||||
SmsCampaignStatus,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
import { TrackingService } from '../tracking/tracking.service';
|
||||
|
||||
@Injectable()
|
||||
export class SmsCampaignsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly tracking: TrackingService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -50,6 +54,171 @@ export class SmsCampaignsService {
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async report(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
): Promise<SmsCampaignReportDto> {
|
||||
const campaign = await this.prisma.smsCampaign.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!campaign) throw new NotFoundException('کمپین پیدا نشد');
|
||||
|
||||
const links = await this.prisma.trackingLink.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
sourceType: 'sms_campaign',
|
||||
sourceId: id,
|
||||
clickCount: { gt: 0 },
|
||||
},
|
||||
select: {
|
||||
personId: true,
|
||||
leadId: true,
|
||||
clickCount: true,
|
||||
firstClickedAt: true,
|
||||
lastClickedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
type Agg = {
|
||||
personId: string | null;
|
||||
leadId: string | null;
|
||||
clickCount: number;
|
||||
firstClickedAt: Date | null;
|
||||
lastClickedAt: Date | null;
|
||||
};
|
||||
const byContact = new Map<string, Agg>();
|
||||
let totalClicks = 0;
|
||||
for (const link of links) {
|
||||
totalClicks += link.clickCount;
|
||||
const key = link.personId
|
||||
? `person:${link.personId}`
|
||||
: link.leadId
|
||||
? `lead:${link.leadId}`
|
||||
: null;
|
||||
if (!key) continue;
|
||||
const prev = byContact.get(key);
|
||||
if (!prev) {
|
||||
byContact.set(key, {
|
||||
personId: link.personId,
|
||||
leadId: link.leadId,
|
||||
clickCount: link.clickCount,
|
||||
firstClickedAt: link.firstClickedAt,
|
||||
lastClickedAt: link.lastClickedAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
prev.clickCount += link.clickCount;
|
||||
if (
|
||||
link.firstClickedAt &&
|
||||
(!prev.firstClickedAt || link.firstClickedAt < prev.firstClickedAt)
|
||||
) {
|
||||
prev.firstClickedAt = link.firstClickedAt;
|
||||
}
|
||||
if (
|
||||
link.lastClickedAt &&
|
||||
(!prev.lastClickedAt || link.lastClickedAt > prev.lastClickedAt)
|
||||
) {
|
||||
prev.lastClickedAt = link.lastClickedAt;
|
||||
}
|
||||
}
|
||||
|
||||
const personIds = [...byContact.values()]
|
||||
.map((c) => c.personId)
|
||||
.filter((x): x is string => Boolean(x));
|
||||
const leadIds = [...byContact.values()]
|
||||
.map((c) => c.leadId)
|
||||
.filter((x): x is string => Boolean(x));
|
||||
|
||||
const [people, leads, recipients] = await Promise.all([
|
||||
personIds.length
|
||||
? this.prisma.person.findMany({
|
||||
where: { id: { in: personIds }, organizationId },
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
leadIds.length
|
||||
? this.prisma.lead.findMany({
|
||||
where: { id: { in: leadIds }, organizationId },
|
||||
select: { id: true, title: true, contactName: true, mobile: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
this.prisma.smsCampaignRecipient.findMany({
|
||||
where: { campaignId: id },
|
||||
select: { personId: true, leadId: true, mobile: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const personMap = new Map(people.map((p) => [p.id, p]));
|
||||
const leadMap = new Map(leads.map((l) => [l.id, l]));
|
||||
const mobileByPerson = new Map(
|
||||
recipients
|
||||
.filter((r) => r.personId)
|
||||
.map((r) => [r.personId!, r.mobile ?? ''] as const),
|
||||
);
|
||||
const mobileByLead = new Map(
|
||||
recipients
|
||||
.filter((r) => r.leadId)
|
||||
.map((r) => [r.leadId!, r.mobile ?? ''] as const),
|
||||
);
|
||||
|
||||
const clickedUsers: SmsCampaignClickerDto[] = [...byContact.values()]
|
||||
.flatMap((agg): SmsCampaignClickerDto[] => {
|
||||
if (agg.personId) {
|
||||
const p = personMap.get(agg.personId);
|
||||
const name = p
|
||||
? `${p.firstName} ${p.lastName}`.trim()
|
||||
: 'مخاطب';
|
||||
return [
|
||||
{
|
||||
contactType: 'customer',
|
||||
personId: agg.personId,
|
||||
leadId: null,
|
||||
name,
|
||||
mobile: p?.mobile || mobileByPerson.get(agg.personId) || null,
|
||||
firstClickedAt: agg.firstClickedAt?.toISOString() ?? null,
|
||||
lastClickedAt: agg.lastClickedAt?.toISOString() ?? null,
|
||||
clickCount: agg.clickCount,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (agg.leadId) {
|
||||
const l = leadMap.get(agg.leadId);
|
||||
return [
|
||||
{
|
||||
contactType: 'lead',
|
||||
personId: null,
|
||||
leadId: agg.leadId,
|
||||
name: l?.contactName || l?.title || 'سرنخ',
|
||||
mobile: l?.mobile || mobileByLead.get(agg.leadId) || null,
|
||||
firstClickedAt: agg.firstClickedAt?.toISOString() ?? null,
|
||||
lastClickedAt: agg.lastClickedAt?.toISOString() ?? null,
|
||||
clickCount: agg.clickCount,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.sort((a, b) => b.clickCount - a.clickCount);
|
||||
|
||||
const uniqueClickers = clickedUsers.length;
|
||||
const sent = campaign.sentCount;
|
||||
const clickRate =
|
||||
sent > 0 ? Math.round((uniqueClickers / sent) * 10000) / 100 : 0;
|
||||
|
||||
return {
|
||||
campaign: this.toDto(campaign),
|
||||
totalCount: campaign.totalCount,
|
||||
sentCount: campaign.sentCount,
|
||||
failedCount: campaign.failedCount,
|
||||
skippedCount: campaign.skippedCount,
|
||||
uniqueClickers,
|
||||
totalClicks,
|
||||
clickRate,
|
||||
multiClickers: clickedUsers.filter((u) => u.clickCount > 1).length,
|
||||
clickedUsers,
|
||||
};
|
||||
}
|
||||
|
||||
async create(
|
||||
actor: Actor,
|
||||
dto: CreateSmsCampaignRequest,
|
||||
@@ -198,9 +367,23 @@ export class SmsCampaignsService {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const personalized = await this.tracking.personalizeSmsBody({
|
||||
organizationId: campaign.organizationId,
|
||||
channel: 'sms',
|
||||
sourceType: 'sms_campaign',
|
||||
sourceId: campaign.id,
|
||||
recipientType: 'sms_campaign_recipient',
|
||||
recipientId: recipient.id,
|
||||
smsRecipientId: recipient.id,
|
||||
personId: recipient.personId,
|
||||
leadId: recipient.leadId,
|
||||
body: campaign.body,
|
||||
});
|
||||
|
||||
const result = await this.sms.sendSmsDetailed(
|
||||
recipient.mobile,
|
||||
campaign.body,
|
||||
personalized.body,
|
||||
);
|
||||
if (result.ok) {
|
||||
await this.prisma.smsCampaignRecipient.update({
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { TrackingService } from './tracking.service';
|
||||
|
||||
/**
|
||||
* ریدایرکت عمومی بدون احراز هویت.
|
||||
* مسیر نهایی: GET /api/t/:token
|
||||
*/
|
||||
@Controller('t')
|
||||
export class PublicTrackingController {
|
||||
constructor(private readonly tracking: TrackingService) {}
|
||||
|
||||
@Get(':token')
|
||||
async redirect(
|
||||
@Param('token') token: string,
|
||||
@Headers('user-agent') userAgent: string | undefined,
|
||||
@Headers('referer') referer: string | undefined,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const ip =
|
||||
(typeof req.headers['x-forwarded-for'] === 'string'
|
||||
? req.headers['x-forwarded-for'].split(',')[0]?.trim()
|
||||
: undefined) ||
|
||||
req.ip ||
|
||||
undefined;
|
||||
|
||||
const target = await this.tracking.handleClick(token, {
|
||||
userAgent,
|
||||
referer,
|
||||
ip,
|
||||
});
|
||||
|
||||
return res.redirect(302, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
import { PublicTrackingController } from './public-tracking.controller';
|
||||
import { TrackingService } from './tracking.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationsModule, MessageAutomationsModule],
|
||||
controllers: [PublicTrackingController],
|
||||
providers: [TrackingService],
|
||||
exports: [TrackingService],
|
||||
})
|
||||
export class TrackingModule {}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"'\)\]]+/gi;
|
||||
|
||||
export type TrackingChannel = 'sms' | 'email' | 'whatsapp' | 'notification' | 'other';
|
||||
|
||||
export type CreateTrackingLinksInput = {
|
||||
organizationId: string;
|
||||
channel: TrackingChannel;
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
recipientType?: string | null;
|
||||
recipientId?: string | null;
|
||||
smsRecipientId?: string | null;
|
||||
personId?: string | null;
|
||||
leadId?: string | null;
|
||||
/** متن حاوی لینکهای اصلی */
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type PersonalizedBodyResult = {
|
||||
body: string;
|
||||
linkIds: string[];
|
||||
hasLinks: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TrackingService {
|
||||
private readonly logger = new Logger(TrackingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
) {}
|
||||
|
||||
/** پایهٔ عمومی مثل https://crm.montzar.ir/api */
|
||||
publicBaseUrl(): string {
|
||||
const explicit = this.config.get<string>('PUBLIC_API_BASE_URL')?.trim();
|
||||
if (explicit) return explicit.replace(/\/$/, '');
|
||||
const cors = this.config.get<string>('CORS_ORIGIN')?.trim();
|
||||
if (cors) return `${cors.replace(/\/$/, '')}/api`;
|
||||
return 'http://localhost:3000/api';
|
||||
}
|
||||
|
||||
trackingUrl(token: string): string {
|
||||
return `${this.publicBaseUrl()}/t/${token}`;
|
||||
}
|
||||
|
||||
extractUrls(text: string): string[] {
|
||||
const found = text.match(URL_RE) ?? [];
|
||||
const cleaned = found.map((u) => u.replace(/[.,;:!?]+$/g, ''));
|
||||
return [...new Set(cleaned)];
|
||||
}
|
||||
|
||||
async personalizeSmsBody(
|
||||
input: CreateTrackingLinksInput,
|
||||
): Promise<PersonalizedBodyResult> {
|
||||
const urls = this.extractUrls(input.body);
|
||||
if (!urls.length) {
|
||||
return { body: input.body, linkIds: [], hasLinks: false };
|
||||
}
|
||||
|
||||
const linkIds: string[] = [];
|
||||
let body = input.body;
|
||||
for (const originalUrl of urls) {
|
||||
const link = await this.createLink({
|
||||
...input,
|
||||
originalUrl,
|
||||
});
|
||||
linkIds.push(link.id);
|
||||
const tracked = this.trackingUrl(link.token);
|
||||
body = body.split(originalUrl).join(tracked);
|
||||
}
|
||||
return { body, linkIds, hasLinks: true };
|
||||
}
|
||||
|
||||
async createLink(input: {
|
||||
organizationId: string;
|
||||
channel: TrackingChannel;
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
originalUrl: string;
|
||||
recipientType?: string | null;
|
||||
recipientId?: string | null;
|
||||
smsRecipientId?: string | null;
|
||||
personId?: string | null;
|
||||
leadId?: string | null;
|
||||
}) {
|
||||
const token = await this.allocateToken();
|
||||
return this.prisma.trackingLink.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
token,
|
||||
originalUrl: input.originalUrl,
|
||||
channel: input.channel,
|
||||
sourceType: input.sourceType,
|
||||
sourceId: input.sourceId,
|
||||
recipientType: input.recipientType ?? null,
|
||||
recipientId: input.recipientId ?? null,
|
||||
smsRecipientId: input.smsRecipientId ?? null,
|
||||
personId: input.personId ?? null,
|
||||
leadId: input.leadId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ثبت کلیک + ریدایرکت. بدون افشای PII در URL.
|
||||
*/
|
||||
async handleClick(
|
||||
token: string,
|
||||
meta?: { userAgent?: string; referer?: string; ip?: string },
|
||||
): Promise<string> {
|
||||
const link = await this.prisma.trackingLink.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
if (!link) throw new NotFoundException('لینک نامعتبر است');
|
||||
|
||||
const now = new Date();
|
||||
const updated = await this.prisma.trackingLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
clickCount: { increment: 1 },
|
||||
firstClickedAt: link.firstClickedAt ?? now,
|
||||
lastClickedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
const metadata: Prisma.InputJsonValue = {
|
||||
...(meta?.userAgent ? { ua: meta.userAgent.slice(0, 240) } : {}),
|
||||
...(meta?.referer ? { referer: meta.referer.slice(0, 240) } : {}),
|
||||
...(meta?.ip ? { ipHash: hashIp(meta.ip) } : {}),
|
||||
clickCount: updated.clickCount,
|
||||
};
|
||||
|
||||
const eventType =
|
||||
link.channel === 'sms'
|
||||
? 'sms_link_clicked'
|
||||
: `${link.channel}_link_clicked`;
|
||||
|
||||
await this.prisma.trackingEvent.create({
|
||||
data: {
|
||||
organizationId: link.organizationId,
|
||||
trackingLinkId: link.id,
|
||||
eventType,
|
||||
channel: link.channel,
|
||||
sourceType: link.sourceType,
|
||||
sourceId: link.sourceId,
|
||||
recipientType: link.recipientType,
|
||||
recipientId: link.recipientId,
|
||||
personId: link.personId,
|
||||
leadId: link.leadId,
|
||||
occurredAt: now,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (link.channel === 'sms') {
|
||||
void this.fireAutomations(link, updated.clickCount).catch((err) => {
|
||||
this.logger.warn(
|
||||
`tracking automation failed: ${
|
||||
err instanceof Error ? err.message : 'unknown'
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return link.originalUrl;
|
||||
}
|
||||
|
||||
private async fireAutomations(
|
||||
link: {
|
||||
organizationId: string;
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
personId: string | null;
|
||||
leadId: string | null;
|
||||
id: string;
|
||||
},
|
||||
clickCount: number,
|
||||
) {
|
||||
const personId = link.personId;
|
||||
if (!personId) return;
|
||||
|
||||
let title = 'کلیک روی لینک پیامک';
|
||||
if (link.sourceType === 'sms_campaign') {
|
||||
const camp = await this.prisma.smsCampaign.findFirst({
|
||||
where: { id: link.sourceId, organizationId: link.organizationId },
|
||||
select: { name: true },
|
||||
});
|
||||
if (camp?.name) title = `کلیک لینک کمپین: ${camp.name}`;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
organizationId: link.organizationId,
|
||||
entityType: 'person' as const,
|
||||
entityId: personId,
|
||||
title,
|
||||
personId,
|
||||
sourceId: link.sourceId,
|
||||
sourceType: link.sourceType,
|
||||
clickCount,
|
||||
};
|
||||
|
||||
await this.automations.runTrigger('sms_link_clicked', ctx);
|
||||
await this.messageAutomations.onEvent('sms_link_clicked', ctx);
|
||||
}
|
||||
|
||||
private async allocateToken(): Promise<string> {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const token = randomBytes(9).toString('base64url');
|
||||
const exists = await this.prisma.trackingLink.findUnique({
|
||||
where: { token },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!exists) return token;
|
||||
}
|
||||
return randomBytes(12).toString('base64url');
|
||||
}
|
||||
}
|
||||
|
||||
function hashIp(ip: string): string {
|
||||
return createHash('sha256').update(ip.trim()).digest('hex').slice(0, 16);
|
||||
}
|
||||
@@ -301,6 +301,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/SmsCampaignsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/sms-campaigns/:id/report',
|
||||
name: 'sms-campaign-report',
|
||||
component: () => import('@/views/SmsCampaignReportView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/custom-fields',
|
||||
name: 'custom-fields',
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { SmsCampaignReportDto } from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const report = ref<SmsCampaignReportDto | null>(null);
|
||||
|
||||
const id = computed(() => String(route.params.id || ''));
|
||||
|
||||
function maskMobile(m: string | null) {
|
||||
if (!m) return '—';
|
||||
const digits = m.replace(/\D/g, '');
|
||||
if (digits.length < 6) return m;
|
||||
return `${digits.slice(0, 4)}…${digits.slice(-2)}`;
|
||||
}
|
||||
|
||||
function clickAt(iso: string | null) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return `${formatJalaliNumeric(d)} ${formatClockFa(d)}`;
|
||||
}
|
||||
|
||||
function openContact(row: SmsCampaignReportDto['clickedUsers'][number]) {
|
||||
if (row.personId) {
|
||||
void router.push({ name: 'person-detail', params: { id: row.personId } });
|
||||
return;
|
||||
}
|
||||
if (row.leadId) {
|
||||
void router.push({ name: 'lead-detail', params: { id: row.leadId } });
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<SmsCampaignReportDto>(
|
||||
`/sms-campaigns/${id.value}/report`,
|
||||
);
|
||||
report.value = data;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'ماژول پیامک گروهی برای این محیط فعال نیست.';
|
||||
} else if (axios.isAxiosError(e) && e.response?.status === 404) {
|
||||
error.value = 'کمپین پیدا نشد.';
|
||||
} else {
|
||||
error.value = 'بارگذاری گزارش ناموفق بود.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
:title="report?.campaign.name || 'گزارش کمپین'"
|
||||
description="آمار ارسال و کلیک لینکهای ردیابیشده"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
@click="router.push({ name: 'sms-campaigns' })"
|
||||
>
|
||||
بازگشت
|
||||
</UiButton>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<template v-if="report && !loading">
|
||||
<p class="body muted">{{ report.campaign.body }}</p>
|
||||
|
||||
<div class="stats">
|
||||
<UiCard title="ارسالشده">
|
||||
<p class="stat">{{ toFaDigits(report.totalCount) }}</p>
|
||||
</UiCard>
|
||||
<UiCard title="ارسال موفق">
|
||||
<p class="stat">{{ toFaDigits(report.sentCount) }}</p>
|
||||
</UiCard>
|
||||
<UiCard title="کلیک یکتا">
|
||||
<p class="stat">{{ toFaDigits(report.uniqueClickers) }}</p>
|
||||
</UiCard>
|
||||
<UiCard title="نرخ کلیک">
|
||||
<p class="stat">{{ toFaDigits(report.clickRate) }}٪</p>
|
||||
</UiCard>
|
||||
<UiCard title="کل کلیک">
|
||||
<p class="stat">{{ toFaDigits(report.totalClicks) }}</p>
|
||||
</UiCard>
|
||||
<UiCard title="چندبار کلیک">
|
||||
<p class="stat">{{ toFaDigits(report.multiClickers) }}</p>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard title="کلیککنندگان">
|
||||
<UiEmptyState
|
||||
v-if="!report.clickedUsers.length"
|
||||
title="هنوز کلیکی ثبت نشده"
|
||||
description="وقتی مخاطب روی لینک داخل پیامک بزند، اینجا دیده میشود."
|
||||
/>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>مخاطب</th>
|
||||
<th>نوع</th>
|
||||
<th>شماره</th>
|
||||
<th>اولین کلیک</th>
|
||||
<th>آخرین کلیک</th>
|
||||
<th>تعداد</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, idx) in report.clickedUsers"
|
||||
:key="`${row.contactType}-${row.personId || row.leadId}-${idx}`"
|
||||
class="row"
|
||||
@click="openContact(row)"
|
||||
>
|
||||
<td>
|
||||
<strong>{{ row.name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{{ row.contactType === 'lead' ? 'Lead' : 'Customer' }}
|
||||
</td>
|
||||
<td>{{ maskMobile(row.mobile) }}</td>
|
||||
<td>{{ clickAt(row.firstClickedAt) }}</td>
|
||||
<td>{{ clickAt(row.lastClickedAt) }}</td>
|
||||
<td>{{ toFaDigits(row.clickCount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.body {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.stat {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
color: #0f766e;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: right;
|
||||
padding: 0.55rem 0.4rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.table th {
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:hover {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const peopleLoading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -192,7 +194,7 @@ onMounted(() => {
|
||||
<UiTextarea
|
||||
v-model="body"
|
||||
:rows="4"
|
||||
placeholder="متن پیامک را بنویسید…"
|
||||
placeholder="متن پیامک را بنویسید… در صورت وجود لینک http(s)، برای هر مخاطب لینک یکتا ساخته میشود."
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
@@ -245,6 +247,12 @@ onMounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
@click="router.push({ name: 'sms-campaign-report', params: { id: c.id } })"
|
||||
>
|
||||
گزارش
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="c.status === 'draft' || c.status === 'cancelled'"
|
||||
variant="secondary"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
NODE_ENV=production
|
||||
PORT=3032
|
||||
CORS_ORIGIN=https://crm2.montzar.ir
|
||||
PUBLIC_API_BASE_URL=https://crm2.montzar.ir/api
|
||||
JWT_SECRET=change-me-long-random-production-secret
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
|
||||
NODE_ENV=production
|
||||
PORT=3030
|
||||
CORS_ORIGIN=https://crm.montzar.ir
|
||||
JWT_SECRET=change-me-long-random-production-secret
|
||||
JWT_EXPIRES_IN=7d
|
||||
PUBLIC_API_BASE_URL=https://crm.montzar.ir/api
|
||||
|
||||
|
||||
# Postgres روی همین سرور — پورت واقعی این هاست معمولاً 15432 است (نه 5432)
|
||||
DATABASE_URL="postgresql://mento:CHANGE_ME@127.0.0.1:15432/mento_crm?schema=public"
|
||||
|
||||
Generated
-8203
File diff suppressed because it is too large
Load Diff
@@ -2547,7 +2547,8 @@ export type AutomationTrigger =
|
||||
| "ticket_resolved"
|
||||
| "invoice_issued"
|
||||
| "invoice_paid"
|
||||
| "activity_done";
|
||||
| "activity_done"
|
||||
| "sms_link_clicked";
|
||||
export type AutomationAction =
|
||||
| "create_activity"
|
||||
| "create_notification"
|
||||
@@ -2565,6 +2566,7 @@ export const AUTOMATION_TRIGGER_LABELS: Record<AutomationTrigger, string> = {
|
||||
invoice_issued: "فاکتور صادر شد",
|
||||
invoice_paid: "پرداخت روی فاکتور ثبت شد",
|
||||
activity_done: "فعالیت تمام شد",
|
||||
sms_link_clicked: "کلیک روی لینک پیامک",
|
||||
};
|
||||
|
||||
export const AUTOMATION_ACTION_LABELS: Record<AutomationAction, string> = {
|
||||
@@ -2593,6 +2595,7 @@ export const AUTOMATION_TRIGGERS = [
|
||||
"invoice_issued",
|
||||
"invoice_paid",
|
||||
"activity_done",
|
||||
"sms_link_clicked",
|
||||
] as const satisfies readonly AutomationTrigger[];
|
||||
|
||||
export const AUTOMATION_TRIGGER_GROUPS: {
|
||||
@@ -2607,6 +2610,7 @@ export const AUTOMATION_TRIGGER_GROUPS: {
|
||||
{ label: "تیکت", items: ["ticket_created", "ticket_resolved"] },
|
||||
{ label: "فعالیت", items: ["activity_done"] },
|
||||
{ label: "مالی", items: ["invoice_issued", "invoice_paid"] },
|
||||
{ label: "پیامک", items: ["sms_link_clicked"] },
|
||||
];
|
||||
|
||||
export type AutomationStepKind = "action" | "condition";
|
||||
@@ -2719,6 +2723,8 @@ export interface AutomationRuleConfig {
|
||||
notifyTarget?: AutomationNotifyTarget;
|
||||
activityType?: "call" | "meeting" | "task" | "note";
|
||||
points?: number;
|
||||
/** فیلتر اختیاری: فقط برای منبع خاص (مثلاً id کمپین SMS) */
|
||||
sourceId?: string | null;
|
||||
/** زنجیرهٔ اعمال؛ اگر خالی باشد همان action تکی استفاده میشود */
|
||||
steps?: AutomationStep[];
|
||||
}
|
||||
@@ -2917,7 +2923,8 @@ export type MessageAutomationTrigger =
|
||||
| "invoice_issued"
|
||||
| "invoice_paid"
|
||||
| "activity_created"
|
||||
| "activity_done";
|
||||
| "activity_done"
|
||||
| "sms_link_clicked";
|
||||
|
||||
export type MessageAutomationDelayUnit =
|
||||
| "minutes"
|
||||
@@ -2941,6 +2948,7 @@ export const MESSAGE_AUTOMATION_TRIGGERS = [
|
||||
"invoice_paid",
|
||||
"activity_created",
|
||||
"activity_done",
|
||||
"sms_link_clicked",
|
||||
] as const satisfies readonly MessageAutomationTrigger[];
|
||||
|
||||
export const MESSAGE_AUTOMATION_TRIGGER_LABELS: Record<
|
||||
@@ -2959,6 +2967,7 @@ export const MESSAGE_AUTOMATION_TRIGGER_LABELS: Record<
|
||||
invoice_paid: "پرداخت روی فاکتور ثبت شد",
|
||||
activity_created: "فعالیت ثبت شد",
|
||||
activity_done: "فعالیت تمام شد",
|
||||
sms_link_clicked: "کلیک روی لینک پیامک",
|
||||
};
|
||||
|
||||
export const MESSAGE_AUTOMATION_UNIT_LABELS: Record<
|
||||
@@ -3444,6 +3453,33 @@ export interface SmsCampaignListResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type SmsCampaignContactType = "customer" | "lead";
|
||||
|
||||
export interface SmsCampaignClickerDto {
|
||||
contactType: SmsCampaignContactType;
|
||||
personId: string | null;
|
||||
leadId: string | null;
|
||||
name: string;
|
||||
mobile: string | null;
|
||||
firstClickedAt: string | null;
|
||||
lastClickedAt: string | null;
|
||||
clickCount: number;
|
||||
}
|
||||
|
||||
export interface SmsCampaignReportDto {
|
||||
campaign: SmsCampaignDto;
|
||||
totalCount: number;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
skippedCount: number;
|
||||
uniqueClickers: number;
|
||||
totalClicks: number;
|
||||
/** درصد یکتا نسبت به ارسالشده */
|
||||
clickRate: number;
|
||||
multiClickers: number;
|
||||
clickedUsers: SmsCampaignClickerDto[];
|
||||
}
|
||||
|
||||
/* ——— تیمها و سطح دسترسی به پروندهها ——— */
|
||||
|
||||
export type TeamMemberRole = "member" | "manager";
|
||||
|
||||
Reference in New Issue
Block a user