feat: global search, client merge/dedup, data export APIs
This commit is contained in:
@@ -27,6 +27,9 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { tagRoutes } from './routes/tags';
|
import { tagRoutes } from './routes/tags';
|
||||||
import { engagementRoutes } from './routes/engagement';
|
import { engagementRoutes } from './routes/engagement';
|
||||||
import { statsRoutes } from './routes/stats';
|
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';
|
import { initJobQueue } from './services/jobs';
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
@@ -80,6 +83,9 @@ const app = new Elysia()
|
|||||||
.use(tagRoutes)
|
.use(tagRoutes)
|
||||||
.use(engagementRoutes)
|
.use(engagementRoutes)
|
||||||
.use(statsRoutes)
|
.use(statsRoutes)
|
||||||
|
.use(mergeRoutes)
|
||||||
|
.use(searchRoutes)
|
||||||
|
.use(exportRoutes)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
|
|||||||
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';
|
||||||
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(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
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