feat: add reports/analytics API - overview stats, growth charts, engagement breakdown, industry/tag distributions, CSV export, notification alerts
This commit is contained in:
440
src/routes/reports.ts
Normal file
440
src/routes/reports.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
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()
|
||||
// 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,
|
||||
newThisMonth: newClientsMonth.count,
|
||||
newThisWeek: newClientsWeek.count,
|
||||
contactedRecently: contactedRecently.count,
|
||||
neverContacted: neverContacted.count,
|
||||
},
|
||||
emails: {
|
||||
total: totalEmails.count,
|
||||
sent: emailsSent.count,
|
||||
draft: emailsDraft.count,
|
||||
sentLast30Days: emailsRecent.count,
|
||||
},
|
||||
events: {
|
||||
total: totalEvents.count,
|
||||
upcoming30Days: upcomingEvents.count,
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
// 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) {
|
||||
notifications.push({
|
||||
id: 'drafts',
|
||||
type: 'drafts' as const,
|
||||
title: `${draftCount.count} draft email${draftCount.count > 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user