From 0ccfa8d0fc92101719c160f3ffecfd9abbc52c0d Mon Sep 17 00:00:00 2001 From: Hammer Date: Fri, 30 Jan 2026 04:10:42 +0000 Subject: [PATCH] feat: global search, client merge/dedup, data export APIs --- src/index.ts | 6 + src/routes/export.ts | 270 +++++++++++++++++++++++++++++++++++++++++++ src/routes/merge.ts | 259 +++++++++++++++++++++++++++++++++++++++++ src/routes/search.ts | 267 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 802 insertions(+) create mode 100644 src/routes/export.ts create mode 100644 src/routes/merge.ts create mode 100644 src/routes/search.ts diff --git a/src/index.ts b/src/index.ts index 6008379..5a0c3cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,9 @@ 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 { initJobQueue } from './services/jobs'; const app = new Elysia() @@ -80,6 +83,9 @@ const app = new Elysia() .use(tagRoutes) .use(engagementRoutes) .use(statsRoutes) + .use(mergeRoutes) + .use(searchRoutes) + .use(exportRoutes) ) // Error handler diff --git a/src/routes/export.ts b/src/routes/export.ts new file mode 100644 index 0000000..cc595a0 --- /dev/null +++ b/src/routes/export.ts @@ -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'; diff --git a/src/routes/merge.ts b/src/routes/merge.ts new file mode 100644 index 0000000..ab51e20 --- /dev/null +++ b/src/routes/merge.ts @@ -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 = {}; + 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 = { 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(), + }), + }); diff --git a/src/routes/search.ts b/src/routes/search.ts new file mode 100644 index 0000000..1b6e0c4 --- /dev/null +++ b/src/routes/search.ts @@ -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 = { 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()), + }), + });