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:
2026-01-30 01:16:12 +00:00
parent 0ec1b9c448
commit 6c2851e93a
9 changed files with 800 additions and 1 deletions

206
src/__tests__/audit.test.ts Normal file
View 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);
});
});

View File

@@ -44,6 +44,13 @@ export const userProfiles = pgTable('user_profiles', {
company: text('company'), // e.g., "ABC Financial Group" company: text('company'), // e.g., "ABC Financial Group"
phone: text('phone'), phone: text('phone'),
emailSignature: text('email_signature'), // Custom signature block 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(), createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_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(), 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 // Relations
export const usersRelations = relations(users, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({
clients: many(clients), clients: many(clients),

View File

@@ -18,6 +18,8 @@ import { notificationRoutes } from './routes/notifications';
import { interactionRoutes } from './routes/interactions'; import { interactionRoutes } from './routes/interactions';
import { templateRoutes } from './routes/templates'; import { templateRoutes } from './routes/templates';
import { segmentRoutes } from './routes/segments'; import { segmentRoutes } from './routes/segments';
import { auditLogRoutes } from './routes/audit-logs';
import { meetingPrepRoutes } from './routes/meeting-prep';
import { db } from './db'; import { db } from './db';
import { users } from './db/schema'; import { users } from './db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
@@ -81,6 +83,8 @@ const app = new Elysia()
.use(interactionRoutes) .use(interactionRoutes)
.use(templateRoutes) .use(templateRoutes)
.use(segmentRoutes) .use(segmentRoutes)
.use(auditLogRoutes)
.use(meetingPrepRoutes)
) )
// Error handler // Error handler

101
src/routes/audit-logs.ts Normal file
View 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()),
}),
});

View File

@@ -59,6 +59,7 @@ export const emailRoutes = new Elysia({ prefix: '/emails' })
notes: client.notes || '', notes: client.notes || '',
purpose: body.purpose, purpose: body.purpose,
provider: body.provider, provider: body.provider,
communicationStyle: profile?.communicationStyle as any,
}); });
console.log(`[${new Date().toISOString()}] Email content generated successfully`); console.log(`[${new Date().toISOString()}] Email content generated successfully`);
} catch (e) { } catch (e) {

170
src/routes/meeting-prep.ts Normal file
View 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()),
}),
});

View File

@@ -3,6 +3,7 @@ import { db } from '../db';
import { users, userProfiles, accounts } from '../db/schema'; import { users, userProfiles, accounts } from '../db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import type { User } from '../lib/auth'; import type { User } from '../lib/auth';
import { logAudit, getRequestMeta } from '../services/audit';
export const profileRoutes = new Elysia({ prefix: '/profile' }) export const profileRoutes = new Elysia({ prefix: '/profile' })
// Get current user's 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 // Change password
.put('/password', async ({ body, user, set }: { .put('/password', async ({ body, user, set }: {
body: { currentPassword: string; newPassword: string }; body: { currentPassword: string; newPassword: string };
@@ -162,6 +237,14 @@ export const profileRoutes = new Elysia({ prefix: '/profile' })
eq(accounts.providerId, 'credential'), eq(accounts.providerId, 'credential'),
)); ));
logAudit({
userId: user.id,
action: 'password_change',
entityType: 'auth',
entityId: user.id,
details: { event: 'password_changed' },
});
return { success: true }; return { success: true };
}, { }, {
body: t.Object({ body: t.Object({

View File

@@ -24,13 +24,47 @@ function getModel(provider: AIProvider = 'openai') {
throw new Error(`Provider ${provider} not supported`); 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 // Email generation prompt
const emailPrompt = ChatPromptTemplate.fromMessages([ const emailPrompt = ChatPromptTemplate.fromMessages([
['system', `You are a professional wealth advisor writing to a valued client. ['system', `You are a professional wealth advisor writing to a valued client.
Maintain a warm but professional tone. Incorporate personal details naturally. Maintain a warm but professional tone. Incorporate personal details naturally.
Keep emails concise (3-4 paragraphs max). Keep emails concise (3-4 paragraphs max).
Do not include subject line - just the body. 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: ['human', `Advisor Info:
- Name: {advisorName} - Name: {advisorName}
- Title: {advisorTitle} - 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.`], 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 { export interface GenerateEmailParams {
advisorName: string; advisorName: string;
advisorTitle?: string; advisorTitle?: string;
@@ -58,6 +100,7 @@ export interface GenerateEmailParams {
notes: string; notes: string;
purpose: string; purpose: string;
provider?: AIProvider; provider?: AIProvider;
communicationStyle?: CommunicationStyle | null;
} }
export async function generateEmail(params: GenerateEmailParams): Promise<string> { 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', interests: params.interests.join(', ') || 'not specified',
notes: params.notes || 'No recent notes', notes: params.notes || 'No recent notes',
purpose: params.purpose, purpose: params.purpose,
styleInstructions: buildStyleInstructions(params.communicationStyle),
}); });
return response; return response;
@@ -112,6 +156,121 @@ export async function generateBirthdayMessage(params: GenerateBirthdayMessagePar
return response; 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 // Email subject generation
const subjectPrompt = ChatPromptTemplate.fromMessages([ const subjectPrompt = ChatPromptTemplate.fromMessages([
['system', `Generate a professional but warm email subject line for a wealth advisor's email. ['system', `Generate a professional but warm email subject line for a wealth advisor's email.

55
src/services/audit.ts Normal file
View 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;
}