Files
adiuva/src/main/router/index.ts

602 lines
21 KiB
TypeScript

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<TRPCContext>().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<string | null>`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${parentClients.name} ELSE ${clients.name} END`,
subClientName: sql<string | null>`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<string>();
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;