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:
@@ -9,6 +9,9 @@ import { adminRoutes } from './routes/admin';
|
|||||||
import { networkRoutes } from './routes/network';
|
import { networkRoutes } from './routes/network';
|
||||||
import { inviteRoutes } from './routes/invite';
|
import { inviteRoutes } from './routes/invite';
|
||||||
import { passwordResetRoutes } from './routes/password-reset';
|
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 { db } from './db';
|
||||||
import { users } from './db/schema';
|
import { users } from './db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -57,11 +60,14 @@ const app = new Elysia()
|
|||||||
// API routes (all require auth due to derive above)
|
// API routes (all require auth due to derive above)
|
||||||
.group('/api', app => app
|
.group('/api', app => app
|
||||||
.use(clientRoutes)
|
.use(clientRoutes)
|
||||||
|
.use(importRoutes)
|
||||||
|
.use(activityRoutes)
|
||||||
.use(emailRoutes)
|
.use(emailRoutes)
|
||||||
.use(eventRoutes)
|
.use(eventRoutes)
|
||||||
.use(profileRoutes)
|
.use(profileRoutes)
|
||||||
.use(adminRoutes)
|
.use(adminRoutes)
|
||||||
.use(networkRoutes)
|
.use(networkRoutes)
|
||||||
|
.use(insightsRoutes)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
|
|||||||
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