import { Prisma, ResponseStatus } from '@prisma/client'; import { prisma } from '../../../config/database'; import type { EffectivenessQuery, TrendQuery, GeoQuery, RepQuery } from './effectiveness.schemas'; function buildDateFilter(query: EffectivenessQuery) { const filter: { gte?: Date; lte?: Date } = {}; if (query.dateFrom) filter.gte = new Date(query.dateFrom); if (query.dateTo) filter.lte = new Date(query.dateTo); return Object.keys(filter).length > 0 ? filter : undefined; } export const effectivenessService = { /** * Per-campaign KPIs: email counts, response counts, response rate */ async getOverviewStats(query: EffectivenessQuery) { const dateFilter = buildDateFilter(query); const campaignWhere: Prisma.CampaignWhereInput = {}; if (query.campaignId) campaignWhere.id = query.campaignId; const emailWhere: Prisma.CampaignEmailWhereInput = {}; if (query.campaignId) emailWhere.campaignId = query.campaignId; if (dateFilter) emailWhere.sentAt = dateFilter; const responseWhere: Prisma.RepresentativeResponseWhereInput = {}; if (query.campaignId) responseWhere.campaignId = query.campaignId; if (dateFilter) responseWhere.createdAt = dateFilter; const callWhere: Prisma.CallWhereInput = {}; if (query.campaignId) callWhere.campaignId = query.campaignId; if (dateFilter) callWhere.calledAt = dateFilter; const [campaigns, emailsByStatus, responsesByStatus, totalCalls, totalEmails, totalResponses] = await Promise.all([ prisma.campaign.findMany({ where: campaignWhere, select: { id: true, title: true, slug: true, status: true, createdAt: true, _count: { select: { emails: true, responses: true, calls: true } }, }, orderBy: { createdAt: 'desc' }, }), prisma.campaignEmail.groupBy({ by: ['campaignId', 'status'], where: emailWhere, _count: true, }), prisma.representativeResponse.groupBy({ by: ['campaignId', 'status'], where: responseWhere, _count: true, }), prisma.call.count({ where: callWhere }), prisma.campaignEmail.count({ where: emailWhere }), prisma.representativeResponse.count({ where: { ...responseWhere, status: ResponseStatus.APPROVED } }), ]); // Build per-campaign email status map const emailStatusMap = new Map>(); for (const row of emailsByStatus) { if (!emailStatusMap.has(row.campaignId)) { emailStatusMap.set(row.campaignId, {}); } emailStatusMap.get(row.campaignId)![row.status] = row._count; } // Build per-campaign response status map const responseStatusMap = new Map>(); for (const row of responsesByStatus) { if (!responseStatusMap.has(row.campaignId)) { responseStatusMap.set(row.campaignId, {}); } responseStatusMap.get(row.campaignId)![row.status] = row._count; } const campaignStats = campaigns.map((c) => { const emailBreakdown = emailStatusMap.get(c.id) || {}; const responseBreakdown = responseStatusMap.get(c.id) || {}; const emailTotal = Object.values(emailBreakdown).reduce((a, b) => a + b, 0); const approvedResponses = responseBreakdown[ResponseStatus.APPROVED] || 0; const responseRate = emailTotal > 0 ? approvedResponses / emailTotal : 0; return { campaignId: c.id, title: c.title, slug: c.slug, status: c.status, createdAt: c.createdAt, emailTotal, emailBreakdown, responseTotal: Object.values(responseBreakdown).reduce((a, b) => a + b, 0), approvedResponses, responseBreakdown, responseRate, callCount: c._count.calls, }; }); const activeCampaigns = campaigns.filter((c) => c.status === 'ACTIVE').length; const avgResponseRate = totalEmails > 0 ? totalResponses / totalEmails : 0; return { summary: { totalEmails, totalResponses, totalCalls, activeCampaigns, totalCampaigns: campaigns.length, avgResponseRate, }, campaigns: campaignStats, }; }, /** * Cross-campaign representative tracking: emails received, responses given, response rate */ async getRepresentativeEffectiveness(query: RepQuery) { const dateFilter = buildDateFilter(query); const emailWhere: Prisma.CampaignEmailWhereInput = {}; if (query.campaignId) emailWhere.campaignId = query.campaignId; if (dateFilter) emailWhere.sentAt = dateFilter; const responseWhere: Prisma.RepresentativeResponseWhereInput = {}; if (query.campaignId) responseWhere.campaignId = query.campaignId; if (dateFilter) responseWhere.createdAt = dateFilter; const [emailsByRecipient, responsesByRep] = await Promise.all([ prisma.campaignEmail.groupBy({ by: ['recipientEmail', 'recipientName', 'recipientLevel'], where: emailWhere, _count: true, }), prisma.representativeResponse.groupBy({ by: ['representativeName', 'representativeLevel'], where: { ...responseWhere, status: ResponseStatus.APPROVED }, _count: true, }), ]); // Also count verified responses const verifiedByRep = await prisma.representativeResponse.groupBy({ by: ['representativeName'], where: { ...responseWhere, isVerified: true }, _count: true, }); const verifiedMap = new Map(verifiedByRep.map((r) => [r.representativeName, r._count])); // Build response map keyed by rep name (normalized lowercase) const responseMap = new Map(); for (const row of responsesByRep) { responseMap.set(row.representativeName.toLowerCase(), { count: row._count, level: row.representativeLevel, }); } // Merge: start from email recipients, enrich with response data const repMap = new Map(); for (const row of emailsByRecipient) { const key = (row.recipientName || row.recipientEmail).toLowerCase(); const existing = repMap.get(key); if (existing) { existing.emailsReceived += row._count; } else { const respData = responseMap.get(key); const verifiedCount = verifiedMap.get(row.recipientName || row.recipientEmail) || 0; repMap.set(key, { name: row.recipientName || row.recipientEmail, email: row.recipientEmail, level: row.recipientLevel, emailsReceived: row._count, responsesGiven: respData?.count || 0, verifiedCount, responseRate: 0, }); } } // Also add reps who responded but weren't in email records for (const row of responsesByRep) { const key = row.representativeName.toLowerCase(); if (!repMap.has(key)) { const verifiedCount = verifiedMap.get(row.representativeName) || 0; repMap.set(key, { name: row.representativeName, email: '', level: row.representativeLevel, emailsReceived: 0, responsesGiven: row._count, verifiedCount, responseRate: 0, }); } } // Compute response rates const reps = Array.from(repMap.values()).map((r) => ({ ...r, responseRate: r.emailsReceived > 0 ? r.responsesGiven / r.emailsReceived : 0, })); // Sort if (query.sortBy === 'responseRate') { reps.sort((a, b) => b.responseRate - a.responseRate); } else if (query.sortBy === 'name') { reps.sort((a, b) => a.name.localeCompare(b.name)); } else { reps.sort((a, b) => b.responsesGiven - a.responsesGiven); } // Level distribution const levelCounts: Record = {}; for (const row of responsesByRep) { levelCounts[row.representativeLevel] = (levelCounts[row.representativeLevel] || 0) + row._count; } return { representatives: reps.slice(0, query.limit), totalRepresentatives: reps.length, levelDistribution: Object.entries(levelCounts).map(([level, count]) => ({ level, count })), }; }, /** * Engagement breakdown by geographic area (postal code, city, or province) */ async getGeographicBreakdown(query: GeoQuery) { const dateFilter = buildDateFilter(query); const emailWhere: Prisma.CampaignEmailWhereInput = { userPostalCode: { not: null }, }; if (query.campaignId) emailWhere.campaignId = query.campaignId; if (dateFilter) emailWhere.sentAt = dateFilter; if (query.groupBy === 'postalCode') { const results = await prisma.campaignEmail.groupBy({ by: ['userPostalCode'], where: emailWhere, _count: true, orderBy: { _count: { userPostalCode: 'desc' } }, take: query.limit, }); // Enrich with city/province from postal code cache const postalCodes = results .map((r) => r.userPostalCode) .filter((pc): pc is string => pc !== null); const cacheEntries = postalCodes.length > 0 ? await prisma.postalCodeCache.findMany({ where: { postalCode: { in: postalCodes } }, select: { postalCode: true, city: true, province: true }, }) : []; const cacheMap = new Map(cacheEntries.map((e) => [e.postalCode, e])); return { groupBy: query.groupBy, data: results.map((r) => { const cache = cacheMap.get(r.userPostalCode || ''); return { key: r.userPostalCode || 'Unknown', emailCount: r._count, city: cache?.city || null, province: cache?.province || null, }; }), }; } // For city/province grouping, we need to join with postal_code_cache // Use pre-validated lookup map to prevent SQL injection if enum expands const GROUP_COL_MAP: Record> = { province: Prisma.sql`pcc.province`, city: Prisma.sql`pcc.city`, }; const groupCol = GROUP_COL_MAP[query.groupBy!] ?? GROUP_COL_MAP.city; const campaignFilter = query.campaignId ? Prisma.sql`AND ce."campaignId" = ${query.campaignId}` : Prisma.sql``; const dateGteFilter = dateFilter?.gte ? Prisma.sql`AND ce."sentAt" >= ${dateFilter.gte}` : Prisma.sql``; const dateLteFilter = dateFilter?.lte ? Prisma.sql`AND ce."sentAt" <= ${dateFilter.lte}` : Prisma.sql``; const rawResults = await prisma.$queryRaw>` SELECT ${groupCol} as key, COUNT(*) as email_count FROM campaign_emails ce LEFT JOIN postal_code_cache pcc ON ce."userPostalCode" = pcc."postalCode" WHERE ce."userPostalCode" IS NOT NULL AND ${groupCol} IS NOT NULL ${campaignFilter} ${dateGteFilter} ${dateLteFilter} GROUP BY ${groupCol} ORDER BY email_count DESC LIMIT ${query.limit} `; return { groupBy: query.groupBy, data: rawResults.map((r) => ({ key: r.key, emailCount: Number(r.email_count), city: null, province: null, })), }; }, /** * Conversion funnel: emails → unique participants → responses → verified responses */ async getFunnelData(query: EffectivenessQuery) { const dateFilter = buildDateFilter(query); const emailWhere: Prisma.CampaignEmailWhereInput = {}; if (query.campaignId) emailWhere.campaignId = query.campaignId; if (dateFilter) emailWhere.sentAt = dateFilter; const responseWhere: Prisma.RepresentativeResponseWhereInput = {}; if (query.campaignId) responseWhere.campaignId = query.campaignId; if (dateFilter) responseWhere.createdAt = dateFilter; const callWhere: Prisma.CallWhereInput = {}; if (query.campaignId) callWhere.campaignId = query.campaignId; if (dateFilter) callWhere.calledAt = dateFilter; // Build parameterized conditions for unique participant count const campaignFilter = query.campaignId ? Prisma.sql`AND "campaignId" = ${query.campaignId}` : Prisma.sql``; const dateGteFilter = dateFilter?.gte ? Prisma.sql`AND "sentAt" >= ${dateFilter.gte}` : Prisma.sql``; const dateLteFilter = dateFilter?.lte ? Prisma.sql`AND "sentAt" <= ${dateFilter.lte}` : Prisma.sql``; const [emailsSent, uniqueParticipants, approvedResponses, verifiedResponses, callsMade] = await Promise.all([ prisma.campaignEmail.count({ where: emailWhere }), prisma.$queryRaw<[{ count: bigint }]>` SELECT COUNT(DISTINCT "userEmail") as count FROM campaign_emails WHERE 1=1 ${campaignFilter} ${dateGteFilter} ${dateLteFilter} `, prisma.representativeResponse.count({ where: { ...responseWhere, status: ResponseStatus.APPROVED }, }), prisma.representativeResponse.count({ where: { ...responseWhere, isVerified: true }, }), prisma.call.count({ where: callWhere }), ]); const participantCount = Number(uniqueParticipants[0]?.count || 0); const stages = [ { name: 'Emails Sent', count: emailsSent }, { name: 'Unique Participants', count: participantCount }, { name: 'Responses Received', count: approvedResponses }, { name: 'Verified Responses', count: verifiedResponses }, { name: 'Calls Made', count: callsMade }, ]; // Compute percentages relative to first stage and dropoff from previous const firstCount = stages[0].count || 1; return stages.map((stage, i) => ({ ...stage, percentOfFirst: stage.count / firstCount, dropoff: i > 0 ? (stages[i - 1].count > 0 ? 1 - stage.count / stages[i - 1].count : 0) : 0, })); }, /** * Time-series: daily/weekly email + response volumes */ async getActivityTrends(query: TrendQuery) { const dateFilter = buildDateFilter(query); const truncFn = query.granularity === 'week' ? 'week' : 'day'; // Default: last 30 days const defaultFrom = new Date(); defaultFrom.setDate(defaultFrom.getDate() - 30); const from = dateFilter?.gte || defaultFrom; const to = dateFilter?.lte || new Date(); // Use a lookup map instead of Prisma.raw() to prevent SQL injection if enum changes const truncFnMap: Record> = { day: Prisma.sql`'day'`, week: Prisma.sql`'week'`, }; const truncFnSql = truncFnMap[truncFn] || truncFnMap.day; const campaignFilter = query.campaignId ? Prisma.sql`AND "campaignId" = ${query.campaignId}` : Prisma.sql``; const [emailTrends, responseTrends] = await Promise.all([ prisma.$queryRaw>` SELECT DATE_TRUNC(${truncFnSql}, "sentAt") as period, COUNT(*) as count FROM campaign_emails WHERE "sentAt" >= ${from} AND "sentAt" <= ${to} ${campaignFilter} GROUP BY period ORDER BY period ASC `, prisma.$queryRaw>` SELECT DATE_TRUNC(${truncFnSql}, "createdAt") as period, COUNT(*) as count FROM representative_responses WHERE "createdAt" >= ${from} AND "createdAt" <= ${to} AND status = 'APPROVED' ${campaignFilter} GROUP BY period ORDER BY period ASC `, ]); // Merge into a single series with both email and response counts const periodMap = new Map(); for (const row of emailTrends) { const key = row.period.toISOString().split('T')[0]; if (!periodMap.has(key)) periodMap.set(key, { emails: 0, responses: 0 }); periodMap.get(key)!.emails = Number(row.count); } for (const row of responseTrends) { const key = row.period.toISOString().split('T')[0]; if (!periodMap.has(key)) periodMap.set(key, { emails: 0, responses: 0 }); periodMap.get(key)!.responses = Number(row.count); } // Sort by date and return const series = Array.from(periodMap.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([date, counts]) => ({ date, emails: counts.emails, responses: counts.responses, })); return { granularity: query.granularity, dateFrom: from.toISOString(), dateTo: to.toISOString(), series, }; }, };