Implementing a link tracking and user click logging system.Implementing a link tracking and user click logging system.
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user