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"
|
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),
|
||||||
|
|||||||
@@ -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
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 || '',
|
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
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 { 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({
|
||||||
|
|||||||
@@ -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
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