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:
121
src/routes/activity.ts
Normal file
121
src/routes/activity.ts
Normal 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
283
src/routes/import.ts
Normal 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
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