Files
network-app-api/src/routes/insights.ts
Hammer 567791e6bb feat: add CSV import, activity timeline, and insights endpoints
- POST /api/clients/import/preview - CSV preview with auto column mapping
- POST /api/clients/import - Import clients from CSV with custom mapping
- GET /api/clients/:id/activity - Activity timeline for client
- GET /api/insights - Dashboard AI insights (stale clients, birthdays, follow-ups)
2026-01-29 12:43:24 +00:00

141 lines
4.8 KiB
TypeScript

import { Elysia } from 'elysia';
import { db } from '../db';
import { clients, events } from '../db/schema';
import { eq, and, sql, lte, gte, isNull, or } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const insightsRoutes = new Elysia({ prefix: '/insights' })
.get('/', async ({ user }: { user: User }) => {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
// 1. Clients not contacted in 30+ days (or never contacted)
const staleClients = await db.select({
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
email: clients.email,
company: clients.company,
lastContactedAt: clients.lastContactedAt,
})
.from(clients)
.where(and(
eq(clients.userId, user.id),
or(
isNull(clients.lastContactedAt),
lte(clients.lastContactedAt, thirtyDaysAgo),
),
))
.orderBy(clients.lastContactedAt)
.limit(10);
// 2. Upcoming birthdays this week
// We need to compare month/day regardless of year
const allClients = await db.select({
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
birthday: clients.birthday,
email: clients.email,
})
.from(clients)
.where(and(
eq(clients.userId, user.id),
sql`${clients.birthday} IS NOT NULL`,
));
const upcomingBirthdays = allClients.filter(client => {
if (!client.birthday) return false;
const bday = new Date(client.birthday);
// Set birthday to this year
const thisYearBday = new Date(now.getFullYear(), bday.getMonth(), bday.getDate());
// Check if within next 7 days
const diff = thisYearBday.getTime() - now.getTime();
return diff >= -24 * 60 * 60 * 1000 && diff <= 7 * 24 * 60 * 60 * 1000; // include today
}).map(c => ({
id: c.id,
firstName: c.firstName,
lastName: c.lastName,
birthday: c.birthday!.toISOString(),
daysUntil: (() => {
const bday = new Date(c.birthday!);
const thisYearBday = new Date(now.getFullYear(), bday.getMonth(), bday.getDate());
return Math.ceil((thisYearBday.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
})(),
}));
// 3. Suggested follow-ups: clients contacted 14-30 days ago (proactive outreach window)
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const suggestedFollowups = await db.select({
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
email: clients.email,
company: clients.company,
lastContactedAt: clients.lastContactedAt,
})
.from(clients)
.where(and(
eq(clients.userId, user.id),
lte(clients.lastContactedAt, fourteenDaysAgo),
gte(clients.lastContactedAt, thirtyDaysAgo),
))
.orderBy(clients.lastContactedAt)
.limit(5);
// 4. Upcoming events (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, user.id),
gte(events.date, now),
lte(events.date, sevenDaysFromNow),
))
.orderBy(events.date)
.limit(10);
// 5. Summary stats
const totalClients = await db.select({ count: sql<number>`count(*)::int` })
.from(clients)
.where(eq(clients.userId, user.id));
const neverContactedCount = await db.select({ count: sql<number>`count(*)::int` })
.from(clients)
.where(and(eq(clients.userId, user.id), isNull(clients.lastContactedAt)));
return {
staleClients: staleClients.map(c => ({
...c,
lastContactedAt: c.lastContactedAt?.toISOString() || null,
daysSinceContact: c.lastContactedAt
? Math.floor((now.getTime() - c.lastContactedAt.getTime()) / (24 * 60 * 60 * 1000))
: null,
})),
upcomingBirthdays,
suggestedFollowups: suggestedFollowups.map(c => ({
...c,
lastContactedAt: c.lastContactedAt?.toISOString() || null,
daysSinceContact: c.lastContactedAt
? Math.floor((now.getTime() - c.lastContactedAt.getTime()) / (24 * 60 * 60 * 1000))
: null,
})),
upcomingEvents: upcomingEvents.map(e => ({
...e,
date: e.date.toISOString(),
})),
summary: {
totalClients: totalClients[0]?.count || 0,
neverContacted: neverContactedCount[0]?.count || 0,
staleCount: staleClients.length,
birthdaysThisWeek: upcomingBirthdays.length,
},
};
});