Security hardening from Mar 31 audit:
- Separate login rate limit (10/15min) from general auth budget (15/15min)
- Timing-safe webhook secret comparison (Listmonk)
- Docs file creation ACL check (matches PUT/DELETE guards)
- Key separation warnings for GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
- Clear GITEA_ADMIN_PASSWORD from .env after auto-setup
- SQL injection prevention in effectiveness groupBy (pre-validated map)
- Token hashing for password reset and verification tokens
Mobile responsiveness (Phase 2C):
- Add MobilePageHeader component and useMobile hook
- Responsive table columns (hide secondary cols on mobile)
- scroll={{ x: 'max-content' }} across all data tables
- Mobile-adapted layouts for Dashboard, Settings, Calendar, SMS, Social pages
- Conditional toolbar buttons on mobile viewports
Infrastructure:
- Updated docker-compose and nginx templates
- Build script and mirror script updates
Bunker Admin
473 lines
16 KiB
TypeScript
473 lines
16 KiB
TypeScript
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<string, Record<string, number>>();
|
|
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<string, Record<string, number>>();
|
|
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<string, { count: number; level: string }>();
|
|
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<string, {
|
|
name: string;
|
|
email: string;
|
|
level: string | null;
|
|
emailsReceived: number;
|
|
responsesGiven: number;
|
|
verifiedCount: number;
|
|
responseRate: number;
|
|
}>();
|
|
|
|
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<string, number> = {};
|
|
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<string, ReturnType<typeof Prisma.sql>> = {
|
|
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<Array<{ key: string; email_count: bigint }>>`
|
|
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<string, ReturnType<typeof Prisma.sql>> = {
|
|
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<Array<{ period: Date; count: bigint }>>`
|
|
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<Array<{ period: Date; count: bigint }>>`
|
|
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<string, { emails: number; responses: number }>();
|
|
|
|
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,
|
|
};
|
|
},
|
|
};
|