From 567791e6bbf1767c113260b20035d0854e3e15ae Mon Sep 17 00:00:00 2001 From: Hammer Date: Thu, 29 Jan 2026 12:43:24 +0000 Subject: [PATCH] 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) --- src/index.ts | 6 + src/routes/activity.ts | 121 ++++++++++++++++++ src/routes/import.ts | 283 +++++++++++++++++++++++++++++++++++++++++ src/routes/insights.ts | 140 ++++++++++++++++++++ 4 files changed, 550 insertions(+) create mode 100644 src/routes/activity.ts create mode 100644 src/routes/import.ts create mode 100644 src/routes/insights.ts diff --git a/src/index.ts b/src/index.ts index c4ffdb2..aed9afc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ import { adminRoutes } from './routes/admin'; import { networkRoutes } from './routes/network'; import { inviteRoutes } from './routes/invite'; import { passwordResetRoutes } from './routes/password-reset'; +import { importRoutes } from './routes/import'; +import { activityRoutes } from './routes/activity'; +import { insightsRoutes } from './routes/insights'; import { db } from './db'; import { users } from './db/schema'; import { eq } from 'drizzle-orm'; @@ -57,11 +60,14 @@ const app = new Elysia() // API routes (all require auth due to derive above) .group('/api', app => app .use(clientRoutes) + .use(importRoutes) + .use(activityRoutes) .use(emailRoutes) .use(eventRoutes) .use(profileRoutes) .use(adminRoutes) .use(networkRoutes) + .use(insightsRoutes) ) // Error handler diff --git a/src/routes/activity.ts b/src/routes/activity.ts new file mode 100644 index 0000000..2821d3d --- /dev/null +++ b/src/routes/activity.ts @@ -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; +} + +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' }), + }), + }); diff --git a/src/routes/import.ts b/src/routes/import.ts new file mode 100644 index 0000000..ad0a984 --- /dev/null +++ b/src/routes/import.ts @@ -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 = { + '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 { + const mapping: Record = {}; + 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; + 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 = {}; + + // 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(), + }), + }); diff --git a/src/routes/insights.ts b/src/routes/insights.ts new file mode 100644 index 0000000..84439ef --- /dev/null +++ b/src/routes/insights.ts @@ -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`count(*)::int` }) + .from(clients) + .where(eq(clients.userId, user.id)); + + const neverContactedCount = await db.select({ count: sql`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, + }, + }; + });