Implementing a link tracking and user click logging system.Implementing a link tracking and user click logging system.

This commit is contained in:
2026-10-01 15:59:18 +03:30
parent ae21d9bbc7
commit 5d5b3a3213
20 changed files with 978 additions and 8211 deletions
@@ -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;
+78
View File
@@ -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
+2
View File
@@ -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,
+1
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+236
View File
@@ -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);
}
+6
View File
@@ -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>
+9 -1
View File
@@ -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"
+1
View File
@@ -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
+2 -3
View File
@@ -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"
-8203
View File
File diff suppressed because it is too large Load Diff
+38 -2
View File
@@ -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";