import { initTRPC } from '@trpc/server'; import { z } from 'zod'; import { eq, asc, inArray, and, or, like, sql } from 'drizzle-orm'; import { alias } from 'drizzle-orm/sqlite-core'; import { getDb } from '../db'; import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema'; import { getStore } from '../store'; import { saveTokenAndInit, hasActiveToken } from '../ai/provider'; import { orchestrate, dailyBrief } from '../ai/orchestrator'; import { upsertNoteEmbedding } from '../db/vectordb'; import type { TRPCContext } from '../ipc'; const t = initTRPC.context().create(); const router = t.router; const publicProcedure = t.procedure; // Health router — ping/pong verification const healthRouter = router({ ping: publicProcedure.query(() => 'pong' as const), }); const clientsRouter = router({ list: publicProcedure.query(() => { return getDb().select().from(clients).orderBy(asc(clients.name)).all(); }), create: publicProcedure .input(z.object({ name: z.string(), parentId: z.string().optional(), industry: z.string().optional() })) .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 .input(z.object({ id: z.string(), name: z.string().optional(), industry: z.string().optional() })) .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 .input(z.object({ id: z.string() })) .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 .input(z.object({ id: z.string() })) .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({ list: publicProcedure .input(z.object({ clientId: z.string().optional(), includeArchived: z.boolean().optional() }).optional()) .query(({ input }) => { const where = and( input?.clientId !== undefined ? eq(projects.clientId, input.clientId) : undefined, !input?.includeArchived ? eq(projects.status, 'active') : undefined, ); return getDb().select().from(projects).where(where).orderBy(asc(projects.name)).all(); }), listAll: publicProcedure.query(() => { return getDb().select({ id: projects.id, name: projects.name }).from(projects).orderBy(asc(projects.name)).all(); }), get: publicProcedure .input(z.object({ id: z.string() })) .query(({ input }) => { const result = getDb().select().from(projects).where(eq(projects.id, input.id)).all(); return result[0] ?? null; }), create: publicProcedure .input(z.object({ name: z.string(), clientId: z.string().optional() })) .mutation(({ input }) => { const id = crypto.randomUUID(); const now = Date.now(); getDb().insert(projects).values({ id, name: input.name, clientId: input.clientId ?? null, status: 'active', createdAt: now, }).run(); return { id }; }), update: publicProcedure .input(z.object({ id: z.string(), name: z.string().optional(), clientId: z.string().nullable().optional(), status: z.enum(['active', 'archived']).optional(), aiSummary: z.string().optional(), })) .mutation(({ input }) => { const set: Partial<{ name: string; clientId: string | null; status: 'active' | 'archived'; aiSummary: string | null }> = {}; if (input.name !== undefined) set.name = input.name; if (input.clientId !== undefined) set.clientId = input.clientId; if (input.status !== undefined) set.status = input.status; if (input.aiSummary !== undefined) set.aiSummary = input.aiSummary; if (Object.keys(set).length > 0) { getDb().update(projects).set(set).where(eq(projects.id, input.id)).run(); } return null; }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(({ input }) => { const db = getDb(); // Null out projectId on tasks belonging to this project db.update(tasks).set({ projectId: null }).where(eq(tasks.projectId, input.id)).run(); // Delete the project db.delete(projects).where(eq(projects.id, input.id)).run(); return { success: true as const }; }), archiveByClient: publicProcedure .input(z.object({ clientId: z.string(), status: z.enum(['active', 'archived']) })) .mutation(({ input }) => { getDb().update(projects).set({ status: input.status }).where(eq(projects.clientId, input.clientId)).run(); return { success: true as const }; }), }); const tasksRouter = router({ list: publicProcedure .input(z.object({ projectId: z.string().optional(), status: z.enum(['todo', 'in_progress', 'done']).optional(), search: z.string().optional(), orderBy: z.enum(['dueDate', 'priority', 'createdAt']).optional(), }).optional()) .query(({ input }) => { const db = getDb(); const parentClients = alias(clients, 'parent_clients'); const searchTerm = input?.search?.trim(); const conditions = and( input?.projectId !== undefined ? eq(tasks.projectId, input.projectId) : undefined, input?.status !== undefined ? eq(tasks.status, input.status) : undefined, searchTerm ? or( like(tasks.title, `%${searchTerm}%`), like(tasks.description, `%${searchTerm}%`), ) : undefined, ); const priorityExpr = sql`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; const orderByClauses = input?.orderBy === 'dueDate' ? [asc(tasks.dueDate), asc(priorityExpr)] : input?.orderBy === 'priority' ? [asc(priorityExpr), asc(tasks.dueDate)] : [asc(tasks.dueDate), asc(priorityExpr)]; return db .select({ id: tasks.id, projectId: tasks.projectId, title: tasks.title, description: tasks.description, status: tasks.status, priority: tasks.priority, assignee: tasks.assignee, dueDate: tasks.dueDate, isAiSuggested: tasks.isAiSuggested, isApproved: tasks.isApproved, createdAt: tasks.createdAt, projectName: projects.name, clientName: sql`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${parentClients.name} ELSE ${clients.name} END`, subClientName: sql`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${clients.name} ELSE NULL END`, }) .from(tasks) .leftJoin(projects, eq(tasks.projectId, projects.id)) .leftJoin(clients, eq(projects.clientId, clients.id)) .leftJoin(parentClients, eq(clients.parentId, parentClients.id)) .where(conditions) .orderBy(...orderByClauses) .all(); }), listAssignees: publicProcedure.query(() => { const rows = getDb() .select({ assignee: tasks.assignee }) .from(tasks) .all(); const names = new Set(); for (const row of rows) { if (!row.assignee) continue; try { const parsed = JSON.parse(row.assignee) as unknown; if (Array.isArray(parsed)) { for (const n of parsed) { if (typeof n === 'string' && n) names.add(n); } } else { names.add(row.assignee); } } catch { names.add(row.assignee); } } return [...names].sort(); }), create: publicProcedure .input(z.object({ title: z.string(), description: z.string().optional(), status: z.string().optional(), priority: z.string().optional(), assignees: z.array(z.string()).optional(), dueDate: z.number().optional(), projectId: z.string().optional(), isAiSuggested: z.number().optional(), isApproved: z.number().optional(), })) .mutation(({ input }) => { const id = crypto.randomUUID(); const now = Date.now(); getDb().insert(tasks).values({ id, title: input.title, description: input.description ?? null, status: input.status ?? 'todo', priority: input.priority ?? 'medium', assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null, dueDate: input.dueDate ?? null, projectId: input.projectId ?? null, isAiSuggested: input.isAiSuggested ?? 0, isApproved: input.isApproved ?? 1, createdAt: now, }).run(); return { id }; }), update: publicProcedure .input(z.object({ id: z.string(), title: z.string().optional(), description: z.string().optional(), status: z.string().optional(), priority: z.string().optional(), assignees: z.array(z.string()).optional(), dueDate: z.number().optional(), projectId: z.string().optional(), isApproved: z.number().optional(), })) .mutation(({ input }) => { const set: Partial<{ title: string; description: string | null; status: string; priority: string; assignee: string | null; dueDate: number | null; projectId: string | null; isApproved: number; }> = {}; if (input.title !== undefined) set.title = input.title; if (input.description !== undefined) set.description = input.description; if (input.status !== undefined) set.status = input.status; if (input.priority !== undefined) set.priority = input.priority; if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null; if (input.dueDate !== undefined) set.dueDate = input.dueDate; if (input.isApproved !== undefined) set.isApproved = input.isApproved; if (input.projectId !== undefined) set.projectId = input.projectId; if (Object.keys(set).length > 0) { getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run(); } return null; }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(({ input }) => { getDb().delete(tasks).where(eq(tasks.id, input.id)).run(); return { success: true as const }; }), dueToday: publicProcedure.query(() => { const now = new Date(); const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime(); return getDb() .select({ id: tasks.id, title: tasks.title, priority: tasks.priority, dueDate: tasks.dueDate, projectId: tasks.projectId, }) .from(tasks) .where( and( sql`${tasks.dueDate} IS NOT NULL`, sql`${tasks.dueDate} <= ${endOfToday}`, sql`${tasks.status} != 'done'`, ) ) .orderBy(asc(tasks.dueDate)) .all(); }), }); const checkpointsRouter = router({ list: publicProcedure .input(z.object({ projectId: z.string().optional() }).optional()) .query(({ input }) => { const where = input?.projectId !== undefined ? eq(checkpoints.projectId, input.projectId) : undefined; return getDb().select().from(checkpoints).where(where).orderBy(asc(checkpoints.date)).all(); }), create: publicProcedure .input(z.object({ projectId: z.string(), title: z.string(), date: z.number(), isAiSuggested: z.number().optional(), isApproved: z.number().optional(), })) .mutation(({ input }) => { const id = crypto.randomUUID(); const now = Date.now(); getDb().insert(checkpoints).values({ id, projectId: input.projectId, title: input.title, date: input.date, isAiSuggested: input.isAiSuggested ?? 0, isApproved: input.isApproved ?? 0, createdAt: now, }).run(); return { id }; }), update: publicProcedure .input(z.object({ id: z.string(), title: z.string().optional(), date: z.number().optional(), isApproved: z.number().optional(), })) .mutation(({ input }) => { const set: Partial<{ title: string; date: number; isApproved: number }> = {}; if (input.title !== undefined) set.title = input.title; if (input.date !== undefined) set.date = input.date; if (input.isApproved !== undefined) set.isApproved = input.isApproved; if (Object.keys(set).length > 0) { getDb().update(checkpoints).set(set).where(eq(checkpoints.id, input.id)).run(); } return null; }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(({ input }) => { getDb().delete(checkpoints).where(eq(checkpoints.id, input.id)).run(); return { success: true as const }; }), }); const notesRouter = router({ list: publicProcedure .input(z.object({ projectId: z.string().optional() }).optional()) .query(({ input }) => { const where = input?.projectId !== undefined ? eq(notes.projectId, input.projectId) : undefined; return getDb() .select({ id: notes.id, projectId: notes.projectId, title: notes.title, createdAt: notes.createdAt, updatedAt: notes.updatedAt }) .from(notes) .where(where) .orderBy(asc(notes.createdAt)) .all(); }), get: publicProcedure .input(z.object({ id: z.string() })) .query(({ input }) => { const result = getDb().select().from(notes).where(eq(notes.id, input.id)).all(); return result[0] ?? null; }), create: publicProcedure .input(z.object({ title: z.string(), content: z.string(), projectId: z.string().optional() })) .mutation(async ({ input }) => { const id = crypto.randomUUID(); const now = Date.now(); getDb().insert(notes).values({ id, title: input.title, content: input.content, projectId: input.projectId ?? null, createdAt: now, updatedAt: now, }).run(); // Fire-and-forget: embed the note. Errors are logged, never thrown. upsertNoteEmbedding(id, input.projectId ?? null, `${input.title}\n\n${input.content}`) .catch((err) => console.error('[VectorDB] Failed to embed note on create:', err)); return { id }; }), update: publicProcedure .input(z.object({ id: z.string(), title: z.string().optional(), content: z.string().optional() })) .mutation(async ({ input }) => { const set: Partial<{ title: string; content: string; updatedAt: number }> = {}; if (input.title !== undefined) set.title = input.title; if (input.content !== undefined) set.content = input.content; // Always update updatedAt set.updatedAt = Date.now(); getDb().update(notes).set(set).where(eq(notes.id, input.id)).run(); // Re-embed if searchable text fields changed. // Re-fetch from SQLite so the embedding reflects the full current note // (the update may have changed only one of title or content). if (input.title !== undefined || input.content !== undefined) { const updated = getDb() .select({ id: notes.id, projectId: notes.projectId, title: notes.title, content: notes.content }) .from(notes) .where(eq(notes.id, input.id)) .all()[0]; if (updated) { upsertNoteEmbedding(updated.id, updated.projectId ?? null, `${updated.title}\n\n${updated.content}`) .catch((err) => console.error('[VectorDB] Failed to embed note on update:', err)); } } return null; }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(({ input }) => { getDb().delete(notes).where(eq(notes.id, input.id)).run(); return { success: true as const }; }), }); const taskCommentsRouter = router({ list: publicProcedure .input(z.object({ taskId: z.string() })) .query(({ input }) => { return getDb() .select() .from(taskComments) .where(eq(taskComments.taskId, input.taskId)) .orderBy(asc(taskComments.createdAt)) .all(); }), create: publicProcedure .input(z.object({ taskId: z.string(), author: z.string(), content: z.string() })) .mutation(({ input }) => { const id = crypto.randomUUID(); const now = Date.now(); getDb().insert(taskComments).values({ id, taskId: input.taskId, author: input.author, content: input.content, createdAt: now, }).run(); return { id, taskId: input.taskId, author: input.author, content: input.content, createdAt: now }; }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(({ input }) => { getDb().delete(taskComments).where(eq(taskComments.id, input.id)).run(); return { success: true as const }; }), }); const settingsRouter = router({ getSidebarCollapsed: publicProcedure.query(() => getStore().get('sidebarCollapsed')), setSidebarCollapsed: publicProcedure .input(z.object({ collapsed: z.boolean() })) .mutation(({ input }) => { getStore().set('sidebarCollapsed', input.collapsed); return null; }), getUserName: publicProcedure.query(() => getStore().get('userName')), setUserName: publicProcedure .input(z.object({ name: z.string() })) .mutation(({ input }) => { getStore().set('userName', input.name); return null; }), }); const aiRouter = router({ chat: publicProcedure .input(z.object({ message: z.string(), context: z.object({ type: z.enum(['global', 'project']), projectId: z.string().optional(), }), })) .mutation(async ({ input, ctx }) => { try { return await orchestrate({ message: input.message, context: input.context, sender: ctx.sender, }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; return { response: '', error: msg }; } }), setToken: publicProcedure .input(z.object({ token: z.string() })) .mutation(async ({ input }) => { await saveTokenAndInit(input.token); return { success: true }; }), dailyBrief: publicProcedure .mutation(async ({ ctx }) => { try { return await dailyBrief(ctx.sender); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; return { response: '', error: msg }; } }), hasToken: publicProcedure.query(async () => { return hasActiveToken(); }), }); export const appRouter = router({ health: healthRouter, settings: settingsRouter, clients: clientsRouter, projects: projectsRouter, tasks: tasksRouter, checkpoints: checkpointsRouter, notes: notesRouter, taskComments: taskCommentsRouter, ai: aiRouter, }); export type AppRouter = typeof appRouter;