Compare commits
13 Commits
030f5545de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 229b9407c2 | |||
| 5c9df803c2 | |||
| 30a535c481 | |||
| ee3cfa263f | |||
| 0ccfa8d0fc | |||
| bd2a4c017c | |||
| 7634306832 | |||
| 4dc641db02 | |||
| 6c2851e93a | |||
| 0ec1b9c448 | |||
| 93fce809e2 | |||
| bb87ba169a | |||
| 33a0e1d110 |
40
.gitea/workflows/ci.yml
Normal file
40
.gitea/workflows/ci.yml
Normal 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/compose.deploy" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
|
||||||
|
-d '{"composeId": "${{ secrets.DOKPLOY_COMPOSE_ID }}"}'
|
||||||
|
echo "Deploy triggered on Dokploy"
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Run seed (creates test user if not exists)
|
echo "Running db:push..."
|
||||||
echo "Running database seed..."
|
bun run db:push || echo "db:push skipped"
|
||||||
bun run db:seed || echo "Seed skipped or failed (may already exist)"
|
|
||||||
|
echo "Running database seed..."
|
||||||
|
bun run db:seed || echo "Seed skipped"
|
||||||
|
|
||||||
# Start the app
|
|
||||||
echo "Starting API..."
|
echo "Starting API..."
|
||||||
exec bun run src/index.ts
|
exec bun run src/index.ts
|
||||||
|
|||||||
208
src/__tests__/audit.test.ts
Normal file
208
src/__tests__/audit.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { describe, test, expect } from 'bun:test';
|
|||||||
describe('Client Routes', () => {
|
describe('Client Routes', () => {
|
||||||
describe('Validation', () => {
|
describe('Validation', () => {
|
||||||
test('clientSchema requires firstName', () => {
|
test('clientSchema requires firstName', () => {
|
||||||
const invalidClient = {
|
const invalidClient: Record<string, unknown> = {
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
};
|
};
|
||||||
// Schema validation test - firstName is required
|
// Schema validation test - firstName is required
|
||||||
@@ -11,7 +11,7 @@ describe('Client Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('clientSchema requires lastName', () => {
|
test('clientSchema requires lastName', () => {
|
||||||
const invalidClient = {
|
const invalidClient: Record<string, unknown> = {
|
||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
};
|
};
|
||||||
// Schema validation test - lastName is required
|
// Schema validation test - lastName is required
|
||||||
@@ -107,8 +107,8 @@ describe('Search Functionality', () => {
|
|||||||
|
|
||||||
const filtered = clients.filter(c => c.tags?.includes('vip'));
|
const filtered = clients.filter(c => c.tags?.includes('vip'));
|
||||||
expect(filtered).toHaveLength(2);
|
expect(filtered).toHaveLength(2);
|
||||||
expect(filtered[0].firstName).toBe('John');
|
expect(filtered[0]!.firstName).toBe('John');
|
||||||
expect(filtered[1].firstName).toBe('Bob');
|
expect(filtered[1]!.firstName).toBe('Bob');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -93,11 +93,14 @@ describe('Email Service', () => {
|
|||||||
|
|
||||||
describe('Default From Email', () => {
|
describe('Default From Email', () => {
|
||||||
test('falls back to default when from not provided', () => {
|
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 from = undefined;
|
||||||
const defaultFrom = 'onboarding@resend.dev';
|
const defaultFrom = 'onboarding@resend.dev';
|
||||||
const result = from || process.env.DEFAULT_FROM_EMAIL || defaultFrom;
|
const result = from || process.env.DEFAULT_FROM_EMAIL || defaultFrom;
|
||||||
|
|
||||||
expect(result).toBe(defaultFrom);
|
expect(result).toBe(defaultFrom);
|
||||||
|
if (savedEnv) process.env.DEFAULT_FROM_EMAIL = savedEnv;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uses provided from when available', () => {
|
test('uses provided from when available', () => {
|
||||||
|
|||||||
152
src/__tests__/engagement.test.ts
Normal file
152
src/__tests__/engagement.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
248
src/db/schema.ts
248
src/db/schema.ts
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, text, timestamp, uuid, boolean, jsonb, integer } from 'drizzle-orm/pg-core';
|
import { pgTable, text, timestamp, uuid, boolean, jsonb, integer, numeric } from 'drizzle-orm/pg-core';
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
// Users table (managed by BetterAuth - uses text IDs)
|
// Users table (managed by BetterAuth - uses text IDs)
|
||||||
@@ -44,6 +44,14 @@ export const userProfiles = pgTable('user_profiles', {
|
|||||||
company: text('company'), // e.g., "ABC Financial Group"
|
company: text('company'), // e.g., "ABC Financial Group"
|
||||||
phone: text('phone'),
|
phone: text('phone'),
|
||||||
emailSignature: text('email_signature'), // Custom signature block
|
emailSignature: text('email_signature'), // Custom signature block
|
||||||
|
communicationStyle: jsonb('communication_style').$type<{
|
||||||
|
tone?: 'formal' | 'friendly' | 'casual';
|
||||||
|
greeting?: string;
|
||||||
|
signoff?: string;
|
||||||
|
writingSamples?: string[];
|
||||||
|
avoidWords?: string[];
|
||||||
|
}>(),
|
||||||
|
onboardingComplete: boolean('onboarding_complete').default(false),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
@@ -118,6 +126,7 @@ export const clients = pgTable('clients', {
|
|||||||
|
|
||||||
// Organization
|
// Organization
|
||||||
tags: jsonb('tags').$type<string[]>().default([]),
|
tags: jsonb('tags').$type<string[]>().default([]),
|
||||||
|
stage: text('stage').default('lead'), // 'lead' | 'prospect' | 'onboarding' | 'active' | 'inactive'
|
||||||
|
|
||||||
// Tracking
|
// Tracking
|
||||||
lastContactedAt: timestamp('last_contacted_at'),
|
lastContactedAt: timestamp('last_contacted_at'),
|
||||||
@@ -154,17 +163,172 @@ export const communications = pgTable('communications', {
|
|||||||
aiModel: text('ai_model'), // Which model was used
|
aiModel: text('ai_model'), // Which model was used
|
||||||
status: text('status').default('draft'), // 'draft' | 'approved' | 'sent'
|
status: text('status').default('draft'), // 'draft' | 'approved' | 'sent'
|
||||||
sentAt: timestamp('sent_at'),
|
sentAt: timestamp('sent_at'),
|
||||||
|
batchId: text('batch_id'), // for grouping bulk sends
|
||||||
|
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client Documents table (file attachments)
|
||||||
|
export const clientDocuments = pgTable('client_documents', {
|
||||||
|
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(),
|
||||||
|
name: text('name').notNull(), // user-facing display name
|
||||||
|
filename: text('filename').notNull(), // actual filename on disk
|
||||||
|
mimeType: text('mime_type').notNull(),
|
||||||
|
size: integer('size').notNull(), // bytes
|
||||||
|
category: text('category').default('other').notNull(), // 'contract' | 'agreement' | 'id' | 'statement' | 'correspondence' | 'other'
|
||||||
|
path: text('path').notNull(), // filesystem path
|
||||||
|
notes: text('notes'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client Goals / Financial Objectives table
|
||||||
|
export const clientGoals = pgTable('client_goals', {
|
||||||
|
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(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
category: text('category').default('other').notNull(), // 'retirement' | 'investment' | 'savings' | 'insurance' | 'estate' | 'education' | 'debt' | 'other'
|
||||||
|
targetAmount: numeric('target_amount', { precision: 15, scale: 2 }),
|
||||||
|
currentAmount: numeric('current_amount', { precision: 15, scale: 2 }).default('0'),
|
||||||
|
targetDate: timestamp('target_date'),
|
||||||
|
status: text('status').default('on-track').notNull(), // 'on-track' | 'at-risk' | 'behind' | 'completed'
|
||||||
|
priority: text('priority').default('medium').notNull(), // 'high' | 'medium' | 'low'
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Referrals table
|
||||||
|
export const referrals = pgTable('referrals', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
referrerId: uuid('referrer_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
referredId: uuid('referred_id').references(() => clients.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
type: text('type').default('client').notNull(), // 'client' | 'partner' | 'event'
|
||||||
|
notes: text('notes'),
|
||||||
|
status: text('status').default('pending').notNull(), // 'pending' | 'contacted' | 'converted' | 'lost'
|
||||||
|
value: numeric('value', { precision: 15, scale: 2 }), // estimated deal value
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
clients: many(clients),
|
clients: many(clients),
|
||||||
events: many(events),
|
events: many(events),
|
||||||
communications: many(communications),
|
communications: many(communications),
|
||||||
|
notifications: many(notifications),
|
||||||
|
interactions: many(interactions),
|
||||||
sessions: many(sessions),
|
sessions: many(sessions),
|
||||||
accounts: many(accounts),
|
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 }) => ({
|
export const clientsRelations = relations(clients, ({ one, many }) => ({
|
||||||
@@ -174,6 +338,88 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
|
|||||||
}),
|
}),
|
||||||
events: many(events),
|
events: many(events),
|
||||||
communications: many(communications),
|
communications: many(communications),
|
||||||
|
notes: many(clientNotes),
|
||||||
|
interactions: many(interactions),
|
||||||
|
documents: many(clientDocuments),
|
||||||
|
goals: many(clientGoals),
|
||||||
|
referralsMade: many(referrals, { relationName: 'referrer' }),
|
||||||
|
referralsReceived: many(referrals, { relationName: 'referred' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const clientDocumentsRelations = relations(clientDocuments, ({ one }) => ({
|
||||||
|
client: one(clients, {
|
||||||
|
fields: [clientDocuments.clientId],
|
||||||
|
references: [clients.id],
|
||||||
|
}),
|
||||||
|
user: one(users, {
|
||||||
|
fields: [clientDocuments.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const clientGoalsRelations = relations(clientGoals, ({ one }) => ({
|
||||||
|
client: one(clients, {
|
||||||
|
fields: [clientGoals.clientId],
|
||||||
|
references: [clients.id],
|
||||||
|
}),
|
||||||
|
user: one(users, {
|
||||||
|
fields: [clientGoals.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const referralsRelations = relations(referrals, ({ one }) => ({
|
||||||
|
referrer: one(clients, {
|
||||||
|
fields: [referrals.referrerId],
|
||||||
|
references: [clients.id],
|
||||||
|
relationName: 'referrer',
|
||||||
|
}),
|
||||||
|
referred: one(clients, {
|
||||||
|
fields: [referrals.referredId],
|
||||||
|
references: [clients.id],
|
||||||
|
relationName: 'referred',
|
||||||
|
}),
|
||||||
|
user: one(users, {
|
||||||
|
fields: [referrals.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 }) => ({
|
export const eventsRelations = relations(events, ({ one }) => ({
|
||||||
|
|||||||
163
src/db/seed.ts
163
src/db/seed.ts
@@ -1,156 +1,29 @@
|
|||||||
import { db } from './index';
|
import { db } from './index';
|
||||||
import { users, accounts, clients } from './schema';
|
import { sql } from 'drizzle-orm';
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { hashPassword } from 'better-auth/crypto';
|
|
||||||
|
|
||||||
const fakeClients = [
|
|
||||||
{
|
|
||||||
firstName: 'Sarah',
|
|
||||||
lastName: 'Mitchell',
|
|
||||||
email: 'sarah.mitchell@email.com',
|
|
||||||
phone: '(555) 234-5678',
|
|
||||||
company: 'Mitchell & Associates',
|
|
||||||
role: 'Managing Partner',
|
|
||||||
city: 'Austin',
|
|
||||||
state: 'TX',
|
|
||||||
birthday: new Date('1978-06-15'),
|
|
||||||
anniversary: new Date('2005-09-20'),
|
|
||||||
interests: ['golf', 'wine collecting', 'travel'],
|
|
||||||
family: { spouse: 'David', children: ['Emma', 'Jack'] },
|
|
||||||
notes: 'Very interested in sustainable investing. Prefers morning meetings.',
|
|
||||||
tags: ['high-value', 'quarterly-review'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
firstName: 'Marcus',
|
|
||||||
lastName: 'Johnson',
|
|
||||||
email: 'marcus.j@techventures.io',
|
|
||||||
phone: '(555) 876-5432',
|
|
||||||
company: 'TechVentures Capital',
|
|
||||||
role: 'CEO',
|
|
||||||
city: 'San Francisco',
|
|
||||||
state: 'CA',
|
|
||||||
birthday: new Date('1985-03-22'),
|
|
||||||
interests: ['startups', 'AI', 'marathon running', 'podcasts'],
|
|
||||||
family: { spouse: 'Michelle' },
|
|
||||||
notes: 'Recently sold his startup. Looking for aggressive growth strategies.',
|
|
||||||
tags: ['new-client', 'tech'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
firstName: 'Linda',
|
|
||||||
lastName: 'Chen',
|
|
||||||
email: 'lchen@globalhealth.org',
|
|
||||||
phone: '(555) 345-9876',
|
|
||||||
company: 'Global Health Foundation',
|
|
||||||
role: 'Executive Director',
|
|
||||||
city: 'Seattle',
|
|
||||||
state: 'WA',
|
|
||||||
birthday: new Date('1972-11-08'),
|
|
||||||
anniversary: new Date('1998-07-12'),
|
|
||||||
interests: ['philanthropy', 'hiking', 'classical music', 'book clubs'],
|
|
||||||
family: { spouse: 'Robert', children: ['Olivia', 'Noah', 'Sophia'] },
|
|
||||||
notes: 'Focused on legacy planning and charitable giving. Daughter Olivia graduating medical school in May.',
|
|
||||||
tags: ['philanthropy', 'estate-planning'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
firstName: 'James',
|
|
||||||
lastName: 'Rodriguez',
|
|
||||||
email: 'james.rodriguez@email.com',
|
|
||||||
phone: '(555) 567-8901',
|
|
||||||
company: 'Rodriguez Construction',
|
|
||||||
role: 'Owner',
|
|
||||||
city: 'Phoenix',
|
|
||||||
state: 'AZ',
|
|
||||||
birthday: new Date('1968-09-30'),
|
|
||||||
interests: ['fishing', 'classic cars', 'football'],
|
|
||||||
family: { spouse: 'Maria', children: ['Carlos', 'Isabella'] },
|
|
||||||
notes: 'Planning to retire in 5 years. Wants to transition business to son Carlos.',
|
|
||||||
tags: ['retirement', 'business-succession'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
firstName: 'Emily',
|
|
||||||
lastName: 'Watson',
|
|
||||||
email: 'emily.watson@lawfirm.com',
|
|
||||||
phone: '(555) 432-1098',
|
|
||||||
company: 'Watson Legal Group',
|
|
||||||
role: 'Senior Partner',
|
|
||||||
city: 'Chicago',
|
|
||||||
state: 'IL',
|
|
||||||
birthday: new Date('1980-04-17'),
|
|
||||||
interests: ['art collecting', 'yoga', 'french cuisine'],
|
|
||||||
notes: 'Recently divorced. Needs portfolio restructuring. Interested in real estate investments.',
|
|
||||||
tags: ['life-change', 'real-estate'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
const testEmail = 'test@test.com';
|
console.log('Clearing all user data...');
|
||||||
|
|
||||||
// Check if test user already exists
|
// Disable FK constraints
|
||||||
const [existing] = await db.select()
|
await db.execute(sql`SET session_replication_role = 'replica'`);
|
||||||
.from(users)
|
|
||||||
.where(eq(users.email, testEmail))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let userId: string;
|
// Get all tables and truncate them
|
||||||
|
const tables = await db.execute(sql`
|
||||||
|
SELECT tablename FROM pg_tables WHERE schemaname = 'public'
|
||||||
|
`);
|
||||||
|
|
||||||
if (existing) {
|
for (const row of tables as any) {
|
||||||
console.log('✓ Test user already exists');
|
const table = (row as any).tablename;
|
||||||
userId = existing.id;
|
if (table.startsWith('__drizzle') || table.startsWith('pgboss')) continue;
|
||||||
} else {
|
console.log(` Truncating ${table}...`);
|
||||||
// Create test user
|
await db.execute(sql.raw(`TRUNCATE TABLE "${table}" CASCADE`));
|
||||||
userId = crypto.randomUUID();
|
|
||||||
const hashedPassword = await hashPassword('test');
|
|
||||||
|
|
||||||
await db.insert(users).values({
|
|
||||||
id: userId,
|
|
||||||
email: testEmail,
|
|
||||||
name: 'Test User',
|
|
||||||
emailVerified: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create credential account (for email/password login)
|
|
||||||
await db.insert(accounts).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
userId: userId,
|
|
||||||
accountId: userId,
|
|
||||||
providerId: 'credential',
|
|
||||||
password: hashedPassword,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✓ Created test user: test@test.com / test');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if fake clients already exist (check for Sarah Mitchell specifically)
|
// Re-enable FK constraints
|
||||||
const [existingFakeClient] = await db.select()
|
await db.execute(sql`SET session_replication_role = 'origin'`);
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.email, 'sarah.mitchell@email.com'))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingFakeClient) {
|
console.log('Database cleared! No accounts exist.');
|
||||||
console.log('✓ Test clients already exist');
|
process.exit(0);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add fake clients
|
|
||||||
for (const client of fakeClients) {
|
|
||||||
await db.insert(clients).values({
|
|
||||||
userId,
|
|
||||||
...client,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✓ Created ${fakeClients.length} test clients`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
seed()
|
seed().catch(e => { console.error(e); process.exit(1); });
|
||||||
.then(() => process.exit(0))
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('Seed failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|||||||
84
src/index.ts
84
src/index.ts
@@ -1,5 +1,6 @@
|
|||||||
import { Elysia } from 'elysia';
|
import { Elysia } from 'elysia';
|
||||||
import { cors } from '@elysiajs/cors';
|
import { cors } from '@elysiajs/cors';
|
||||||
|
import { rateLimitPlugin } from './middleware/rate-limit';
|
||||||
import { auth } from './lib/auth';
|
import { auth } from './lib/auth';
|
||||||
import { clientRoutes } from './routes/clients';
|
import { clientRoutes } from './routes/clients';
|
||||||
import { emailRoutes } from './routes/emails';
|
import { emailRoutes } from './routes/emails';
|
||||||
@@ -13,12 +14,31 @@ import { importRoutes } from './routes/import';
|
|||||||
import { activityRoutes } from './routes/activity';
|
import { activityRoutes } from './routes/activity';
|
||||||
import { insightsRoutes } from './routes/insights';
|
import { insightsRoutes } from './routes/insights';
|
||||||
import { reportsRoutes } from './routes/reports';
|
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 { db } from './db';
|
||||||
import { users } from './db/schema';
|
import { users } from './db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
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 { searchRoutes } from './routes/search';
|
||||||
|
import { mergeRoutes } from './routes/merge';
|
||||||
|
import { exportRoutes } from './routes/export';
|
||||||
|
import { documentRoutes } from './routes/documents';
|
||||||
|
import { goalRoutes } from './routes/goals';
|
||||||
|
import { referralRoutes } from './routes/referrals';
|
||||||
|
import { initJobQueue } from './services/jobs';
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
|
// Rate limiting (before everything else)
|
||||||
|
.use(rateLimitPlugin)
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
.use(cors({
|
.use(cors({
|
||||||
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
|
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
|
||||||
@@ -44,21 +64,7 @@ const app = new Elysia()
|
|||||||
.use(inviteRoutes)
|
.use(inviteRoutes)
|
||||||
.use(passwordResetRoutes)
|
.use(passwordResetRoutes)
|
||||||
|
|
||||||
// Protected routes - require auth
|
// API routes (auth middleware is in each route plugin)
|
||||||
.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)
|
|
||||||
.group('/api', app => app
|
.group('/api', app => app
|
||||||
.use(clientRoutes)
|
.use(clientRoutes)
|
||||||
.use(importRoutes)
|
.use(importRoutes)
|
||||||
@@ -70,45 +76,73 @@ const app = new Elysia()
|
|||||||
.use(networkRoutes)
|
.use(networkRoutes)
|
||||||
.use(insightsRoutes)
|
.use(insightsRoutes)
|
||||||
.use(reportsRoutes)
|
.use(reportsRoutes)
|
||||||
|
.use(notesRoutes)
|
||||||
|
.use(notificationRoutes)
|
||||||
|
.use(interactionRoutes)
|
||||||
|
.use(templateRoutes)
|
||||||
|
.use(segmentRoutes)
|
||||||
|
.use(auditLogRoutes)
|
||||||
|
.use(meetingPrepRoutes)
|
||||||
|
.use(tagRoutes)
|
||||||
|
.use(engagementRoutes)
|
||||||
|
.use(statsRoutes)
|
||||||
|
.use(mergeRoutes)
|
||||||
|
.use(searchRoutes)
|
||||||
|
.use(exportRoutes)
|
||||||
|
.use(documentRoutes)
|
||||||
|
.use(goalRoutes)
|
||||||
|
.use(referralRoutes)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
.onError(({ code, error, set, path }) => {
|
.onError(({ code, error, set, path }) => {
|
||||||
// Always log errors with full details
|
// 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}:`, {
|
console.error(`[${new Date().toISOString()}] ERROR on ${path}:`, {
|
||||||
code,
|
code,
|
||||||
message: error.message,
|
message,
|
||||||
stack: error.stack,
|
stack,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (code === 'VALIDATION') {
|
if (code === 'VALIDATION') {
|
||||||
set.status = 400;
|
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;
|
set.status = 401;
|
||||||
return { error: 'Unauthorized' };
|
return { error: 'Unauthorized' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.message.includes('Forbidden')) {
|
if (message.includes('Forbidden')) {
|
||||||
set.status = 403;
|
set.status = 403;
|
||||||
return { error: error.message };
|
return { error: message };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (message.includes('not found')) {
|
||||||
set.status = 404;
|
set.status = 404;
|
||||||
return { error: error.message };
|
return { error: message };
|
||||||
}
|
}
|
||||||
|
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { error: 'Internal server error', details: error.message };
|
return { error: 'Internal server error', details: message };
|
||||||
})
|
})
|
||||||
|
|
||||||
.listen(process.env.PORT || 3000);
|
.listen(process.env.PORT || 3000);
|
||||||
|
|
||||||
console.log(`🚀 Network App API running at ${app.server?.hostname}:${app.server?.port}`);
|
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
|
// Bootstrap: ensure donovan@donovankelly.xyz is admin
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
20
src/middleware/auth.ts
Normal file
20
src/middleware/auth.ts
Normal 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 };
|
||||||
|
});
|
||||||
95
src/middleware/rate-limit.ts
Normal file
95
src/middleware/rate-limit.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { db } from '../db';
|
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 { eq, and, desc } from 'drizzle-orm';
|
||||||
import type { User } from '../lib/auth';
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
export interface ActivityItem {
|
export interface ActivityItem {
|
||||||
id: string;
|
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;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -14,6 +15,7 @@ export interface ActivityItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const activityRoutes = new Elysia({ prefix: '/clients' })
|
export const activityRoutes = new Elysia({ prefix: '/clients' })
|
||||||
|
.use(authMiddleware)
|
||||||
// Get activity timeline for a client
|
// Get activity timeline for a client
|
||||||
.get('/:id/activity', async ({ params, user }: { params: { id: string }; user: User }) => {
|
.get('/:id/activity', async ({ params, user }: { params: { id: string }; user: User }) => {
|
||||||
// Verify client belongs to 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
|
// Sort by date descending
|
||||||
activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { users, invites, passwordResetTokens } from '../db/schema';
|
import { users, invites, passwordResetTokens } from '../db/schema';
|
||||||
@@ -6,6 +7,7 @@ import { auth } from '../lib/auth';
|
|||||||
import type { User } from '../lib/auth';
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
export const adminRoutes = new Elysia({ prefix: '/admin' })
|
export const adminRoutes = new Elysia({ prefix: '/admin' })
|
||||||
|
.use(authMiddleware)
|
||||||
// Admin guard — all routes in this group require admin role
|
// Admin guard — all routes in this group require admin role
|
||||||
.onBeforeHandle(({ user, set }: { user: User; set: any }) => {
|
.onBeforeHandle(({ user, set }: { user: User; set: any }) => {
|
||||||
if ((user as any).role !== 'admin') {
|
if ((user as any).role !== 'admin') {
|
||||||
|
|||||||
103
src/routes/audit-logs.ts
Normal file
103
src/routes/audit-logs.ts
Normal 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()),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { clients, events } from '../db/schema';
|
import { clients, events } from '../db/schema';
|
||||||
@@ -78,13 +79,15 @@ const clientSchema = t.Object({
|
|||||||
})),
|
})),
|
||||||
notes: t.Optional(t.String()),
|
notes: t.Optional(t.String()),
|
||||||
tags: t.Optional(t.Array(t.String())),
|
tags: t.Optional(t.Array(t.String())),
|
||||||
|
stage: t.Optional(t.String()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateClientSchema = t.Partial(clientSchema);
|
const updateClientSchema = t.Partial(clientSchema);
|
||||||
|
|
||||||
export const clientRoutes = new Elysia({ prefix: '/clients' })
|
export const clientRoutes = new Elysia({ prefix: '/clients' })
|
||||||
// List clients with optional search
|
.use(authMiddleware)
|
||||||
.get('/', async ({ query, user }: { query: { search?: string; tag?: string }; user: User }) => {
|
// 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));
|
let baseQuery = db.select().from(clients).where(eq(clients.userId, user.id));
|
||||||
|
|
||||||
if (query.search) {
|
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)
|
// Filter by tag in-memory if needed (JSONB filtering)
|
||||||
if (query.tag) {
|
if (query.tag) {
|
||||||
return results.filter(c => c.tags?.includes(query.tag!));
|
results = results.filter(c => c.tags?.includes(query.tag!));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
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({
|
query: t.Object({
|
||||||
search: t.Optional(t.String()),
|
search: t.Optional(t.String()),
|
||||||
tag: 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,
|
family: body.family,
|
||||||
notes: body.notes,
|
notes: body.notes,
|
||||||
tags: body.tags || [],
|
tags: body.tags || [],
|
||||||
|
stage: body.stage || 'lead',
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Auto-sync birthday/anniversary events
|
// Auto-sync birthday/anniversary events
|
||||||
|
if (client) {
|
||||||
await syncClientEvents(user.id, client);
|
await syncClientEvents(user.id, client);
|
||||||
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}, {
|
}, {
|
||||||
@@ -192,6 +214,7 @@ export const clientRoutes = new Elysia({ prefix: '/clients' })
|
|||||||
if (body.family !== undefined) updateData.family = body.family;
|
if (body.family !== undefined) updateData.family = body.family;
|
||||||
if (body.notes !== undefined) updateData.notes = body.notes;
|
if (body.notes !== undefined) updateData.notes = body.notes;
|
||||||
if (body.tags !== undefined) updateData.tags = body.tags;
|
if (body.tags !== undefined) updateData.tags = body.tags;
|
||||||
|
if (body.stage !== undefined) updateData.stage = body.stage;
|
||||||
|
|
||||||
const [client] = await db.update(clients)
|
const [client] = await db.update(clients)
|
||||||
.set(updateData)
|
.set(updateData)
|
||||||
|
|||||||
150
src/routes/documents.ts
Normal file
150
src/routes/documents.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { clientDocuments, clients } from '../db/schema';
|
||||||
|
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||||
|
import type { User } from '../lib/auth';
|
||||||
|
import { mkdir, unlink } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads/documents';
|
||||||
|
|
||||||
|
async function verifyClientOwnership(clientId: string, userId: string) {
|
||||||
|
const [client] = await db.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(and(eq(clients.id, clientId), eq(clients.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (!client) throw new Error('Client not found');
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const documentRoutes = new Elysia()
|
||||||
|
.use(authMiddleware)
|
||||||
|
|
||||||
|
// List documents for a client
|
||||||
|
.get('/clients/:clientId/documents', async ({ params, query, user }: { params: { clientId: string }; query: { category?: string }; user: User }) => {
|
||||||
|
await verifyClientOwnership(params.clientId, user.id);
|
||||||
|
|
||||||
|
let q = db.select()
|
||||||
|
.from(clientDocuments)
|
||||||
|
.where(eq(clientDocuments.clientId, params.clientId))
|
||||||
|
.orderBy(desc(clientDocuments.createdAt));
|
||||||
|
|
||||||
|
if (query.category) {
|
||||||
|
q = db.select()
|
||||||
|
.from(clientDocuments)
|
||||||
|
.where(and(
|
||||||
|
eq(clientDocuments.clientId, params.clientId),
|
||||||
|
eq(clientDocuments.category, query.category),
|
||||||
|
))
|
||||||
|
.orderBy(desc(clientDocuments.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return q;
|
||||||
|
}, {
|
||||||
|
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
|
||||||
|
query: t.Object({ category: t.Optional(t.String()) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Upload document
|
||||||
|
.post('/clients/:clientId/documents', async ({ params, body, user }: { params: { clientId: string }; body: { file: File; name?: string; category?: string; notes?: string }; user: User }) => {
|
||||||
|
await verifyClientOwnership(params.clientId, user.id);
|
||||||
|
|
||||||
|
const file = body.file;
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
throw new Error('File is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directory
|
||||||
|
const clientDir = join(UPLOAD_DIR, params.clientId);
|
||||||
|
await mkdir(clientDir, { recursive: true });
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const ext = file.name.split('.').pop() || 'bin';
|
||||||
|
const uniqueName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||||
|
const filePath = join(clientDir, uniqueName);
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
await Bun.write(filePath, buffer);
|
||||||
|
|
||||||
|
const [doc] = await db.insert(clientDocuments)
|
||||||
|
.values({
|
||||||
|
clientId: params.clientId,
|
||||||
|
userId: user.id,
|
||||||
|
name: body.name || file.name,
|
||||||
|
filename: uniqueName,
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
category: body.category || 'other',
|
||||||
|
path: filePath,
|
||||||
|
notes: body.notes || null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}, {
|
||||||
|
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
|
||||||
|
body: t.Object({
|
||||||
|
file: t.File(),
|
||||||
|
name: t.Optional(t.String()),
|
||||||
|
category: t.Optional(t.String()),
|
||||||
|
notes: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Download document
|
||||||
|
.get('/documents/:documentId/download', async ({ params, user, set }: { params: { documentId: string }; user: User; set: any }) => {
|
||||||
|
const [doc] = await db.select()
|
||||||
|
.from(clientDocuments)
|
||||||
|
.where(and(
|
||||||
|
eq(clientDocuments.id, params.documentId),
|
||||||
|
eq(clientDocuments.userId, user.id),
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!doc) throw new Error('Document not found');
|
||||||
|
|
||||||
|
const file = Bun.file(doc.path);
|
||||||
|
if (!await file.exists()) {
|
||||||
|
throw new Error('File not found on disk');
|
||||||
|
}
|
||||||
|
|
||||||
|
set.headers['content-type'] = doc.mimeType;
|
||||||
|
set.headers['content-disposition'] = `attachment; filename="${doc.name}"`;
|
||||||
|
return file;
|
||||||
|
}, {
|
||||||
|
params: t.Object({ documentId: t.String({ format: 'uuid' }) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete document
|
||||||
|
.delete('/documents/:documentId', async ({ params, user }: { params: { documentId: string }; user: User }) => {
|
||||||
|
const [doc] = await db.delete(clientDocuments)
|
||||||
|
.where(and(
|
||||||
|
eq(clientDocuments.id, params.documentId),
|
||||||
|
eq(clientDocuments.userId, user.id),
|
||||||
|
))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!doc) throw new Error('Document not found');
|
||||||
|
|
||||||
|
// Try to delete file from disk
|
||||||
|
try { await unlink(doc.path); } catch {}
|
||||||
|
|
||||||
|
return { success: true, id: doc.id };
|
||||||
|
}, {
|
||||||
|
params: t.Object({ documentId: t.String({ format: 'uuid' }) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get document count for a client (used by client cards)
|
||||||
|
.get('/clients/:clientId/documents/count', async ({ params, user }: { params: { clientId: string }; user: User }) => {
|
||||||
|
await verifyClientOwnership(params.clientId, user.id);
|
||||||
|
|
||||||
|
const [result] = await db.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(clientDocuments)
|
||||||
|
.where(eq(clientDocuments.clientId, params.clientId));
|
||||||
|
|
||||||
|
return { count: result?.count || 0 };
|
||||||
|
}, {
|
||||||
|
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
|
||||||
|
});
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { clients, communications, userProfiles } from '../db/schema';
|
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 { generateEmail, generateSubject, generateBirthdayMessage, type AIProvider } from '../services/ai';
|
||||||
import { sendEmail } from '../services/email';
|
import { sendEmail } from '../services/email';
|
||||||
import type { User } from '../lib/auth';
|
import type { User } from '../lib/auth';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
export const emailRoutes = new Elysia({ prefix: '/emails' })
|
export const emailRoutes = new Elysia({ prefix: '/emails' })
|
||||||
|
.use(authMiddleware)
|
||||||
// Generate email for a client
|
// Generate email for a client
|
||||||
.post('/generate', async ({ body, user }: {
|
.post('/generate', async ({ body, user }: {
|
||||||
body: {
|
body: {
|
||||||
@@ -58,6 +61,7 @@ export const emailRoutes = new Elysia({ prefix: '/emails' })
|
|||||||
notes: client.notes || '',
|
notes: client.notes || '',
|
||||||
purpose: body.purpose,
|
purpose: body.purpose,
|
||||||
provider: body.provider,
|
provider: body.provider,
|
||||||
|
communicationStyle: profile?.communicationStyle as any,
|
||||||
});
|
});
|
||||||
console.log(`[${new Date().toISOString()}] Email content generated successfully`);
|
console.log(`[${new Date().toISOString()}] Email content generated successfully`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -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 draft
|
||||||
.delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
|
.delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
|
||||||
const [deleted] = await db.delete(communications)
|
const [deleted] = await db.delete(communications)
|
||||||
|
|||||||
326
src/routes/engagement.ts
Normal file
326
src/routes/engagement.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { events, clients } from '../db/schema';
|
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';
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
export const eventRoutes = new Elysia({ prefix: '/events' })
|
export const eventRoutes = new Elysia({ prefix: '/events' })
|
||||||
|
.use(authMiddleware)
|
||||||
// List events with optional filters
|
// List events with optional filters
|
||||||
.get('/', async ({ query, user }: {
|
.get('/', async ({ query, user }: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
270
src/routes/export.ts
Normal file
270
src/routes/export.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { clients, communications, events, interactions, clientNotes, emailTemplates, clientSegments } from '../db/schema';
|
||||||
|
import { and, eq, desc } from 'drizzle-orm';
|
||||||
|
import { logAudit, getRequestMeta } from '../services/audit';
|
||||||
|
|
||||||
|
export const exportRoutes = new Elysia({ prefix: '/export' })
|
||||||
|
// Full data export (JSON)
|
||||||
|
.get('/json', async ({ headers, request }) => {
|
||||||
|
const sessionToken = headers['cookie']?.match(/better-auth\.session_token=([^;]+)/)?.[1];
|
||||||
|
const bearerToken = headers['authorization']?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
let userId: string | null = null;
|
||||||
|
if (sessionToken) {
|
||||||
|
const session = await db.query.sessions.findFirst({
|
||||||
|
where: (s, { eq, gt }) => and(eq(s.token, sessionToken), gt(s.expiresAt, new Date())),
|
||||||
|
});
|
||||||
|
userId = session?.userId ?? null;
|
||||||
|
}
|
||||||
|
if (!userId && bearerToken) {
|
||||||
|
const admin = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.role, 'admin') });
|
||||||
|
userId = admin?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!userId) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const [
|
||||||
|
allClients,
|
||||||
|
allEmails,
|
||||||
|
allEvents,
|
||||||
|
allInteractions,
|
||||||
|
allNotes,
|
||||||
|
allTemplates,
|
||||||
|
allSegments,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.select().from(clients).where(eq(clients.userId, userId)).orderBy(desc(clients.createdAt)),
|
||||||
|
db.select().from(communications).where(eq(communications.userId, userId)).orderBy(desc(communications.createdAt)),
|
||||||
|
db.select().from(events).where(eq(events.userId, userId)).orderBy(desc(events.date)),
|
||||||
|
db.select().from(interactions).where(eq(interactions.userId, userId)).orderBy(desc(interactions.createdAt)),
|
||||||
|
db.select().from(clientNotes).where(eq(clientNotes.userId, userId)).orderBy(desc(clientNotes.createdAt)),
|
||||||
|
db.select().from(emailTemplates).where(eq(emailTemplates.userId, userId)).orderBy(desc(emailTemplates.createdAt)),
|
||||||
|
db.select().from(clientSegments).where(eq(clientSegments.userId, userId)).orderBy(desc(clientSegments.createdAt)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const meta = getRequestMeta(request);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
action: 'view',
|
||||||
|
entityType: 'export' as any,
|
||||||
|
entityId: 'full-json',
|
||||||
|
details: {
|
||||||
|
clients: allClients.length,
|
||||||
|
emails: allEmails.length,
|
||||||
|
events: allEvents.length,
|
||||||
|
interactions: allInteractions.length,
|
||||||
|
notes: allNotes.length,
|
||||||
|
templates: allTemplates.length,
|
||||||
|
segments: allSegments.length,
|
||||||
|
},
|
||||||
|
...meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
version: '1.0',
|
||||||
|
summary: {
|
||||||
|
clients: allClients.length,
|
||||||
|
emails: allEmails.length,
|
||||||
|
events: allEvents.length,
|
||||||
|
interactions: allInteractions.length,
|
||||||
|
notes: allNotes.length,
|
||||||
|
templates: allTemplates.length,
|
||||||
|
segments: allSegments.length,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
clients: allClients,
|
||||||
|
emails: allEmails,
|
||||||
|
events: allEvents,
|
||||||
|
interactions: allInteractions,
|
||||||
|
notes: allNotes,
|
||||||
|
templates: allTemplates,
|
||||||
|
segments: allSegments,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(exportData, null, 2), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Disposition': `attachment; filename="network-app-export-${new Date().toISOString().split('T')[0]}.json"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
// CSV export for clients
|
||||||
|
.get('/clients/csv', async ({ headers, request }) => {
|
||||||
|
const sessionToken = headers['cookie']?.match(/better-auth\.session_token=([^;]+)/)?.[1];
|
||||||
|
const bearerToken = headers['authorization']?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
let userId: string | null = null;
|
||||||
|
if (sessionToken) {
|
||||||
|
const session = await db.query.sessions.findFirst({
|
||||||
|
where: (s, { eq, gt }) => and(eq(s.token, sessionToken), gt(s.expiresAt, new Date())),
|
||||||
|
});
|
||||||
|
userId = session?.userId ?? null;
|
||||||
|
}
|
||||||
|
if (!userId && bearerToken) {
|
||||||
|
const admin = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.role, 'admin') });
|
||||||
|
userId = admin?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!userId) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const allClients = await db.select().from(clients).where(eq(clients.userId, userId)).orderBy(desc(clients.createdAt));
|
||||||
|
|
||||||
|
const csvHeaders = [
|
||||||
|
'First Name', 'Last Name', 'Email', 'Phone', 'Company', 'Role', 'Industry',
|
||||||
|
'Stage', 'Street', 'City', 'State', 'Zip',
|
||||||
|
'Birthday', 'Anniversary', 'Interests', 'Tags', 'Notes',
|
||||||
|
'Last Contacted', 'Created At',
|
||||||
|
];
|
||||||
|
|
||||||
|
const escCsv = (v: any) => {
|
||||||
|
if (v == null) return '';
|
||||||
|
const s = String(v);
|
||||||
|
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = allClients.map(c => [
|
||||||
|
c.firstName, c.lastName, c.email, c.phone, c.company, c.role, c.industry,
|
||||||
|
c.stage, c.street, c.city, c.state, c.zip,
|
||||||
|
c.birthday?.toISOString().split('T')[0],
|
||||||
|
c.anniversary?.toISOString().split('T')[0],
|
||||||
|
((c.interests as string[]) || []).join('; '),
|
||||||
|
((c.tags as string[]) || []).join('; '),
|
||||||
|
c.notes,
|
||||||
|
c.lastContactedAt?.toISOString(),
|
||||||
|
c.createdAt.toISOString(),
|
||||||
|
].map(escCsv).join(','));
|
||||||
|
|
||||||
|
const csv = [csvHeaders.join(','), ...rows].join('\n');
|
||||||
|
|
||||||
|
const csvMeta = getRequestMeta(request);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
action: 'view',
|
||||||
|
entityType: 'export' as any,
|
||||||
|
entityId: 'clients-csv',
|
||||||
|
details: { clientCount: allClients.length },
|
||||||
|
...csvMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(csv, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv',
|
||||||
|
'Content-Disposition': `attachment; filename="clients-export-${new Date().toISOString().split('T')[0]}.csv"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
// CSV export for interactions
|
||||||
|
.get('/interactions/csv', async ({ headers, request }) => {
|
||||||
|
const sessionToken = headers['cookie']?.match(/better-auth\.session_token=([^;]+)/)?.[1];
|
||||||
|
const bearerToken = headers['authorization']?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
let userId: string | null = null;
|
||||||
|
if (sessionToken) {
|
||||||
|
const session = await db.query.sessions.findFirst({
|
||||||
|
where: (s, { eq, gt }) => and(eq(s.token, sessionToken), gt(s.expiresAt, new Date())),
|
||||||
|
});
|
||||||
|
userId = session?.userId ?? null;
|
||||||
|
}
|
||||||
|
if (!userId && bearerToken) {
|
||||||
|
const admin = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.role, 'admin') });
|
||||||
|
userId = admin?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!userId) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const allInteractions = await db
|
||||||
|
.select({
|
||||||
|
id: interactions.id,
|
||||||
|
type: interactions.type,
|
||||||
|
title: interactions.title,
|
||||||
|
description: interactions.description,
|
||||||
|
duration: interactions.duration,
|
||||||
|
contactedAt: interactions.contactedAt,
|
||||||
|
createdAt: interactions.createdAt,
|
||||||
|
clientFirstName: clients.firstName,
|
||||||
|
clientLastName: clients.lastName,
|
||||||
|
clientEmail: clients.email,
|
||||||
|
})
|
||||||
|
.from(interactions)
|
||||||
|
.leftJoin(clients, eq(interactions.clientId, clients.id))
|
||||||
|
.where(eq(interactions.userId, userId))
|
||||||
|
.orderBy(desc(interactions.contactedAt));
|
||||||
|
|
||||||
|
const csvHeaders = ['Client Name', 'Client Email', 'Type', 'Title', 'Description', 'Duration (min)', 'Date', 'Created At'];
|
||||||
|
|
||||||
|
const escCsv = (v: any) => {
|
||||||
|
if (v == null) return '';
|
||||||
|
const s = String(v);
|
||||||
|
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = allInteractions.map(i => [
|
||||||
|
`${i.clientFirstName} ${i.clientLastName}`,
|
||||||
|
i.clientEmail,
|
||||||
|
i.type,
|
||||||
|
i.title,
|
||||||
|
i.description,
|
||||||
|
i.duration,
|
||||||
|
i.contactedAt.toISOString(),
|
||||||
|
i.createdAt.toISOString(),
|
||||||
|
].map(escCsv).join(','));
|
||||||
|
|
||||||
|
const csv = [csvHeaders.join(','), ...rows].join('\n');
|
||||||
|
|
||||||
|
return new Response(csv, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv',
|
||||||
|
'Content-Disposition': `attachment; filename="interactions-export-${new Date().toISOString().split('T')[0]}.csv"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
// Export summary/stats
|
||||||
|
.get('/summary', async ({ headers }) => {
|
||||||
|
const sessionToken = headers['cookie']?.match(/better-auth\.session_token=([^;]+)/)?.[1];
|
||||||
|
const bearerToken = headers['authorization']?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
let userId: string | null = null;
|
||||||
|
if (sessionToken) {
|
||||||
|
const session = await db.query.sessions.findFirst({
|
||||||
|
where: (s, { eq, gt }) => and(eq(s.token, sessionToken), gt(s.expiresAt, new Date())),
|
||||||
|
});
|
||||||
|
userId = session?.userId ?? null;
|
||||||
|
}
|
||||||
|
if (!userId && bearerToken) {
|
||||||
|
const admin = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.role, 'admin') });
|
||||||
|
userId = admin?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!userId) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const [clientCount, emailCount, eventCount, interactionCount, noteCount, templateCount, segmentCount] = await Promise.all([
|
||||||
|
db.select({ count: sql`count(*)` }).from(clients).where(eq(clients.userId, userId)),
|
||||||
|
db.select({ count: sql`count(*)` }).from(communications).where(eq(communications.userId, userId)),
|
||||||
|
db.select({ count: sql`count(*)` }).from(events).where(eq(events.userId, userId)),
|
||||||
|
db.select({ count: sql`count(*)` }).from(interactions).where(eq(interactions.userId, userId)),
|
||||||
|
db.select({ count: sql`count(*)` }).from(clientNotes).where(eq(clientNotes.userId, userId)),
|
||||||
|
db.select({ count: sql`count(*)` }).from(emailTemplates).where(eq(emailTemplates.userId, userId)),
|
||||||
|
db.select({ count: sql`count(*)` }).from(clientSegments).where(eq(clientSegments.userId, userId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
clients: Number(clientCount[0]?.count || 0),
|
||||||
|
emails: Number(emailCount[0]?.count || 0),
|
||||||
|
events: Number(eventCount[0]?.count || 0),
|
||||||
|
interactions: Number(interactionCount[0]?.count || 0),
|
||||||
|
notes: Number(noteCount[0]?.count || 0),
|
||||||
|
templates: Number(templateCount[0]?.count || 0),
|
||||||
|
segments: Number(segmentCount[0]?.count || 0),
|
||||||
|
exportFormats: ['json', 'clients-csv', 'interactions-csv'],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Need sql import
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
153
src/routes/goals.ts
Normal file
153
src/routes/goals.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { clientGoals, clients } from '../db/schema';
|
||||||
|
import { eq, and, desc, sql, ne } from 'drizzle-orm';
|
||||||
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
|
async function verifyClientOwnership(clientId: string, userId: string) {
|
||||||
|
const [client] = await db.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(and(eq(clients.id, clientId), eq(clients.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (!client) throw new Error('Client not found');
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const goalRoutes = new Elysia()
|
||||||
|
.use(authMiddleware)
|
||||||
|
|
||||||
|
// List goals for a client
|
||||||
|
.get('/clients/:clientId/goals', async ({ params, user }: { params: { clientId: string }; user: User }) => {
|
||||||
|
await verifyClientOwnership(params.clientId, user.id);
|
||||||
|
|
||||||
|
return db.select()
|
||||||
|
.from(clientGoals)
|
||||||
|
.where(eq(clientGoals.clientId, params.clientId))
|
||||||
|
.orderBy(desc(clientGoals.createdAt));
|
||||||
|
}, {
|
||||||
|
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create goal
|
||||||
|
.post('/clients/:clientId/goals', async ({ params, body, user }: { params: { clientId: string }; body: any; user: User }) => {
|
||||||
|
await verifyClientOwnership(params.clientId, user.id);
|
||||||
|
|
||||||
|
const [goal] = await db.insert(clientGoals)
|
||||||
|
.values({
|
||||||
|
clientId: params.clientId,
|
||||||
|
userId: user.id,
|
||||||
|
title: body.title,
|
||||||
|
description: body.description || null,
|
||||||
|
category: body.category || 'other',
|
||||||
|
targetAmount: body.targetAmount || null,
|
||||||
|
currentAmount: body.currentAmount || '0',
|
||||||
|
targetDate: body.targetDate ? new Date(body.targetDate) : null,
|
||||||
|
status: body.status || 'on-track',
|
||||||
|
priority: body.priority || 'medium',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return goal;
|
||||||
|
}, {
|
||||||
|
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
|
||||||
|
body: t.Object({
|
||||||
|
title: t.String({ minLength: 1 }),
|
||||||
|
description: t.Optional(t.String()),
|
||||||
|
category: t.Optional(t.String()),
|
||||||
|
targetAmount: t.Optional(t.String()),
|
||||||
|
currentAmount: t.Optional(t.String()),
|
||||||
|
targetDate: t.Optional(t.String()),
|
||||||
|
status: t.Optional(t.String()),
|
||||||
|
priority: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update goal
|
||||||
|
.put('/goals/:goalId', async ({ params, body, user }: { params: { goalId: string }; body: any; user: User }) => {
|
||||||
|
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
if (body.title !== undefined) updateData.title = body.title;
|
||||||
|
if (body.description !== undefined) updateData.description = body.description;
|
||||||
|
if (body.category !== undefined) updateData.category = body.category;
|
||||||
|
if (body.targetAmount !== undefined) updateData.targetAmount = body.targetAmount;
|
||||||
|
if (body.currentAmount !== undefined) updateData.currentAmount = body.currentAmount;
|
||||||
|
if (body.targetDate !== undefined) updateData.targetDate = body.targetDate ? new Date(body.targetDate) : null;
|
||||||
|
if (body.status !== undefined) updateData.status = body.status;
|
||||||
|
if (body.priority !== undefined) updateData.priority = body.priority;
|
||||||
|
|
||||||
|
const [goal] = await db.update(clientGoals)
|
||||||
|
.set(updateData)
|
||||||
|
.where(and(
|
||||||
|
eq(clientGoals.id, params.goalId),
|
||||||
|
eq(clientGoals.userId, user.id),
|
||||||
|
))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!goal) throw new Error('Goal not found');
|
||||||
|
return goal;
|
||||||
|
}, {
|
||||||
|
params: t.Object({ goalId: t.String({ format: 'uuid' }) }),
|
||||||
|
body: t.Object({
|
||||||
|
title: t.Optional(t.String({ minLength: 1 })),
|
||||||
|
description: t.Optional(t.String()),
|
||||||
|
category: t.Optional(t.String()),
|
||||||
|
targetAmount: t.Optional(t.String()),
|
||||||
|
currentAmount: t.Optional(t.String()),
|
||||||
|
targetDate: t.Optional(t.Nullable(t.String())),
|
||||||
|
status: t.Optional(t.String()),
|
||||||
|
priority: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete goal
|
||||||
|
.delete('/goals/:goalId', async ({ params, user }: { params: { goalId: string }; user: User }) => {
|
||||||
|
const [deleted] = await db.delete(clientGoals)
|
||||||
|
.where(and(
|
||||||
|
eq(clientGoals.id, params.goalId),
|
||||||
|
eq(clientGoals.userId, user.id),
|
||||||
|
))
|
||||||
|
.returning({ id: clientGoals.id });
|
||||||
|
|
||||||
|
if (!deleted) throw new Error('Goal not found');
|
||||||
|
return { success: true, id: deleted.id };
|
||||||
|
}, {
|
||||||
|
params: t.Object({ goalId: t.String({ format: 'uuid' }) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Goals overview (dashboard) - at-risk goals across all clients
|
||||||
|
.get('/goals/overview', async ({ user }: { user: User }) => {
|
||||||
|
const allGoals = await db.select({
|
||||||
|
id: clientGoals.id,
|
||||||
|
clientId: clientGoals.clientId,
|
||||||
|
title: clientGoals.title,
|
||||||
|
category: clientGoals.category,
|
||||||
|
targetAmount: clientGoals.targetAmount,
|
||||||
|
currentAmount: clientGoals.currentAmount,
|
||||||
|
targetDate: clientGoals.targetDate,
|
||||||
|
status: clientGoals.status,
|
||||||
|
priority: clientGoals.priority,
|
||||||
|
clientFirstName: clients.firstName,
|
||||||
|
clientLastName: clients.lastName,
|
||||||
|
})
|
||||||
|
.from(clientGoals)
|
||||||
|
.innerJoin(clients, eq(clientGoals.clientId, clients.id))
|
||||||
|
.where(eq(clientGoals.userId, user.id))
|
||||||
|
.orderBy(desc(clientGoals.updatedAt));
|
||||||
|
|
||||||
|
const total = allGoals.length;
|
||||||
|
const byStatus = {
|
||||||
|
'on-track': allGoals.filter(g => g.status === 'on-track').length,
|
||||||
|
'at-risk': allGoals.filter(g => g.status === 'at-risk').length,
|
||||||
|
'behind': allGoals.filter(g => g.status === 'behind').length,
|
||||||
|
'completed': allGoals.filter(g => g.status === 'completed').length,
|
||||||
|
};
|
||||||
|
const atRiskGoals = allGoals.filter(g => g.status === 'at-risk' || g.status === 'behind');
|
||||||
|
const highPriorityGoals = allGoals.filter(g => g.priority === 'high' && g.status !== 'completed');
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
byStatus,
|
||||||
|
atRiskGoals: atRiskGoals.slice(0, 10),
|
||||||
|
highPriorityGoals: highPriorityGoals.slice(0, 10),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { clients, events } from '../db/schema';
|
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' })
|
export const importRoutes = new Elysia({ prefix: '/clients' })
|
||||||
|
.use(authMiddleware)
|
||||||
// Preview CSV - returns headers and auto-mapped columns + sample rows
|
// Preview CSV - returns headers and auto-mapped columns + sample rows
|
||||||
.post('/import/preview', async ({ body, user }: { body: { file: File }; user: User }) => {
|
.post('/import/preview', async ({ body, user }: { body: { file: File }; user: User }) => {
|
||||||
const text = await body.file.text();
|
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++) {
|
for (let i = 0; i < dataRows.length; i++) {
|
||||||
const row = dataRows[i];
|
const row = dataRows[i];
|
||||||
|
if (!row) continue;
|
||||||
try {
|
try {
|
||||||
const record: Record<string, any> = {};
|
const record: Record<string, any> = {};
|
||||||
|
|
||||||
@@ -260,12 +263,14 @@ export const importRoutes = new Elysia({ prefix: '/clients' })
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Sync events
|
// Sync events
|
||||||
|
if (client) {
|
||||||
await syncClientEvents(user.id, {
|
await syncClientEvents(user.id, {
|
||||||
id: client.id,
|
id: client.id,
|
||||||
firstName: client.firstName,
|
firstName: client.firstName,
|
||||||
birthday: client.birthday,
|
birthday: client.birthday,
|
||||||
anniversary: client.anniversary,
|
anniversary: client.anniversary,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
results.imported++;
|
results.imported++;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { Elysia } from 'elysia';
|
import { Elysia } from 'elysia';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { clients, events } from '../db/schema';
|
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';
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
export const insightsRoutes = new Elysia({ prefix: '/insights' })
|
export const insightsRoutes = new Elysia({ prefix: '/insights' })
|
||||||
|
.use(authMiddleware)
|
||||||
.get('/', async ({ user }: { user: User }) => {
|
.get('/', async ({ user }: { user: User }) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|||||||
143
src/routes/interactions.ts
Normal file
143
src/routes/interactions.ts
Normal 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
172
src/routes/meeting-prep.ts
Normal 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()),
|
||||||
|
}),
|
||||||
|
});
|
||||||
259
src/routes/merge.ts
Normal file
259
src/routes/merge.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { clients, communications, events, interactions, clientNotes, notifications } from '../db/schema';
|
||||||
|
import { and, eq, or, ilike, sql, desc, ne } from 'drizzle-orm';
|
||||||
|
import { logAudit, getRequestMeta } from '../services/audit';
|
||||||
|
|
||||||
|
export const mergeRoutes = new Elysia({ prefix: '/clients' })
|
||||||
|
// Find potential duplicates for a specific client
|
||||||
|
.get('/:id/duplicates', async ({ params, headers }) => {
|
||||||
|
const sessionToken = headers['cookie']?.match(/better-auth\.session_token=([^;]+)/)?.[1];
|
||||||
|
const bearerToken = headers['authorization']?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
let userId: string | null = null;
|
||||||
|
if (sessionToken) {
|
||||||
|
const session = await db.query.sessions.findFirst({
|
||||||
|
where: (s, { eq, gt }) => and(eq(s.token, sessionToken), gt(s.expiresAt, new Date())),
|
||||||
|
});
|
||||||
|
userId = session?.userId ?? null;
|
||||||
|
}
|
||||||
|
if (!userId && bearerToken) {
|
||||||
|
const admin = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.role, 'admin') });
|
||||||
|
userId = admin?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!userId) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const client = await db.query.clients.findFirst({
|
||||||
|
where: (c, { eq: e }) => and(e(c.id, params.id), e(c.userId, userId)),
|
||||||
|
});
|
||||||
|
if (!client) return new Response('Client not found', { status: 404 });
|
||||||
|
|
||||||
|
// Find duplicates by name, email, phone, or company+role
|
||||||
|
const conditions: any[] = [];
|
||||||
|
|
||||||
|
// Same first+last name (fuzzy)
|
||||||
|
conditions.push(
|
||||||
|
and(
|
||||||
|
ilike(clients.firstName, `%${client.firstName}%`),
|
||||||
|
ilike(clients.lastName, `%${client.lastName}%`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same email
|
||||||
|
if (client.email) {
|
||||||
|
conditions.push(eq(clients.email, client.email));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same phone
|
||||||
|
if (client.phone) {
|
||||||
|
conditions.push(eq(clients.phone, client.phone));
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicates = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.userId, userId),
|
||||||
|
ne(clients.id, params.id),
|
||||||
|
or(...conditions)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(clients.updatedAt));
|
||||||
|
|
||||||
|
// Score each duplicate
|
||||||
|
const scored = duplicates.map(dup => {
|
||||||
|
let score = 0;
|
||||||
|
const reasons: string[] = [];
|
||||||
|
|
||||||
|
// Exact name match
|
||||||
|
if (dup.firstName.toLowerCase() === client.firstName.toLowerCase() &&
|
||||||
|
dup.lastName.toLowerCase() === client.lastName.toLowerCase()) {
|
||||||
|
score += 40;
|
||||||
|
reasons.push('Exact name match');
|
||||||
|
} else if (dup.firstName.toLowerCase().includes(client.firstName.toLowerCase()) ||
|
||||||
|
client.firstName.toLowerCase().includes(dup.firstName.toLowerCase())) {
|
||||||
|
score += 20;
|
||||||
|
reasons.push('Similar name');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email match
|
||||||
|
if (client.email && dup.email && dup.email.toLowerCase() === client.email.toLowerCase()) {
|
||||||
|
score += 35;
|
||||||
|
reasons.push('Same email');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone match (normalize)
|
||||||
|
if (client.phone && dup.phone) {
|
||||||
|
const norm = (p: string) => p.replace(/\D/g, '');
|
||||||
|
if (norm(dup.phone) === norm(client.phone)) {
|
||||||
|
score += 30;
|
||||||
|
reasons.push('Same phone');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same company + role
|
||||||
|
if (client.company && dup.company &&
|
||||||
|
dup.company.toLowerCase() === client.company.toLowerCase()) {
|
||||||
|
score += 10;
|
||||||
|
reasons.push('Same company');
|
||||||
|
if (client.role && dup.role && dup.role.toLowerCase() === client.role.toLowerCase()) {
|
||||||
|
score += 5;
|
||||||
|
reasons.push('Same role');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...dup, duplicateScore: Math.min(score, 100), matchReasons: reasons };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only return those with score >= 20
|
||||||
|
return scored
|
||||||
|
.filter(d => d.duplicateScore >= 20)
|
||||||
|
.sort((a, b) => b.duplicateScore - a.duplicateScore);
|
||||||
|
})
|
||||||
|
|
||||||
|
// Merge two clients: keep primary, absorb secondary
|
||||||
|
.post('/:id/merge', async ({ params, body, headers, request }) => {
|
||||||
|
const sessionToken = headers['cookie']?.match(/better-auth\.session_token=([^;]+)/)?.[1];
|
||||||
|
const bearerToken = headers['authorization']?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
let userId: string | null = null;
|
||||||
|
if (sessionToken) {
|
||||||
|
const session = await db.query.sessions.findFirst({
|
||||||
|
where: (s, { eq, gt }) => and(eq(s.token, sessionToken), gt(s.expiresAt, new Date())),
|
||||||
|
});
|
||||||
|
userId = session?.userId ?? null;
|
||||||
|
}
|
||||||
|
if (!userId && bearerToken) {
|
||||||
|
const admin = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.role, 'admin') });
|
||||||
|
userId = admin?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!userId) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const primaryId = params.id;
|
||||||
|
const secondaryId = body.mergeFromId;
|
||||||
|
|
||||||
|
if (primaryId === secondaryId) {
|
||||||
|
return new Response('Cannot merge a client with itself', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [primary, secondary] = await Promise.all([
|
||||||
|
db.query.clients.findFirst({ where: (c, { eq: e }) => and(e(c.id, primaryId), e(c.userId, userId)) }),
|
||||||
|
db.query.clients.findFirst({ where: (c, { eq: e }) => and(e(c.id, secondaryId), e(c.userId, userId)) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!primary || !secondary) {
|
||||||
|
return new Response('One or both clients not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge fields: primary wins, fill gaps from secondary
|
||||||
|
const mergedFields: Record<string, any> = {};
|
||||||
|
const fillable = ['email', 'phone', 'street', 'city', 'state', 'zip', 'company', 'role', 'industry', 'birthday', 'anniversary', 'notes'] as const;
|
||||||
|
|
||||||
|
for (const field of fillable) {
|
||||||
|
if (!primary[field] && secondary[field]) {
|
||||||
|
mergedFields[field] = secondary[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge interests (union)
|
||||||
|
const primaryInterests = (primary.interests as string[]) || [];
|
||||||
|
const secondaryInterests = (secondary.interests as string[]) || [];
|
||||||
|
const mergedInterests = [...new Set([...primaryInterests, ...secondaryInterests])];
|
||||||
|
if (mergedInterests.length > primaryInterests.length) {
|
||||||
|
mergedFields.interests = mergedInterests;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge tags (union)
|
||||||
|
const primaryTags = (primary.tags as string[]) || [];
|
||||||
|
const secondaryTags = (secondary.tags as string[]) || [];
|
||||||
|
const mergedTags = [...new Set([...primaryTags, ...secondaryTags])];
|
||||||
|
if (mergedTags.length > primaryTags.length) {
|
||||||
|
mergedFields.tags = mergedTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge family
|
||||||
|
if (!primary.family && secondary.family) {
|
||||||
|
mergedFields.family = secondary.family;
|
||||||
|
} else if (primary.family && secondary.family) {
|
||||||
|
const pf = primary.family as any;
|
||||||
|
const sf = secondary.family as any;
|
||||||
|
mergedFields.family = {
|
||||||
|
spouse: pf.spouse || sf.spouse,
|
||||||
|
children: [...new Set([...(pf.children || []), ...(sf.children || [])])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge notes (append if both have)
|
||||||
|
if (primary.notes && secondary.notes && primary.notes !== secondary.notes) {
|
||||||
|
mergedFields.notes = `${primary.notes}\n\n--- Merged from ${secondary.firstName} ${secondary.lastName} ---\n${secondary.notes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use more recent lastContactedAt
|
||||||
|
if (secondary.lastContactedAt) {
|
||||||
|
if (!primary.lastContactedAt || secondary.lastContactedAt > primary.lastContactedAt) {
|
||||||
|
mergedFields.lastContactedAt = secondary.lastContactedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep better stage (active > onboarding > prospect > lead > inactive)
|
||||||
|
const stageRank: Record<string, number> = { active: 4, onboarding: 3, prospect: 2, lead: 1, inactive: 0 };
|
||||||
|
if ((stageRank[secondary.stage || 'lead'] || 0) > (stageRank[primary.stage || 'lead'] || 0)) {
|
||||||
|
mergedFields.stage = secondary.stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedFields.updatedAt = new Date();
|
||||||
|
|
||||||
|
// Execute merge in transaction
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// 1. Update primary with merged fields
|
||||||
|
if (Object.keys(mergedFields).length > 0) {
|
||||||
|
await tx.update(clients).set(mergedFields).where(eq(clients.id, primaryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Move all secondary's related records to primary
|
||||||
|
await tx.update(communications).set({ clientId: primaryId }).where(eq(communications.clientId, secondaryId));
|
||||||
|
await tx.update(events).set({ clientId: primaryId }).where(eq(events.clientId, secondaryId));
|
||||||
|
await tx.update(interactions).set({ clientId: primaryId }).where(eq(interactions.clientId, secondaryId));
|
||||||
|
await tx.update(clientNotes).set({ clientId: primaryId }).where(eq(clientNotes.clientId, secondaryId));
|
||||||
|
await tx.update(notifications).set({ clientId: primaryId }).where(eq(notifications.clientId, secondaryId));
|
||||||
|
|
||||||
|
// 3. Delete secondary client
|
||||||
|
await tx.delete(clients).where(eq(clients.id, secondaryId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
const meta = getRequestMeta(request);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'client',
|
||||||
|
entityId: primaryId,
|
||||||
|
details: {
|
||||||
|
type: 'merge',
|
||||||
|
mergedFromId: secondaryId,
|
||||||
|
mergedFromName: `${secondary.firstName} ${secondary.lastName}`,
|
||||||
|
fieldsUpdated: Object.keys(mergedFields).filter(k => k !== 'updatedAt'),
|
||||||
|
},
|
||||||
|
...meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get updated primary
|
||||||
|
const updated = await db.query.clients.findFirst({
|
||||||
|
where: (c, { eq: e }) => e(c.id, primaryId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
client: updated,
|
||||||
|
merged: {
|
||||||
|
fromId: secondaryId,
|
||||||
|
fromName: `${secondary.firstName} ${secondary.lastName}`,
|
||||||
|
fieldsUpdated: Object.keys(mergedFields).filter(k => k !== 'updatedAt'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
mergeFromId: t.String(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { clients } from '../db/schema';
|
import { clients } from '../db/schema';
|
||||||
@@ -23,6 +24,7 @@ function toClientProfile(c: typeof clients.$inferSelect): ClientProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const networkRoutes = new Elysia({ prefix: '/network' })
|
export const networkRoutes = new Elysia({ prefix: '/network' })
|
||||||
|
.use(authMiddleware)
|
||||||
// Get all network matches for the user's clients
|
// Get all network matches for the user's clients
|
||||||
.get('/matches', async (ctx) => {
|
.get('/matches', async (ctx) => {
|
||||||
const user = (ctx as any).user;
|
const user = (ctx as any).user;
|
||||||
|
|||||||
105
src/routes/notes.ts
Normal file
105
src/routes/notes.ts
Normal 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' }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
87
src/routes/notifications.ts
Normal file
87
src/routes/notifications.ts
Normal 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' }) }),
|
||||||
|
});
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { users, userProfiles, accounts } from '../db/schema';
|
import { users, userProfiles, accounts } from '../db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import type { User } from '../lib/auth';
|
import type { User } from '../lib/auth';
|
||||||
|
import { logAudit, getRequestMeta } from '../services/audit';
|
||||||
|
|
||||||
export const profileRoutes = new Elysia({ prefix: '/profile' })
|
export const profileRoutes = new Elysia({ prefix: '/profile' })
|
||||||
|
.use(authMiddleware)
|
||||||
// Get current user's profile
|
// Get current user's profile
|
||||||
.get('/', async ({ user }: { user: User }) => {
|
.get('/', async ({ user }: { user: User }) => {
|
||||||
// Get user and profile
|
// 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
|
// Change password
|
||||||
.put('/password', async ({ body, user, set }: {
|
.put('/password', async ({ body, user, set }: {
|
||||||
body: { currentPassword: string; newPassword: string };
|
body: { currentPassword: string; newPassword: string };
|
||||||
@@ -162,6 +275,14 @@ export const profileRoutes = new Elysia({ prefix: '/profile' })
|
|||||||
eq(accounts.providerId, 'credential'),
|
eq(accounts.providerId, 'credential'),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: user.id,
|
||||||
|
action: 'password_change',
|
||||||
|
entityType: 'auth',
|
||||||
|
entityId: user.id,
|
||||||
|
details: { event: 'password_changed' },
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}, {
|
}, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
|
|||||||
203
src/routes/referrals.ts
Normal file
203
src/routes/referrals.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { referrals, clients } from '../db/schema';
|
||||||
|
import { eq, and, desc, sql, or } from 'drizzle-orm';
|
||||||
|
import { alias } from 'drizzle-orm/pg-core';
|
||||||
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
|
async function verifyClientOwnership(clientId: string, userId: string) {
|
||||||
|
const [client] = await db.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(and(eq(clients.id, clientId), eq(clients.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (!client) throw new Error('Client not found');
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referrerClient = alias(clients, 'referrerClient');
|
||||||
|
const referredClient = alias(clients, 'referredClient');
|
||||||
|
|
||||||
|
export const referralRoutes = new Elysia()
|
||||||
|
.use(authMiddleware)
|
||||||
|
|
||||||
|
// List referrals for a client (given + received)
|
||||||
|
.get('/clients/:clientId/referrals', async ({ params, user }: { params: { clientId: string }; user: User }) => {
|
||||||
|
await verifyClientOwnership(params.clientId, user.id);
|
||||||
|
|
||||||
|
const results = await db.select({
|
||||||
|
id: referrals.id,
|
||||||
|
referrerId: referrals.referrerId,
|
||||||
|
referredId: referrals.referredId,
|
||||||
|
type: referrals.type,
|
||||||
|
notes: referrals.notes,
|
||||||
|
status: referrals.status,
|
||||||
|
value: referrals.value,
|
||||||
|
createdAt: referrals.createdAt,
|
||||||
|
updatedAt: referrals.updatedAt,
|
||||||
|
referrerFirstName: referrerClient.firstName,
|
||||||
|
referrerLastName: referrerClient.lastName,
|
||||||
|
referredFirstName: referredClient.firstName,
|
||||||
|
referredLastName: referredClient.lastName,
|
||||||
|
})
|
||||||
|
.from(referrals)
|
||||||
|
.innerJoin(referrerClient, eq(referrals.referrerId, referrerClient.id))
|
||||||
|
.innerJoin(referredClient, eq(referrals.referredId, referredClient.id))
|
||||||
|
.where(and(
|
||||||
|
eq(referrals.userId, user.id),
|
||||||
|
or(
|
||||||
|
eq(referrals.referrerId, params.clientId),
|
||||||
|
eq(referrals.referredId, params.clientId),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.orderBy(desc(referrals.createdAt));
|
||||||
|
|
||||||
|
return results.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
referrerId: r.referrerId,
|
||||||
|
referredId: r.referredId,
|
||||||
|
type: r.type,
|
||||||
|
notes: r.notes,
|
||||||
|
status: r.status,
|
||||||
|
value: r.value,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
referrer: { id: r.referrerId, firstName: r.referrerFirstName, lastName: r.referrerLastName },
|
||||||
|
referred: { id: r.referredId, firstName: r.referredFirstName, lastName: r.referredLastName },
|
||||||
|
}));
|
||||||
|
}, {
|
||||||
|
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create referral
|
||||||
|
.post('/clients/:clientId/referrals', async ({ params, body, user }: { params: { clientId: string }; body: any; user: User }) => {
|
||||||
|
await verifyClientOwnership(params.clientId, user.id);
|
||||||
|
|
||||||
|
// Verify the other client exists and belongs to user
|
||||||
|
const referredClientId = body.referredId;
|
||||||
|
await verifyClientOwnership(referredClientId, user.id);
|
||||||
|
|
||||||
|
const [ref] = await db.insert(referrals)
|
||||||
|
.values({
|
||||||
|
referrerId: params.clientId,
|
||||||
|
referredId: referredClientId,
|
||||||
|
userId: user.id,
|
||||||
|
type: body.type || 'client',
|
||||||
|
notes: body.notes || null,
|
||||||
|
status: body.status || 'pending',
|
||||||
|
value: body.value || null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}, {
|
||||||
|
params: t.Object({ clientId: t.String({ format: 'uuid' }) }),
|
||||||
|
body: t.Object({
|
||||||
|
referredId: t.String({ format: 'uuid' }),
|
||||||
|
type: t.Optional(t.String()),
|
||||||
|
notes: t.Optional(t.String()),
|
||||||
|
status: t.Optional(t.String()),
|
||||||
|
value: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update referral
|
||||||
|
.put('/referrals/:referralId', async ({ params, body, user }: { params: { referralId: string }; body: any; user: User }) => {
|
||||||
|
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
if (body.type !== undefined) updateData.type = body.type;
|
||||||
|
if (body.notes !== undefined) updateData.notes = body.notes;
|
||||||
|
if (body.status !== undefined) updateData.status = body.status;
|
||||||
|
if (body.value !== undefined) updateData.value = body.value;
|
||||||
|
|
||||||
|
const [ref] = await db.update(referrals)
|
||||||
|
.set(updateData)
|
||||||
|
.where(and(
|
||||||
|
eq(referrals.id, params.referralId),
|
||||||
|
eq(referrals.userId, user.id),
|
||||||
|
))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!ref) throw new Error('Referral not found');
|
||||||
|
return ref;
|
||||||
|
}, {
|
||||||
|
params: t.Object({ referralId: t.String({ format: 'uuid' }) }),
|
||||||
|
body: t.Object({
|
||||||
|
type: t.Optional(t.String()),
|
||||||
|
notes: t.Optional(t.String()),
|
||||||
|
status: t.Optional(t.String()),
|
||||||
|
value: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete referral
|
||||||
|
.delete('/referrals/:referralId', async ({ params, user }: { params: { referralId: string }; user: User }) => {
|
||||||
|
const [deleted] = await db.delete(referrals)
|
||||||
|
.where(and(
|
||||||
|
eq(referrals.id, params.referralId),
|
||||||
|
eq(referrals.userId, user.id),
|
||||||
|
))
|
||||||
|
.returning({ id: referrals.id });
|
||||||
|
|
||||||
|
if (!deleted) throw new Error('Referral not found');
|
||||||
|
return { success: true, id: deleted.id };
|
||||||
|
}, {
|
||||||
|
params: t.Object({ referralId: t.String({ format: 'uuid' }) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Referral stats (dashboard)
|
||||||
|
.get('/referrals/stats', async ({ user }: { user: User }) => {
|
||||||
|
const allReferrals = await db.select({
|
||||||
|
id: referrals.id,
|
||||||
|
referrerId: referrals.referrerId,
|
||||||
|
referredId: referrals.referredId,
|
||||||
|
type: referrals.type,
|
||||||
|
status: referrals.status,
|
||||||
|
value: referrals.value,
|
||||||
|
referrerFirstName: referrerClient.firstName,
|
||||||
|
referrerLastName: referrerClient.lastName,
|
||||||
|
})
|
||||||
|
.from(referrals)
|
||||||
|
.innerJoin(referrerClient, eq(referrals.referrerId, referrerClient.id))
|
||||||
|
.where(eq(referrals.userId, user.id));
|
||||||
|
|
||||||
|
const total = allReferrals.length;
|
||||||
|
const converted = allReferrals.filter(r => r.status === 'converted').length;
|
||||||
|
const conversionRate = total > 0 ? Math.round((converted / total) * 100) : 0;
|
||||||
|
const totalValue = allReferrals
|
||||||
|
.filter(r => r.value)
|
||||||
|
.reduce((sum, r) => sum + parseFloat(r.value || '0'), 0);
|
||||||
|
const convertedValue = allReferrals
|
||||||
|
.filter(r => r.status === 'converted' && r.value)
|
||||||
|
.reduce((sum, r) => sum + parseFloat(r.value || '0'), 0);
|
||||||
|
|
||||||
|
// Top referrers
|
||||||
|
const referrerMap = new Map<string, { name: string; count: number; convertedCount: number }>();
|
||||||
|
for (const r of allReferrals) {
|
||||||
|
const key = r.referrerId;
|
||||||
|
const existing = referrerMap.get(key) || { name: `${r.referrerFirstName} ${r.referrerLastName}`, count: 0, convertedCount: 0 };
|
||||||
|
existing.count++;
|
||||||
|
if (r.status === 'converted') existing.convertedCount++;
|
||||||
|
referrerMap.set(key, existing);
|
||||||
|
}
|
||||||
|
const topReferrers = Array.from(referrerMap.entries())
|
||||||
|
.map(([id, data]) => ({ id, ...data }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const byStatus = {
|
||||||
|
pending: allReferrals.filter(r => r.status === 'pending').length,
|
||||||
|
contacted: allReferrals.filter(r => r.status === 'contacted').length,
|
||||||
|
converted,
|
||||||
|
lost: allReferrals.filter(r => r.status === 'lost').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
converted,
|
||||||
|
conversionRate,
|
||||||
|
totalValue,
|
||||||
|
convertedValue,
|
||||||
|
byStatus,
|
||||||
|
topReferrers,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { Elysia } from 'elysia';
|
import { Elysia } from 'elysia';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { clients, events, communications } from '../db/schema';
|
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';
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
export const reportsRoutes = new Elysia()
|
export const reportsRoutes = new Elysia()
|
||||||
|
.use(authMiddleware)
|
||||||
// Analytics overview
|
// Analytics overview
|
||||||
.get('/reports/overview', async ({ user }: { user: User }) => {
|
.get('/reports/overview', async ({ user }: { user: User }) => {
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
@@ -84,21 +86,21 @@ export const reportsRoutes = new Elysia()
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
clients: {
|
clients: {
|
||||||
total: totalClients.count,
|
total: totalClients?.count ?? 0,
|
||||||
newThisMonth: newClientsMonth.count,
|
newThisMonth: newClientsMonth?.count ?? 0,
|
||||||
newThisWeek: newClientsWeek.count,
|
newThisWeek: newClientsWeek?.count ?? 0,
|
||||||
contactedRecently: contactedRecently.count,
|
contactedRecently: contactedRecently?.count ?? 0,
|
||||||
neverContacted: neverContacted.count,
|
neverContacted: neverContacted?.count ?? 0,
|
||||||
},
|
},
|
||||||
emails: {
|
emails: {
|
||||||
total: totalEmails.count,
|
total: totalEmails?.count ?? 0,
|
||||||
sent: emailsSent.count,
|
sent: emailsSent?.count ?? 0,
|
||||||
draft: emailsDraft.count,
|
draft: emailsDraft?.count ?? 0,
|
||||||
sentLast30Days: emailsRecent.count,
|
sentLast30Days: emailsRecent?.count ?? 0,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
total: totalEvents.count,
|
total: totalEvents?.count ?? 0,
|
||||||
upcoming30Days: upcomingEvents.count,
|
upcoming30Days: upcomingEvents?.count ?? 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -406,11 +408,11 @@ export const reportsRoutes = new Elysia()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draftCount.count > 0) {
|
if ((draftCount?.count ?? 0) > 0) {
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: 'drafts',
|
id: 'drafts',
|
||||||
type: 'drafts' as const,
|
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',
|
description: 'Review and send your drafted emails',
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
link: '/emails',
|
link: '/emails',
|
||||||
@@ -434,7 +436,7 @@ export const reportsRoutes = new Elysia()
|
|||||||
overdue: overdueEvents.length,
|
overdue: overdueEvents.length,
|
||||||
upcoming: upcomingEvents.length,
|
upcoming: upcomingEvents.length,
|
||||||
stale: staleClients.length,
|
stale: staleClients.length,
|
||||||
drafts: draftCount.count,
|
drafts: draftCount?.count ?? 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
267
src/routes/search.ts
Normal file
267
src/routes/search.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { clients, communications, events, interactions, clientNotes } from '../db/schema';
|
||||||
|
import { and, eq, or, ilike, sql, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const searchRoutes = new Elysia({ prefix: '/search' })
|
||||||
|
.get('/', async ({ query, headers }) => {
|
||||||
|
// Auth check
|
||||||
|
const sessionToken = headers['cookie']?.match(/better-auth\.session_token=([^;]+)/)?.[1];
|
||||||
|
const bearerToken = headers['authorization']?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
let userId: string | null = null;
|
||||||
|
if (sessionToken) {
|
||||||
|
const session = await db.query.sessions.findFirst({
|
||||||
|
where: (s, { eq, gt }) => and(eq(s.token, sessionToken), gt(s.expiresAt, new Date())),
|
||||||
|
});
|
||||||
|
userId = session?.userId ?? null;
|
||||||
|
}
|
||||||
|
if (!userId && bearerToken) {
|
||||||
|
// Service account / bearer auth - get first admin
|
||||||
|
const admin = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.role, 'admin') });
|
||||||
|
userId = admin?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!userId) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const q = query.q?.trim();
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
return { results: [], query: q, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(Number(query.limit) || 20, 50);
|
||||||
|
const types = query.types?.split(',') || ['clients', 'emails', 'events', 'interactions', 'notes'];
|
||||||
|
const pattern = `%${q}%`;
|
||||||
|
|
||||||
|
const results: Array<{
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientName?: string;
|
||||||
|
matchField: string;
|
||||||
|
createdAt: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Search clients
|
||||||
|
if (types.includes('clients')) {
|
||||||
|
const clientResults = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.userId, userId),
|
||||||
|
or(
|
||||||
|
ilike(clients.firstName, pattern),
|
||||||
|
ilike(clients.lastName, pattern),
|
||||||
|
ilike(clients.email, pattern),
|
||||||
|
ilike(clients.phone, pattern),
|
||||||
|
ilike(clients.company, pattern),
|
||||||
|
ilike(clients.industry, pattern),
|
||||||
|
ilike(clients.city, pattern),
|
||||||
|
ilike(clients.notes, pattern),
|
||||||
|
sql`${clients.firstName} || ' ' || ${clients.lastName} ILIKE ${pattern}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(clients.updatedAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
for (const c of clientResults) {
|
||||||
|
const fullName = `${c.firstName} ${c.lastName}`;
|
||||||
|
let matchField = 'name';
|
||||||
|
if (fullName.toLowerCase().includes(q.toLowerCase())) matchField = 'name';
|
||||||
|
else if (c.email?.toLowerCase().includes(q.toLowerCase())) matchField = 'email';
|
||||||
|
else if (c.phone?.toLowerCase().includes(q.toLowerCase())) matchField = 'phone';
|
||||||
|
else if (c.company?.toLowerCase().includes(q.toLowerCase())) matchField = 'company';
|
||||||
|
else if (c.industry?.toLowerCase().includes(q.toLowerCase())) matchField = 'industry';
|
||||||
|
else if (c.notes?.toLowerCase().includes(q.toLowerCase())) matchField = 'notes';
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
type: 'client',
|
||||||
|
id: c.id,
|
||||||
|
title: fullName,
|
||||||
|
subtitle: [c.company, c.role].filter(Boolean).join(' · ') || c.email || undefined,
|
||||||
|
matchField,
|
||||||
|
createdAt: c.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search emails/communications
|
||||||
|
if (types.includes('emails')) {
|
||||||
|
const emailResults = await db
|
||||||
|
.select({
|
||||||
|
id: communications.id,
|
||||||
|
subject: communications.subject,
|
||||||
|
content: communications.content,
|
||||||
|
status: communications.status,
|
||||||
|
clientId: communications.clientId,
|
||||||
|
createdAt: communications.createdAt,
|
||||||
|
clientFirstName: clients.firstName,
|
||||||
|
clientLastName: clients.lastName,
|
||||||
|
})
|
||||||
|
.from(communications)
|
||||||
|
.leftJoin(clients, eq(communications.clientId, clients.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(communications.userId, userId),
|
||||||
|
or(
|
||||||
|
ilike(communications.subject, pattern),
|
||||||
|
ilike(communications.content, pattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(communications.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
for (const e of emailResults) {
|
||||||
|
results.push({
|
||||||
|
type: 'email',
|
||||||
|
id: e.id,
|
||||||
|
title: e.subject || '(No subject)',
|
||||||
|
subtitle: `${e.status} · ${e.clientFirstName} ${e.clientLastName}`,
|
||||||
|
clientId: e.clientId,
|
||||||
|
clientName: `${e.clientFirstName} ${e.clientLastName}`,
|
||||||
|
matchField: e.subject?.toLowerCase().includes(q.toLowerCase()) ? 'subject' : 'content',
|
||||||
|
createdAt: e.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search events
|
||||||
|
if (types.includes('events')) {
|
||||||
|
const eventResults = await db
|
||||||
|
.select({
|
||||||
|
id: events.id,
|
||||||
|
title: events.title,
|
||||||
|
type: events.type,
|
||||||
|
date: events.date,
|
||||||
|
clientId: events.clientId,
|
||||||
|
createdAt: events.createdAt,
|
||||||
|
clientFirstName: clients.firstName,
|
||||||
|
clientLastName: clients.lastName,
|
||||||
|
})
|
||||||
|
.from(events)
|
||||||
|
.leftJoin(clients, eq(events.clientId, clients.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(events.userId, userId),
|
||||||
|
ilike(events.title, pattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(events.date))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
for (const ev of eventResults) {
|
||||||
|
results.push({
|
||||||
|
type: 'event',
|
||||||
|
id: ev.id,
|
||||||
|
title: ev.title,
|
||||||
|
subtitle: `${ev.type} · ${ev.clientFirstName} ${ev.clientLastName}`,
|
||||||
|
clientId: ev.clientId,
|
||||||
|
clientName: `${ev.clientFirstName} ${ev.clientLastName}`,
|
||||||
|
matchField: 'title',
|
||||||
|
createdAt: ev.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search interactions
|
||||||
|
if (types.includes('interactions')) {
|
||||||
|
const intResults = await db
|
||||||
|
.select({
|
||||||
|
id: interactions.id,
|
||||||
|
title: interactions.title,
|
||||||
|
description: interactions.description,
|
||||||
|
type: interactions.type,
|
||||||
|
clientId: interactions.clientId,
|
||||||
|
createdAt: interactions.createdAt,
|
||||||
|
clientFirstName: clients.firstName,
|
||||||
|
clientLastName: clients.lastName,
|
||||||
|
})
|
||||||
|
.from(interactions)
|
||||||
|
.leftJoin(clients, eq(interactions.clientId, clients.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactions.userId, userId),
|
||||||
|
or(
|
||||||
|
ilike(interactions.title, pattern),
|
||||||
|
ilike(interactions.description, pattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(interactions.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
for (const i of intResults) {
|
||||||
|
results.push({
|
||||||
|
type: 'interaction',
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
subtitle: `${i.type} · ${i.clientFirstName} ${i.clientLastName}`,
|
||||||
|
clientId: i.clientId,
|
||||||
|
clientName: `${i.clientFirstName} ${i.clientLastName}`,
|
||||||
|
matchField: i.title.toLowerCase().includes(q.toLowerCase()) ? 'title' : 'description',
|
||||||
|
createdAt: i.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search notes
|
||||||
|
if (types.includes('notes')) {
|
||||||
|
const noteResults = await db
|
||||||
|
.select({
|
||||||
|
id: clientNotes.id,
|
||||||
|
content: clientNotes.content,
|
||||||
|
clientId: clientNotes.clientId,
|
||||||
|
createdAt: clientNotes.createdAt,
|
||||||
|
clientFirstName: clients.firstName,
|
||||||
|
clientLastName: clients.lastName,
|
||||||
|
})
|
||||||
|
.from(clientNotes)
|
||||||
|
.leftJoin(clients, eq(clientNotes.clientId, clients.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientNotes.userId, userId),
|
||||||
|
ilike(clientNotes.content, pattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(clientNotes.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
for (const n of noteResults) {
|
||||||
|
const preview = n.content.length > 100 ? n.content.slice(0, 100) + '…' : n.content;
|
||||||
|
results.push({
|
||||||
|
type: 'note',
|
||||||
|
id: n.id,
|
||||||
|
title: preview,
|
||||||
|
subtitle: `Note · ${n.clientFirstName} ${n.clientLastName}`,
|
||||||
|
clientId: n.clientId,
|
||||||
|
clientName: `${n.clientFirstName} ${n.clientLastName}`,
|
||||||
|
matchField: 'content',
|
||||||
|
createdAt: n.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort all results by relevance (clients first, then by date)
|
||||||
|
const typeOrder: Record<string, number> = { client: 0, email: 1, event: 2, interaction: 3, note: 4 };
|
||||||
|
results.sort((a, b) => {
|
||||||
|
const typeDiff = (typeOrder[a.type] ?? 5) - (typeOrder[b.type] ?? 5);
|
||||||
|
if (typeDiff !== 0) return typeDiff;
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: results.slice(0, limit),
|
||||||
|
query: q,
|
||||||
|
total: results.length,
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
query: t.Object({
|
||||||
|
q: t.Optional(t.String()),
|
||||||
|
types: t.Optional(t.String()),
|
||||||
|
limit: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
});
|
||||||
248
src/routes/segments.ts
Normal file
248
src/routes/segments.ts
Normal 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
84
src/routes/stats.ts
Normal 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
120
src/routes/tags.ts
Normal 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
147
src/routes/templates.ts
Normal 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' }) }),
|
||||||
|
});
|
||||||
@@ -24,13 +24,47 @@ function getModel(provider: AIProvider = 'openai') {
|
|||||||
throw new Error(`Provider ${provider} not supported`);
|
throw new Error(`Provider ${provider} not supported`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build communication style instructions
|
||||||
|
function buildStyleInstructions(style?: CommunicationStyle | null): string {
|
||||||
|
if (!style) return '';
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (style.tone) {
|
||||||
|
const toneMap: Record<string, string> = {
|
||||||
|
formal: 'Use a formal, professional tone. Maintain distance and respect.',
|
||||||
|
friendly: 'Use a warm, friendly tone. Be personable but still professional.',
|
||||||
|
casual: 'Use a casual, relaxed tone. Be conversational and approachable.',
|
||||||
|
};
|
||||||
|
parts.push(toneMap[style.tone] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.greeting) {
|
||||||
|
parts.push(`Always start emails with this greeting style: "${style.greeting}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.signoff) {
|
||||||
|
parts.push(`Always end emails with this sign-off: "${style.signoff}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.writingSamples && style.writingSamples.length > 0) {
|
||||||
|
parts.push(`Match the writing style of these samples from the advisor:\n${style.writingSamples.map((s, i) => `Sample ${i + 1}: "${s}"`).join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.avoidWords && style.avoidWords.length > 0) {
|
||||||
|
parts.push(`NEVER use these words/phrases: ${style.avoidWords.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? `\n\nCommunication Style Preferences:\n${parts.join('\n')}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
// Email generation prompt
|
// Email generation prompt
|
||||||
const emailPrompt = ChatPromptTemplate.fromMessages([
|
const emailPrompt = ChatPromptTemplate.fromMessages([
|
||||||
['system', `You are a professional wealth advisor writing to a valued client.
|
['system', `You are a professional wealth advisor writing to a valued client.
|
||||||
Maintain a warm but professional tone. Incorporate personal details naturally.
|
Maintain a warm but professional tone. Incorporate personal details naturally.
|
||||||
Keep emails concise (3-4 paragraphs max).
|
Keep emails concise (3-4 paragraphs max).
|
||||||
Do not include subject line - just the body.
|
Do not include subject line - just the body.
|
||||||
Always sign off with the advisor's actual name and details provided. Never use placeholders like [Your Name].`],
|
Always sign off with the advisor's actual name and details provided. Never use placeholders like [Your Name].{styleInstructions}`],
|
||||||
['human', `Advisor Info:
|
['human', `Advisor Info:
|
||||||
- Name: {advisorName}
|
- Name: {advisorName}
|
||||||
- Title: {advisorTitle}
|
- Title: {advisorTitle}
|
||||||
@@ -47,6 +81,14 @@ Purpose of email: {purpose}
|
|||||||
Generate a personalized email that feels genuine, not templated. End with an appropriate signature using the advisor's real name and details.`],
|
Generate a personalized email that feels genuine, not templated. End with an appropriate signature using the advisor's real name and details.`],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export interface CommunicationStyle {
|
||||||
|
tone?: 'formal' | 'friendly' | 'casual';
|
||||||
|
greeting?: string;
|
||||||
|
signoff?: string;
|
||||||
|
writingSamples?: string[];
|
||||||
|
avoidWords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GenerateEmailParams {
|
export interface GenerateEmailParams {
|
||||||
advisorName: string;
|
advisorName: string;
|
||||||
advisorTitle?: string;
|
advisorTitle?: string;
|
||||||
@@ -58,6 +100,7 @@ export interface GenerateEmailParams {
|
|||||||
notes: string;
|
notes: string;
|
||||||
purpose: string;
|
purpose: string;
|
||||||
provider?: AIProvider;
|
provider?: AIProvider;
|
||||||
|
communicationStyle?: CommunicationStyle | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateEmail(params: GenerateEmailParams): Promise<string> {
|
export async function generateEmail(params: GenerateEmailParams): Promise<string> {
|
||||||
@@ -75,6 +118,7 @@ export async function generateEmail(params: GenerateEmailParams): Promise<string
|
|||||||
interests: params.interests.join(', ') || 'not specified',
|
interests: params.interests.join(', ') || 'not specified',
|
||||||
notes: params.notes || 'No recent notes',
|
notes: params.notes || 'No recent notes',
|
||||||
purpose: params.purpose,
|
purpose: params.purpose,
|
||||||
|
styleInstructions: buildStyleInstructions(params.communicationStyle),
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -112,6 +156,121 @@ export async function generateBirthdayMessage(params: GenerateBirthdayMessagePar
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meeting prep generation
|
||||||
|
const meetingPrepPrompt = ChatPromptTemplate.fromMessages([
|
||||||
|
['system', `You are an AI assistant helping a wealth advisor prepare for a client meeting.
|
||||||
|
Analyze the client data and generate useful talking points.
|
||||||
|
Be specific and actionable - reference actual data when available.
|
||||||
|
Output valid JSON with this structure:
|
||||||
|
{{
|
||||||
|
"summary": "Brief 2-3 sentence overview of the client relationship",
|
||||||
|
"suggestedTopics": ["topic1", "topic2", ...],
|
||||||
|
"conversationStarters": ["starter1", "starter2", ...],
|
||||||
|
"followUpItems": ["item1", "item2", ...]
|
||||||
|
}}
|
||||||
|
Only output the JSON, nothing else.`],
|
||||||
|
['human', `Client Profile:
|
||||||
|
- Name: {clientName}
|
||||||
|
- Company: {clientCompany}
|
||||||
|
- Role: {clientRole}
|
||||||
|
- Industry: {clientIndustry}
|
||||||
|
- Stage: {clientStage}
|
||||||
|
- Interests: {clientInterests}
|
||||||
|
- Family: {clientFamily}
|
||||||
|
- Days since last contact: {daysSinceContact}
|
||||||
|
|
||||||
|
Recent Interactions:
|
||||||
|
{interactions}
|
||||||
|
|
||||||
|
Recent Emails:
|
||||||
|
{emails}
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
{notes}
|
||||||
|
|
||||||
|
Upcoming Events:
|
||||||
|
{upcomingEvents}
|
||||||
|
|
||||||
|
Important Dates:
|
||||||
|
{importantDates}
|
||||||
|
|
||||||
|
Generate meeting prep JSON.`],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface MeetingPrepInput {
|
||||||
|
clientSummary: {
|
||||||
|
name: string;
|
||||||
|
company: string;
|
||||||
|
role: string;
|
||||||
|
industry: string;
|
||||||
|
stage: string;
|
||||||
|
interests: string[];
|
||||||
|
family?: { spouse?: string; children?: string[] } | null;
|
||||||
|
daysSinceLastContact: number;
|
||||||
|
};
|
||||||
|
recentInteractions: { type: string; title: string; description?: string | null; date: Date | string }[];
|
||||||
|
recentEmails: { subject?: string | null; status?: string | null; date: Date | string }[];
|
||||||
|
notes: string;
|
||||||
|
upcomingEvents: { type: string; title: string; date: Date | string }[];
|
||||||
|
importantDates: { type: string; date: string; label: string }[];
|
||||||
|
provider?: AIProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeetingPrepOutput {
|
||||||
|
summary: string;
|
||||||
|
suggestedTopics: string[];
|
||||||
|
conversationStarters: string[];
|
||||||
|
followUpItems: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMeetingPrep(params: MeetingPrepInput): Promise<MeetingPrepOutput> {
|
||||||
|
const model = getModel(params.provider);
|
||||||
|
const parser = new StringOutputParser();
|
||||||
|
const chain = meetingPrepPrompt.pipe(model).pipe(parser);
|
||||||
|
|
||||||
|
const familyStr = params.clientSummary.family
|
||||||
|
? `Spouse: ${params.clientSummary.family.spouse || 'N/A'}, Children: ${params.clientSummary.family.children?.join(', ') || 'N/A'}`
|
||||||
|
: 'Not available';
|
||||||
|
|
||||||
|
const response = await chain.invoke({
|
||||||
|
clientName: params.clientSummary.name,
|
||||||
|
clientCompany: params.clientSummary.company,
|
||||||
|
clientRole: params.clientSummary.role,
|
||||||
|
clientIndustry: params.clientSummary.industry,
|
||||||
|
clientStage: params.clientSummary.stage,
|
||||||
|
clientInterests: params.clientSummary.interests.join(', ') || 'Not specified',
|
||||||
|
clientFamily: familyStr,
|
||||||
|
daysSinceContact: String(params.clientSummary.daysSinceLastContact),
|
||||||
|
interactions: params.recentInteractions.length > 0
|
||||||
|
? params.recentInteractions.map(i => `- [${i.type}] ${i.title} (${new Date(i.date).toLocaleDateString()})${i.description ? ': ' + i.description : ''}`).join('\n')
|
||||||
|
: 'No recent interactions',
|
||||||
|
emails: params.recentEmails.length > 0
|
||||||
|
? params.recentEmails.map(e => `- ${e.subject || 'No subject'} (${e.status}, ${new Date(e.date).toLocaleDateString()})`).join('\n')
|
||||||
|
: 'No recent emails',
|
||||||
|
notes: params.notes || 'No notes',
|
||||||
|
upcomingEvents: params.upcomingEvents.length > 0
|
||||||
|
? params.upcomingEvents.map(e => `- ${e.title} (${e.type}, ${new Date(e.date).toLocaleDateString()})`).join('\n')
|
||||||
|
: 'No upcoming events',
|
||||||
|
importantDates: params.importantDates.length > 0
|
||||||
|
? params.importantDates.map(d => `- ${d.label}`).join('\n')
|
||||||
|
: 'No notable upcoming dates',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract JSON from response (handle markdown code blocks)
|
||||||
|
const jsonStr = response.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
|
||||||
|
return JSON.parse(jsonStr);
|
||||||
|
} catch {
|
||||||
|
// Fallback if AI returns non-JSON
|
||||||
|
return {
|
||||||
|
summary: response.slice(0, 500),
|
||||||
|
suggestedTopics: ['Review financial goals', 'Market update', 'Personal check-in'],
|
||||||
|
conversationStarters: ['How have things been going?'],
|
||||||
|
followUpItems: ['Send meeting summary'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Email subject generation
|
// Email subject generation
|
||||||
const subjectPrompt = ChatPromptTemplate.fromMessages([
|
const subjectPrompt = ChatPromptTemplate.fromMessages([
|
||||||
['system', `Generate a professional but warm email subject line for a wealth advisor's email.
|
['system', `Generate a professional but warm email subject line for a wealth advisor's email.
|
||||||
|
|||||||
55
src/services/audit.ts
Normal file
55
src/services/audit.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { db } from '../db';
|
||||||
|
import { auditLogs } from '../db/schema';
|
||||||
|
|
||||||
|
export type AuditAction = 'create' | 'update' | 'delete' | 'view' | 'send' | 'login' | 'logout' | 'password_change';
|
||||||
|
export type AuditEntityType = 'client' | 'email' | 'event' | 'template' | 'segment' | 'user' | 'auth' | 'interaction' | 'note' | 'notification' | 'invite' | 'profile';
|
||||||
|
|
||||||
|
export interface AuditLogParams {
|
||||||
|
userId?: string | null;
|
||||||
|
action: AuditAction;
|
||||||
|
entityType: AuditEntityType;
|
||||||
|
entityId?: string | null;
|
||||||
|
details?: Record<string, unknown> | null;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logAudit(params: AuditLogParams): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.insert(auditLogs).values({
|
||||||
|
userId: params.userId || null,
|
||||||
|
action: params.action,
|
||||||
|
entityType: params.entityType,
|
||||||
|
entityId: params.entityId || null,
|
||||||
|
details: params.details || null,
|
||||||
|
ipAddress: params.ipAddress || null,
|
||||||
|
userAgent: params.userAgent || null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Don't let audit logging failures break the app
|
||||||
|
console.error('[AUDIT] Failed to log:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract IP and User-Agent from request
|
||||||
|
export function getRequestMeta(request: Request): { ipAddress: string | null; userAgent: string | null } {
|
||||||
|
return {
|
||||||
|
ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||||
|
|| request.headers.get('x-real-ip')
|
||||||
|
|| null,
|
||||||
|
userAgent: request.headers.get('user-agent') || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to compute diff between old and new values
|
||||||
|
export function computeDiff(oldVal: Record<string, unknown>, newVal: Record<string, unknown>): Record<string, { from: unknown; to: unknown }> {
|
||||||
|
const diff: Record<string, { from: unknown; to: unknown }> = {};
|
||||||
|
for (const key of Object.keys(newVal)) {
|
||||||
|
const oldV = oldVal[key];
|
||||||
|
const newV = newVal[key];
|
||||||
|
if (JSON.stringify(oldV) !== JSON.stringify(newV)) {
|
||||||
|
diff[key] = { from: oldV, to: newV };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diff;
|
||||||
|
}
|
||||||
304
src/services/jobs.ts
Normal file
304
src/services/jobs.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user