603 lines
21 KiB
TypeScript
603 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(),
|
|
uiContext: 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;
|