feat: add client documents, goals, and referral tracking
All checks were successful
CI/CD / check (push) Successful in 1m1s
CI/CD / deploy (push) Successful in 1s

- New client_documents table with file upload/download/delete API
- New client_goals table with full CRUD and dashboard overview
- New referrals table with stats (top referrers, conversion rate)
- All routes follow existing auth middleware pattern
This commit is contained in:
2026-01-30 04:41:20 +00:00
parent 5c9df803c2
commit 229b9407c2
5 changed files with 602 additions and 1 deletions

View File

@@ -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'; import { relations } from 'drizzle-orm';
// Users table (managed by BetterAuth - uses text IDs) // Users table (managed by BetterAuth - uses text IDs)
@@ -258,6 +258,52 @@ export const auditLogs = pgTable('audit_logs', {
createdAt: timestamp('created_at').defaultNow().notNull(), 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 // Relations
export const usersRelations = relations(users, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({
clients: many(clients), clients: many(clients),
@@ -294,6 +340,49 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
communications: many(communications), communications: many(communications),
notes: many(clientNotes), notes: many(clientNotes),
interactions: many(interactions), 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 }) => ({ export const notificationsRelations = relations(notifications, ({ one }) => ({

View File

@@ -30,6 +30,9 @@ import { statsRoutes } from './routes/stats';
import { searchRoutes } from './routes/search'; import { searchRoutes } from './routes/search';
import { mergeRoutes } from './routes/merge'; import { mergeRoutes } from './routes/merge';
import { exportRoutes } from './routes/export'; 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'; import { initJobQueue } from './services/jobs';
const app = new Elysia() const app = new Elysia()
@@ -86,6 +89,9 @@ const app = new Elysia()
.use(mergeRoutes) .use(mergeRoutes)
.use(searchRoutes) .use(searchRoutes)
.use(exportRoutes) .use(exportRoutes)
.use(documentRoutes)
.use(goalRoutes)
.use(referralRoutes)
) )
// Error handler // Error handler

150
src/routes/documents.ts Normal file
View File

@@ -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<number>`count(*)::int` })
.from(clientDocuments)
.where(eq(clientDocuments.clientId, params.clientId));
return { count: result?.count || 0 };
}, {
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
});

153
src/routes/goals.ts Normal file
View File

@@ -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<string, unknown> = { 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),
};
});

203
src/routes/referrals.ts Normal file
View File

@@ -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<string, unknown> = { 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<string, { name: string; count: number; convertedCount: number }>();
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,
};
});