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