- 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)
141 lines
4.8 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
});
|