Compare commits
7 Commits
bd2a4c017c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 73e9c81a68 | |||
| e0e0b48321 | |||
| 229b9407c2 | |||
| 5c9df803c2 | |||
| 30a535c481 | |||
| ee3cfa263f | |||
| 0ccfa8d0fc |
@@ -33,8 +33,8 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to Dokploy
|
||||
run: |
|
||||
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/application.deploy" \
|
||||
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/compose.deploy" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
|
||||
-d '{"applicationId": "${{ secrets.DOKPLOY_APP_ID }}"}'
|
||||
-d '{"composeId": "${{ secrets.DOKPLOY_COMPOSE_ID }}"}'
|
||||
echo "Deploy triggered on Dokploy"
|
||||
|
||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
# Install dependencies
|
||||
FROM base AS install
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile --production
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Production image
|
||||
FROM base AS release
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Run seed (creates test user if not exists)
|
||||
echo "Running database seed..."
|
||||
bun run db:seed || echo "Seed skipped or failed (may already exist)"
|
||||
echo "Running db:push..."
|
||||
bun run db:push || echo "db:push skipped"
|
||||
|
||||
# Seed disabled — was a one-time DB reset that truncates all tables
|
||||
# echo "Running database seed..."
|
||||
# bun run db:seed || echo "Seed skipped"
|
||||
|
||||
# Start the app
|
||||
echo "Starting API..."
|
||||
exec bun run src/index.ts
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/pg": "^8.16.0",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
"@types/pg": "^8.16.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
@@ -35,6 +34,7 @@
|
||||
"pg-boss": "^12.7.0",
|
||||
"postgres": "^3.4.8",
|
||||
"resend": "^6.8.0",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.3.6",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
// Users table (managed by BetterAuth - uses text IDs)
|
||||
@@ -258,6 +258,52 @@ export const auditLogs = pgTable('audit_logs', {
|
||||
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
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
clients: many(clients),
|
||||
@@ -294,6 +340,49 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
|
||||
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 }) => ({
|
||||
|
||||
163
src/db/seed.ts
163
src/db/seed.ts
@@ -1,156 +1,29 @@
|
||||
import { db } from './index';
|
||||
import { users, accounts, clients } from './schema';
|
||||
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'],
|
||||
},
|
||||
];
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
async function seed() {
|
||||
const testEmail = 'test@test.com';
|
||||
console.log('Clearing all user data...');
|
||||
|
||||
// Check if test user already exists
|
||||
const [existing] = await db.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, testEmail))
|
||||
.limit(1);
|
||||
// Disable FK constraints
|
||||
await db.execute(sql`SET session_replication_role = 'replica'`);
|
||||
|
||||
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) {
|
||||
console.log('✓ Test user already exists');
|
||||
userId = existing.id;
|
||||
} else {
|
||||
// Create test user
|
||||
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');
|
||||
for (const row of tables as any) {
|
||||
const table = (row as any).tablename;
|
||||
if (table.startsWith('__drizzle') || table.startsWith('pgboss')) continue;
|
||||
console.log(` Truncating ${table}...`);
|
||||
await db.execute(sql.raw(`TRUNCATE TABLE "${table}" CASCADE`));
|
||||
}
|
||||
|
||||
// Check if fake clients already exist (check for Sarah Mitchell specifically)
|
||||
const [existingFakeClient] = await db.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.email, 'sarah.mitchell@email.com'))
|
||||
.limit(1);
|
||||
// Re-enable FK constraints
|
||||
await db.execute(sql`SET session_replication_role = 'origin'`);
|
||||
|
||||
if (existingFakeClient) {
|
||||
console.log('✓ Test clients already exist');
|
||||
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`);
|
||||
console.log('Database cleared! No accounts exist.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seed()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error('Seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
seed().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -27,6 +27,12 @@ import { eq } from 'drizzle-orm';
|
||||
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()
|
||||
@@ -80,6 +86,12 @@ const app = new Elysia()
|
||||
.use(tagRoutes)
|
||||
.use(engagementRoutes)
|
||||
.use(statsRoutes)
|
||||
.use(mergeRoutes)
|
||||
.use(searchRoutes)
|
||||
.use(exportRoutes)
|
||||
.use(documentRoutes)
|
||||
.use(goalRoutes)
|
||||
.use(referralRoutes)
|
||||
)
|
||||
|
||||
// Error handler
|
||||
|
||||
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' }) }),
|
||||
});
|
||||
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),
|
||||
};
|
||||
});
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
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,
|
||||
};
|
||||
});
|
||||
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()),
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user