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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user