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);
}