feat: audit logging, meeting prep AI, communication style settings
- New audit_logs table for compliance tracking - Audit logging service with helper functions - GET /api/audit-logs endpoint (admin only, with filters) - Communication style JSONB field on userProfiles - GET/PATCH /api/profile/communication-style endpoints - AI meeting prep: GET /api/clients/:id/meeting-prep - AI email generation now incorporates communication style - Password change audit logging - 56 passing tests (21 new)
This commit is contained in:
206
src/__tests__/audit.test.ts
Normal file
206
src/__tests__/audit.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
|
||||
describe('Audit Logging', () => {
|
||||
describe('Audit Log Data Structure', () => {
|
||||
test('audit log entry has required fields', () => {
|
||||
const entry = {
|
||||
userId: 'user-123',
|
||||
action: 'create',
|
||||
entityType: 'client',
|
||||
entityId: 'client-456',
|
||||
details: { firstName: 'John', lastName: 'Doe' },
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
};
|
||||
expect(entry.userId).toBeDefined();
|
||||
expect(entry.action).toBe('create');
|
||||
expect(entry.entityType).toBe('client');
|
||||
});
|
||||
|
||||
test('valid action types', () => {
|
||||
const validActions = ['create', 'update', 'delete', 'view', 'send', 'login', 'logout', 'password_change'];
|
||||
validActions.forEach(action => {
|
||||
expect(validActions).toContain(action);
|
||||
});
|
||||
});
|
||||
|
||||
test('valid entity types', () => {
|
||||
const validTypes = ['client', 'email', 'event', 'template', 'segment', 'user', 'auth', 'interaction', 'note', 'notification', 'invite', 'profile'];
|
||||
expect(validTypes.length).toBeGreaterThan(0);
|
||||
expect(validTypes).toContain('client');
|
||||
expect(validTypes).toContain('auth');
|
||||
});
|
||||
|
||||
test('details can be JSONB with diff info', () => {
|
||||
const details = {
|
||||
changes: {
|
||||
firstName: { from: 'John', to: 'Jonathan' },
|
||||
stage: { from: 'lead', to: 'active' },
|
||||
},
|
||||
};
|
||||
expect(details.changes.firstName.from).toBe('John');
|
||||
expect(details.changes.firstName.to).toBe('Jonathan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Metadata', () => {
|
||||
test('IP address extracted from x-forwarded-for', () => {
|
||||
const header = '192.168.1.1, 10.0.0.1';
|
||||
const ip = header.split(',')[0].trim();
|
||||
expect(ip).toBe('192.168.1.1');
|
||||
});
|
||||
|
||||
test('User-Agent is captured', () => {
|
||||
const ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)';
|
||||
expect(ua).toContain('Mozilla');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Diff Computation', () => {
|
||||
test('detects changed fields', () => {
|
||||
const oldVal = { firstName: 'John', lastName: 'Doe', stage: 'lead' };
|
||||
const newVal = { firstName: 'Jonathan', lastName: 'Doe', stage: 'active' };
|
||||
|
||||
const diff: Record<string, { from: unknown; to: unknown }> = {};
|
||||
for (const key of Object.keys(newVal)) {
|
||||
const oldV = (oldVal as any)[key];
|
||||
const newV = (newVal as any)[key];
|
||||
if (JSON.stringify(oldV) !== JSON.stringify(newV)) {
|
||||
diff[key] = { from: oldV, to: newV };
|
||||
}
|
||||
}
|
||||
|
||||
expect(Object.keys(diff)).toHaveLength(2);
|
||||
expect(diff.firstName.from).toBe('John');
|
||||
expect(diff.firstName.to).toBe('Jonathan');
|
||||
expect(diff.stage.from).toBe('lead');
|
||||
expect(diff.lastName).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles array differences', () => {
|
||||
const old = { tags: ['vip'] };
|
||||
const cur = { tags: ['vip', 'active'] };
|
||||
const same = JSON.stringify(old.tags) === JSON.stringify(cur.tags);
|
||||
expect(same).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audit Log Filters', () => {
|
||||
test('page and limit defaults', () => {
|
||||
const page = parseInt(undefined || '1');
|
||||
const limit = Math.min(parseInt(undefined || '50'), 100);
|
||||
expect(page).toBe(1);
|
||||
expect(limit).toBe(50);
|
||||
});
|
||||
|
||||
test('limit capped at 100', () => {
|
||||
const limit = Math.min(parseInt('200'), 100);
|
||||
expect(limit).toBe(100);
|
||||
});
|
||||
|
||||
test('date range filtering', () => {
|
||||
const startDate = new Date('2024-01-01');
|
||||
const endDate = new Date('2024-12-31');
|
||||
const logDate = new Date('2024-06-15');
|
||||
expect(logDate >= startDate && logDate <= endDate).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meeting Prep', () => {
|
||||
describe('Health Score Calculation', () => {
|
||||
test('perfect score for recently contacted client', () => {
|
||||
let healthScore = 100;
|
||||
const daysSinceContact = 5;
|
||||
if (daysSinceContact > 90) healthScore -= 40;
|
||||
else if (daysSinceContact > 60) healthScore -= 25;
|
||||
else if (daysSinceContact > 30) healthScore -= 10;
|
||||
expect(healthScore).toBe(100);
|
||||
});
|
||||
|
||||
test('score decreases with time since contact', () => {
|
||||
let healthScore = 100;
|
||||
const daysSinceContact = 95;
|
||||
if (daysSinceContact > 90) healthScore -= 40;
|
||||
else if (daysSinceContact > 60) healthScore -= 25;
|
||||
else if (daysSinceContact > 30) healthScore -= 10;
|
||||
expect(healthScore).toBe(60);
|
||||
});
|
||||
|
||||
test('interaction frequency adds bonus', () => {
|
||||
let healthScore = 75;
|
||||
const interactionsLast90Days = 5;
|
||||
if (interactionsLast90Days >= 5) healthScore = Math.min(100, healthScore + 15);
|
||||
expect(healthScore).toBe(90);
|
||||
});
|
||||
|
||||
test('score clamped to 0-100', () => {
|
||||
let healthScore = -10;
|
||||
healthScore = Math.max(0, Math.min(100, healthScore));
|
||||
expect(healthScore).toBe(0);
|
||||
|
||||
healthScore = 120;
|
||||
healthScore = Math.max(0, Math.min(100, healthScore));
|
||||
expect(healthScore).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Important Dates', () => {
|
||||
test('birthday within 90 days is included', () => {
|
||||
const now = new Date();
|
||||
const birthday = new Date(now);
|
||||
birthday.setDate(birthday.getDate() + 30);
|
||||
const daysUntil = Math.ceil((birthday.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
expect(daysUntil <= 90).toBe(true);
|
||||
});
|
||||
|
||||
test('birthday beyond 90 days is excluded', () => {
|
||||
const now = new Date();
|
||||
const birthday = new Date(now);
|
||||
birthday.setDate(birthday.getDate() + 120);
|
||||
const daysUntil = Math.ceil((birthday.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
expect(daysUntil <= 90).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Communication Style', () => {
|
||||
test('style has valid tone options', () => {
|
||||
const validTones = ['formal', 'friendly', 'casual'];
|
||||
expect(validTones).toContain('formal');
|
||||
expect(validTones).toContain('friendly');
|
||||
expect(validTones).toContain('casual');
|
||||
});
|
||||
|
||||
test('writing samples capped at 3', () => {
|
||||
const samples = ['sample1', 'sample2', 'sample3', 'sample4'];
|
||||
const capped = samples.slice(0, 3);
|
||||
expect(capped).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('style object structure', () => {
|
||||
const style = {
|
||||
tone: 'friendly' as const,
|
||||
greeting: 'Hi',
|
||||
signoff: 'Best regards',
|
||||
writingSamples: ['Sample email text'],
|
||||
avoidWords: ['synergy', 'leverage'],
|
||||
};
|
||||
expect(style.tone).toBe('friendly');
|
||||
expect(style.avoidWords).toHaveLength(2);
|
||||
expect(style.writingSamples).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('default style when none set', () => {
|
||||
const profile = { communicationStyle: null };
|
||||
const style = profile.communicationStyle || {
|
||||
tone: 'friendly',
|
||||
greeting: '',
|
||||
signoff: '',
|
||||
writingSamples: [],
|
||||
avoidWords: [],
|
||||
};
|
||||
expect(style.tone).toBe('friendly');
|
||||
expect(style.writingSamples).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,13 @@ export const userProfiles = pgTable('user_profiles', {
|
||||
company: text('company'), // e.g., "ABC Financial Group"
|
||||
phone: text('phone'),
|
||||
emailSignature: text('email_signature'), // Custom signature block
|
||||
communicationStyle: jsonb('communication_style').$type<{
|
||||
tone?: 'formal' | 'friendly' | 'casual';
|
||||
greeting?: string;
|
||||
signoff?: string;
|
||||
writingSamples?: string[];
|
||||
avoidWords?: string[];
|
||||
}>(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
@@ -237,6 +244,19 @@ export const clientSegments = pgTable('client_segments', {
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Audit logs table (compliance)
|
||||
export const auditLogs = pgTable('audit_logs', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
action: text('action').notNull(), // 'create' | 'update' | 'delete' | 'view' | 'send' | 'login' | 'logout' | 'password_change'
|
||||
entityType: text('entity_type').notNull(), // 'client' | 'email' | 'event' | 'template' | 'segment' | 'user' | 'auth' | etc
|
||||
entityId: text('entity_id'), // ID of the affected entity
|
||||
details: jsonb('details').$type<Record<string, unknown>>(), // what changed
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
clients: many(clients),
|
||||
|
||||
@@ -18,6 +18,8 @@ import { notificationRoutes } from './routes/notifications';
|
||||
import { interactionRoutes } from './routes/interactions';
|
||||
import { templateRoutes } from './routes/templates';
|
||||
import { segmentRoutes } from './routes/segments';
|
||||
import { auditLogRoutes } from './routes/audit-logs';
|
||||
import { meetingPrepRoutes } from './routes/meeting-prep';
|
||||
import { db } from './db';
|
||||
import { users } from './db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@@ -81,6 +83,8 @@ const app = new Elysia()
|
||||
.use(interactionRoutes)
|
||||
.use(templateRoutes)
|
||||
.use(segmentRoutes)
|
||||
.use(auditLogRoutes)
|
||||
.use(meetingPrepRoutes)
|
||||
)
|
||||
|
||||
// Error handler
|
||||
|
||||
101
src/routes/audit-logs.ts
Normal file
101
src/routes/audit-logs.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
import { auditLogs, users } from '../db/schema';
|
||||
import { eq, desc, and, gte, lte, ilike, or, sql } from 'drizzle-orm';
|
||||
import type { User } from '../lib/auth';
|
||||
|
||||
export const auditLogRoutes = new Elysia({ prefix: '/audit-logs' })
|
||||
// Admin guard
|
||||
.onBeforeHandle(({ user, set }: { user: User; set: any }) => {
|
||||
if ((user as any).role !== 'admin') {
|
||||
set.status = 403;
|
||||
throw new Error('Forbidden: admin access required');
|
||||
}
|
||||
})
|
||||
|
||||
// List audit logs with filters
|
||||
.get('/', async ({ query }: {
|
||||
query: {
|
||||
entityType?: string;
|
||||
action?: string;
|
||||
userId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
search?: string;
|
||||
page?: string;
|
||||
limit?: string;
|
||||
};
|
||||
}) => {
|
||||
const page = parseInt(query.page || '1');
|
||||
const limit = Math.min(parseInt(query.limit || '50'), 100);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (query.entityType) {
|
||||
conditions.push(eq(auditLogs.entityType, query.entityType));
|
||||
}
|
||||
if (query.action) {
|
||||
conditions.push(eq(auditLogs.action, query.action));
|
||||
}
|
||||
if (query.userId) {
|
||||
conditions.push(eq(auditLogs.userId, query.userId));
|
||||
}
|
||||
if (query.startDate) {
|
||||
conditions.push(gte(auditLogs.createdAt, new Date(query.startDate)));
|
||||
}
|
||||
if (query.endDate) {
|
||||
conditions.push(lte(auditLogs.createdAt, new Date(query.endDate)));
|
||||
}
|
||||
if (query.search) {
|
||||
conditions.push(
|
||||
sql`${auditLogs.details}::text ILIKE ${'%' + query.search + '%'}`
|
||||
);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [logs, countResult] = await Promise.all([
|
||||
db.select({
|
||||
id: auditLogs.id,
|
||||
userId: auditLogs.userId,
|
||||
action: auditLogs.action,
|
||||
entityType: auditLogs.entityType,
|
||||
entityId: auditLogs.entityId,
|
||||
details: auditLogs.details,
|
||||
ipAddress: auditLogs.ipAddress,
|
||||
userAgent: auditLogs.userAgent,
|
||||
createdAt: auditLogs.createdAt,
|
||||
userName: users.name,
|
||||
userEmail: users.email,
|
||||
})
|
||||
.from(auditLogs)
|
||||
.leftJoin(users, eq(auditLogs.userId, users.id))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(auditLogs)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: Number(countResult[0]?.count || 0),
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(Number(countResult[0]?.count || 0) / limit),
|
||||
};
|
||||
}, {
|
||||
query: t.Object({
|
||||
entityType: t.Optional(t.String()),
|
||||
action: t.Optional(t.String()),
|
||||
userId: t.Optional(t.String()),
|
||||
startDate: t.Optional(t.String()),
|
||||
endDate: t.Optional(t.String()),
|
||||
search: t.Optional(t.String()),
|
||||
page: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
}),
|
||||
});
|
||||
@@ -59,6 +59,7 @@ export const emailRoutes = new Elysia({ prefix: '/emails' })
|
||||
notes: client.notes || '',
|
||||
purpose: body.purpose,
|
||||
provider: body.provider,
|
||||
communicationStyle: profile?.communicationStyle as any,
|
||||
});
|
||||
console.log(`[${new Date().toISOString()}] Email content generated successfully`);
|
||||
} catch (e) {
|
||||
|
||||
170
src/routes/meeting-prep.ts
Normal file
170
src/routes/meeting-prep.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
import { clients, interactions, communications, events, clientNotes } from '../db/schema';
|
||||
import { eq, and, desc, gte } from 'drizzle-orm';
|
||||
import type { User } from '../lib/auth';
|
||||
import { generateMeetingPrep } from '../services/ai';
|
||||
|
||||
export const meetingPrepRoutes = new Elysia()
|
||||
// Get meeting prep for a client
|
||||
.get('/clients/:id/meeting-prep', async ({ params, user, query }: {
|
||||
params: { id: string };
|
||||
user: User;
|
||||
query: { provider?: string };
|
||||
}) => {
|
||||
// Fetch client
|
||||
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');
|
||||
}
|
||||
|
||||
// Fetch recent interactions (last 10)
|
||||
const recentInteractions = await db.select()
|
||||
.from(interactions)
|
||||
.where(and(eq(interactions.clientId, params.id), eq(interactions.userId, user.id)))
|
||||
.orderBy(desc(interactions.contactedAt))
|
||||
.limit(10);
|
||||
|
||||
// Fetch recent emails (last 5)
|
||||
const recentEmails = await db.select()
|
||||
.from(communications)
|
||||
.where(and(eq(communications.clientId, params.id), eq(communications.userId, user.id)))
|
||||
.orderBy(desc(communications.createdAt))
|
||||
.limit(5);
|
||||
|
||||
// Fetch upcoming events
|
||||
const now = new Date();
|
||||
const upcomingEvents = await db.select()
|
||||
.from(events)
|
||||
.where(and(
|
||||
eq(events.clientId, params.id),
|
||||
eq(events.userId, user.id),
|
||||
gte(events.date, now),
|
||||
))
|
||||
.orderBy(events.date)
|
||||
.limit(5);
|
||||
|
||||
// Fetch notes
|
||||
const notes = await db.select()
|
||||
.from(clientNotes)
|
||||
.where(and(eq(clientNotes.clientId, params.id), eq(clientNotes.userId, user.id)))
|
||||
.orderBy(desc(clientNotes.createdAt))
|
||||
.limit(10);
|
||||
|
||||
// Calculate relationship health score (0-100)
|
||||
const daysSinceContact = client.lastContactedAt
|
||||
? Math.floor((now.getTime() - new Date(client.lastContactedAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 999;
|
||||
|
||||
let healthScore = 100;
|
||||
if (daysSinceContact > 90) healthScore -= 40;
|
||||
else if (daysSinceContact > 60) healthScore -= 25;
|
||||
else if (daysSinceContact > 30) healthScore -= 10;
|
||||
|
||||
// Interaction frequency bonus
|
||||
const last90Days = recentInteractions.filter(i => {
|
||||
const d = new Date(i.contactedAt);
|
||||
return (now.getTime() - d.getTime()) < 90 * 24 * 60 * 60 * 1000;
|
||||
});
|
||||
if (last90Days.length >= 5) healthScore = Math.min(100, healthScore + 15);
|
||||
else if (last90Days.length >= 3) healthScore = Math.min(100, healthScore + 10);
|
||||
else if (last90Days.length >= 1) healthScore = Math.min(100, healthScore + 5);
|
||||
|
||||
// Note presence bonus
|
||||
if (notes.length > 0) healthScore = Math.min(100, healthScore + 5);
|
||||
|
||||
healthScore = Math.max(0, Math.min(100, healthScore));
|
||||
|
||||
// Important upcoming dates
|
||||
const importantDates: { type: string; date: string; label: string }[] = [];
|
||||
if (client.birthday) {
|
||||
const bd = new Date(client.birthday);
|
||||
const thisYear = new Date(now.getFullYear(), bd.getMonth(), bd.getDate());
|
||||
if (thisYear < now) thisYear.setFullYear(now.getFullYear() + 1);
|
||||
const daysUntil = Math.ceil((thisYear.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (daysUntil <= 90) {
|
||||
importantDates.push({ type: 'birthday', date: thisYear.toISOString(), label: `Birthday in ${daysUntil} days` });
|
||||
}
|
||||
}
|
||||
if (client.anniversary) {
|
||||
const ann = new Date(client.anniversary);
|
||||
const thisYear = new Date(now.getFullYear(), ann.getMonth(), ann.getDate());
|
||||
if (thisYear < now) thisYear.setFullYear(now.getFullYear() + 1);
|
||||
const daysUntil = Math.ceil((thisYear.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (daysUntil <= 90) {
|
||||
importantDates.push({ type: 'anniversary', date: thisYear.toISOString(), label: `Anniversary in ${daysUntil} days` });
|
||||
}
|
||||
}
|
||||
|
||||
// Build context for AI
|
||||
const clientSummary = {
|
||||
name: `${client.firstName} ${client.lastName}`,
|
||||
company: client.company || 'N/A',
|
||||
role: client.role || 'N/A',
|
||||
industry: client.industry || 'N/A',
|
||||
stage: client.stage || 'lead',
|
||||
interests: client.interests || [],
|
||||
family: client.family,
|
||||
daysSinceLastContact: daysSinceContact,
|
||||
};
|
||||
|
||||
const interactionSummary = recentInteractions.map(i => ({
|
||||
type: i.type,
|
||||
title: i.title,
|
||||
description: i.description,
|
||||
date: i.contactedAt,
|
||||
}));
|
||||
|
||||
const emailSummary = recentEmails.map(e => ({
|
||||
subject: e.subject,
|
||||
status: e.status,
|
||||
date: e.createdAt,
|
||||
}));
|
||||
|
||||
const notesSummary = notes.map(n => n.content).join('\n---\n');
|
||||
|
||||
// Generate AI talking points
|
||||
const provider = (query.provider as 'openai' | 'anthropic') || 'openai';
|
||||
let aiTalkingPoints;
|
||||
try {
|
||||
aiTalkingPoints = await generateMeetingPrep({
|
||||
clientSummary,
|
||||
recentInteractions: interactionSummary,
|
||||
recentEmails: emailSummary,
|
||||
notes: notesSummary,
|
||||
upcomingEvents: upcomingEvents.map(e => ({ type: e.type, title: e.title, date: e.date })),
|
||||
importantDates,
|
||||
provider,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('AI meeting prep failed:', err);
|
||||
aiTalkingPoints = {
|
||||
suggestedTopics: ['Review current financial goals', 'Discuss market conditions', 'Check in on personal milestones'],
|
||||
conversationStarters: [`How have things been since we last spoke?`],
|
||||
followUpItems: ['Follow up on previous discussion items'],
|
||||
summary: `Meeting with ${client.firstName} ${client.lastName}. Last contact was ${daysSinceContact} days ago.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
client: clientSummary,
|
||||
healthScore,
|
||||
importantDates,
|
||||
recentInteractions: interactionSummary.slice(0, 5),
|
||||
recentEmails: emailSummary.slice(0, 3),
|
||||
upcomingEvents: upcomingEvents.map(e => ({ id: e.id, type: e.type, title: e.title, date: e.date })),
|
||||
notes: notes.slice(0, 5).map(n => ({ id: n.id, content: n.content, pinned: n.pinned, createdAt: n.createdAt })),
|
||||
aiTalkingPoints,
|
||||
};
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ format: 'uuid' }),
|
||||
}),
|
||||
query: t.Object({
|
||||
provider: t.Optional(t.String()),
|
||||
}),
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { db } from '../db';
|
||||
import { users, userProfiles, accounts } from '../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { User } from '../lib/auth';
|
||||
import { logAudit, getRequestMeta } from '../services/audit';
|
||||
|
||||
export const profileRoutes = new Elysia({ prefix: '/profile' })
|
||||
// Get current user's profile
|
||||
@@ -126,6 +127,80 @@ export const profileRoutes = new Elysia({ prefix: '/profile' })
|
||||
}),
|
||||
})
|
||||
|
||||
// Communication style
|
||||
.get('/communication-style', async ({ user }: { user: User }) => {
|
||||
const [profile] = await db.select()
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, user.id))
|
||||
.limit(1);
|
||||
|
||||
return profile?.communicationStyle || {
|
||||
tone: 'friendly',
|
||||
greeting: '',
|
||||
signoff: '',
|
||||
writingSamples: [],
|
||||
avoidWords: [],
|
||||
};
|
||||
})
|
||||
|
||||
.patch('/communication-style', async ({ body, user, request }: {
|
||||
body: {
|
||||
tone?: string;
|
||||
greeting?: string;
|
||||
signoff?: string;
|
||||
writingSamples?: string[];
|
||||
avoidWords?: string[];
|
||||
};
|
||||
user: User;
|
||||
request: Request;
|
||||
}) => {
|
||||
const [existing] = await db.select()
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, user.id))
|
||||
.limit(1);
|
||||
|
||||
const style = {
|
||||
tone: body.tone || 'friendly',
|
||||
greeting: body.greeting || '',
|
||||
signoff: body.signoff || '',
|
||||
writingSamples: (body.writingSamples || []).slice(0, 3),
|
||||
avoidWords: body.avoidWords || [],
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db.update(userProfiles)
|
||||
.set({ communicationStyle: style, updatedAt: new Date() })
|
||||
.where(eq(userProfiles.userId, user.id));
|
||||
} else {
|
||||
await db.insert(userProfiles).values({
|
||||
userId: user.id,
|
||||
communicationStyle: style,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
const meta = getRequestMeta(request);
|
||||
logAudit({
|
||||
userId: user.id,
|
||||
action: 'update',
|
||||
entityType: 'profile',
|
||||
entityId: user.id,
|
||||
details: { communicationStyle: style },
|
||||
...meta,
|
||||
});
|
||||
|
||||
return style;
|
||||
}, {
|
||||
body: t.Object({
|
||||
tone: t.Optional(t.String()),
|
||||
greeting: t.Optional(t.String()),
|
||||
signoff: t.Optional(t.String()),
|
||||
writingSamples: t.Optional(t.Array(t.String())),
|
||||
avoidWords: t.Optional(t.Array(t.String())),
|
||||
}),
|
||||
})
|
||||
|
||||
// Change password
|
||||
.put('/password', async ({ body, user, set }: {
|
||||
body: { currentPassword: string; newPassword: string };
|
||||
@@ -162,6 +237,14 @@ export const profileRoutes = new Elysia({ prefix: '/profile' })
|
||||
eq(accounts.providerId, 'credential'),
|
||||
));
|
||||
|
||||
logAudit({
|
||||
userId: user.id,
|
||||
action: 'password_change',
|
||||
entityType: 'auth',
|
||||
entityId: user.id,
|
||||
details: { event: 'password_changed' },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}, {
|
||||
body: t.Object({
|
||||
|
||||
@@ -24,13 +24,47 @@ function getModel(provider: AIProvider = 'openai') {
|
||||
throw new Error(`Provider ${provider} not supported`);
|
||||
}
|
||||
|
||||
// Build communication style instructions
|
||||
function buildStyleInstructions(style?: CommunicationStyle | null): string {
|
||||
if (!style) return '';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (style.tone) {
|
||||
const toneMap: Record<string, string> = {
|
||||
formal: 'Use a formal, professional tone. Maintain distance and respect.',
|
||||
friendly: 'Use a warm, friendly tone. Be personable but still professional.',
|
||||
casual: 'Use a casual, relaxed tone. Be conversational and approachable.',
|
||||
};
|
||||
parts.push(toneMap[style.tone] || '');
|
||||
}
|
||||
|
||||
if (style.greeting) {
|
||||
parts.push(`Always start emails with this greeting style: "${style.greeting}"`);
|
||||
}
|
||||
|
||||
if (style.signoff) {
|
||||
parts.push(`Always end emails with this sign-off: "${style.signoff}"`);
|
||||
}
|
||||
|
||||
if (style.writingSamples && style.writingSamples.length > 0) {
|
||||
parts.push(`Match the writing style of these samples from the advisor:\n${style.writingSamples.map((s, i) => `Sample ${i + 1}: "${s}"`).join('\n')}`);
|
||||
}
|
||||
|
||||
if (style.avoidWords && style.avoidWords.length > 0) {
|
||||
parts.push(`NEVER use these words/phrases: ${style.avoidWords.join(', ')}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? `\n\nCommunication Style Preferences:\n${parts.join('\n')}` : '';
|
||||
}
|
||||
|
||||
// Email generation prompt
|
||||
const emailPrompt = ChatPromptTemplate.fromMessages([
|
||||
['system', `You are a professional wealth advisor writing to a valued client.
|
||||
Maintain a warm but professional tone. Incorporate personal details naturally.
|
||||
Keep emails concise (3-4 paragraphs max).
|
||||
Do not include subject line - just the body.
|
||||
Always sign off with the advisor's actual name and details provided. Never use placeholders like [Your Name].`],
|
||||
Always sign off with the advisor's actual name and details provided. Never use placeholders like [Your Name].{styleInstructions}`],
|
||||
['human', `Advisor Info:
|
||||
- Name: {advisorName}
|
||||
- Title: {advisorTitle}
|
||||
@@ -47,6 +81,14 @@ Purpose of email: {purpose}
|
||||
Generate a personalized email that feels genuine, not templated. End with an appropriate signature using the advisor's real name and details.`],
|
||||
]);
|
||||
|
||||
export interface CommunicationStyle {
|
||||
tone?: 'formal' | 'friendly' | 'casual';
|
||||
greeting?: string;
|
||||
signoff?: string;
|
||||
writingSamples?: string[];
|
||||
avoidWords?: string[];
|
||||
}
|
||||
|
||||
export interface GenerateEmailParams {
|
||||
advisorName: string;
|
||||
advisorTitle?: string;
|
||||
@@ -58,6 +100,7 @@ export interface GenerateEmailParams {
|
||||
notes: string;
|
||||
purpose: string;
|
||||
provider?: AIProvider;
|
||||
communicationStyle?: CommunicationStyle | null;
|
||||
}
|
||||
|
||||
export async function generateEmail(params: GenerateEmailParams): Promise<string> {
|
||||
@@ -75,6 +118,7 @@ export async function generateEmail(params: GenerateEmailParams): Promise<string
|
||||
interests: params.interests.join(', ') || 'not specified',
|
||||
notes: params.notes || 'No recent notes',
|
||||
purpose: params.purpose,
|
||||
styleInstructions: buildStyleInstructions(params.communicationStyle),
|
||||
});
|
||||
|
||||
return response;
|
||||
@@ -112,6 +156,121 @@ export async function generateBirthdayMessage(params: GenerateBirthdayMessagePar
|
||||
return response;
|
||||
}
|
||||
|
||||
// Meeting prep generation
|
||||
const meetingPrepPrompt = ChatPromptTemplate.fromMessages([
|
||||
['system', `You are an AI assistant helping a wealth advisor prepare for a client meeting.
|
||||
Analyze the client data and generate useful talking points.
|
||||
Be specific and actionable - reference actual data when available.
|
||||
Output valid JSON with this structure:
|
||||
{{
|
||||
"summary": "Brief 2-3 sentence overview of the client relationship",
|
||||
"suggestedTopics": ["topic1", "topic2", ...],
|
||||
"conversationStarters": ["starter1", "starter2", ...],
|
||||
"followUpItems": ["item1", "item2", ...]
|
||||
}}
|
||||
Only output the JSON, nothing else.`],
|
||||
['human', `Client Profile:
|
||||
- Name: {clientName}
|
||||
- Company: {clientCompany}
|
||||
- Role: {clientRole}
|
||||
- Industry: {clientIndustry}
|
||||
- Stage: {clientStage}
|
||||
- Interests: {clientInterests}
|
||||
- Family: {clientFamily}
|
||||
- Days since last contact: {daysSinceContact}
|
||||
|
||||
Recent Interactions:
|
||||
{interactions}
|
||||
|
||||
Recent Emails:
|
||||
{emails}
|
||||
|
||||
Notes:
|
||||
{notes}
|
||||
|
||||
Upcoming Events:
|
||||
{upcomingEvents}
|
||||
|
||||
Important Dates:
|
||||
{importantDates}
|
||||
|
||||
Generate meeting prep JSON.`],
|
||||
]);
|
||||
|
||||
export interface MeetingPrepInput {
|
||||
clientSummary: {
|
||||
name: string;
|
||||
company: string;
|
||||
role: string;
|
||||
industry: string;
|
||||
stage: string;
|
||||
interests: string[];
|
||||
family?: { spouse?: string; children?: string[] } | null;
|
||||
daysSinceLastContact: number;
|
||||
};
|
||||
recentInteractions: { type: string; title: string; description?: string | null; date: Date | string }[];
|
||||
recentEmails: { subject?: string | null; status?: string | null; date: Date | string }[];
|
||||
notes: string;
|
||||
upcomingEvents: { type: string; title: string; date: Date | string }[];
|
||||
importantDates: { type: string; date: string; label: string }[];
|
||||
provider?: AIProvider;
|
||||
}
|
||||
|
||||
export interface MeetingPrepOutput {
|
||||
summary: string;
|
||||
suggestedTopics: string[];
|
||||
conversationStarters: string[];
|
||||
followUpItems: string[];
|
||||
}
|
||||
|
||||
export async function generateMeetingPrep(params: MeetingPrepInput): Promise<MeetingPrepOutput> {
|
||||
const model = getModel(params.provider);
|
||||
const parser = new StringOutputParser();
|
||||
const chain = meetingPrepPrompt.pipe(model).pipe(parser);
|
||||
|
||||
const familyStr = params.clientSummary.family
|
||||
? `Spouse: ${params.clientSummary.family.spouse || 'N/A'}, Children: ${params.clientSummary.family.children?.join(', ') || 'N/A'}`
|
||||
: 'Not available';
|
||||
|
||||
const response = await chain.invoke({
|
||||
clientName: params.clientSummary.name,
|
||||
clientCompany: params.clientSummary.company,
|
||||
clientRole: params.clientSummary.role,
|
||||
clientIndustry: params.clientSummary.industry,
|
||||
clientStage: params.clientSummary.stage,
|
||||
clientInterests: params.clientSummary.interests.join(', ') || 'Not specified',
|
||||
clientFamily: familyStr,
|
||||
daysSinceContact: String(params.clientSummary.daysSinceLastContact),
|
||||
interactions: params.recentInteractions.length > 0
|
||||
? params.recentInteractions.map(i => `- [${i.type}] ${i.title} (${new Date(i.date).toLocaleDateString()})${i.description ? ': ' + i.description : ''}`).join('\n')
|
||||
: 'No recent interactions',
|
||||
emails: params.recentEmails.length > 0
|
||||
? params.recentEmails.map(e => `- ${e.subject || 'No subject'} (${e.status}, ${new Date(e.date).toLocaleDateString()})`).join('\n')
|
||||
: 'No recent emails',
|
||||
notes: params.notes || 'No notes',
|
||||
upcomingEvents: params.upcomingEvents.length > 0
|
||||
? params.upcomingEvents.map(e => `- ${e.title} (${e.type}, ${new Date(e.date).toLocaleDateString()})`).join('\n')
|
||||
: 'No upcoming events',
|
||||
importantDates: params.importantDates.length > 0
|
||||
? params.importantDates.map(d => `- ${d.label}`).join('\n')
|
||||
: 'No notable upcoming dates',
|
||||
});
|
||||
|
||||
try {
|
||||
// Extract JSON from response (handle markdown code blocks)
|
||||
const jsonStr = response.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
|
||||
return JSON.parse(jsonStr);
|
||||
} catch {
|
||||
// Fallback if AI returns non-JSON
|
||||
return {
|
||||
summary: response.slice(0, 500),
|
||||
suggestedTopics: ['Review financial goals', 'Market update', 'Personal check-in'],
|
||||
conversationStarters: ['How have things been going?'],
|
||||
followUpItems: ['Send meeting summary'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Email subject generation
|
||||
const subjectPrompt = ChatPromptTemplate.fromMessages([
|
||||
['system', `Generate a professional but warm email subject line for a wealth advisor's email.
|
||||
|
||||
55
src/services/audit.ts
Normal file
55
src/services/audit.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { db } from '../db';
|
||||
import { auditLogs } from '../db/schema';
|
||||
|
||||
export type AuditAction = 'create' | 'update' | 'delete' | 'view' | 'send' | 'login' | 'logout' | 'password_change';
|
||||
export type AuditEntityType = 'client' | 'email' | 'event' | 'template' | 'segment' | 'user' | 'auth' | 'interaction' | 'note' | 'notification' | 'invite' | 'profile';
|
||||
|
||||
export interface AuditLogParams {
|
||||
userId?: string | null;
|
||||
action: AuditAction;
|
||||
entityType: AuditEntityType;
|
||||
entityId?: string | null;
|
||||
details?: Record<string, unknown> | null;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
export async function logAudit(params: AuditLogParams): Promise<void> {
|
||||
try {
|
||||
await db.insert(auditLogs).values({
|
||||
userId: params.userId || null,
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId || null,
|
||||
details: params.details || null,
|
||||
ipAddress: params.ipAddress || null,
|
||||
userAgent: params.userAgent || null,
|
||||
});
|
||||
} catch (err) {
|
||||
// Don't let audit logging failures break the app
|
||||
console.error('[AUDIT] Failed to log:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to extract IP and User-Agent from request
|
||||
export function getRequestMeta(request: Request): { ipAddress: string | null; userAgent: string | null } {
|
||||
return {
|
||||
ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| request.headers.get('x-real-ip')
|
||||
|| null,
|
||||
userAgent: request.headers.get('user-agent') || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to compute diff between old and new values
|
||||
export function computeDiff(oldVal: Record<string, unknown>, newVal: Record<string, unknown>): Record<string, { from: unknown; to: unknown }> {
|
||||
const diff: Record<string, { from: unknown; to: unknown }> = {};
|
||||
for (const key of Object.keys(newVal)) {
|
||||
const oldV = oldVal[key];
|
||||
const newV = newVal[key];
|
||||
if (JSON.stringify(oldV) !== JSON.stringify(newV)) {
|
||||
diff[key] = { from: oldV, to: newV };
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
Reference in New Issue
Block a user