feat: add reports/analytics API - overview stats, growth charts, engagement breakdown, industry/tag distributions, CSV export, notification alerts
This commit is contained in:
@@ -12,6 +12,7 @@ import { passwordResetRoutes } from './routes/password-reset';
|
|||||||
import { importRoutes } from './routes/import';
|
import { importRoutes } from './routes/import';
|
||||||
import { activityRoutes } from './routes/activity';
|
import { activityRoutes } from './routes/activity';
|
||||||
import { insightsRoutes } from './routes/insights';
|
import { insightsRoutes } from './routes/insights';
|
||||||
|
import { reportsRoutes } from './routes/reports';
|
||||||
import { db } from './db';
|
import { db } from './db';
|
||||||
import { users } from './db/schema';
|
import { users } from './db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -68,6 +69,7 @@ const app = new Elysia()
|
|||||||
.use(adminRoutes)
|
.use(adminRoutes)
|
||||||
.use(networkRoutes)
|
.use(networkRoutes)
|
||||||
.use(insightsRoutes)
|
.use(insightsRoutes)
|
||||||
|
.use(reportsRoutes)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
|
|||||||
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