feat: US-005 — Client tRPC procedures (CRUD)
Implemented full CRUD for the clients router: - clients.list: returns all clients ordered by name - clients.create: inserts with UUID + createdAt timestamp - clients.update: partial update of name and/or industry - clients.delete: guard check — returns error payload if client has sub-clients or projects (does not delete) - clients.deleteWithCascade: BFS recursion to collect all descendant clients, nulls projectId on orphaned tasks, then deletes projects and all collected clients in order Imports added: eq, asc, inArray from drizzle-orm; getDb and schema tables (clients, projects, tasks) from db module. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import { initTRPC } from '@trpc/server';
|
import { initTRPC } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { eq, asc, inArray } from 'drizzle-orm';
|
||||||
|
import { getDb } from '../db';
|
||||||
|
import { clients, projects, tasks } from '../db/schema';
|
||||||
import { getStore } from '../store';
|
import { getStore } from '../store';
|
||||||
|
|
||||||
const t = initTRPC.create();
|
const t = initTRPC.create();
|
||||||
@@ -12,21 +15,87 @@ const healthRouter = router({
|
|||||||
ping: publicProcedure.query(() => 'pong' as const),
|
ping: publicProcedure.query(() => 'pong' as const),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stub routers — full implementations come in later user stories
|
|
||||||
const clientsRouter = router({
|
const clientsRouter = router({
|
||||||
list: publicProcedure.query(() => []),
|
list: publicProcedure.query(() => {
|
||||||
|
return getDb().select().from(clients).orderBy(asc(clients.name)).all();
|
||||||
|
}),
|
||||||
|
|
||||||
create: publicProcedure
|
create: publicProcedure
|
||||||
.input(z.object({ name: z.string(), parentId: z.string().optional(), industry: z.string().optional() }))
|
.input(z.object({ name: z.string(), parentId: z.string().optional(), industry: z.string().optional() }))
|
||||||
.mutation(() => null),
|
.mutation(({ input }) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
getDb().insert(clients).values({
|
||||||
|
id,
|
||||||
|
name: input.name,
|
||||||
|
parentId: input.parentId ?? null,
|
||||||
|
industry: input.industry ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
return { id };
|
||||||
|
}),
|
||||||
|
|
||||||
update: publicProcedure
|
update: publicProcedure
|
||||||
.input(z.object({ id: z.string(), name: z.string().optional(), industry: z.string().optional() }))
|
.input(z.object({ id: z.string(), name: z.string().optional(), industry: z.string().optional() }))
|
||||||
.mutation(() => null),
|
.mutation(({ input }) => {
|
||||||
|
const set: Partial<{ name: string; industry: string | null }> = {};
|
||||||
|
if (input.name !== undefined) set.name = input.name;
|
||||||
|
if (input.industry !== undefined) set.industry = input.industry;
|
||||||
|
if (Object.keys(set).length > 0) {
|
||||||
|
getDb().update(clients).set(set).where(eq(clients.id, input.id)).run();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
|
||||||
delete: publicProcedure
|
delete: publicProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(() => null),
|
.mutation(({ input }) => {
|
||||||
|
const db = getDb();
|
||||||
|
const childClients = db.select({ id: clients.id }).from(clients).where(eq(clients.parentId, input.id)).all();
|
||||||
|
if (childClients.length > 0) {
|
||||||
|
return { error: 'This client has sub-clients. Use cascade delete to remove all.' };
|
||||||
|
}
|
||||||
|
const childProjects = db.select({ id: projects.id }).from(projects).where(eq(projects.clientId, input.id)).all();
|
||||||
|
if (childProjects.length > 0) {
|
||||||
|
return { error: 'This client has projects. Use cascade delete to remove all.' };
|
||||||
|
}
|
||||||
|
db.delete(clients).where(eq(clients.id, input.id)).run();
|
||||||
|
return { success: true as const };
|
||||||
|
}),
|
||||||
|
|
||||||
deleteWithCascade: publicProcedure
|
deleteWithCascade: publicProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(() => null),
|
.mutation(({ input }) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Recursively collect all descendant client IDs (BFS)
|
||||||
|
const allClientIds: string[] = [input.id];
|
||||||
|
const queue: string[] = [input.id];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentId = queue.shift()!;
|
||||||
|
const children = db.select({ id: clients.id }).from(clients).where(eq(clients.parentId, currentId)).all();
|
||||||
|
for (const child of children) {
|
||||||
|
allClientIds.push(child.id);
|
||||||
|
queue.push(child.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all projects belonging to these clients
|
||||||
|
const clientProjects = db.select({ id: projects.id }).from(projects).where(inArray(projects.clientId, allClientIds)).all();
|
||||||
|
const projectIds = clientProjects.map((p) => p.id);
|
||||||
|
|
||||||
|
if (projectIds.length > 0) {
|
||||||
|
// Null out projectId on orphaned tasks
|
||||||
|
db.update(tasks).set({ projectId: null }).where(inArray(tasks.projectId, projectIds)).run();
|
||||||
|
// Delete the projects
|
||||||
|
db.delete(projects).where(inArray(projects.id, projectIds)).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all collected clients
|
||||||
|
db.delete(clients).where(inArray(clients.id, allClientIds)).run();
|
||||||
|
|
||||||
|
return { success: true as const };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectsRouter = router({
|
const projectsRouter = router({
|
||||||
|
|||||||
Reference in New Issue
Block a user