diff --git a/src/db/schema.ts b/src/db/schema.ts index 3fe4086..c695ed1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, text, timestamp, uuid, boolean, jsonb, integer } from 'drizzle-orm/pg-core'; +import { pgTable, text, timestamp, uuid, boolean, jsonb, integer, numeric } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; // Users table (managed by BetterAuth - uses text IDs) @@ -258,6 +258,52 @@ export const auditLogs = pgTable('audit_logs', { createdAt: timestamp('created_at').defaultNow().notNull(), }); +// Client Documents table (file attachments) +export const clientDocuments = pgTable('client_documents', { + id: uuid('id').primaryKey().defaultRandom(), + clientId: uuid('client_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + name: text('name').notNull(), // user-facing display name + filename: text('filename').notNull(), // actual filename on disk + mimeType: text('mime_type').notNull(), + size: integer('size').notNull(), // bytes + category: text('category').default('other').notNull(), // 'contract' | 'agreement' | 'id' | 'statement' | 'correspondence' | 'other' + path: text('path').notNull(), // filesystem path + notes: text('notes'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +// Client Goals / Financial Objectives table +export const clientGoals = pgTable('client_goals', { + id: uuid('id').primaryKey().defaultRandom(), + clientId: uuid('client_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + title: text('title').notNull(), + description: text('description'), + category: text('category').default('other').notNull(), // 'retirement' | 'investment' | 'savings' | 'insurance' | 'estate' | 'education' | 'debt' | 'other' + targetAmount: numeric('target_amount', { precision: 15, scale: 2 }), + currentAmount: numeric('current_amount', { precision: 15, scale: 2 }).default('0'), + targetDate: timestamp('target_date'), + status: text('status').default('on-track').notNull(), // 'on-track' | 'at-risk' | 'behind' | 'completed' + priority: text('priority').default('medium').notNull(), // 'high' | 'medium' | 'low' + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Referrals table +export const referrals = pgTable('referrals', { + id: uuid('id').primaryKey().defaultRandom(), + referrerId: uuid('referrer_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(), + referredId: uuid('referred_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + type: text('type').default('client').notNull(), // 'client' | 'partner' | 'event' + notes: text('notes'), + status: text('status').default('pending').notNull(), // 'pending' | 'contacted' | 'converted' | 'lost' + value: numeric('value', { precision: 15, scale: 2 }), // estimated deal value + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + // Relations export const usersRelations = relations(users, ({ many }) => ({ clients: many(clients), @@ -294,6 +340,49 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({ communications: many(communications), notes: many(clientNotes), interactions: many(interactions), + documents: many(clientDocuments), + goals: many(clientGoals), + referralsMade: many(referrals, { relationName: 'referrer' }), + referralsReceived: many(referrals, { relationName: 'referred' }), +})); + +export const clientDocumentsRelations = relations(clientDocuments, ({ one }) => ({ + client: one(clients, { + fields: [clientDocuments.clientId], + references: [clients.id], + }), + user: one(users, { + fields: [clientDocuments.userId], + references: [users.id], + }), +})); + +export const clientGoalsRelations = relations(clientGoals, ({ one }) => ({ + client: one(clients, { + fields: [clientGoals.clientId], + references: [clients.id], + }), + user: one(users, { + fields: [clientGoals.userId], + references: [users.id], + }), +})); + +export const referralsRelations = relations(referrals, ({ one }) => ({ + referrer: one(clients, { + fields: [referrals.referrerId], + references: [clients.id], + relationName: 'referrer', + }), + referred: one(clients, { + fields: [referrals.referredId], + references: [clients.id], + relationName: 'referred', + }), + user: one(users, { + fields: [referrals.userId], + references: [users.id], + }), })); export const notificationsRelations = relations(notifications, ({ one }) => ({ diff --git a/src/index.ts b/src/index.ts index 5a0c3cd..c31e44e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,9 @@ import { statsRoutes } from './routes/stats'; import { searchRoutes } from './routes/search'; import { mergeRoutes } from './routes/merge'; import { exportRoutes } from './routes/export'; +import { documentRoutes } from './routes/documents'; +import { goalRoutes } from './routes/goals'; +import { referralRoutes } from './routes/referrals'; import { initJobQueue } from './services/jobs'; const app = new Elysia() @@ -86,6 +89,9 @@ const app = new Elysia() .use(mergeRoutes) .use(searchRoutes) .use(exportRoutes) + .use(documentRoutes) + .use(goalRoutes) + .use(referralRoutes) ) // Error handler diff --git a/src/routes/documents.ts b/src/routes/documents.ts new file mode 100644 index 0000000..80d1c45 --- /dev/null +++ b/src/routes/documents.ts @@ -0,0 +1,150 @@ +import { authMiddleware } from '../middleware/auth'; +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { clientDocuments, clients } from '../db/schema'; +import { eq, and, desc, sql } from 'drizzle-orm'; +import type { User } from '../lib/auth'; +import { mkdir, unlink } from 'fs/promises'; +import { join } from 'path'; + +const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads/documents'; + +async function verifyClientOwnership(clientId: string, userId: string) { + const [client] = await db.select({ id: clients.id }) + .from(clients) + .where(and(eq(clients.id, clientId), eq(clients.userId, userId))) + .limit(1); + if (!client) throw new Error('Client not found'); + return client; +} + +export const documentRoutes = new Elysia() + .use(authMiddleware) + + // List documents for a client + .get('/clients/:clientId/documents', async ({ params, query, user }: { params: { clientId: string }; query: { category?: string }; user: User }) => { + await verifyClientOwnership(params.clientId, user.id); + + let q = db.select() + .from(clientDocuments) + .where(eq(clientDocuments.clientId, params.clientId)) + .orderBy(desc(clientDocuments.createdAt)); + + if (query.category) { + q = db.select() + .from(clientDocuments) + .where(and( + eq(clientDocuments.clientId, params.clientId), + eq(clientDocuments.category, query.category), + )) + .orderBy(desc(clientDocuments.createdAt)); + } + + return q; + }, { + params: t.Object({ clientId: t.String({ format: 'uuid' }) }), + query: t.Object({ category: t.Optional(t.String()) }), + }) + + // Upload document + .post('/clients/:clientId/documents', async ({ params, body, user }: { params: { clientId: string }; body: { file: File; name?: string; category?: string; notes?: string }; user: User }) => { + await verifyClientOwnership(params.clientId, user.id); + + const file = body.file; + if (!file || !(file instanceof File)) { + throw new Error('File is required'); + } + + // Create directory + const clientDir = join(UPLOAD_DIR, params.clientId); + await mkdir(clientDir, { recursive: true }); + + // Generate unique filename + const ext = file.name.split('.').pop() || 'bin'; + const uniqueName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; + const filePath = join(clientDir, uniqueName); + + // Write file + const buffer = await file.arrayBuffer(); + await Bun.write(filePath, buffer); + + const [doc] = await db.insert(clientDocuments) + .values({ + clientId: params.clientId, + userId: user.id, + name: body.name || file.name, + filename: uniqueName, + mimeType: file.type || 'application/octet-stream', + size: file.size, + category: body.category || 'other', + path: filePath, + notes: body.notes || null, + }) + .returning(); + + return doc; + }, { + params: t.Object({ clientId: t.String({ format: 'uuid' }) }), + body: t.Object({ + file: t.File(), + name: t.Optional(t.String()), + category: t.Optional(t.String()), + notes: t.Optional(t.String()), + }), + }) + + // Download document + .get('/documents/:documentId/download', async ({ params, user, set }: { params: { documentId: string }; user: User; set: any }) => { + const [doc] = await db.select() + .from(clientDocuments) + .where(and( + eq(clientDocuments.id, params.documentId), + eq(clientDocuments.userId, user.id), + )) + .limit(1); + + if (!doc) throw new Error('Document not found'); + + const file = Bun.file(doc.path); + if (!await file.exists()) { + throw new Error('File not found on disk'); + } + + set.headers['content-type'] = doc.mimeType; + set.headers['content-disposition'] = `attachment; filename="${doc.name}"`; + return file; + }, { + params: t.Object({ documentId: t.String({ format: 'uuid' }) }), + }) + + // Delete document + .delete('/documents/:documentId', async ({ params, user }: { params: { documentId: string }; user: User }) => { + const [doc] = await db.delete(clientDocuments) + .where(and( + eq(clientDocuments.id, params.documentId), + eq(clientDocuments.userId, user.id), + )) + .returning(); + + if (!doc) throw new Error('Document not found'); + + // Try to delete file from disk + try { await unlink(doc.path); } catch {} + + return { success: true, id: doc.id }; + }, { + params: t.Object({ documentId: t.String({ format: 'uuid' }) }), + }) + + // Get document count for a client (used by client cards) + .get('/clients/:clientId/documents/count', async ({ params, user }: { params: { clientId: string }; user: User }) => { + await verifyClientOwnership(params.clientId, user.id); + + const [result] = await db.select({ count: sql`count(*)::int` }) + .from(clientDocuments) + .where(eq(clientDocuments.clientId, params.clientId)); + + return { count: result?.count || 0 }; + }, { + params: t.Object({ clientId: t.String({ format: 'uuid' }) }), + }); diff --git a/src/routes/goals.ts b/src/routes/goals.ts new file mode 100644 index 0000000..d7156e2 --- /dev/null +++ b/src/routes/goals.ts @@ -0,0 +1,153 @@ +import { authMiddleware } from '../middleware/auth'; +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { clientGoals, clients } from '../db/schema'; +import { eq, and, desc, sql, ne } from 'drizzle-orm'; +import type { User } from '../lib/auth'; + +async function verifyClientOwnership(clientId: string, userId: string) { + const [client] = await db.select({ id: clients.id }) + .from(clients) + .where(and(eq(clients.id, clientId), eq(clients.userId, userId))) + .limit(1); + if (!client) throw new Error('Client not found'); + return client; +} + +export const goalRoutes = new Elysia() + .use(authMiddleware) + + // List goals for a client + .get('/clients/:clientId/goals', async ({ params, user }: { params: { clientId: string }; user: User }) => { + await verifyClientOwnership(params.clientId, user.id); + + return db.select() + .from(clientGoals) + .where(eq(clientGoals.clientId, params.clientId)) + .orderBy(desc(clientGoals.createdAt)); + }, { + params: t.Object({ clientId: t.String({ format: 'uuid' }) }), + }) + + // Create goal + .post('/clients/:clientId/goals', async ({ params, body, user }: { params: { clientId: string }; body: any; user: User }) => { + await verifyClientOwnership(params.clientId, user.id); + + const [goal] = await db.insert(clientGoals) + .values({ + clientId: params.clientId, + userId: user.id, + title: body.title, + description: body.description || null, + category: body.category || 'other', + targetAmount: body.targetAmount || null, + currentAmount: body.currentAmount || '0', + targetDate: body.targetDate ? new Date(body.targetDate) : null, + status: body.status || 'on-track', + priority: body.priority || 'medium', + }) + .returning(); + + return goal; + }, { + params: t.Object({ clientId: t.String({ format: 'uuid' }) }), + body: t.Object({ + title: t.String({ minLength: 1 }), + description: t.Optional(t.String()), + category: t.Optional(t.String()), + targetAmount: t.Optional(t.String()), + currentAmount: t.Optional(t.String()), + targetDate: t.Optional(t.String()), + status: t.Optional(t.String()), + priority: t.Optional(t.String()), + }), + }) + + // Update goal + .put('/goals/:goalId', async ({ params, body, user }: { params: { goalId: string }; body: any; user: User }) => { + const updateData: Record = { updatedAt: new Date() }; + if (body.title !== undefined) updateData.title = body.title; + if (body.description !== undefined) updateData.description = body.description; + if (body.category !== undefined) updateData.category = body.category; + if (body.targetAmount !== undefined) updateData.targetAmount = body.targetAmount; + if (body.currentAmount !== undefined) updateData.currentAmount = body.currentAmount; + if (body.targetDate !== undefined) updateData.targetDate = body.targetDate ? new Date(body.targetDate) : null; + if (body.status !== undefined) updateData.status = body.status; + if (body.priority !== undefined) updateData.priority = body.priority; + + const [goal] = await db.update(clientGoals) + .set(updateData) + .where(and( + eq(clientGoals.id, params.goalId), + eq(clientGoals.userId, user.id), + )) + .returning(); + + if (!goal) throw new Error('Goal not found'); + return goal; + }, { + params: t.Object({ goalId: t.String({ format: 'uuid' }) }), + body: t.Object({ + title: t.Optional(t.String({ minLength: 1 })), + description: t.Optional(t.String()), + category: t.Optional(t.String()), + targetAmount: t.Optional(t.String()), + currentAmount: t.Optional(t.String()), + targetDate: t.Optional(t.Nullable(t.String())), + status: t.Optional(t.String()), + priority: t.Optional(t.String()), + }), + }) + + // Delete goal + .delete('/goals/:goalId', async ({ params, user }: { params: { goalId: string }; user: User }) => { + const [deleted] = await db.delete(clientGoals) + .where(and( + eq(clientGoals.id, params.goalId), + eq(clientGoals.userId, user.id), + )) + .returning({ id: clientGoals.id }); + + if (!deleted) throw new Error('Goal not found'); + return { success: true, id: deleted.id }; + }, { + params: t.Object({ goalId: t.String({ format: 'uuid' }) }), + }) + + // Goals overview (dashboard) - at-risk goals across all clients + .get('/goals/overview', async ({ user }: { user: User }) => { + const allGoals = await db.select({ + id: clientGoals.id, + clientId: clientGoals.clientId, + title: clientGoals.title, + category: clientGoals.category, + targetAmount: clientGoals.targetAmount, + currentAmount: clientGoals.currentAmount, + targetDate: clientGoals.targetDate, + status: clientGoals.status, + priority: clientGoals.priority, + clientFirstName: clients.firstName, + clientLastName: clients.lastName, + }) + .from(clientGoals) + .innerJoin(clients, eq(clientGoals.clientId, clients.id)) + .where(eq(clientGoals.userId, user.id)) + .orderBy(desc(clientGoals.updatedAt)); + + const total = allGoals.length; + const byStatus = { + 'on-track': allGoals.filter(g => g.status === 'on-track').length, + 'at-risk': allGoals.filter(g => g.status === 'at-risk').length, + 'behind': allGoals.filter(g => g.status === 'behind').length, + 'completed': allGoals.filter(g => g.status === 'completed').length, + }; + const atRiskGoals = allGoals.filter(g => g.status === 'at-risk' || g.status === 'behind'); + const highPriorityGoals = allGoals.filter(g => g.priority === 'high' && g.status !== 'completed'); + + return { + total, + byStatus, + atRiskGoals: atRiskGoals.slice(0, 10), + highPriorityGoals: highPriorityGoals.slice(0, 10), + }; + }); diff --git a/src/routes/referrals.ts b/src/routes/referrals.ts new file mode 100644 index 0000000..bda4fbd --- /dev/null +++ b/src/routes/referrals.ts @@ -0,0 +1,203 @@ +import { authMiddleware } from '../middleware/auth'; +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { referrals, clients } from '../db/schema'; +import { eq, and, desc, sql, or } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; +import type { User } from '../lib/auth'; + +async function verifyClientOwnership(clientId: string, userId: string) { + const [client] = await db.select({ id: clients.id }) + .from(clients) + .where(and(eq(clients.id, clientId), eq(clients.userId, userId))) + .limit(1); + if (!client) throw new Error('Client not found'); + return client; +} + +const referrerClient = alias(clients, 'referrerClient'); +const referredClient = alias(clients, 'referredClient'); + +export const referralRoutes = new Elysia() + .use(authMiddleware) + + // List referrals for a client (given + received) + .get('/clients/:clientId/referrals', async ({ params, user }: { params: { clientId: string }; user: User }) => { + await verifyClientOwnership(params.clientId, user.id); + + const results = await db.select({ + id: referrals.id, + referrerId: referrals.referrerId, + referredId: referrals.referredId, + type: referrals.type, + notes: referrals.notes, + status: referrals.status, + value: referrals.value, + createdAt: referrals.createdAt, + updatedAt: referrals.updatedAt, + referrerFirstName: referrerClient.firstName, + referrerLastName: referrerClient.lastName, + referredFirstName: referredClient.firstName, + referredLastName: referredClient.lastName, + }) + .from(referrals) + .innerJoin(referrerClient, eq(referrals.referrerId, referrerClient.id)) + .innerJoin(referredClient, eq(referrals.referredId, referredClient.id)) + .where(and( + eq(referrals.userId, user.id), + or( + eq(referrals.referrerId, params.clientId), + eq(referrals.referredId, params.clientId), + ), + )) + .orderBy(desc(referrals.createdAt)); + + return results.map(r => ({ + id: r.id, + referrerId: r.referrerId, + referredId: r.referredId, + type: r.type, + notes: r.notes, + status: r.status, + value: r.value, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + referrer: { id: r.referrerId, firstName: r.referrerFirstName, lastName: r.referrerLastName }, + referred: { id: r.referredId, firstName: r.referredFirstName, lastName: r.referredLastName }, + })); + }, { + params: t.Object({ clientId: t.String({ format: 'uuid' }) }), + }) + + // Create referral + .post('/clients/:clientId/referrals', async ({ params, body, user }: { params: { clientId: string }; body: any; user: User }) => { + await verifyClientOwnership(params.clientId, user.id); + + // Verify the other client exists and belongs to user + const referredClientId = body.referredId; + await verifyClientOwnership(referredClientId, user.id); + + const [ref] = await db.insert(referrals) + .values({ + referrerId: params.clientId, + referredId: referredClientId, + userId: user.id, + type: body.type || 'client', + notes: body.notes || null, + status: body.status || 'pending', + value: body.value || null, + }) + .returning(); + + return ref; + }, { + params: t.Object({ clientId: t.String({ format: 'uuid' }) }), + body: t.Object({ + referredId: t.String({ format: 'uuid' }), + type: t.Optional(t.String()), + notes: t.Optional(t.String()), + status: t.Optional(t.String()), + value: t.Optional(t.String()), + }), + }) + + // Update referral + .put('/referrals/:referralId', async ({ params, body, user }: { params: { referralId: string }; body: any; user: User }) => { + const updateData: Record = { updatedAt: new Date() }; + if (body.type !== undefined) updateData.type = body.type; + if (body.notes !== undefined) updateData.notes = body.notes; + if (body.status !== undefined) updateData.status = body.status; + if (body.value !== undefined) updateData.value = body.value; + + const [ref] = await db.update(referrals) + .set(updateData) + .where(and( + eq(referrals.id, params.referralId), + eq(referrals.userId, user.id), + )) + .returning(); + + if (!ref) throw new Error('Referral not found'); + return ref; + }, { + params: t.Object({ referralId: t.String({ format: 'uuid' }) }), + body: t.Object({ + type: t.Optional(t.String()), + notes: t.Optional(t.String()), + status: t.Optional(t.String()), + value: t.Optional(t.String()), + }), + }) + + // Delete referral + .delete('/referrals/:referralId', async ({ params, user }: { params: { referralId: string }; user: User }) => { + const [deleted] = await db.delete(referrals) + .where(and( + eq(referrals.id, params.referralId), + eq(referrals.userId, user.id), + )) + .returning({ id: referrals.id }); + + if (!deleted) throw new Error('Referral not found'); + return { success: true, id: deleted.id }; + }, { + params: t.Object({ referralId: t.String({ format: 'uuid' }) }), + }) + + // Referral stats (dashboard) + .get('/referrals/stats', async ({ user }: { user: User }) => { + const allReferrals = await db.select({ + id: referrals.id, + referrerId: referrals.referrerId, + referredId: referrals.referredId, + type: referrals.type, + status: referrals.status, + value: referrals.value, + referrerFirstName: referrerClient.firstName, + referrerLastName: referrerClient.lastName, + }) + .from(referrals) + .innerJoin(referrerClient, eq(referrals.referrerId, referrerClient.id)) + .where(eq(referrals.userId, user.id)); + + const total = allReferrals.length; + const converted = allReferrals.filter(r => r.status === 'converted').length; + const conversionRate = total > 0 ? Math.round((converted / total) * 100) : 0; + const totalValue = allReferrals + .filter(r => r.value) + .reduce((sum, r) => sum + parseFloat(r.value || '0'), 0); + const convertedValue = allReferrals + .filter(r => r.status === 'converted' && r.value) + .reduce((sum, r) => sum + parseFloat(r.value || '0'), 0); + + // Top referrers + const referrerMap = new Map(); + for (const r of allReferrals) { + const key = r.referrerId; + const existing = referrerMap.get(key) || { name: `${r.referrerFirstName} ${r.referrerLastName}`, count: 0, convertedCount: 0 }; + existing.count++; + if (r.status === 'converted') existing.convertedCount++; + referrerMap.set(key, existing); + } + const topReferrers = Array.from(referrerMap.entries()) + .map(([id, data]) => ({ id, ...data })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const byStatus = { + pending: allReferrals.filter(r => r.status === 'pending').length, + contacted: allReferrals.filter(r => r.status === 'contacted').length, + converted, + lost: allReferrals.filter(r => r.status === 'lost').length, + }; + + return { + total, + converted, + conversionRate, + totalValue, + convertedValue, + byStatus, + topReferrers, + }; + });