Compare commits

...

8 Commits

Author SHA1 Message Date
bd2a4c017c feat: engagement scoring, birthday auto-emails, stats overview API
Some checks failed
CI/CD / check (push) Successful in 35s
CI/CD / deploy (push) Failing after 1s
- Engagement scoring system (0-100) based on recency, interactions, emails, events, notes
- GET /api/engagement — all client scores with distribution summary
- GET /api/clients/:id/engagement — individual client score with recommendations
- GET /api/stats/overview — system-wide dashboard stats
- Automated birthday check job (pg-boss, daily at 8am UTC)
- Birthday notification emails to advisors (7 days before + day of)
- 73 tests passing (up from 56)
2026-01-30 03:41:26 +00:00
7634306832 fix: resolve TypeScript errors for CI pipeline
- Fix pg-boss Job type imports (PgBoss.Job -> Job from pg-boss)
- Replace deprecated teamConcurrency with localConcurrency
- Add null checks for possibly undefined values (clients, import rows)
- Fix tone type narrowing in profile.ts
- Fix test type assertions (non-null assertions, explicit Record types)
- Extract auth middleware into shared module
- Fix rate limiter Map generic type
2026-01-30 03:27:58 +00:00
4dc641db02 feat: production hardening - rate limiting, tags API, onboarding, pagination
- Rate limiting middleware: 100/min global, 5/min auth, 10/min AI endpoints
- Tags CRUD API: list with counts, rename, delete, merge across all clients
- Onboarding: added onboardingComplete field to userProfiles schema
- Profile routes: GET /onboarding-status, POST /complete-onboarding
- Clients pagination: page/limit query params with backwards-compatible response
2026-01-30 01:37:32 +00:00
6c2851e93a 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)
2026-01-30 01:16:12 +00:00
0ec1b9c448 feat: email templates + client segments with advanced filtering
- Email templates: CRUD API with categories, placeholders ({{firstName}}, etc.), usage tracking, default per category
- Client segments: save filtered views with multi-criteria filters (stage, tags, industry, city, state, contact dates, email/phone presence)
- Segment preview: test filters before saving, returns matching client list
- Filter options: GET /api/segments/filter-options returns unique values for all filterable fields
- New tables: email_templates, client_segments (auto-created via db:push)
2026-01-30 01:07:35 +00:00
93fce809e2 feat: pg-boss job queue, notifications, client interactions, bulk email 2026-01-30 00:48:07 +00:00
bb87ba169a feat: client pipeline stages + notes system
- Added 'stage' column to clients (lead/prospect/onboarding/active/inactive)
- New client_notes table with CRUD API at /clients/:id/notes
- Notes support pinning, editing, and deletion
- Stage field in create/update client endpoints
- Fixed flaky email test (env var interference)
2026-01-30 00:35:49 +00:00
33a0e1d110 ci: add Gitea Actions CI/CD workflow 2026-01-29 23:18:09 +00:00
32 changed files with 3147 additions and 60 deletions

40
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,40 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Type check
run: bun x tsc --noEmit
deploy:
needs: check
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Deploy to Dokploy
run: |
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/application.deploy" \
-H "Content-Type: application/json" \
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
-d '{"applicationId": "${{ secrets.DOKPLOY_APP_ID }}"}'
echo "Deploy triggered on Dokploy"

208
src/__tests__/audit.test.ts Normal file
View File

@@ -0,0 +1,208 @@
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 noPage: string | undefined = undefined;
const noLimit: string | undefined = undefined;
const page = parseInt(noPage || '1');
const limit = Math.min(parseInt(noLimit || '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

@@ -3,7 +3,7 @@ import { describe, test, expect } from 'bun:test';
describe('Client Routes', () => {
describe('Validation', () => {
test('clientSchema requires firstName', () => {
const invalidClient = {
const invalidClient: Record<string, unknown> = {
lastName: 'Doe',
};
// Schema validation test - firstName is required
@@ -11,7 +11,7 @@ describe('Client Routes', () => {
});
test('clientSchema requires lastName', () => {
const invalidClient = {
const invalidClient: Record<string, unknown> = {
firstName: 'John',
};
// Schema validation test - lastName is required
@@ -107,8 +107,8 @@ describe('Search Functionality', () => {
const filtered = clients.filter(c => c.tags?.includes('vip'));
expect(filtered).toHaveLength(2);
expect(filtered[0].firstName).toBe('John');
expect(filtered[1].firstName).toBe('Bob');
expect(filtered[0]!.firstName).toBe('John');
expect(filtered[1]!.firstName).toBe('Bob');
});
});

View File

@@ -93,11 +93,14 @@ describe('Email Service', () => {
describe('Default From Email', () => {
test('falls back to default when from not provided', () => {
const savedEnv = process.env.DEFAULT_FROM_EMAIL;
delete process.env.DEFAULT_FROM_EMAIL;
const from = undefined;
const defaultFrom = 'onboarding@resend.dev';
const result = from || process.env.DEFAULT_FROM_EMAIL || defaultFrom;
expect(result).toBe(defaultFrom);
if (savedEnv) process.env.DEFAULT_FROM_EMAIL = savedEnv;
});
test('uses provided from when available', () => {

View File

@@ -0,0 +1,152 @@
import { describe, it, expect } from 'bun:test';
// Unit tests for engagement scoring logic
describe('Engagement Scoring', () => {
describe('Recency Score', () => {
const calculateRecencyScore = (lastContactedAt: Date | null): number => {
if (!lastContactedAt) return 0;
const daysSince = Math.floor((Date.now() - lastContactedAt.getTime()) / (1000 * 60 * 60 * 24));
if (daysSince <= 7) return 40;
if (daysSince <= 14) return 35;
if (daysSince <= 30) return 28;
if (daysSince <= 60) return 18;
if (daysSince <= 90) return 10;
if (daysSince <= 180) return 5;
return 0;
};
it('returns 40 for contact within last week', () => {
const recent = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
expect(calculateRecencyScore(recent)).toBe(40);
});
it('returns 35 for contact within 2 weeks', () => {
const twoWeeksAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
expect(calculateRecencyScore(twoWeeksAgo)).toBe(35);
});
it('returns 28 for contact within 30 days', () => {
const monthAgo = new Date(Date.now() - 25 * 24 * 60 * 60 * 1000);
expect(calculateRecencyScore(monthAgo)).toBe(28);
});
it('returns 18 for contact within 60 days', () => {
const twoMonthsAgo = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000);
expect(calculateRecencyScore(twoMonthsAgo)).toBe(18);
});
it('returns 10 for contact within 90 days', () => {
const threeMonthsAgo = new Date(Date.now() - 75 * 24 * 60 * 60 * 1000);
expect(calculateRecencyScore(threeMonthsAgo)).toBe(10);
});
it('returns 0 for no contact', () => {
expect(calculateRecencyScore(null)).toBe(0);
});
it('returns 0 for very old contact', () => {
const longAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
expect(calculateRecencyScore(longAgo)).toBe(0);
});
});
describe('Interaction Score', () => {
const calculateInteractionScore = (count: number): number => {
if (count >= 10) return 25;
if (count >= 7) return 22;
if (count >= 5) return 18;
if (count >= 3) return 14;
if (count >= 1) return 8;
return 0;
};
it('returns max score for 10+ interactions', () => {
expect(calculateInteractionScore(10)).toBe(25);
expect(calculateInteractionScore(20)).toBe(25);
});
it('returns 0 for no interactions', () => {
expect(calculateInteractionScore(0)).toBe(0);
});
it('scales appropriately', () => {
expect(calculateInteractionScore(1)).toBe(8);
expect(calculateInteractionScore(3)).toBe(14);
expect(calculateInteractionScore(5)).toBe(18);
expect(calculateInteractionScore(7)).toBe(22);
});
});
describe('Email Score', () => {
const calculateEmailScore = (count: number): number => {
if (count >= 8) return 15;
if (count >= 5) return 12;
if (count >= 3) return 9;
if (count >= 1) return 5;
return 0;
};
it('returns max score for 8+ emails', () => {
expect(calculateEmailScore(8)).toBe(15);
});
it('returns 0 for no emails', () => {
expect(calculateEmailScore(0)).toBe(0);
});
});
describe('Total Score', () => {
it('max possible score is 100', () => {
const maxRecency = 40;
const maxInteractions = 25;
const maxEmails = 15;
const maxEvents = 10;
const maxNotes = 10;
expect(maxRecency + maxInteractions + maxEmails + maxEvents + maxNotes).toBe(100);
});
it('all zeros gives score of 0', () => {
expect(0 + 0 + 0 + 0 + 0).toBe(0);
});
});
describe('Engagement Labels', () => {
const getEngagementLabel = (score: number): string => {
if (score >= 80) return 'highly_engaged';
if (score >= 60) return 'engaged';
if (score >= 40) return 'warm';
if (score >= 20) return 'cooling';
return 'cold';
};
it('classifies scores correctly', () => {
expect(getEngagementLabel(95)).toBe('highly_engaged');
expect(getEngagementLabel(80)).toBe('highly_engaged');
expect(getEngagementLabel(70)).toBe('engaged');
expect(getEngagementLabel(50)).toBe('warm');
expect(getEngagementLabel(25)).toBe('cooling');
expect(getEngagementLabel(10)).toBe('cold');
expect(getEngagementLabel(0)).toBe('cold');
});
});
describe('Recommendations', () => {
it('generates recency recommendation for old contacts', () => {
const breakdown = { recency: 5, interactions: 25, emails: 15, events: 10, notes: 10 };
const recs: string[] = [];
if (breakdown.recency < 18) {
recs.push('Reach out soon');
}
expect(recs.length).toBeGreaterThan(0);
});
it('generates no recency recommendation for recent contacts', () => {
const breakdown = { recency: 40, interactions: 25, emails: 15, events: 10, notes: 10 };
const recs: string[] = [];
if (breakdown.recency < 18) {
recs.push('Reach out soon');
}
expect(recs.length).toBe(0);
});
});
});

View File

@@ -44,6 +44,14 @@ 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[];
}>(),
onboardingComplete: boolean('onboarding_complete').default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
@@ -118,6 +126,7 @@ export const clients = pgTable('clients', {
// Organization
tags: jsonb('tags').$type<string[]>().default([]),
stage: text('stage').default('lead'), // 'lead' | 'prospect' | 'onboarding' | 'active' | 'inactive'
// Tracking
lastContactedAt: timestamp('last_contacted_at'),
@@ -154,17 +163,126 @@ export const communications = pgTable('communications', {
aiModel: text('ai_model'), // Which model was used
status: text('status').default('draft'), // 'draft' | 'approved' | 'sent'
sentAt: timestamp('sent_at'),
batchId: text('batch_id'), // for grouping bulk sends
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Notifications table
export const notifications = pgTable('notifications', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
type: text('type').notNull(), // 'event_reminder' | 'interaction' | 'system'
title: text('title').notNull(),
message: text('message').notNull(),
read: boolean('read').default(false),
clientId: uuid('client_id').references(() => clients.id, { onDelete: 'set null' }),
eventId: uuid('event_id').references(() => events.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Interactions table (touchpoint logging)
export const interactions = pgTable('interactions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
clientId: uuid('client_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(),
type: text('type').notNull(), // 'call' | 'meeting' | 'email' | 'note' | 'other'
title: text('title').notNull(),
description: text('description'),
duration: integer('duration'), // in minutes
contactedAt: timestamp('contacted_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Client notes table
export const clientNotes = pgTable('client_notes', {
id: uuid('id').primaryKey().defaultRandom(),
clientId: uuid('client_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
content: text('content').notNull(),
pinned: boolean('pinned').default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Email templates table
export const emailTemplates = pgTable('email_templates', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
name: text('name').notNull(),
category: text('category').notNull(), // 'follow-up' | 'birthday' | 'introduction' | 'check-in' | 'thank-you' | 'custom'
subject: text('subject').notNull(),
content: text('content').notNull(), // supports {{firstName}}, {{lastName}}, {{company}} placeholders
isDefault: boolean('is_default').default(false), // mark as default for category
usageCount: integer('usage_count').default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Saved client filters / segments
export const clientSegments = pgTable('client_segments', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
name: text('name').notNull(),
description: text('description'),
filters: jsonb('filters').$type<{
stages?: string[];
tags?: string[];
industries?: string[];
cities?: string[];
states?: string[];
lastContactedBefore?: string; // ISO date
lastContactedAfter?: string;
createdBefore?: string;
createdAfter?: string;
hasEmail?: boolean;
hasPhone?: boolean;
search?: string;
}>().notNull(),
color: text('color').default('#3b82f6'), // badge color
pinned: boolean('pinned').default(false),
createdAt: timestamp('created_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
export const usersRelations = relations(users, ({ many }) => ({
clients: many(clients),
events: many(events),
communications: many(communications),
notifications: many(notifications),
interactions: many(interactions),
sessions: many(sessions),
accounts: many(accounts),
emailTemplates: many(emailTemplates),
clientSegments: many(clientSegments),
}));
export const emailTemplatesRelations = relations(emailTemplates, ({ one }) => ({
user: one(users, {
fields: [emailTemplates.userId],
references: [users.id],
}),
}));
export const clientSegmentsRelations = relations(clientSegments, ({ one }) => ({
user: one(users, {
fields: [clientSegments.userId],
references: [users.id],
}),
}));
export const clientsRelations = relations(clients, ({ one, many }) => ({
@@ -174,6 +292,45 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
}),
events: many(events),
communications: many(communications),
notes: many(clientNotes),
interactions: many(interactions),
}));
export const notificationsRelations = relations(notifications, ({ one }) => ({
user: one(users, {
fields: [notifications.userId],
references: [users.id],
}),
client: one(clients, {
fields: [notifications.clientId],
references: [clients.id],
}),
event: one(events, {
fields: [notifications.eventId],
references: [events.id],
}),
}));
export const interactionsRelations = relations(interactions, ({ one }) => ({
user: one(users, {
fields: [interactions.userId],
references: [users.id],
}),
client: one(clients, {
fields: [interactions.clientId],
references: [clients.id],
}),
}));
export const clientNotesRelations = relations(clientNotes, ({ one }) => ({
client: one(clients, {
fields: [clientNotes.clientId],
references: [clients.id],
}),
user: one(users, {
fields: [clientNotes.userId],
references: [users.id],
}),
}));
export const eventsRelations = relations(events, ({ one }) => ({

View File

@@ -1,5 +1,6 @@
import { Elysia } from 'elysia';
import { cors } from '@elysiajs/cors';
import { rateLimitPlugin } from './middleware/rate-limit';
import { auth } from './lib/auth';
import { clientRoutes } from './routes/clients';
import { emailRoutes } from './routes/emails';
@@ -13,12 +14,25 @@ import { importRoutes } from './routes/import';
import { activityRoutes } from './routes/activity';
import { insightsRoutes } from './routes/insights';
import { reportsRoutes } from './routes/reports';
import { notesRoutes } from './routes/notes';
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';
import type { User } from './lib/auth';
import { tagRoutes } from './routes/tags';
import { engagementRoutes } from './routes/engagement';
import { statsRoutes } from './routes/stats';
import { initJobQueue } from './services/jobs';
const app = new Elysia()
// Rate limiting (before everything else)
.use(rateLimitPlugin)
// CORS
.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
@@ -44,21 +58,7 @@ const app = new Elysia()
.use(inviteRoutes)
.use(passwordResetRoutes)
// Protected routes - require auth
.derive(async ({ request, set }): Promise<{ user: User }> => {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
set.status = 401;
throw new Error('Unauthorized');
}
return { user: session.user as User };
})
// API routes (all require auth due to derive above)
// API routes (auth middleware is in each route plugin)
.group('/api', app => app
.use(clientRoutes)
.use(importRoutes)
@@ -70,45 +70,67 @@ const app = new Elysia()
.use(networkRoutes)
.use(insightsRoutes)
.use(reportsRoutes)
.use(notesRoutes)
.use(notificationRoutes)
.use(interactionRoutes)
.use(templateRoutes)
.use(segmentRoutes)
.use(auditLogRoutes)
.use(meetingPrepRoutes)
.use(tagRoutes)
.use(engagementRoutes)
.use(statsRoutes)
)
// Error handler
.onError(({ code, error, set, path }) => {
// Always log errors with full details
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.error(`[${new Date().toISOString()}] ERROR on ${path}:`, {
code,
message: error.message,
stack: error.stack,
message,
stack,
});
if (code === 'VALIDATION') {
set.status = 400;
return { error: 'Validation error', details: error.message };
return { error: 'Validation error', details: message };
}
if (error.message === 'Unauthorized') {
if (message === 'Unauthorized') {
set.status = 401;
return { error: 'Unauthorized' };
}
if (error.message.includes('Forbidden')) {
if (message.includes('Forbidden')) {
set.status = 403;
return { error: error.message };
return { error: message };
}
if (error.message.includes('not found')) {
if (message.includes('not found')) {
set.status = 404;
return { error: error.message };
return { error: message };
}
set.status = 500;
return { error: 'Internal server error', details: error.message };
return { error: 'Internal server error', details: message };
})
.listen(process.env.PORT || 3000);
console.log(`🚀 Network App API running at ${app.server?.hostname}:${app.server?.port}`);
// Initialize pg-boss job queue
(async () => {
try {
await initJobQueue();
} catch (e) {
console.error('pg-boss init failed (will retry on next restart):', e);
}
})();
// Bootstrap: ensure donovan@donovankelly.xyz is admin
(async () => {
try {

20
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Elysia } from 'elysia';
import { auth, type User } from '../lib/auth';
/**
* Auth middleware plugin - adds `user` to the Elysia context.
* Import and `.use(authMiddleware)` in route files that need authentication.
*/
export const authMiddleware = new Elysia({ name: 'auth-middleware' })
.derive({ as: 'scoped' }, async ({ request, set }): Promise<{ user: User }> => {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
set.status = 401;
throw new Error('Unauthorized');
}
return { user: session.user as User };
});

View File

@@ -0,0 +1,95 @@
import { Elysia } from 'elysia';
interface RateLimitEntry {
count: number;
resetAt: number;
}
interface RateLimitConfig {
windowMs: number;
maxRequests: number;
}
// In-memory store keyed by "bucket:ip"
const store = new Map<string, RateLimitEntry>();
// Cleanup expired entries every 60s
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (entry.resetAt <= now) {
store.delete(key);
}
}
}, 60_000);
function checkRateLimit(key: string, config: RateLimitConfig): { allowed: boolean; remaining: number; retryAfterSec: number } {
const now = Date.now();
const entry = store.get(key);
if (!entry || entry.resetAt <= now) {
store.set(key, { count: 1, resetAt: now + config.windowMs });
return { allowed: true, remaining: config.maxRequests - 1, retryAfterSec: 0 };
}
entry.count++;
if (entry.count > config.maxRequests) {
const retryAfterSec = Math.ceil((entry.resetAt - now) / 1000);
return { allowed: false, remaining: 0, retryAfterSec };
}
return { allowed: true, remaining: config.maxRequests - entry.count, retryAfterSec: 0 };
}
function getClientIP(request: Request): string {
// Check common proxy headers
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) return forwarded.split(',')[0]?.trim() ?? '127.0.0.1';
const realIp = request.headers.get('x-real-ip');
if (realIp) return realIp;
return '127.0.0.1';
}
// Route-specific limits
const AUTH_PATHS = ['/api/auth/sign-in', '/api/auth/sign-up', '/auth/reset-password'];
const AI_PATHS = ['/meeting-prep', '/emails/generate', '/emails/bulk-generate', '/network/intro'];
function getBucket(path: string): { bucket: string; config: RateLimitConfig } {
const lowerPath = path.toLowerCase();
// Auth endpoints: 5 req/min
if (AUTH_PATHS.some(p => lowerPath.startsWith(p))) {
return { bucket: 'auth', config: { windowMs: 60_000, maxRequests: 5 } };
}
// AI endpoints: 10 req/min
if (AI_PATHS.some(p => lowerPath.includes(p))) {
return { bucket: 'ai', config: { windowMs: 60_000, maxRequests: 10 } };
}
// Global: 100 req/min
return { bucket: 'global', config: { windowMs: 60_000, maxRequests: 100 } };
}
export const rateLimitPlugin = new Elysia({ name: 'rate-limit' })
.onBeforeHandle(({ request, set }) => {
const ip = getClientIP(request);
const url = new URL(request.url);
const { bucket, config } = getBucket(url.pathname);
const key = `${bucket}:${ip}`;
const result = checkRateLimit(key, config);
// Always set rate limit headers
set.headers['X-RateLimit-Limit'] = String(config.maxRequests);
set.headers['X-RateLimit-Remaining'] = String(result.remaining);
if (!result.allowed) {
set.status = 429;
set.headers['Retry-After'] = String(result.retryAfterSec);
return {
error: 'Too many requests',
retryAfter: result.retryAfterSec,
};
}
});

View File

@@ -1,12 +1,13 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients, events, communications } from '../db/schema';
import { clients, events, communications, interactions } from '../db/schema';
import { eq, and, desc } from 'drizzle-orm';
import type { User } from '../lib/auth';
export interface ActivityItem {
id: string;
type: 'email_sent' | 'email_drafted' | 'event_created' | 'client_contacted' | 'client_created' | 'client_updated';
type: 'email_sent' | 'email_drafted' | 'event_created' | 'client_contacted' | 'client_created' | 'client_updated' | 'interaction';
title: string;
description?: string;
date: string;
@@ -14,6 +15,7 @@ export interface ActivityItem {
}
export const activityRoutes = new Elysia({ prefix: '/clients' })
.use(authMiddleware)
// Get activity timeline for a client
.get('/:id/activity', async ({ params, user }: { params: { id: string }; user: User }) => {
// Verify client belongs to user
@@ -110,6 +112,37 @@ export const activityRoutes = new Elysia({ prefix: '/clients' })
});
}
// Interactions
const clientInteractions = await db.select()
.from(interactions)
.where(and(
eq(interactions.clientId, params.id),
eq(interactions.userId, user.id),
))
.orderBy(desc(interactions.contactedAt));
for (const interaction of clientInteractions) {
const typeLabels: Record<string, string> = {
call: '📞 Phone Call',
meeting: '🤝 Meeting',
email: '✉️ Email',
note: '📝 Note',
other: '📌 Interaction',
};
activities.push({
id: `interaction-${interaction.id}`,
type: 'interaction',
title: `${typeLabels[interaction.type] || typeLabels.other}: ${interaction.title}`,
description: interaction.description || undefined,
date: interaction.contactedAt.toISOString(),
metadata: {
interactionId: interaction.id,
interactionType: interaction.type,
duration: interaction.duration,
},
});
}
// Sort by date descending
activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());

View File

@@ -1,3 +1,4 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { users, invites, passwordResetTokens } from '../db/schema';
@@ -6,6 +7,7 @@ import { auth } from '../lib/auth';
import type { User } from '../lib/auth';
export const adminRoutes = new Elysia({ prefix: '/admin' })
.use(authMiddleware)
// Admin guard — all routes in this group require admin role
.onBeforeHandle(({ user, set }: { user: User; set: any }) => {
if ((user as any).role !== 'admin') {

103
src/routes/audit-logs.ts Normal file
View File

@@ -0,0 +1,103 @@
import { authMiddleware } from '../middleware/auth';
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' })
.use(authMiddleware)
// 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

@@ -1,3 +1,4 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients, events } from '../db/schema';
@@ -78,13 +79,15 @@ const clientSchema = t.Object({
})),
notes: t.Optional(t.String()),
tags: t.Optional(t.Array(t.String())),
stage: t.Optional(t.String()),
});
const updateClientSchema = t.Partial(clientSchema);
export const clientRoutes = new Elysia({ prefix: '/clients' })
// List clients with optional search
.get('/', async ({ query, user }: { query: { search?: string; tag?: string }; user: User }) => {
.use(authMiddleware)
// List clients with optional search and pagination
.get('/', async ({ query, user }: { query: { search?: string; tag?: string; page?: string; limit?: string }; user: User }) => {
let baseQuery = db.select().from(clients).where(eq(clients.userId, user.id));
if (query.search) {
@@ -102,18 +105,34 @@ export const clientRoutes = new Elysia({ prefix: '/clients' })
);
}
const results = await baseQuery.orderBy(clients.lastName, clients.firstName);
let results = await baseQuery.orderBy(clients.lastName, clients.firstName);
// Filter by tag in-memory if needed (JSONB filtering)
if (query.tag) {
return results.filter(c => c.tags?.includes(query.tag!));
results = results.filter(c => c.tags?.includes(query.tag!));
}
return results;
// Pagination
const page = Math.max(1, parseInt(query.page || '1', 10) || 1);
const limit = Math.min(200, Math.max(1, parseInt(query.limit || '0', 10) || 0));
// If no limit specified, return all (backwards compatible)
if (!query.limit) {
return results;
}
const total = results.length;
const totalPages = Math.ceil(total / limit);
const offset = (page - 1) * limit;
const data = results.slice(offset, offset + limit);
return { data, total, page, limit, totalPages };
}, {
query: t.Object({
search: t.Optional(t.String()),
tag: t.Optional(t.String()),
page: t.Optional(t.String()),
limit: t.Optional(t.String()),
}),
})
@@ -157,11 +176,14 @@ export const clientRoutes = new Elysia({ prefix: '/clients' })
family: body.family,
notes: body.notes,
tags: body.tags || [],
stage: body.stage || 'lead',
})
.returning();
// Auto-sync birthday/anniversary events
await syncClientEvents(user.id, client);
if (client) {
await syncClientEvents(user.id, client);
}
return client;
}, {
@@ -192,6 +214,7 @@ export const clientRoutes = new Elysia({ prefix: '/clients' })
if (body.family !== undefined) updateData.family = body.family;
if (body.notes !== undefined) updateData.notes = body.notes;
if (body.tags !== undefined) updateData.tags = body.tags;
if (body.stage !== undefined) updateData.stage = body.stage;
const [client] = await db.update(clients)
.set(updateData)

View File

@@ -1,12 +1,15 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients, communications, userProfiles } from '../db/schema';
import { eq, and } from 'drizzle-orm';
import { eq, and, inArray } from 'drizzle-orm';
import { generateEmail, generateSubject, generateBirthdayMessage, type AIProvider } from '../services/ai';
import { sendEmail } from '../services/email';
import type { User } from '../lib/auth';
import { randomUUID } from 'crypto';
export const emailRoutes = new Elysia({ prefix: '/emails' })
.use(authMiddleware)
// Generate email for a client
.post('/generate', async ({ body, user }: {
body: {
@@ -58,6 +61,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) {
@@ -294,6 +298,147 @@ export const emailRoutes = new Elysia({ prefix: '/emails' })
}),
})
// Bulk generate emails
.post('/bulk-generate', async ({ body, user }: {
body: { clientIds: string[]; purpose: string; provider?: AIProvider };
user: User;
}) => {
const batchId = randomUUID();
// Get user profile
const [profile] = await db.select()
.from(userProfiles)
.where(eq(userProfiles.userId, user.id))
.limit(1);
const advisorInfo = {
name: user.name,
title: profile?.title || '',
company: profile?.company || '',
phone: profile?.phone || '',
signature: profile?.emailSignature || '',
};
// Get all selected clients
const selectedClients = await db.select()
.from(clients)
.where(and(
inArray(clients.id, body.clientIds),
eq(clients.userId, user.id),
));
if (selectedClients.length === 0) {
throw new Error('No valid clients found');
}
const results = [];
for (const client of selectedClients) {
try {
const content = await generateEmail({
advisorName: advisorInfo.name,
advisorTitle: advisorInfo.title,
advisorCompany: advisorInfo.company,
advisorPhone: advisorInfo.phone,
advisorSignature: advisorInfo.signature,
clientName: client.firstName,
interests: client.interests || [],
notes: client.notes || '',
purpose: body.purpose,
provider: body.provider,
});
const subject = await generateSubject(body.purpose, client.firstName, body.provider);
const [comm] = await db.insert(communications)
.values({
userId: user.id,
clientId: client.id,
type: 'email',
subject,
content,
aiGenerated: true,
aiModel: body.provider || 'anthropic',
status: 'draft',
batchId,
})
.returning();
results.push({ clientId: client.id, email: comm, success: true });
} catch (error: any) {
results.push({ clientId: client.id, error: error.message, success: false });
}
}
return { batchId, results, total: selectedClients.length, generated: results.filter(r => r.success).length };
}, {
body: t.Object({
clientIds: t.Array(t.String({ format: 'uuid' }), { minItems: 1 }),
purpose: t.String({ minLength: 1 }),
provider: t.Optional(t.Union([t.Literal('anthropic'), t.Literal('openai')])),
}),
})
// Bulk send all drafts in a batch
.post('/bulk-send', async ({ body, user }: {
body: { batchId: string };
user: User;
}) => {
// Get all drafts in this batch
const drafts = await db.select({
email: communications,
client: clients,
})
.from(communications)
.innerJoin(clients, eq(communications.clientId, clients.id))
.where(and(
eq(communications.batchId, body.batchId),
eq(communications.userId, user.id),
eq(communications.status, 'draft'),
));
const results = [];
for (const { email, client } of drafts) {
if (!client.email) {
results.push({ id: email.id, success: false, error: 'Client has no email' });
continue;
}
try {
await sendEmail({
to: client.email,
subject: email.subject || 'Message from your advisor',
content: email.content,
});
await db.update(communications)
.set({ status: 'sent', sentAt: new Date() })
.where(eq(communications.id, email.id));
await db.update(clients)
.set({ lastContactedAt: new Date() })
.where(eq(clients.id, client.id));
results.push({ id: email.id, success: true });
} catch (error: any) {
results.push({ id: email.id, success: false, error: error.message });
}
}
return {
batchId: body.batchId,
total: drafts.length,
sent: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
results,
};
}, {
body: t.Object({
batchId: t.String({ minLength: 1 }),
}),
})
// Delete draft
.delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [deleted] = await db.delete(communications)

326
src/routes/engagement.ts Normal file
View File

@@ -0,0 +1,326 @@
import { Elysia } from 'elysia';
import { db } from '../db';
import { clients, interactions, communications, events, clientNotes } from '../db/schema';
import { eq, and, gte, count, desc, sql } from 'drizzle-orm';
import { authMiddleware } from '../middleware/auth';
import type { User } from '../lib/auth';
/**
* Client Engagement Scoring
*
* Scores each client 0-100 based on:
* - Recency of last contact (40 pts)
* - Interaction frequency in last 90 days (25 pts)
* - Email communication activity (15 pts)
* - Events/meetings scheduled (10 pts)
* - Notes activity (10 pts)
*/
interface EngagementScore {
clientId: string;
clientName: string;
score: number;
breakdown: {
recency: number; // 0-40
interactions: number; // 0-25
emails: number; // 0-15
events: number; // 0-10
notes: number; // 0-10
};
lastContactedAt: string | null;
stage: string;
trend: 'rising' | 'stable' | 'declining';
}
function calculateRecencyScore(lastContactedAt: Date | null): number {
if (!lastContactedAt) return 0;
const daysSince = Math.floor((Date.now() - lastContactedAt.getTime()) / (1000 * 60 * 60 * 24));
if (daysSince <= 7) return 40;
if (daysSince <= 14) return 35;
if (daysSince <= 30) return 28;
if (daysSince <= 60) return 18;
if (daysSince <= 90) return 10;
if (daysSince <= 180) return 5;
return 0;
}
function calculateInteractionScore(count: number): number {
if (count >= 10) return 25;
if (count >= 7) return 22;
if (count >= 5) return 18;
if (count >= 3) return 14;
if (count >= 1) return 8;
return 0;
}
function calculateEmailScore(count: number): number {
if (count >= 8) return 15;
if (count >= 5) return 12;
if (count >= 3) return 9;
if (count >= 1) return 5;
return 0;
}
function calculateEventScore(count: number): number {
if (count >= 4) return 10;
if (count >= 2) return 7;
if (count >= 1) return 4;
return 0;
}
function calculateNoteScore(count: number): number {
if (count >= 5) return 10;
if (count >= 3) return 7;
if (count >= 1) return 4;
return 0;
}
function getEngagementLabel(score: number): string {
if (score >= 80) return 'highly_engaged';
if (score >= 60) return 'engaged';
if (score >= 40) return 'warm';
if (score >= 20) return 'cooling';
return 'cold';
}
export const engagementRoutes = new Elysia()
.use(authMiddleware)
// Get engagement scores for all clients
.get('/engagement', async ({ user }) => {
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
// Get all clients
const allClients = await db.select().from(clients).where(eq(clients.userId, user.id));
const scores: EngagementScore[] = [];
for (const client of allClients) {
// Count interactions in last 90 days
const [interactionCount] = await db.select({ count: count() })
.from(interactions)
.where(and(
eq(interactions.clientId, client.id),
gte(interactions.contactedAt, ninetyDaysAgo),
));
// Count emails in last 90 days
const [emailCount] = await db.select({ count: count() })
.from(communications)
.where(and(
eq(communications.clientId, client.id),
gte(communications.createdAt, ninetyDaysAgo),
));
// Count events in last 90 days
const [eventCount] = await db.select({ count: count() })
.from(events)
.where(and(
eq(events.clientId, client.id),
gte(events.date, ninetyDaysAgo),
));
// Count notes in last 90 days
const [noteCount] = await db.select({ count: count() })
.from(clientNotes)
.where(and(
eq(clientNotes.clientId, client.id),
gte(clientNotes.createdAt, ninetyDaysAgo),
));
const breakdown = {
recency: calculateRecencyScore(client.lastContactedAt),
interactions: calculateInteractionScore(interactionCount?.count || 0),
emails: calculateEmailScore(emailCount?.count || 0),
events: calculateEventScore(eventCount?.count || 0),
notes: calculateNoteScore(noteCount?.count || 0),
};
const score = breakdown.recency + breakdown.interactions + breakdown.emails + breakdown.events + breakdown.notes;
// Simple trend: compare current 45-day vs previous 45-day interaction count
const fortyFiveDaysAgo = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000);
const [recentInteractions] = await db.select({ count: count() })
.from(interactions)
.where(and(
eq(interactions.clientId, client.id),
gte(interactions.contactedAt, fortyFiveDaysAgo),
));
const [olderInteractions] = await db.select({ count: count() })
.from(interactions)
.where(and(
eq(interactions.clientId, client.id),
gte(interactions.contactedAt, ninetyDaysAgo),
));
const recent = recentInteractions?.count || 0;
const older = (olderInteractions?.count || 0) - recent;
const trend: 'rising' | 'stable' | 'declining' = recent > older ? 'rising' : recent < older ? 'declining' : 'stable';
scores.push({
clientId: client.id,
clientName: `${client.firstName} ${client.lastName}`,
score,
breakdown,
lastContactedAt: client.lastContactedAt?.toISOString() || null,
stage: client.stage || 'lead',
trend,
});
}
// Sort by score descending
scores.sort((a, b) => b.score - a.score);
// Summary stats
const distribution = {
highly_engaged: scores.filter(s => s.score >= 80).length,
engaged: scores.filter(s => s.score >= 60 && s.score < 80).length,
warm: scores.filter(s => s.score >= 40 && s.score < 60).length,
cooling: scores.filter(s => s.score >= 20 && s.score < 40).length,
cold: scores.filter(s => s.score < 20).length,
};
const avgScore = scores.length > 0
? Math.round(scores.reduce((sum, s) => sum + s.score, 0) / scores.length)
: 0;
return {
scores,
summary: {
totalClients: scores.length,
averageScore: avgScore,
distribution,
topClients: scores.slice(0, 5).map(s => ({ name: s.clientName, score: s.score })),
needsAttention: scores.filter(s => s.score < 20).map(s => ({
name: s.clientName,
score: s.score,
lastContactedAt: s.lastContactedAt,
})),
},
};
})
// Get engagement score for a single client
.get('/clients/:id/engagement', async ({ user, params, set }) => {
const clientId = params.id;
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const [client] = await db.select().from(clients)
.where(and(eq(clients.id, clientId), eq(clients.userId, user.id)))
.limit(1);
if (!client) {
set.status = 404;
return { error: 'Client not found' };
}
// Get interaction counts
const [interactionCount] = await db.select({ count: count() })
.from(interactions)
.where(and(
eq(interactions.clientId, clientId),
gte(interactions.contactedAt, ninetyDaysAgo),
));
const [emailCount] = await db.select({ count: count() })
.from(communications)
.where(and(
eq(communications.clientId, clientId),
gte(communications.createdAt, ninetyDaysAgo),
));
const [eventCount] = await db.select({ count: count() })
.from(events)
.where(and(
eq(events.clientId, clientId),
gte(events.date, ninetyDaysAgo),
));
const [noteCount] = await db.select({ count: count() })
.from(clientNotes)
.where(and(
eq(clientNotes.clientId, clientId),
gte(clientNotes.createdAt, ninetyDaysAgo),
));
const breakdown = {
recency: calculateRecencyScore(client.lastContactedAt),
interactions: calculateInteractionScore(interactionCount?.count || 0),
emails: calculateEmailScore(emailCount?.count || 0),
events: calculateEventScore(eventCount?.count || 0),
notes: calculateNoteScore(noteCount?.count || 0),
};
const score = breakdown.recency + breakdown.interactions + breakdown.emails + breakdown.events + breakdown.notes;
// Get recent interaction history for sparkline/chart
const recentInteractions = await db.select({
contactedAt: interactions.contactedAt,
type: interactions.type,
})
.from(interactions)
.where(and(
eq(interactions.clientId, clientId),
gte(interactions.contactedAt, ninetyDaysAgo),
))
.orderBy(desc(interactions.contactedAt))
.limit(20);
return {
clientId,
clientName: `${client.firstName} ${client.lastName}`,
score,
label: getEngagementLabel(score),
breakdown,
rawCounts: {
interactions: interactionCount?.count || 0,
emails: emailCount?.count || 0,
events: eventCount?.count || 0,
notes: noteCount?.count || 0,
},
recentInteractions: recentInteractions.map(i => ({
date: i.contactedAt?.toISOString(),
type: i.type,
})),
recommendations: generateRecommendations(score, breakdown, client),
};
});
function generateRecommendations(
score: number,
breakdown: EngagementScore['breakdown'],
client: { firstName: string; lastName: string; email: string | null; lastContactedAt: Date | null }
): string[] {
const recs: string[] = [];
if (breakdown.recency < 18) {
const daysSince = client.lastContactedAt
? Math.floor((Date.now() - client.lastContactedAt.getTime()) / (1000 * 60 * 60 * 24))
: null;
recs.push(
daysSince
? `It's been ${daysSince} days since last contact — reach out soon.`
: `No contact recorded yet — schedule an intro call.`
);
}
if (breakdown.interactions < 10) {
recs.push('Log more interactions — calls, meetings, and notes all count.');
}
if (breakdown.emails < 5 && client.email) {
recs.push('Send a personalized email to strengthen the relationship.');
}
if (breakdown.events < 4) {
recs.push('Schedule a follow-up meeting or check-in event.');
}
if (breakdown.notes < 4) {
recs.push('Add notes after each interaction to track relationship context.');
}
if (score >= 80) {
recs.push(`${client.firstName} is highly engaged — consider them for referrals or network introductions.`);
}
return recs;
}

View File

@@ -1,3 +1,4 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { events, clients } from '../db/schema';
@@ -5,6 +6,7 @@ import { eq, and, gte, lte, sql } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const eventRoutes = new Elysia({ prefix: '/events' })
.use(authMiddleware)
// List events with optional filters
.get('/', async ({ query, user }: {
query: {

View File

@@ -1,3 +1,4 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients, events } from '../db/schema';
@@ -160,6 +161,7 @@ async function syncClientEvents(userId: string, client: { id: string; firstName:
}
export const importRoutes = new Elysia({ prefix: '/clients' })
.use(authMiddleware)
// Preview CSV - returns headers and auto-mapped columns + sample rows
.post('/import/preview', async ({ body, user }: { body: { file: File }; user: User }) => {
const text = await body.file.text();
@@ -211,6 +213,7 @@ export const importRoutes = new Elysia({ prefix: '/clients' })
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
if (!row) continue;
try {
const record: Record<string, any> = {};
@@ -260,12 +263,14 @@ export const importRoutes = new Elysia({ prefix: '/clients' })
.returning();
// Sync events
await syncClientEvents(user.id, {
id: client.id,
firstName: client.firstName,
birthday: client.birthday,
anniversary: client.anniversary,
});
if (client) {
await syncClientEvents(user.id, {
id: client.id,
firstName: client.firstName,
birthday: client.birthday,
anniversary: client.anniversary,
});
}
results.imported++;
} catch (err: any) {

View File

@@ -1,3 +1,4 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia } from 'elysia';
import { db } from '../db';
import { clients, events } from '../db/schema';
@@ -5,6 +6,7 @@ import { eq, and, sql, lte, gte, isNull, or } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const insightsRoutes = new Elysia({ prefix: '/insights' })
.use(authMiddleware)
.get('/', async ({ user }: { user: User }) => {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);

143
src/routes/interactions.ts Normal file
View File

@@ -0,0 +1,143 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { interactions, clients } from '../db/schema';
import { eq, and, desc } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const interactionRoutes = new Elysia()
.use(authMiddleware)
// List interactions for a client
.get('/clients/:clientId/interactions', async ({ params, user }: { params: { clientId: string }; user: User }) => {
// Verify client belongs to user
const [client] = await db.select()
.from(clients)
.where(and(eq(clients.id, params.clientId), eq(clients.userId, user.id)))
.limit(1);
if (!client) throw new Error('Client not found');
const items = await db.select()
.from(interactions)
.where(and(eq(interactions.clientId, params.clientId), eq(interactions.userId, user.id)))
.orderBy(desc(interactions.contactedAt));
return items;
}, {
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
})
// Create interaction
.post('/clients/:clientId/interactions', async ({ params, body, user }: {
params: { clientId: string };
body: { type: string; title: string; description?: string; duration?: number; contactedAt: string };
user: User;
}) => {
// Verify client belongs to user
const [client] = await db.select()
.from(clients)
.where(and(eq(clients.id, params.clientId), eq(clients.userId, user.id)))
.limit(1);
if (!client) throw new Error('Client not found');
const [interaction] = await db.insert(interactions)
.values({
userId: user.id,
clientId: params.clientId,
type: body.type,
title: body.title,
description: body.description,
duration: body.duration,
contactedAt: new Date(body.contactedAt),
})
.returning();
// Auto-update lastContactedAt on the client
const contactDate = new Date(body.contactedAt);
if (!client.lastContactedAt || contactDate > client.lastContactedAt) {
await db.update(clients)
.set({ lastContactedAt: contactDate, updatedAt: new Date() })
.where(eq(clients.id, params.clientId));
}
return interaction;
}, {
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
body: t.Object({
type: t.String({ minLength: 1 }),
title: t.String({ minLength: 1 }),
description: t.Optional(t.String()),
duration: t.Optional(t.Number({ minimum: 0 })),
contactedAt: t.String(),
}),
})
// Update interaction
.put('/interactions/:id', async ({ params, body, user }: {
params: { id: string };
body: { type?: string; title?: string; description?: string; duration?: number; contactedAt?: string };
user: User;
}) => {
const updateData: Record<string, unknown> = {};
if (body.type !== undefined) updateData.type = body.type;
if (body.title !== undefined) updateData.title = body.title;
if (body.description !== undefined) updateData.description = body.description;
if (body.duration !== undefined) updateData.duration = body.duration;
if (body.contactedAt !== undefined) updateData.contactedAt = new Date(body.contactedAt);
const [updated] = await db.update(interactions)
.set(updateData)
.where(and(eq(interactions.id, params.id), eq(interactions.userId, user.id)))
.returning();
if (!updated) throw new Error('Interaction not found');
return updated;
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
body: t.Object({
type: t.Optional(t.String()),
title: t.Optional(t.String()),
description: t.Optional(t.String()),
duration: t.Optional(t.Number({ minimum: 0 })),
contactedAt: t.Optional(t.String()),
}),
})
// Delete interaction
.delete('/interactions/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [deleted] = await db.delete(interactions)
.where(and(eq(interactions.id, params.id), eq(interactions.userId, user.id)))
.returning({ id: interactions.id });
if (!deleted) throw new Error('Interaction not found');
return { success: true, id: deleted.id };
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
})
// Get all recent interactions across all clients (for dashboard)
.get('/interactions/recent', async ({ query, user }: { query: { limit?: string }; user: User }) => {
const limit = query.limit ? parseInt(query.limit) : 10;
const items = await db.select({
interaction: interactions,
client: {
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
},
})
.from(interactions)
.innerJoin(clients, eq(interactions.clientId, clients.id))
.where(eq(interactions.userId, user.id))
.orderBy(desc(interactions.contactedAt))
.limit(limit);
return items.map(({ interaction, client }) => ({
...interaction,
client,
}));
}, {
query: t.Object({ limit: t.Optional(t.String()) }),
});

172
src/routes/meeting-prep.ts Normal file
View File

@@ -0,0 +1,172 @@
import { authMiddleware } from '../middleware/auth';
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()
.use(authMiddleware)
// 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

@@ -1,3 +1,4 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients } from '../db/schema';
@@ -23,6 +24,7 @@ function toClientProfile(c: typeof clients.$inferSelect): ClientProfile {
}
export const networkRoutes = new Elysia({ prefix: '/network' })
.use(authMiddleware)
// Get all network matches for the user's clients
.get('/matches', async (ctx) => {
const user = (ctx as any).user;

105
src/routes/notes.ts Normal file
View File

@@ -0,0 +1,105 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clientNotes, clients } from '../db/schema';
import { eq, and, desc } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const notesRoutes = new Elysia({ prefix: '/clients/:clientId/notes' })
.use(authMiddleware)
// List notes for a client
.get('/', async ({ params, user }: { params: { clientId: string }; user: User }) => {
// Verify client belongs to user
const [client] = await db.select({ id: clients.id })
.from(clients)
.where(and(eq(clients.id, params.clientId), eq(clients.userId, user.id)))
.limit(1);
if (!client) throw new Error('Client not found');
const notes = await db.select()
.from(clientNotes)
.where(eq(clientNotes.clientId, params.clientId))
.orderBy(desc(clientNotes.pinned), desc(clientNotes.createdAt));
return notes;
}, {
params: t.Object({
clientId: t.String({ format: 'uuid' }),
}),
})
// Create note
.post('/', async ({ params, body, user }: { params: { clientId: string }; body: { content: string }; user: User }) => {
// Verify client belongs to user
const [client] = await db.select({ id: clients.id })
.from(clients)
.where(and(eq(clients.id, params.clientId), eq(clients.userId, user.id)))
.limit(1);
if (!client) throw new Error('Client not found');
const [note] = await db.insert(clientNotes)
.values({
clientId: params.clientId,
userId: user.id,
content: body.content,
})
.returning();
return note;
}, {
params: t.Object({
clientId: t.String({ format: 'uuid' }),
}),
body: t.Object({
content: t.String({ minLength: 1 }),
}),
})
// Update note
.put('/:noteId', async ({ params, body, user }: { params: { clientId: string; noteId: string }; body: { content?: string; pinned?: boolean }; user: User }) => {
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (body.content !== undefined) updateData.content = body.content;
if (body.pinned !== undefined) updateData.pinned = body.pinned;
const [note] = await db.update(clientNotes)
.set(updateData)
.where(and(
eq(clientNotes.id, params.noteId),
eq(clientNotes.clientId, params.clientId),
eq(clientNotes.userId, user.id),
))
.returning();
if (!note) throw new Error('Note not found');
return note;
}, {
params: t.Object({
clientId: t.String({ format: 'uuid' }),
noteId: t.String({ format: 'uuid' }),
}),
body: t.Object({
content: t.Optional(t.String({ minLength: 1 })),
pinned: t.Optional(t.Boolean()),
}),
})
// Delete note
.delete('/:noteId', async ({ params, user }: { params: { clientId: string; noteId: string }; user: User }) => {
const [deleted] = await db.delete(clientNotes)
.where(and(
eq(clientNotes.id, params.noteId),
eq(clientNotes.clientId, params.clientId),
eq(clientNotes.userId, user.id),
))
.returning({ id: clientNotes.id });
if (!deleted) throw new Error('Note not found');
return { success: true, id: deleted.id };
}, {
params: t.Object({
clientId: t.String({ format: 'uuid' }),
noteId: t.String({ format: 'uuid' }),
}),
});

View File

@@ -0,0 +1,87 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { notifications, clients } from '../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const notificationRoutes = new Elysia({ prefix: '/notifications' })
.use(authMiddleware)
// List notifications
.get('/', async ({ query, user }: { query: { limit?: string; unreadOnly?: string }; user: User }) => {
const limit = query.limit ? parseInt(query.limit) : 50;
const unreadOnly = query.unreadOnly === 'true';
let conditions = [eq(notifications.userId, user.id)];
if (unreadOnly) {
conditions.push(eq(notifications.read, false));
}
const items = await db.select({
notification: notifications,
client: {
id: clients.id,
firstName: clients.firstName,
lastName: clients.lastName,
},
})
.from(notifications)
.leftJoin(clients, eq(notifications.clientId, clients.id))
.where(and(...conditions))
.orderBy(desc(notifications.createdAt))
.limit(limit);
// Unread count
const [unreadResult] = await db.select({
count: sql<number>`count(*)::int`,
})
.from(notifications)
.where(and(eq(notifications.userId, user.id), eq(notifications.read, false)));
return {
notifications: items.map(({ notification, client }) => ({
...notification,
client: client?.id ? client : null,
})),
unreadCount: unreadResult?.count || 0,
};
}, {
query: t.Object({
limit: t.Optional(t.String()),
unreadOnly: t.Optional(t.String()),
}),
})
// Mark notification as read
.put('/:id/read', async ({ params, user }: { params: { id: string }; user: User }) => {
const [updated] = await db.update(notifications)
.set({ read: true })
.where(and(eq(notifications.id, params.id), eq(notifications.userId, user.id)))
.returning();
if (!updated) throw new Error('Notification not found');
return updated;
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
})
// Mark all as read
.post('/mark-all-read', async ({ user }: { user: User }) => {
await db.update(notifications)
.set({ read: true })
.where(and(eq(notifications.userId, user.id), eq(notifications.read, false)));
return { success: true };
})
// Delete notification
.delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [deleted] = await db.delete(notifications)
.where(and(eq(notifications.id, params.id), eq(notifications.userId, user.id)))
.returning({ id: notifications.id });
if (!deleted) throw new Error('Notification not found');
return { success: true };
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
});

View File

@@ -1,10 +1,13 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
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' })
.use(authMiddleware)
// Get current user's profile
.get('/', async ({ user }: { user: User }) => {
// Get user and profile
@@ -126,6 +129,116 @@ 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 tone = (body.tone || 'friendly') as 'formal' | 'friendly' | 'casual';
const style = {
tone,
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())),
}),
})
// Onboarding status
.get('/onboarding-status', async ({ user }: { user: User }) => {
const [profile] = await db.select()
.from(userProfiles)
.where(eq(userProfiles.userId, user.id))
.limit(1);
return {
onboardingComplete: profile?.onboardingComplete ?? false,
};
})
// Complete onboarding
.post('/complete-onboarding', async ({ user }: { user: User }) => {
const [existing] = await db.select()
.from(userProfiles)
.where(eq(userProfiles.userId, user.id))
.limit(1);
if (existing) {
await db.update(userProfiles)
.set({ onboardingComplete: true, updatedAt: new Date() })
.where(eq(userProfiles.userId, user.id));
} else {
await db.insert(userProfiles).values({
userId: user.id,
onboardingComplete: true,
createdAt: new Date(),
updatedAt: new Date(),
});
}
return { success: true, onboardingComplete: true };
})
// Change password
.put('/password', async ({ body, user, set }: {
body: { currentPassword: string; newPassword: string };
@@ -162,6 +275,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({

View File

@@ -1,3 +1,4 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia } from 'elysia';
import { db } from '../db';
import { clients, events, communications } from '../db/schema';
@@ -5,6 +6,7 @@ import { eq, and, sql, gte, lte, count, desc } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const reportsRoutes = new Elysia()
.use(authMiddleware)
// Analytics overview
.get('/reports/overview', async ({ user }: { user: User }) => {
const userId = user.id;
@@ -84,21 +86,21 @@ export const reportsRoutes = new Elysia()
return {
clients: {
total: totalClients.count,
newThisMonth: newClientsMonth.count,
newThisWeek: newClientsWeek.count,
contactedRecently: contactedRecently.count,
neverContacted: neverContacted.count,
total: totalClients?.count ?? 0,
newThisMonth: newClientsMonth?.count ?? 0,
newThisWeek: newClientsWeek?.count ?? 0,
contactedRecently: contactedRecently?.count ?? 0,
neverContacted: neverContacted?.count ?? 0,
},
emails: {
total: totalEmails.count,
sent: emailsSent.count,
draft: emailsDraft.count,
sentLast30Days: emailsRecent.count,
total: totalEmails?.count ?? 0,
sent: emailsSent?.count ?? 0,
draft: emailsDraft?.count ?? 0,
sentLast30Days: emailsRecent?.count ?? 0,
},
events: {
total: totalEvents.count,
upcoming30Days: upcomingEvents.count,
total: totalEvents?.count ?? 0,
upcoming30Days: upcomingEvents?.count ?? 0,
},
};
})
@@ -406,11 +408,11 @@ export const reportsRoutes = new Elysia()
});
}
if (draftCount.count > 0) {
if ((draftCount?.count ?? 0) > 0) {
notifications.push({
id: 'drafts',
type: 'drafts' as const,
title: `${draftCount.count} draft email${draftCount.count > 1 ? 's' : ''} pending`,
title: `${draftCount?.count ?? 0} draft email${(draftCount?.count ?? 0) > 1 ? 's' : ''} pending`,
description: 'Review and send your drafted emails',
date: new Date().toISOString(),
link: '/emails',
@@ -434,7 +436,7 @@ export const reportsRoutes = new Elysia()
overdue: overdueEvents.length,
upcoming: upcomingEvents.length,
stale: staleClients.length,
drafts: draftCount.count,
drafts: draftCount?.count ?? 0,
},
};
});

248
src/routes/segments.ts Normal file
View File

@@ -0,0 +1,248 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clientSegments, clients } from '../db/schema';
import { eq, and, desc, or, ilike, inArray, gte, lte, isNotNull, isNull, sql } from 'drizzle-orm';
import type { User } from '../lib/auth';
type SegmentFilters = {
stages?: string[];
tags?: string[];
industries?: string[];
cities?: string[];
states?: string[];
lastContactedBefore?: string;
lastContactedAfter?: string;
createdBefore?: string;
createdAfter?: string;
hasEmail?: boolean;
hasPhone?: boolean;
search?: string;
};
function buildClientConditions(filters: SegmentFilters, userId: string) {
const conditions = [eq(clients.userId, userId)];
if (filters.stages?.length) {
conditions.push(inArray(clients.stage, filters.stages));
}
if (filters.industries?.length) {
conditions.push(inArray(clients.industry, filters.industries));
}
if (filters.cities?.length) {
conditions.push(inArray(clients.city, filters.cities));
}
if (filters.states?.length) {
conditions.push(inArray(clients.state, filters.states));
}
if (filters.tags?.length) {
// Check if client tags JSONB array contains any of the filter tags
const tagConditions = filters.tags.map(tag =>
sql`${clients.tags}::jsonb @> ${JSON.stringify([tag])}::jsonb`
);
conditions.push(or(...tagConditions)!);
}
if (filters.lastContactedBefore) {
conditions.push(lte(clients.lastContactedAt, new Date(filters.lastContactedBefore)));
}
if (filters.lastContactedAfter) {
conditions.push(gte(clients.lastContactedAt, new Date(filters.lastContactedAfter)));
}
if (filters.createdBefore) {
conditions.push(lte(clients.createdAt, new Date(filters.createdBefore)));
}
if (filters.createdAfter) {
conditions.push(gte(clients.createdAt, new Date(filters.createdAfter)));
}
if (filters.hasEmail === true) {
conditions.push(isNotNull(clients.email));
} else if (filters.hasEmail === false) {
conditions.push(isNull(clients.email));
}
if (filters.hasPhone === true) {
conditions.push(isNotNull(clients.phone));
} else if (filters.hasPhone === false) {
conditions.push(isNull(clients.phone));
}
if (filters.search) {
const q = `%${filters.search}%`;
conditions.push(or(
ilike(clients.firstName, q),
ilike(clients.lastName, q),
ilike(clients.email, q),
ilike(clients.company, q),
)!);
}
return conditions;
}
export const segmentRoutes = new Elysia({ prefix: '/segments' })
.use(authMiddleware)
// List saved segments
.get('/', async ({ user }: { user: User }) => {
return db.select()
.from(clientSegments)
.where(eq(clientSegments.userId, user.id))
.orderBy(desc(clientSegments.pinned), desc(clientSegments.updatedAt));
})
// Preview segment (apply filters, return matching clients)
.post('/preview', async ({ body, user }: { body: { filters: SegmentFilters }; user: User }) => {
const conditions = buildClientConditions(body.filters, user.id);
const results = await db.select()
.from(clients)
.where(and(...conditions))
.orderBy(clients.lastName);
return { count: results.length, clients: results };
}, {
body: t.Object({
filters: t.Object({
stages: t.Optional(t.Array(t.String())),
tags: t.Optional(t.Array(t.String())),
industries: t.Optional(t.Array(t.String())),
cities: t.Optional(t.Array(t.String())),
states: t.Optional(t.Array(t.String())),
lastContactedBefore: t.Optional(t.String()),
lastContactedAfter: t.Optional(t.String()),
createdBefore: t.Optional(t.String()),
createdAfter: t.Optional(t.String()),
hasEmail: t.Optional(t.Boolean()),
hasPhone: t.Optional(t.Boolean()),
search: t.Optional(t.String()),
}),
}),
})
// Get filter options (unique values for dropdowns)
.get('/filter-options', async ({ user }: { user: User }) => {
const allClients = await db.select({
industry: clients.industry,
city: clients.city,
state: clients.state,
tags: clients.tags,
stage: clients.stage,
})
.from(clients)
.where(eq(clients.userId, user.id));
const industries = [...new Set(allClients.map(c => c.industry).filter(Boolean))] as string[];
const cities = [...new Set(allClients.map(c => c.city).filter(Boolean))] as string[];
const states = [...new Set(allClients.map(c => c.state).filter(Boolean))] as string[];
const tags = [...new Set(allClients.flatMap(c => (c.tags as string[]) || []))];
const stages = [...new Set(allClients.map(c => c.stage).filter(Boolean))] as string[];
return { industries: industries.sort(), cities: cities.sort(), states: states.sort(), tags: tags.sort(), stages: stages.sort() };
})
// Create segment
.post('/', async ({ body, user }: {
body: { name: string; description?: string; filters: SegmentFilters; color?: string; pinned?: boolean };
user: User;
}) => {
const [segment] = await db.insert(clientSegments)
.values({
userId: user.id,
name: body.name,
description: body.description,
filters: body.filters,
color: body.color || '#3b82f6',
pinned: body.pinned || false,
})
.returning();
return segment;
}, {
body: t.Object({
name: t.String({ minLength: 1 }),
description: t.Optional(t.String()),
filters: t.Object({
stages: t.Optional(t.Array(t.String())),
tags: t.Optional(t.Array(t.String())),
industries: t.Optional(t.Array(t.String())),
cities: t.Optional(t.Array(t.String())),
states: t.Optional(t.Array(t.String())),
lastContactedBefore: t.Optional(t.String()),
lastContactedAfter: t.Optional(t.String()),
createdBefore: t.Optional(t.String()),
createdAfter: t.Optional(t.String()),
hasEmail: t.Optional(t.Boolean()),
hasPhone: t.Optional(t.Boolean()),
search: t.Optional(t.String()),
}),
color: t.Optional(t.String()),
pinned: t.Optional(t.Boolean()),
}),
})
// Get segment + matching clients
.get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [segment] = await db.select()
.from(clientSegments)
.where(and(eq(clientSegments.id, params.id), eq(clientSegments.userId, user.id)))
.limit(1);
if (!segment) throw new Error('Segment not found');
const conditions = buildClientConditions(segment.filters as SegmentFilters, user.id);
const matchingClients = await db.select()
.from(clients)
.where(and(...conditions))
.orderBy(clients.lastName);
return { ...segment, clientCount: matchingClients.length, clients: matchingClients };
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
})
// Update segment
.put('/:id', async ({ params, body, user }: {
params: { id: string };
body: { name?: string; description?: string; filters?: SegmentFilters; color?: string; pinned?: boolean };
user: User;
}) => {
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (body.name !== undefined) updateData.name = body.name;
if (body.description !== undefined) updateData.description = body.description;
if (body.filters !== undefined) updateData.filters = body.filters;
if (body.color !== undefined) updateData.color = body.color;
if (body.pinned !== undefined) updateData.pinned = body.pinned;
const [segment] = await db.update(clientSegments)
.set(updateData)
.where(and(eq(clientSegments.id, params.id), eq(clientSegments.userId, user.id)))
.returning();
if (!segment) throw new Error('Segment not found');
return segment;
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
body: t.Object({
name: t.Optional(t.String({ minLength: 1 })),
description: t.Optional(t.String()),
filters: t.Optional(t.Object({
stages: t.Optional(t.Array(t.String())),
tags: t.Optional(t.Array(t.String())),
industries: t.Optional(t.Array(t.String())),
cities: t.Optional(t.Array(t.String())),
states: t.Optional(t.Array(t.String())),
lastContactedBefore: t.Optional(t.String()),
lastContactedAfter: t.Optional(t.String()),
createdBefore: t.Optional(t.String()),
createdAfter: t.Optional(t.String()),
hasEmail: t.Optional(t.Boolean()),
hasPhone: t.Optional(t.Boolean()),
search: t.Optional(t.String()),
})),
color: t.Optional(t.String()),
pinned: t.Optional(t.Boolean()),
}),
})
// Delete segment
.delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [deleted] = await db.delete(clientSegments)
.where(and(eq(clientSegments.id, params.id), eq(clientSegments.userId, user.id)))
.returning({ id: clientSegments.id });
if (!deleted) throw new Error('Segment not found');
return { success: true, id: deleted.id };
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
});

84
src/routes/stats.ts Normal file
View File

@@ -0,0 +1,84 @@
import { Elysia } from 'elysia';
import { db } from '../db';
import { clients, interactions, communications, events, clientNotes, notifications, users } from '../db/schema';
import { eq, gte, count, sql } from 'drizzle-orm';
import { authMiddleware } from '../middleware/auth';
import type { User } from '../lib/auth';
/**
* System-wide stats and health metrics for the dashboard.
*/
export const statsRoutes = new Elysia()
.use(authMiddleware)
.get('/stats/overview', async ({ user }) => {
const userId = user.id;
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
// Client counts
const [totalClients] = await db.select({ count: count() })
.from(clients).where(eq(clients.userId, userId));
const [newClientsThisMonth] = await db.select({ count: count() })
.from(clients).where(sql`${clients.userId} = ${userId} AND ${clients.createdAt} >= ${thirtyDaysAgo}`);
// Interaction counts
const [totalInteractions30d] = await db.select({ count: count() })
.from(interactions).where(sql`${interactions.userId} = ${userId} AND ${interactions.contactedAt} >= ${thirtyDaysAgo}`);
const [totalInteractions7d] = await db.select({ count: count() })
.from(interactions).where(sql`${interactions.userId} = ${userId} AND ${interactions.contactedAt} >= ${sevenDaysAgo}`);
// Email counts
const [emailsSent30d] = await db.select({ count: count() })
.from(communications).where(sql`${communications.userId} = ${userId} AND ${communications.status} = 'sent' AND ${communications.createdAt} >= ${thirtyDaysAgo}`);
// Events
const [upcomingEvents] = await db.select({ count: count() })
.from(events).where(sql`${events.userId} = ${userId} AND ${events.date} >= ${now}`);
// Unread notifications
const [unreadNotifications] = await db.select({ count: count() })
.from(notifications).where(sql`${notifications.userId} = ${userId} AND ${notifications.read} = false`);
// Stage distribution
const stageDistribution = await db.select({
stage: clients.stage,
count: count(),
})
.from(clients)
.where(eq(clients.userId, userId))
.groupBy(clients.stage);
// Recent activity (last 7 days)
const interactionsByType = await db.select({
type: interactions.type,
count: count(),
})
.from(interactions)
.where(sql`${interactions.userId} = ${userId} AND ${interactions.contactedAt} >= ${sevenDaysAgo}`)
.groupBy(interactions.type);
return {
clients: {
total: totalClients?.count || 0,
newThisMonth: newClientsThisMonth?.count || 0,
stageDistribution: Object.fromEntries(
stageDistribution.map(s => [s.stage || 'unset', s.count])
),
},
activity: {
interactions30d: totalInteractions30d?.count || 0,
interactions7d: totalInteractions7d?.count || 0,
emailsSent30d: emailsSent30d?.count || 0,
interactionsByType: Object.fromEntries(
interactionsByType.map(i => [i.type, i.count])
),
},
upcoming: {
events: upcomingEvents?.count || 0,
unreadNotifications: unreadNotifications?.count || 0,
},
generatedAt: new Date().toISOString(),
};
});

120
src/routes/tags.ts Normal file
View File

@@ -0,0 +1,120 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { clients } from '../db/schema';
import { eq, sql } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const tagRoutes = new Elysia({ prefix: '/tags' })
.use(authMiddleware)
// GET /api/tags - all unique tags with client counts
.get('/', async ({ user }: { user: User }) => {
const allClients = await db.select({ tags: clients.tags })
.from(clients)
.where(eq(clients.userId, user.id));
const tagCounts = new Map<string, number>();
for (const client of allClients) {
if (client.tags && Array.isArray(client.tags)) {
for (const tag of client.tags) {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
}
}
}
return Array.from(tagCounts.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name));
})
// PUT /api/tags/rename - rename a tag across all clients
.put('/rename', async ({ body, user }: { body: { oldName: string; newName: string }; user: User }) => {
const { oldName, newName } = body;
if (!oldName || !newName) throw new Error('oldName and newName are required');
if (oldName === newName) return { success: true, updated: 0 };
const userClients = await db.select({ id: clients.id, tags: clients.tags })
.from(clients)
.where(eq(clients.userId, user.id));
let updated = 0;
for (const client of userClients) {
if (client.tags && Array.isArray(client.tags) && client.tags.includes(oldName)) {
const newTags = client.tags.map(t => t === oldName ? newName : t);
// Deduplicate
const uniqueTags = [...new Set(newTags)];
await db.update(clients)
.set({ tags: uniqueTags, updatedAt: new Date() })
.where(eq(clients.id, client.id));
updated++;
}
}
return { success: true, updated };
}, {
body: t.Object({
oldName: t.String({ minLength: 1 }),
newName: t.String({ minLength: 1 }),
}),
})
// DELETE /api/tags/:name - remove a tag from all clients
.delete('/:name', async ({ params, user }: { params: { name: string }; user: User }) => {
const tagName = decodeURIComponent(params.name);
const userClients = await db.select({ id: clients.id, tags: clients.tags })
.from(clients)
.where(eq(clients.userId, user.id));
let updated = 0;
for (const client of userClients) {
if (client.tags && Array.isArray(client.tags) && client.tags.includes(tagName)) {
const newTags = client.tags.filter(t => t !== tagName);
await db.update(clients)
.set({ tags: newTags, updatedAt: new Date() })
.where(eq(clients.id, client.id));
updated++;
}
}
return { success: true, removed: updated };
}, {
params: t.Object({
name: t.String(),
}),
})
// POST /api/tags/merge - merge multiple tags into one
.post('/merge', async ({ body, user }: { body: { sourceTags: string[]; targetTag: string }; user: User }) => {
const { sourceTags, targetTag } = body;
if (!sourceTags.length || !targetTag) throw new Error('sourceTags and targetTag are required');
const userClients = await db.select({ id: clients.id, tags: clients.tags })
.from(clients)
.where(eq(clients.userId, user.id));
let updated = 0;
for (const client of userClients) {
if (client.tags && Array.isArray(client.tags)) {
const hasAnySource = client.tags.some(t => sourceTags.includes(t));
if (hasAnySource) {
// Remove source tags, add target tag, deduplicate
const newTags = [...new Set([
...client.tags.filter(t => !sourceTags.includes(t)),
targetTag,
])];
await db.update(clients)
.set({ tags: newTags, updatedAt: new Date() })
.where(eq(clients.id, client.id));
updated++;
}
}
}
return { success: true, updated };
}, {
body: t.Object({
sourceTags: t.Array(t.String({ minLength: 1 }), { minItems: 1 }),
targetTag: t.String({ minLength: 1 }),
}),
});

147
src/routes/templates.ts Normal file
View File

@@ -0,0 +1,147 @@
import { authMiddleware } from '../middleware/auth';
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { emailTemplates } from '../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import type { User } from '../lib/auth';
export const templateRoutes = new Elysia({ prefix: '/templates' })
.use(authMiddleware)
// List templates
.get('/', async ({ query, user }: { query: { category?: string }; user: User }) => {
let conditions = [eq(emailTemplates.userId, user.id)];
if (query.category) {
conditions.push(eq(emailTemplates.category, query.category));
}
return db.select()
.from(emailTemplates)
.where(and(...conditions))
.orderBy(desc(emailTemplates.usageCount));
}, {
query: t.Object({
category: t.Optional(t.String()),
}),
})
// Get single template
.get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
const [template] = await db.select()
.from(emailTemplates)
.where(and(eq(emailTemplates.id, params.id), eq(emailTemplates.userId, user.id)))
.limit(1);
if (!template) throw new Error('Template not found');
return template;
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
})
// Create template
.post('/', async ({ body, user }: { body: { name: string; category: string; subject: string; content: string; isDefault?: boolean }; user: User }) => {
// If marking as default, unmark others in same category
if (body.isDefault) {
await db.update(emailTemplates)
.set({ isDefault: false })
.where(and(eq(emailTemplates.userId, user.id), eq(emailTemplates.category, body.category)));
}
const [template] = await db.insert(emailTemplates)
.values({
userId: user.id,
name: body.name,
category: body.category,
subject: body.subject,
content: body.content,
isDefault: body.isDefault || false,
})
.returning();
return template;
}, {
body: t.Object({
name: t.String({ minLength: 1 }),
category: t.String({ minLength: 1 }),
subject: t.String({ minLength: 1 }),
content: t.String({ minLength: 1 }),
isDefault: t.Optional(t.Boolean()),
}),
})
// Update template
.put('/:id', async (ctx: any) => {
const { params, body, user } = ctx as {
params: { id: string };
body: { name?: string; category?: string; subject?: string; content?: string; isDefault?: boolean };
user: User;
};
// If marking as default, unmark others
if (body.isDefault && body.category) {
await db.update(emailTemplates)
.set({ isDefault: false })
.where(and(eq(emailTemplates.userId, user.id), eq(emailTemplates.category, body.category)));
}
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (body.name !== undefined) updateData.name = body.name;
if (body.category !== undefined) updateData.category = body.category;
if (body.subject !== undefined) updateData.subject = body.subject;
if (body.content !== undefined) updateData.content = body.content;
if (body.isDefault !== undefined) updateData.isDefault = body.isDefault;
const [template] = await db.update(emailTemplates)
.set(updateData)
.where(and(eq(emailTemplates.id, params.id), eq(emailTemplates.userId, user.id)))
.returning();
if (!template) throw new Error('Template not found');
return template;
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
body: t.Object({
name: t.Optional(t.String({ minLength: 1 })),
category: t.Optional(t.String({ minLength: 1 })),
subject: t.Optional(t.String({ minLength: 1 })),
content: t.Optional(t.String({ minLength: 1 })),
isDefault: t.Optional(t.Boolean()),
}),
})
// Use template (increment usage count + return with placeholders filled)
.post('/:id/use', async (ctx: any) => {
const { params, body, user } = ctx;
const [template] = await db.select()
.from(emailTemplates)
.where(and(eq(emailTemplates.id, params.id), eq(emailTemplates.userId, user.id)))
.limit(1);
if (!template) throw new Error('Template not found');
// Increment usage count
await db.update(emailTemplates)
.set({ usageCount: sql`${emailTemplates.usageCount} + 1` })
.where(eq(emailTemplates.id, params.id));
// Fill placeholders
let subject = template.subject;
let content = template.content;
const vars = body.variables || {};
for (const [key, value] of Object.entries(vars)) {
const re = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
subject = subject.replace(re, value as string);
content = content.replace(re, value as string);
}
return { subject, content, templateId: template.id, templateName: template.name };
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
body: t.Object({
clientId: t.Optional(t.String({ format: 'uuid' })),
variables: t.Optional(t.Record(t.String(), t.String())),
}),
})
// Delete template
.delete('/:id', async (ctx: any) => {
const { params, user } = ctx;
const [deleted] = await db.delete(emailTemplates)
.where(and(eq(emailTemplates.id, params.id), eq(emailTemplates.userId, user.id)))
.returning({ id: emailTemplates.id });
if (!deleted) throw new Error('Template not found');
return { success: true, id: deleted.id };
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
});

View File

@@ -24,13 +24,47 @@ function getModel(provider: AIProvider = 'openai') {
throw new Error(`Provider ${provider} not supported`);
}
// Build communication style instructions
function buildStyleInstructions(style?: CommunicationStyle | null): string {
if (!style) return '';
const parts: string[] = [];
if (style.tone) {
const toneMap: Record<string, string> = {
formal: 'Use a formal, professional tone. Maintain distance and respect.',
friendly: 'Use a warm, friendly tone. Be personable but still professional.',
casual: 'Use a casual, relaxed tone. Be conversational and approachable.',
};
parts.push(toneMap[style.tone] || '');
}
if (style.greeting) {
parts.push(`Always start emails with this greeting style: "${style.greeting}"`);
}
if (style.signoff) {
parts.push(`Always end emails with this sign-off: "${style.signoff}"`);
}
if (style.writingSamples && style.writingSamples.length > 0) {
parts.push(`Match the writing style of these samples from the advisor:\n${style.writingSamples.map((s, i) => `Sample ${i + 1}: "${s}"`).join('\n')}`);
}
if (style.avoidWords && style.avoidWords.length > 0) {
parts.push(`NEVER use these words/phrases: ${style.avoidWords.join(', ')}`);
}
return parts.length > 0 ? `\n\nCommunication Style Preferences:\n${parts.join('\n')}` : '';
}
// Email generation prompt
const emailPrompt = ChatPromptTemplate.fromMessages([
['system', `You are a professional wealth advisor writing to a valued client.
Maintain a warm but professional tone. Incorporate personal details naturally.
Keep emails concise (3-4 paragraphs max).
Do not include subject line - just the body.
Always sign off with the advisor's actual name and details provided. Never use placeholders like [Your Name].`],
Always sign off with the advisor's actual name and details provided. Never use placeholders like [Your Name].{styleInstructions}`],
['human', `Advisor Info:
- Name: {advisorName}
- Title: {advisorTitle}
@@ -47,6 +81,14 @@ Purpose of email: {purpose}
Generate a personalized email that feels genuine, not templated. End with an appropriate signature using the advisor's real name and details.`],
]);
export interface CommunicationStyle {
tone?: 'formal' | 'friendly' | 'casual';
greeting?: string;
signoff?: string;
writingSamples?: string[];
avoidWords?: string[];
}
export interface GenerateEmailParams {
advisorName: string;
advisorTitle?: string;
@@ -58,6 +100,7 @@ export interface GenerateEmailParams {
notes: string;
purpose: string;
provider?: AIProvider;
communicationStyle?: CommunicationStyle | null;
}
export async function generateEmail(params: GenerateEmailParams): Promise<string> {
@@ -75,6 +118,7 @@ export async function generateEmail(params: GenerateEmailParams): Promise<string
interests: params.interests.join(', ') || 'not specified',
notes: params.notes || 'No recent notes',
purpose: params.purpose,
styleInstructions: buildStyleInstructions(params.communicationStyle),
});
return response;
@@ -112,6 +156,121 @@ export async function generateBirthdayMessage(params: GenerateBirthdayMessagePar
return response;
}
// Meeting prep generation
const meetingPrepPrompt = ChatPromptTemplate.fromMessages([
['system', `You are an AI assistant helping a wealth advisor prepare for a client meeting.
Analyze the client data and generate useful talking points.
Be specific and actionable - reference actual data when available.
Output valid JSON with this structure:
{{
"summary": "Brief 2-3 sentence overview of the client relationship",
"suggestedTopics": ["topic1", "topic2", ...],
"conversationStarters": ["starter1", "starter2", ...],
"followUpItems": ["item1", "item2", ...]
}}
Only output the JSON, nothing else.`],
['human', `Client Profile:
- Name: {clientName}
- Company: {clientCompany}
- Role: {clientRole}
- Industry: {clientIndustry}
- Stage: {clientStage}
- Interests: {clientInterests}
- Family: {clientFamily}
- Days since last contact: {daysSinceContact}
Recent Interactions:
{interactions}
Recent Emails:
{emails}
Notes:
{notes}
Upcoming Events:
{upcomingEvents}
Important Dates:
{importantDates}
Generate meeting prep JSON.`],
]);
export interface MeetingPrepInput {
clientSummary: {
name: string;
company: string;
role: string;
industry: string;
stage: string;
interests: string[];
family?: { spouse?: string; children?: string[] } | null;
daysSinceLastContact: number;
};
recentInteractions: { type: string; title: string; description?: string | null; date: Date | string }[];
recentEmails: { subject?: string | null; status?: string | null; date: Date | string }[];
notes: string;
upcomingEvents: { type: string; title: string; date: Date | string }[];
importantDates: { type: string; date: string; label: string }[];
provider?: AIProvider;
}
export interface MeetingPrepOutput {
summary: string;
suggestedTopics: string[];
conversationStarters: string[];
followUpItems: string[];
}
export async function generateMeetingPrep(params: MeetingPrepInput): Promise<MeetingPrepOutput> {
const model = getModel(params.provider);
const parser = new StringOutputParser();
const chain = meetingPrepPrompt.pipe(model).pipe(parser);
const familyStr = params.clientSummary.family
? `Spouse: ${params.clientSummary.family.spouse || 'N/A'}, Children: ${params.clientSummary.family.children?.join(', ') || 'N/A'}`
: 'Not available';
const response = await chain.invoke({
clientName: params.clientSummary.name,
clientCompany: params.clientSummary.company,
clientRole: params.clientSummary.role,
clientIndustry: params.clientSummary.industry,
clientStage: params.clientSummary.stage,
clientInterests: params.clientSummary.interests.join(', ') || 'Not specified',
clientFamily: familyStr,
daysSinceContact: String(params.clientSummary.daysSinceLastContact),
interactions: params.recentInteractions.length > 0
? params.recentInteractions.map(i => `- [${i.type}] ${i.title} (${new Date(i.date).toLocaleDateString()})${i.description ? ': ' + i.description : ''}`).join('\n')
: 'No recent interactions',
emails: params.recentEmails.length > 0
? params.recentEmails.map(e => `- ${e.subject || 'No subject'} (${e.status}, ${new Date(e.date).toLocaleDateString()})`).join('\n')
: 'No recent emails',
notes: params.notes || 'No notes',
upcomingEvents: params.upcomingEvents.length > 0
? params.upcomingEvents.map(e => `- ${e.title} (${e.type}, ${new Date(e.date).toLocaleDateString()})`).join('\n')
: 'No upcoming events',
importantDates: params.importantDates.length > 0
? params.importantDates.map(d => `- ${d.label}`).join('\n')
: 'No notable upcoming dates',
});
try {
// Extract JSON from response (handle markdown code blocks)
const jsonStr = response.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
return JSON.parse(jsonStr);
} catch {
// Fallback if AI returns non-JSON
return {
summary: response.slice(0, 500),
suggestedTopics: ['Review financial goals', 'Market update', 'Personal check-in'],
conversationStarters: ['How have things been going?'],
followUpItems: ['Send meeting summary'],
};
}
}
// Email subject generation
const subjectPrompt = ChatPromptTemplate.fromMessages([
['system', `Generate a professional but warm email subject line for a wealth advisor's email.

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

304
src/services/jobs.ts Normal file
View File

@@ -0,0 +1,304 @@
import { PgBoss } from 'pg-boss';
import type { Job } from 'pg-boss';
import { db } from '../db';
import { events, notifications, clients, users, interactions, communications } from '../db/schema';
import { eq, and, gte, lte, sql, count, isNotNull } from 'drizzle-orm';
import { sendEmail } from './email';
let boss: PgBoss | null = null;
export async function initJobQueue(): Promise<PgBoss> {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL required for job queue');
}
boss = new PgBoss(connectionString);
boss.on('error', (error) => {
console.error('[pg-boss] Error:', error);
});
await boss.start();
console.log('✅ pg-boss job queue started');
// Register job handlers
await boss.work('check-upcoming-events', { localConcurrency: 1 }, checkUpcomingEvents);
await boss.work('send-event-reminder', { localConcurrency: 5 }, sendEventReminder);
await boss.work('check-birthdays', { localConcurrency: 1 }, checkBirthdays);
await boss.work('send-birthday-notification', { localConcurrency: 5 }, sendBirthdayNotification);
// Schedule daily checks at 8am UTC
await boss.schedule('check-upcoming-events', '0 8 * * *', {}, {
tz: 'UTC',
});
await boss.schedule('check-birthdays', '0 8 * * *', {}, {
tz: 'UTC',
});
console.log('✅ Job schedules registered');
return boss;
}
export function getJobQueue(): PgBoss | null {
return boss;
}
// Job: Check upcoming events and create notifications
async function checkUpcomingEvents(jobs: Job[]) {
console.log(`[jobs] Running checkUpcomingEvents at ${new Date().toISOString()}`);
try {
const now = new Date();
// Get all events with their reminder windows
const allEvents = await db.select({
event: events,
client: clients,
})
.from(events)
.innerJoin(clients, eq(events.clientId, clients.id));
let created = 0;
for (const { event, client } of allEvents) {
const reminderDays = event.reminderDays || 7;
let eventDate = new Date(event.date);
// For recurring events, adjust to this year
if (event.recurring) {
eventDate = new Date(now.getFullYear(), eventDate.getMonth(), eventDate.getDate());
// If the date already passed this year, check next year
if (eventDate < now) {
eventDate = new Date(now.getFullYear() + 1, eventDate.getMonth(), eventDate.getDate());
}
}
const daysUntil = Math.ceil((eventDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
// Within reminder window and not already triggered recently
if (daysUntil >= 0 && daysUntil <= reminderDays) {
// Check if already notified (within last 24h for this event)
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const existing = await db.select({ id: notifications.id })
.from(notifications)
.where(and(
eq(notifications.eventId, event.id),
eq(notifications.userId, event.userId),
gte(notifications.createdAt, dayAgo),
))
.limit(1);
if (existing.length === 0) {
// Create notification
await db.insert(notifications).values({
userId: event.userId,
type: 'event_reminder',
title: daysUntil === 0 ? `Today: ${event.title}` : `${event.title} in ${daysUntil} day${daysUntil !== 1 ? 's' : ''}`,
message: `${event.title} for ${client.firstName} ${client.lastName} is ${daysUntil === 0 ? 'today' : `coming up in ${daysUntil} day${daysUntil !== 1 ? 's' : ''}`}.`,
clientId: client.id,
eventId: event.id,
});
created++;
// Also queue email reminder
if (boss) {
await boss.send('send-event-reminder', {
userId: event.userId,
eventId: event.id,
clientId: client.id,
eventTitle: event.title,
clientName: `${client.firstName} ${client.lastName}`,
daysUntil,
});
}
}
}
}
console.log(`[jobs] checkUpcomingEvents: created ${created} notifications`);
} catch (error) {
console.error('[jobs] checkUpcomingEvents error:', error);
throw error;
}
}
// Job: Send email reminder to advisor
interface EventReminderData {
userId: string;
eventId: string;
clientId: string;
eventTitle: string;
clientName: string;
daysUntil: number;
}
async function sendEventReminder(jobs: Job<EventReminderData>[]) {
const job = jobs[0];
if (!job) return;
const { userId, eventTitle, clientName, daysUntil } = job.data;
try {
// Get user email
const [user] = await db.select({ email: users.email, name: users.name })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user?.email) {
console.log(`[jobs] sendEventReminder: no email for user ${userId}`);
return;
}
const subject = daysUntil === 0
? `Reminder: ${eventTitle} is today!`
: `Reminder: ${eventTitle} in ${daysUntil} day${daysUntil !== 1 ? 's' : ''}`;
const content = `Hi ${user.name},\n\nThis is a reminder that "${eventTitle}" for ${clientName} is ${daysUntil === 0 ? 'today' : `coming up in ${daysUntil} day${daysUntil !== 1 ? 's' : ''}`}.\n\nLog in to your Network App to prepare.\n\nBest,\nThe Network App`;
await sendEmail({
to: user.email,
subject,
content,
});
console.log(`[jobs] Sent event reminder to ${user.email}: ${subject}`);
} catch (error) {
console.error(`[jobs] sendEventReminder error:`, error);
// Don't throw - email failures shouldn't retry aggressively
}
}
// Job: Check upcoming client birthdays across all users
async function checkBirthdays(jobs: Job[]) {
console.log(`[jobs] Running checkBirthdays at ${new Date().toISOString()}`);
try {
const now = new Date();
const todayMonth = now.getMonth();
const todayDate = now.getDate();
// Get all clients with birthdays set
const allClients = await db.select({
client: clients,
})
.from(clients)
.where(isNotNull(clients.birthday));
let created = 0;
for (const { client } of allClients) {
if (!client.birthday) continue;
const bday = new Date(client.birthday);
const bdayMonth = bday.getMonth();
const bdayDate = bday.getDate();
// Calculate days until birthday (this year or next)
let nextBirthday = new Date(now.getFullYear(), bdayMonth, bdayDate);
if (nextBirthday < now) {
nextBirthday = new Date(now.getFullYear() + 1, bdayMonth, bdayDate);
}
const daysUntil = Math.ceil((nextBirthday.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
// Notify 7 days before and on the day
if (daysUntil <= 7) {
// Check if already notified in last 24h
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const existing = await db.select({ id: notifications.id })
.from(notifications)
.where(and(
eq(notifications.clientId, client.id),
eq(notifications.userId, client.userId),
eq(notifications.type, 'birthday_reminder'),
gte(notifications.createdAt, dayAgo),
))
.limit(1);
if (existing.length === 0) {
const clientName = `${client.firstName} ${client.lastName}`;
const title = daysUntil === 0
? `🎂 ${clientName}'s birthday is today!`
: `🎂 ${clientName}'s birthday in ${daysUntil} day${daysUntil !== 1 ? 's' : ''}`;
await db.insert(notifications).values({
userId: client.userId,
type: 'birthday_reminder',
title,
message: daysUntil === 0
? `Today is ${clientName}'s birthday! Consider sending a personalized message.`
: `${clientName}'s birthday is coming up on ${nextBirthday.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })}. Plan something special!`,
clientId: client.id,
});
created++;
// Queue email notification to the advisor
if (boss) {
await boss.send('send-birthday-notification', {
userId: client.userId,
clientId: client.id,
clientName,
clientEmail: client.email,
daysUntil,
birthdayDate: nextBirthday.toISOString(),
});
}
}
}
}
console.log(`[jobs] checkBirthdays: created ${created} notifications`);
} catch (error) {
console.error('[jobs] checkBirthdays error:', error);
throw error;
}
}
// Job: Send birthday notification email to advisor
interface BirthdayNotificationData {
userId: string;
clientId: string;
clientName: string;
clientEmail: string | null;
daysUntil: number;
birthdayDate: string;
}
async function sendBirthdayNotification(jobs: Job<BirthdayNotificationData>[]) {
const job = jobs[0];
if (!job) return;
const { userId, clientName, clientEmail, daysUntil, birthdayDate } = job.data;
try {
const [user] = await db.select({ email: users.email, name: users.name })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user?.email) return;
const bday = new Date(birthdayDate);
const dateStr = bday.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
const subject = daysUntil === 0
? `🎂 ${clientName}'s birthday is today!`
: `🎂 ${clientName}'s birthday is ${dateStr}`;
let content = `Hi ${user.name},\n\n`;
if (daysUntil === 0) {
content += `Today is ${clientName}'s birthday! 🎉\n\n`;
content += `This is a great opportunity to strengthen your relationship with a personalized message.`;
} else {
content += `${clientName}'s birthday is coming up on ${dateStr} (${daysUntil} day${daysUntil !== 1 ? 's' : ''} away).\n\n`;
content += `Plan ahead — a timely birthday message goes a long way in building strong client relationships.`;
}
if (clientEmail) {
content += `\n\nYou can compose a birthday email directly from the Network App.`;
}
content += `\n\nBest,\nThe Network App`;
await sendEmail({ to: user.email, subject, content });
console.log(`[jobs] Sent birthday notification to ${user.email} for ${clientName}`);
} catch (error) {
console.error('[jobs] sendBirthdayNotification error:', error);
}
}