diff --git a/apps/api/prisma/migrations/20261001120000_tracking_links_events/migration.sql b/apps/api/prisma/migrations/20261001120000_tracking_links_events/migration.sql new file mode 100644 index 0000000..b4221cf --- /dev/null +++ b/apps/api/prisma/migrations/20261001120000_tracking_links_events/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 65a0f4d..8c7e421 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c73d07e..f62abfd 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts index f0feabf..912e5d1 100644 --- a/apps/api/src/automations/automations.service.ts +++ b/apps/api/src/automations/automations.service.ts @@ -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, diff --git a/apps/api/src/automations/dto.ts b/apps/api/src/automations/dto.ts index fd17423..93dd670 100644 --- a/apps/api/src/automations/dto.ts +++ b/apps/api/src/automations/dto.ts @@ -24,6 +24,7 @@ const TRIGGERS = [ 'invoice_issued', 'invoice_paid', 'activity_done', + 'sms_link_clicked', ] as const; const ACTIONS = [ 'create_activity', diff --git a/apps/api/src/message-automations/message-automation.util.ts b/apps/api/src/message-automations/message-automation.util.ts index 17f53be..20e54e7 100644 --- a/apps/api/src/message-automations/message-automation.util.ts +++ b/apps/api/src/message-automations/message-automation.util.ts @@ -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; diff --git a/apps/api/src/message-automations/message-automations.service.ts b/apps/api/src/message-automations/message-automations.service.ts index 718046e..151587c 100644 --- a/apps/api/src/message-automations/message-automations.service.ts +++ b/apps/api/src/message-automations/message-automations.service.ts @@ -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; diff --git a/apps/api/src/sms-campaigns/sms-campaigns.controller.ts b/apps/api/src/sms-campaigns/sms-campaigns.controller.ts index 70ac607..ebb4edb 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.controller.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.controller.ts @@ -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 }, diff --git a/apps/api/src/sms-campaigns/sms-campaigns.module.ts b/apps/api/src/sms-campaigns/sms-campaigns.module.ts index ce830b8..d9d825c 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.module.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.module.ts @@ -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], diff --git a/apps/api/src/sms-campaigns/sms-campaigns.service.ts b/apps/api/src/sms-campaigns/sms-campaigns.service.ts index b23967d..315209e 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.service.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.service.ts @@ -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 { + 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(); + 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({ diff --git a/apps/api/src/tracking/public-tracking.controller.ts b/apps/api/src/tracking/public-tracking.controller.ts new file mode 100644 index 0000000..b1fa79a --- /dev/null +++ b/apps/api/src/tracking/public-tracking.controller.ts @@ -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); + } +} diff --git a/apps/api/src/tracking/tracking.module.ts b/apps/api/src/tracking/tracking.module.ts new file mode 100644 index 0000000..0160b3e --- /dev/null +++ b/apps/api/src/tracking/tracking.module.ts @@ -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 {} diff --git a/apps/api/src/tracking/tracking.service.ts b/apps/api/src/tracking/tracking.service.ts new file mode 100644 index 0000000..7b57e36 --- /dev/null +++ b/apps/api/src/tracking/tracking.service.ts @@ -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('PUBLIC_API_BASE_URL')?.trim(); + if (explicit) return explicit.replace(/\/$/, ''); + const cors = this.config.get('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 { + 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 { + 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 { + 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); +} diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index ec09e70..0c2513e 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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', diff --git a/apps/web/src/views/SmsCampaignReportView.vue b/apps/web/src/views/SmsCampaignReportView.vue new file mode 100644 index 0000000..bdffe99 --- /dev/null +++ b/apps/web/src/views/SmsCampaignReportView.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/apps/web/src/views/SmsCampaignsView.vue b/apps/web/src/views/SmsCampaignsView.vue index 6a5219b..14e40f4 100644 --- a/apps/web/src/views/SmsCampaignsView.vue +++ b/apps/web/src/views/SmsCampaignsView.vue @@ -1,5 +1,6 @@