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