feat: add client documents, goals, and referral tracking
- 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:
@@ -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 }) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
150
src/routes/documents.ts
Normal file
150
src/routes/documents.ts
Normal 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
153
src/routes/goals.ts
Normal 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
203
src/routes/referrals.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user