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:
2026-01-29 12:43:24 +00:00
parent a4c6ada7de
commit 567791e6bb
4 changed files with 550 additions and 0 deletions

121
src/routes/activity.ts Normal file
View File

@@ -0,0 +1,121 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients, events, communications } from '../db/schema';
import { eq, and, desc } from 'drizzle-orm';
import type { User } from '../lib/auth';
export interface ActivityItem {
id: string;
type: 'email_sent' | 'email_drafted' | 'event_created' | 'client_contacted' | 'client_created' | 'client_updated';
title: string;
description?: string;
date: string;
metadata?: Record<string, any>;
}
export const activityRoutes = new Elysia({ prefix: '/clients' })
// Get activity timeline for a client
.get('/:id/activity', async ({ params, user }: { params: { id: string }; user: User }) => {
// Verify client belongs to user
const [client] = await db.select()
.from(clients)
.where(and(eq(clients.id, params.id), eq(clients.userId, user.id)))
.limit(1);
if (!client) {
throw new Error('Client not found');
}
const activities: ActivityItem[] = [];
// Client creation
activities.push({
id: `created-${client.id}`,
type: 'client_created',
title: 'Client added to network',
date: client.createdAt.toISOString(),
});
// Client updated (if different from created)
if (client.updatedAt.getTime() - client.createdAt.getTime() > 60000) {
activities.push({
id: `updated-${client.id}`,
type: 'client_updated',
title: 'Client profile updated',
date: client.updatedAt.toISOString(),
});
}
// Last contacted
if (client.lastContactedAt) {
activities.push({
id: `contacted-${client.id}`,
type: 'client_contacted',
title: 'Marked as contacted',
date: client.lastContactedAt.toISOString(),
});
}
// Communications (emails)
const comms = await db.select()
.from(communications)
.where(and(
eq(communications.clientId, params.id),
eq(communications.userId, user.id),
))
.orderBy(desc(communications.createdAt));
for (const comm of comms) {
if (comm.status === 'sent' && comm.sentAt) {
activities.push({
id: `email-sent-${comm.id}`,
type: 'email_sent',
title: `Email sent: ${comm.subject || 'No subject'}`,
description: comm.content.substring(0, 150) + (comm.content.length > 150 ? '...' : ''),
date: comm.sentAt.toISOString(),
metadata: { emailId: comm.id, aiGenerated: comm.aiGenerated },
});
}
// Also show drafts
if (comm.status === 'draft') {
activities.push({
id: `email-draft-${comm.id}`,
type: 'email_drafted',
title: `Email drafted: ${comm.subject || 'No subject'}`,
description: comm.content.substring(0, 150) + (comm.content.length > 150 ? '...' : ''),
date: comm.createdAt.toISOString(),
metadata: { emailId: comm.id, aiGenerated: comm.aiGenerated },
});
}
}
// Events
const clientEvents = await db.select()
.from(events)
.where(and(
eq(events.clientId, params.id),
eq(events.userId, user.id),
))
.orderBy(desc(events.createdAt));
for (const event of clientEvents) {
activities.push({
id: `event-${event.id}`,
type: 'event_created',
title: `Event: ${event.title}`,
description: `${event.type}${event.recurring ? ' (recurring)' : ''}`,
date: event.createdAt.toISOString(),
metadata: { eventId: event.id, eventType: event.type, eventDate: event.date.toISOString() },
});
}
// Sort by date descending
activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return activities;
}, {
params: t.Object({
id: t.String({ format: 'uuid' }),
}),
});