Files
network-app-api/src/routes/clients.ts
Hammer 7634306832 fix: resolve TypeScript errors for CI pipeline
- 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
2026-01-30 03:27:58 +00:00

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' }),
}),
});