- 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
278 lines
8.9 KiB
TypeScript
278 lines
8.9 KiB
TypeScript
import { authMiddleware } from '../middleware/auth';
|
|
import { Elysia, t } from 'elysia';
|
|
import { db } from '../db';
|
|
import { clients, events } from '../db/schema';
|
|
import { eq, and, ilike, or, sql } from 'drizzle-orm';
|
|
import type { User } from '../lib/auth';
|
|
|
|
// Helper to sync birthday/anniversary events for a client
|
|
async function syncClientEvents(userId: string, client: { id: string; firstName: string; birthday: Date | null; anniversary: Date | null }) {
|
|
// Sync birthday event
|
|
if (client.birthday) {
|
|
const [existing] = await db.select()
|
|
.from(events)
|
|
.where(and(eq(events.clientId, client.id), eq(events.type, 'birthday')))
|
|
.limit(1);
|
|
|
|
if (!existing) {
|
|
await db.insert(events).values({
|
|
userId,
|
|
clientId: client.id,
|
|
type: 'birthday',
|
|
title: `${client.firstName}'s Birthday`,
|
|
date: client.birthday,
|
|
recurring: true,
|
|
reminderDays: 7,
|
|
});
|
|
} else {
|
|
// Update date if changed
|
|
await db.update(events)
|
|
.set({ date: client.birthday, title: `${client.firstName}'s Birthday` })
|
|
.where(eq(events.id, existing.id));
|
|
}
|
|
}
|
|
|
|
// Sync anniversary event
|
|
if (client.anniversary) {
|
|
const [existing] = await db.select()
|
|
.from(events)
|
|
.where(and(eq(events.clientId, client.id), eq(events.type, 'anniversary')))
|
|
.limit(1);
|
|
|
|
if (!existing) {
|
|
await db.insert(events).values({
|
|
userId,
|
|
clientId: client.id,
|
|
type: 'anniversary',
|
|
title: `${client.firstName}'s Anniversary`,
|
|
date: client.anniversary,
|
|
recurring: true,
|
|
reminderDays: 7,
|
|
});
|
|
} else {
|
|
await db.update(events)
|
|
.set({ date: client.anniversary, title: `${client.firstName}'s Anniversary` })
|
|
.where(eq(events.id, existing.id));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validation schemas
|
|
const clientSchema = t.Object({
|
|
firstName: t.String({ minLength: 1 }),
|
|
lastName: t.String({ minLength: 1 }),
|
|
email: t.Optional(t.String({ format: 'email' })),
|
|
phone: t.Optional(t.String()),
|
|
street: t.Optional(t.String()),
|
|
city: t.Optional(t.String()),
|
|
state: t.Optional(t.String()),
|
|
zip: t.Optional(t.String()),
|
|
company: t.Optional(t.String()),
|
|
role: t.Optional(t.String()),
|
|
industry: t.Optional(t.String()),
|
|
birthday: t.Optional(t.String()), // ISO date string
|
|
anniversary: t.Optional(t.String()),
|
|
interests: t.Optional(t.Array(t.String())),
|
|
family: t.Optional(t.Object({
|
|
spouse: t.Optional(t.String()),
|
|
children: t.Optional(t.Array(t.String())),
|
|
})),
|
|
notes: t.Optional(t.String()),
|
|
tags: t.Optional(t.Array(t.String())),
|
|
stage: t.Optional(t.String()),
|
|
});
|
|
|
|
const updateClientSchema = t.Partial(clientSchema);
|
|
|
|
export const clientRoutes = new Elysia({ prefix: '/clients' })
|
|
.use(authMiddleware)
|
|
// List clients with optional search and pagination
|
|
.get('/', async ({ query, user }: { query: { search?: string; tag?: string; page?: string; limit?: string }; user: User }) => {
|
|
let baseQuery = db.select().from(clients).where(eq(clients.userId, user.id));
|
|
|
|
if (query.search) {
|
|
const searchTerm = `%${query.search}%`;
|
|
baseQuery = db.select().from(clients).where(
|
|
and(
|
|
eq(clients.userId, user.id),
|
|
or(
|
|
ilike(clients.firstName, searchTerm),
|
|
ilike(clients.lastName, searchTerm),
|
|
ilike(clients.company, searchTerm),
|
|
ilike(clients.email, searchTerm)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
let results = await baseQuery.orderBy(clients.lastName, clients.firstName);
|
|
|
|
// Filter by tag in-memory if needed (JSONB filtering)
|
|
if (query.tag) {
|
|
results = results.filter(c => c.tags?.includes(query.tag!));
|
|
}
|
|
|
|
// Pagination
|
|
const page = Math.max(1, parseInt(query.page || '1', 10) || 1);
|
|
const limit = Math.min(200, Math.max(1, parseInt(query.limit || '0', 10) || 0));
|
|
|
|
// If no limit specified, return all (backwards compatible)
|
|
if (!query.limit) {
|
|
return results;
|
|
}
|
|
|
|
const total = results.length;
|
|
const totalPages = Math.ceil(total / limit);
|
|
const offset = (page - 1) * limit;
|
|
const data = results.slice(offset, offset + limit);
|
|
|
|
return { data, total, page, limit, totalPages };
|
|
}, {
|
|
query: t.Object({
|
|
search: t.Optional(t.String()),
|
|
tag: t.Optional(t.String()),
|
|
page: t.Optional(t.String()),
|
|
limit: t.Optional(t.String()),
|
|
}),
|
|
})
|
|
|
|
// Get single client
|
|
.get('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
|
|
const [client] = await db.select()
|
|
.from(clients)
|
|
.where(and(eq(clients.id, params.id), eq(clients.userId, user.id)))
|
|
.limit(1);
|
|
|
|
if (!client) {
|
|
throw new Error('Client not found');
|
|
}
|
|
|
|
return client;
|
|
}, {
|
|
params: t.Object({
|
|
id: t.String({ format: 'uuid' }),
|
|
}),
|
|
})
|
|
|
|
// Create client
|
|
.post('/', async ({ body, user }: { body: typeof clientSchema.static; user: User }) => {
|
|
const [client] = await db.insert(clients)
|
|
.values({
|
|
userId: user.id,
|
|
firstName: body.firstName,
|
|
lastName: body.lastName,
|
|
email: body.email,
|
|
phone: body.phone,
|
|
street: body.street,
|
|
city: body.city,
|
|
state: body.state,
|
|
zip: body.zip,
|
|
company: body.company,
|
|
role: body.role,
|
|
industry: body.industry,
|
|
birthday: body.birthday ? new Date(body.birthday) : null,
|
|
anniversary: body.anniversary ? new Date(body.anniversary) : null,
|
|
interests: body.interests || [],
|
|
family: body.family,
|
|
notes: body.notes,
|
|
tags: body.tags || [],
|
|
stage: body.stage || 'lead',
|
|
})
|
|
.returning();
|
|
|
|
// Auto-sync birthday/anniversary events
|
|
if (client) {
|
|
await syncClientEvents(user.id, client);
|
|
}
|
|
|
|
return client;
|
|
}, {
|
|
body: clientSchema,
|
|
})
|
|
|
|
// Update client
|
|
.put('/:id', async ({ params, body, user }: { params: { id: string }; body: typeof updateClientSchema.static; user: User }) => {
|
|
// Build update object, only including provided fields
|
|
const updateData: Record<string, unknown> = {
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
if (body.firstName !== undefined) updateData.firstName = body.firstName;
|
|
if (body.lastName !== undefined) updateData.lastName = body.lastName;
|
|
if (body.email !== undefined) updateData.email = body.email;
|
|
if (body.phone !== undefined) updateData.phone = body.phone;
|
|
if (body.street !== undefined) updateData.street = body.street;
|
|
if (body.city !== undefined) updateData.city = body.city;
|
|
if (body.state !== undefined) updateData.state = body.state;
|
|
if (body.zip !== undefined) updateData.zip = body.zip;
|
|
if (body.company !== undefined) updateData.company = body.company;
|
|
if (body.role !== undefined) updateData.role = body.role;
|
|
if (body.industry !== undefined) updateData.industry = body.industry;
|
|
if (body.birthday !== undefined) updateData.birthday = body.birthday ? new Date(body.birthday) : null;
|
|
if (body.anniversary !== undefined) updateData.anniversary = body.anniversary ? new Date(body.anniversary) : null;
|
|
if (body.interests !== undefined) updateData.interests = body.interests;
|
|
if (body.family !== undefined) updateData.family = body.family;
|
|
if (body.notes !== undefined) updateData.notes = body.notes;
|
|
if (body.tags !== undefined) updateData.tags = body.tags;
|
|
if (body.stage !== undefined) updateData.stage = body.stage;
|
|
|
|
const [client] = await db.update(clients)
|
|
.set(updateData)
|
|
.where(and(eq(clients.id, params.id), eq(clients.userId, user.id)))
|
|
.returning();
|
|
|
|
if (!client) {
|
|
throw new Error('Client not found');
|
|
}
|
|
|
|
// Auto-sync birthday/anniversary events if dates changed
|
|
if (body.birthday !== undefined || body.anniversary !== undefined || body.firstName !== undefined) {
|
|
await syncClientEvents(user.id, client);
|
|
}
|
|
|
|
return client;
|
|
}, {
|
|
params: t.Object({
|
|
id: t.String({ format: 'uuid' }),
|
|
}),
|
|
body: updateClientSchema,
|
|
})
|
|
|
|
// Delete client
|
|
.delete('/:id', async ({ params, user }: { params: { id: string }; user: User }) => {
|
|
const [deleted] = await db.delete(clients)
|
|
.where(and(eq(clients.id, params.id), eq(clients.userId, user.id)))
|
|
.returning({ id: clients.id });
|
|
|
|
if (!deleted) {
|
|
throw new Error('Client not found');
|
|
}
|
|
|
|
return { success: true, id: deleted.id };
|
|
}, {
|
|
params: t.Object({
|
|
id: t.String({ format: 'uuid' }),
|
|
}),
|
|
})
|
|
|
|
// Mark client as contacted
|
|
.post('/:id/contacted', async ({ params, user }: { params: { id: string }; user: User }) => {
|
|
const [client] = await db.update(clients)
|
|
.set({
|
|
lastContactedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(clients.id, params.id), eq(clients.userId, user.id)))
|
|
.returning();
|
|
|
|
if (!client) {
|
|
throw new Error('Client not found');
|
|
}
|
|
|
|
return client;
|
|
}, {
|
|
params: t.Object({
|
|
id: t.String({ format: 'uuid' }),
|
|
}),
|
|
});
|