Files
network-app-api/src/routes/reports.ts
Hammer 7634306832 fix: resolve TypeScript errors for CI pipeline
- Fix pg-boss Job type imports (PgBoss.Job -> Job from pg-boss)
- Replace deprecated teamConcurrency with localConcurrency
- Add null checks for possibly undefined values (clients, import rows)
- Fix tone type narrowing in profile.ts
- Fix test type assertions (non-null assertions, explicit Record types)
- Extract auth middleware into shared module
- Fix rate limiter Map generic type
2026-01-30 03:27:58 +00:00

443 lines
14 KiB
TypeScript

import { authMiddleware } from '../middleware/auth';
import { Elysia } from 'elysia';
import { db } from '../db';
import { clients, events, communications } from '../db/schema';
import { eq, and, sql, gte, lte, count, desc } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const reportsRoutes = new Elysia()
.use(authMiddleware)
// Analytics overview
.get('/reports/overview', async ({ user }: { user: User }) => {
const userId = user.id;
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
// Total clients
const [totalClients] = await db.select({ count: count() })
.from(clients)
.where(eq(clients.userId, userId));
// New clients this month
const [newClientsMonth] = await db.select({ count: count() })
.from(clients)
.where(and(eq(clients.userId, userId), gte(clients.createdAt, thirtyDaysAgo)));
// New clients this week
const [newClientsWeek] = await db.select({ count: count() })
.from(clients)
.where(and(eq(clients.userId, userId), gte(clients.createdAt, sevenDaysAgo)));
// Total emails
const [totalEmails] = await db.select({ count: count() })
.from(communications)
.where(eq(communications.userId, userId));
// Emails sent
const [emailsSent] = await db.select({ count: count() })
.from(communications)
.where(and(eq(communications.userId, userId), eq(communications.status, 'sent')));
// Emails drafted (pending)
const [emailsDraft] = await db.select({ count: count() })
.from(communications)
.where(and(eq(communications.userId, userId), eq(communications.status, 'draft')));
// Emails sent last 30 days
const [emailsRecent] = await db.select({ count: count() })
.from(communications)
.where(and(
eq(communications.userId, userId),
eq(communications.status, 'sent'),
gte(communications.sentAt, thirtyDaysAgo)
));
// Total events
const [totalEvents] = await db.select({ count: count() })
.from(events)
.where(eq(events.userId, userId));
// Upcoming events (next 30 days)
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const [upcomingEvents] = await db.select({ count: count() })
.from(events)
.where(and(
eq(events.userId, userId),
gte(events.date, now),
lte(events.date, thirtyDaysFromNow)
));
// Clients contacted in last 30 days
const [contactedRecently] = await db.select({ count: count() })
.from(clients)
.where(and(
eq(clients.userId, userId),
gte(clients.lastContactedAt, thirtyDaysAgo)
));
// Clients never contacted
const [neverContacted] = await db.select({ count: count() })
.from(clients)
.where(and(
eq(clients.userId, userId),
sql`${clients.lastContactedAt} IS NULL`
));
return {
clients: {
total: totalClients?.count ?? 0,
newThisMonth: newClientsMonth?.count ?? 0,
newThisWeek: newClientsWeek?.count ?? 0,
contactedRecently: contactedRecently?.count ?? 0,
neverContacted: neverContacted?.count ?? 0,
},
emails: {
total: totalEmails?.count ?? 0,
sent: emailsSent?.count ?? 0,
draft: emailsDraft?.count ?? 0,
sentLast30Days: emailsRecent?.count ?? 0,
},
events: {
total: totalEvents?.count ?? 0,
upcoming30Days: upcomingEvents?.count ?? 0,
},
};
})
// Client growth over time (last 12 months)
.get('/reports/growth', async ({ user }: { user: User }) => {
const userId = user.id;
// Monthly client additions for the last 12 months
const monthlyGrowth = await db.select({
month: sql<string>`to_char(${clients.createdAt}, 'YYYY-MM')`,
count: count(),
})
.from(clients)
.where(and(
eq(clients.userId, userId),
gte(clients.createdAt, sql`NOW() - INTERVAL '12 months'`)
))
.groupBy(sql`to_char(${clients.createdAt}, 'YYYY-MM')`)
.orderBy(sql`to_char(${clients.createdAt}, 'YYYY-MM')`);
// Monthly emails sent for the last 12 months
const monthlyEmails = await db.select({
month: sql<string>`to_char(${communications.sentAt}, 'YYYY-MM')`,
count: count(),
})
.from(communications)
.where(and(
eq(communications.userId, userId),
eq(communications.status, 'sent'),
gte(communications.sentAt, sql`NOW() - INTERVAL '12 months'`)
))
.groupBy(sql`to_char(${communications.sentAt}, 'YYYY-MM')`)
.orderBy(sql`to_char(${communications.sentAt}, 'YYYY-MM')`);
return {
clientGrowth: monthlyGrowth,
emailActivity: monthlyEmails,
};
})
// Industry breakdown
.get('/reports/industries', async ({ user }: { user: User }) => {
const userId = user.id;
const industries = await db.select({
industry: clients.industry,
count: count(),
})
.from(clients)
.where(and(
eq(clients.userId, userId),
sql`${clients.industry} IS NOT NULL AND ${clients.industry} != ''`
))
.groupBy(clients.industry)
.orderBy(desc(count()));
return industries;
})
// Tag distribution
.get('/reports/tags', async ({ user }: { user: User }) => {
const userId = user.id;
// Get all clients with tags
const allClients = await db.select({
tags: clients.tags,
})
.from(clients)
.where(eq(clients.userId, userId));
// Count tag occurrences
const tagCounts: Record<string, number> = {};
for (const c of allClients) {
const tags = c.tags as string[] | null;
if (tags) {
for (const tag of tags) {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
}
}
}
return Object.entries(tagCounts)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count);
})
// Engagement score — contact frequency analysis
.get('/reports/engagement', async ({ user }: { user: User }) => {
const userId = user.id;
const now = new Date();
const allClients = await db.select({
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
company: clients.company,
lastContactedAt: clients.lastContactedAt,
createdAt: clients.createdAt,
})
.from(clients)
.where(eq(clients.userId, userId));
// Categorize by engagement level
const engaged: typeof allClients = []; // contacted in last 14 days
const warm: typeof allClients = []; // contacted 15-30 days ago
const cooling: typeof allClients = []; // contacted 31-60 days ago
const cold: typeof allClients = []; // contacted 61+ days ago or never
for (const c of allClients) {
if (!c.lastContactedAt) {
cold.push(c);
continue;
}
const daysSince = Math.floor((now.getTime() - new Date(c.lastContactedAt).getTime()) / (1000 * 60 * 60 * 24));
if (daysSince <= 14) engaged.push(c);
else if (daysSince <= 30) warm.push(c);
else if (daysSince <= 60) cooling.push(c);
else cold.push(c);
}
return {
summary: {
engaged: engaged.length,
warm: warm.length,
cooling: cooling.length,
cold: cold.length,
},
coldClients: cold.slice(0, 10).map(c => ({
id: c.id,
name: `${c.firstName} ${c.lastName}`,
company: c.company,
lastContacted: c.lastContactedAt,
})),
coolingClients: cooling.slice(0, 10).map(c => ({
id: c.id,
name: `${c.firstName} ${c.lastName}`,
company: c.company,
lastContacted: c.lastContactedAt,
})),
};
})
// CSV Export
.get('/reports/export/clients', async ({ user, set }: { user: User; set: any }) => {
const userId = user.id;
const allClients = await db.select()
.from(clients)
.where(eq(clients.userId, userId))
.orderBy(clients.lastName);
// Build CSV
const headers = [
'First Name', 'Last Name', 'Email', 'Phone',
'Company', 'Role', 'Industry',
'Street', 'City', 'State', 'ZIP',
'Birthday', 'Anniversary',
'Interests', 'Tags', 'Notes',
'Last Contacted', 'Created',
];
const escapeCSV = (val: string | null | undefined): string => {
if (!val) return '';
const s = String(val);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
};
const rows = allClients.map(c => [
escapeCSV(c.firstName),
escapeCSV(c.lastName),
escapeCSV(c.email),
escapeCSV(c.phone),
escapeCSV(c.company),
escapeCSV(c.role),
escapeCSV(c.industry),
escapeCSV(c.street),
escapeCSV(c.city),
escapeCSV(c.state),
escapeCSV(c.zip),
escapeCSV(c.birthday ? new Date(c.birthday).toISOString().split('T')[0] : null),
escapeCSV(c.anniversary ? new Date(c.anniversary).toISOString().split('T')[0] : null),
escapeCSV((c.interests as string[] | null)?.join('; ')),
escapeCSV((c.tags as string[] | null)?.join('; ')),
escapeCSV(c.notes),
escapeCSV(c.lastContactedAt ? new Date(c.lastContactedAt).toISOString().split('T')[0] : null),
escapeCSV(c.createdAt ? new Date(c.createdAt).toISOString().split('T')[0] : null),
].join(','));
const csv = [headers.join(','), ...rows].join('\n');
set.headers['Content-Type'] = 'text/csv';
set.headers['Content-Disposition'] = `attachment; filename="clients-export-${new Date().toISOString().split('T')[0]}.csv"`;
return csv;
})
// Notifications / alerts
.get('/reports/notifications', async ({ user }: { user: User }) => {
const userId = user.id;
const now = new Date();
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
// Upcoming events in next 7 days
const upcomingEvents = await db.select({
id: events.id,
title: events.title,
type: events.type,
date: events.date,
clientId: events.clientId,
})
.from(events)
.where(and(
eq(events.userId, userId),
gte(events.date, now),
lte(events.date, sevenDaysFromNow)
))
.orderBy(events.date)
.limit(20);
// Overdue follow-ups (events in the past that haven't been triggered)
const overdueEvents = await db.select({
id: events.id,
title: events.title,
type: events.type,
date: events.date,
clientId: events.clientId,
})
.from(events)
.where(and(
eq(events.userId, userId),
eq(events.type, 'followup'),
lte(events.date, now),
gte(events.date, thirtyDaysAgo)
))
.orderBy(desc(events.date))
.limit(10);
// Stale clients (not contacted in 30+ days)
const staleClients = await db.select({
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
lastContactedAt: clients.lastContactedAt,
})
.from(clients)
.where(and(
eq(clients.userId, userId),
lte(clients.lastContactedAt, thirtyDaysAgo)
))
.orderBy(clients.lastContactedAt)
.limit(10);
// Draft emails pending
const [draftCount] = await db.select({ count: count() })
.from(communications)
.where(and(
eq(communications.userId, userId),
eq(communications.status, 'draft')
));
const notifications = [];
// Build notification items
for (const ev of overdueEvents) {
notifications.push({
id: `overdue-${ev.id}`,
type: 'overdue' as const,
title: `Overdue: ${ev.title}`,
description: `Was due ${new Date(ev.date).toLocaleDateString()}`,
date: ev.date,
link: `/clients/${ev.clientId}`,
priority: 'high' as const,
});
}
for (const ev of upcomingEvents) {
const daysUntil = Math.ceil((new Date(ev.date).getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
notifications.push({
id: `upcoming-${ev.id}`,
type: 'upcoming' as const,
title: ev.title,
description: daysUntil === 0 ? 'Today!' : daysUntil === 1 ? 'Tomorrow' : `In ${daysUntil} days`,
date: ev.date,
link: `/events`,
priority: daysUntil <= 1 ? 'high' as const : 'medium' as const,
});
}
for (const c of staleClients) {
const daysSince = c.lastContactedAt
? Math.floor((now.getTime() - new Date(c.lastContactedAt).getTime()) / (1000 * 60 * 60 * 24))
: null;
notifications.push({
id: `stale-${c.id}`,
type: 'stale' as const,
title: `${c.firstName} ${c.lastName} needs attention`,
description: daysSince ? `Last contacted ${daysSince} days ago` : 'Never contacted',
date: c.lastContactedAt || new Date(0).toISOString(),
link: `/clients/${c.id}`,
priority: 'low' as const,
});
}
if ((draftCount?.count ?? 0) > 0) {
notifications.push({
id: 'drafts',
type: 'drafts' as const,
title: `${draftCount?.count ?? 0} draft email${(draftCount?.count ?? 0) > 1 ? 's' : ''} pending`,
description: 'Review and send your drafted emails',
date: new Date().toISOString(),
link: '/emails',
priority: 'medium' as const,
});
}
// Sort by priority then date
const priorityOrder = { high: 0, medium: 1, low: 2 };
notifications.sort((a, b) => {
const pDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
if (pDiff !== 0) return pDiff;
return new Date(a.date).getTime() - new Date(b.date).getTime();
});
return {
notifications,
counts: {
total: notifications.length,
high: notifications.filter(n => n.priority === 'high').length,
overdue: overdueEvents.length,
upcoming: upcomingEvents.length,
stale: staleClients.length,
drafts: draftCount?.count ?? 0,
},
};
});