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)
This commit is contained in:
140
src/routes/insights.ts
Normal file
140
src/routes/insights.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user