From 6c2851e93a9303251c9b46423d73092774080a59 Mon Sep 17 00:00:00 2001 From: Hammer Date: Fri, 30 Jan 2026 01:16:12 +0000 Subject: [PATCH] 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) --- src/__tests__/audit.test.ts | 206 ++++++++++++++++++++++++++++++++++++ src/db/schema.ts | 20 ++++ src/index.ts | 4 + src/routes/audit-logs.ts | 101 ++++++++++++++++++ src/routes/emails.ts | 1 + src/routes/meeting-prep.ts | 170 +++++++++++++++++++++++++++++ src/routes/profile.ts | 83 +++++++++++++++ src/services/ai.ts | 161 +++++++++++++++++++++++++++- src/services/audit.ts | 55 ++++++++++ 9 files changed, 800 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/audit.test.ts create mode 100644 src/routes/audit-logs.ts create mode 100644 src/routes/meeting-prep.ts create mode 100644 src/services/audit.ts diff --git a/src/__tests__/audit.test.ts b/src/__tests__/audit.test.ts new file mode 100644 index 0000000..59d9b74 --- /dev/null +++ b/src/__tests__/audit.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index 209de47..bcab20e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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>(), // 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), diff --git a/src/index.ts b/src/index.ts index 09c11bf..3762874 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/routes/audit-logs.ts b/src/routes/audit-logs.ts new file mode 100644 index 0000000..a928f15 --- /dev/null +++ b/src/routes/audit-logs.ts @@ -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`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()), + }), + }); diff --git a/src/routes/emails.ts b/src/routes/emails.ts index 57757fc..71696ce 100644 --- a/src/routes/emails.ts +++ b/src/routes/emails.ts @@ -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) { diff --git a/src/routes/meeting-prep.ts b/src/routes/meeting-prep.ts new file mode 100644 index 0000000..648f438 --- /dev/null +++ b/src/routes/meeting-prep.ts @@ -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()), + }), + }); diff --git a/src/routes/profile.ts b/src/routes/profile.ts index 6d672ec..d86cd49 100644 --- a/src/routes/profile.ts +++ b/src/routes/profile.ts @@ -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({ diff --git a/src/services/ai.ts b/src/services/ai.ts index 1e00d06..05fa054 100644 --- a/src/services/ai.ts +++ b/src/services/ai.ts @@ -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 = { + 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 { @@ -75,6 +118,7 @@ export async function generateEmail(params: GenerateEmailParams): Promise { + 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. diff --git a/src/services/audit.ts b/src/services/audit.ts new file mode 100644 index 0000000..34d3956 --- /dev/null +++ b/src/services/audit.ts @@ -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 | null; + ipAddress?: string | null; + userAgent?: string | null; +} + +export async function logAudit(params: AuditLogParams): Promise { + 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, newVal: Record): Record { + const diff: Record = {}; + 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; +}