bunker-admin 5a0c4641a1 Security audit fixes, mobile responsiveness across 40+ admin pages
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
2026-03-31 18:30:17 -06:00

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,
};
},
};