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' }),
}),
});

283
src/routes/import.ts Normal file
View File

@@ -0,0 +1,283 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients, events } from '../db/schema';
import type { User } from '../lib/auth';
// Parse CSV text into rows
function parseCSV(text: string): string[][] {
const rows: string[][] = [];
let current = '';
let inQuotes = false;
let row: string[] = [];
for (let i = 0; i < text.length; i++) {
const char = text[i];
const next = text[i + 1];
if (inQuotes) {
if (char === '"' && next === '"') {
current += '"';
i++; // skip escaped quote
} else if (char === '"') {
inQuotes = false;
} else {
current += char;
}
} else {
if (char === '"') {
inQuotes = true;
} else if (char === ',') {
row.push(current.trim());
current = '';
} else if (char === '\n' || (char === '\r' && next === '\n')) {
row.push(current.trim());
current = '';
if (row.some(cell => cell !== '')) {
rows.push(row);
}
row = [];
if (char === '\r') i++; // skip \n in \r\n
} else {
current += char;
}
}
}
// Last row
if (current || row.length > 0) {
row.push(current.trim());
if (row.some(cell => cell !== '')) {
rows.push(row);
}
}
return rows;
}
// Known column mappings (lowercase header -> client field)
const COLUMN_MAP: Record<string, string> = {
'first name': 'firstName',
'firstname': 'firstName',
'first': 'firstName',
'last name': 'lastName',
'lastname': 'lastName',
'last': 'lastName',
'email': 'email',
'email address': 'email',
'phone': 'phone',
'phone number': 'phone',
'telephone': 'phone',
'mobile': 'phone',
'company': 'company',
'organization': 'company',
'org': 'company',
'role': 'role',
'title': 'role',
'job title': 'role',
'position': 'role',
'industry': 'industry',
'sector': 'industry',
'street': 'street',
'address': 'street',
'street address': 'street',
'city': 'city',
'state': 'state',
'province': 'state',
'zip': 'zip',
'zip code': 'zip',
'postal code': 'zip',
'zipcode': 'zip',
'birthday': 'birthday',
'date of birth': 'birthday',
'dob': 'birthday',
'birth date': 'birthday',
'anniversary': 'anniversary',
'wedding anniversary': 'anniversary',
'notes': 'notes',
'tags': 'tags',
'interests': 'interests',
};
function autoMapColumns(headers: string[]): Record<number, string> {
const mapping: Record<number, string> = {};
headers.forEach((header, index) => {
const normalized = header.toLowerCase().trim();
if (COLUMN_MAP[normalized]) {
mapping[index] = COLUMN_MAP[normalized];
}
});
return mapping;
}
function parseDate(value: string): Date | null {
if (!value) return null;
// Try various formats
const d = new Date(value);
if (!isNaN(d.getTime())) return d;
// Try MM/DD/YYYY
const parts = value.split(/[\/\-\.]/);
if (parts.length === 3) {
const a = Number(parts[0]);
const b = Number(parts[1]);
const c = Number(parts[2]);
if (a > 12) {
// DD/MM/YYYY
const d2 = new Date(c, b - 1, a);
if (!isNaN(d2.getTime())) return d2;
} else {
// MM/DD/YYYY
const d2 = new Date(c, a - 1, b);
if (!isNaN(d2.getTime())) return d2;
}
}
return null;
}
// Sync birthday/anniversary events for imported client
async function syncClientEvents(userId: string, client: { id: string; firstName: string; birthday: Date | null; anniversary: Date | null }) {
if (client.birthday) {
await db.insert(events).values({
userId,
clientId: client.id,
type: 'birthday',
title: `${client.firstName}'s Birthday`,
date: client.birthday,
recurring: true,
reminderDays: 7,
});
}
if (client.anniversary) {
await db.insert(events).values({
userId,
clientId: client.id,
type: 'anniversary',
title: `${client.firstName}'s Anniversary`,
date: client.anniversary,
recurring: true,
reminderDays: 7,
});
}
}
export const importRoutes = new Elysia({ prefix: '/clients' })
// Preview CSV - returns headers and auto-mapped columns + sample rows
.post('/import/preview', async ({ body, user }: { body: { file: File }; user: User }) => {
const text = await body.file.text();
const rows = parseCSV(text);
if (rows.length < 2) {
throw new Error('CSV must have at least a header row and one data row');
}
const headers = rows[0]!;
const mapping = autoMapColumns(headers);
const sampleRows = rows.slice(1, 6); // First 5 data rows as preview
return {
headers,
mapping,
sampleRows,
totalRows: rows.length - 1,
};
}, {
body: t.Object({
file: t.File({ type: 'text/csv' }),
}),
})
// Import CSV with column mapping
.post('/import', async ({ body, user }: { body: { file: File; mapping: string }; user: User }) => {
const text = await body.file.text();
const rows = parseCSV(text);
if (rows.length < 2) {
throw new Error('CSV must have at least a header row and one data row');
}
// Parse the mapping JSON: { columnIndex: fieldName }
let mapping: Record<string, string>;
try {
mapping = JSON.parse(body.mapping);
} catch {
throw new Error('Invalid mapping format');
}
const dataRows = rows.slice(1);
const results = {
imported: 0,
skipped: 0,
errors: [] as string[],
};
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
try {
const record: Record<string, any> = {};
// Map columns to fields
for (const [colIdx, field] of Object.entries(mapping)) {
const value = row[parseInt(colIdx)];
if (value) {
if (field === 'tags' || field === 'interests') {
record[field] = value.split(/[;|,]/).map((s: string) => s.trim()).filter(Boolean);
} else if (field === 'birthday' || field === 'anniversary') {
const d = parseDate(value);
if (d) record[field] = d;
} else {
record[field] = value;
}
}
}
// Must have at least firstName and lastName
if (!record.firstName || !record.lastName) {
results.skipped++;
results.errors.push(`Row ${i + 2}: Missing first or last name`);
continue;
}
// Insert client
const [client] = await db.insert(clients)
.values({
userId: user.id,
firstName: record.firstName,
lastName: record.lastName,
email: record.email || null,
phone: record.phone || null,
street: record.street || null,
city: record.city || null,
state: record.state || null,
zip: record.zip || null,
company: record.company || null,
role: record.role || null,
industry: record.industry || null,
birthday: record.birthday || null,
anniversary: record.anniversary || null,
interests: record.interests || [],
notes: record.notes || null,
tags: record.tags || [],
})
.returning();
// Sync events
await syncClientEvents(user.id, {
id: client.id,
firstName: client.firstName,
birthday: client.birthday,
anniversary: client.anniversary,
});
results.imported++;
} catch (err: any) {
results.skipped++;
results.errors.push(`Row ${i + 2}: ${err.message}`);
}
}
return results;
}, {
body: t.Object({
file: t.File({ type: 'text/csv' }),
mapping: t.String(),
}),
});

140
src/routes/insights.ts Normal file
View 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,
},
};
});