import { and, desc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { clients, githubRepositories, invoiceLineItems, invoices, liveActivitySessions, projectRoots, projects, } from "../db/schema"; import { deleteProjectsCascade } from "../projects/delete"; import { getColorForName } from "../utils/colors"; import { defineCapability } from "./context"; export const listProjects = defineCapability({ name: "listProjects", description: "List all projects in your organization. Use when users ask about their projects or need a project ID to log time.", requiredScope: "projects:read", inputSchema: z.object({}), execute: async (ctx) => { // Scope the client join to the same org so a project never resolves a // client owned by a different tenant (cross-org leak). In local mode // (no organizationId) keep the simple id join. const joinCondition = ctx.organizationId ? and( eq(projects.clientId, clients.id), eq(clients.organizationId, ctx.organizationId), ) : eq(projects.clientId, clients.id); const base = ctx.db .select({ id: projects.id, name: projects.name, color: projects.color, billable: projects.billable, hourlyRate: projects.hourlyRate, clientId: projects.clientId, clientName: clients.name, }) .from(projects) .leftJoin(clients, joinCondition); const rows = ctx.organizationId ? await base.where(eq(projects.organizationId, ctx.organizationId)) : await base; return { count: rows.length, projects: rows.map((p) => ({ id: p.id, name: p.name, billable: p.billable, hourlyRate: p.hourlyRate ? `$${(p.hourlyRate / 100).toFixed(2)}/hr` : null, clientId: p.clientId, clientName: p.clientName, internal: p.clientId == null, })), }; }, }); export const listInvoices = defineCapability({ name: "listInvoices", description: "List invoices in your organization. Use when users ask about billing history or invoice status.", requiredScope: "invoices:read", inputSchema: z.object({ status: z .enum(["draft", "sent", "paid", "overdue", "cancelled"]) .optional(), limit: z.number().optional().default(10), }), execute: async (ctx, { status, limit }) => { const conditions = []; if (status) conditions.push(eq(invoices.status, status)); if (ctx.organizationId) { conditions.push(eq(invoices.organizationId, ctx.organizationId)); } else { conditions.push(eq(invoices.userId, ctx.userId)); } const results = await ctx.db .select({ id: invoices.id, invoiceNumber: invoices.invoiceNumber, status: invoices.status, total: invoices.total, issueDate: invoices.issueDate, periodStart: invoices.periodStart, periodEnd: invoices.periodEnd, }) .from(invoices) .where(and(...conditions)) .orderBy(desc(invoices.createdAt)) .limit(limit || 10); return { count: results.length, invoices: results.map((inv) => ({ number: inv.invoiceNumber, status: inv.status, total: inv.total ? `$${(inv.total / 100).toFixed(2)}` : null, issueDate: inv.issueDate.toISOString(), period: inv.periodStart && inv.periodEnd ? `${new Date(inv.periodStart).toLocaleDateString()} – ${new Date(inv.periodEnd).toLocaleDateString()}` : null, })), }; }, }); export const createProject = defineCapability({ name: "createProject", description: "Create a new project to organize work and bill against. Use when the user asks to add or set up a project. Returns the new project's ID.", requiredScope: "projects:write", requiredPermission: "projects:edit", inputSchema: z.object({ name: z.string().min(1).max(100).describe("Project name"), color: z .string() .optional() .describe( "Hex color for timeline dots (e.g. #4f46e5); auto-picked if omitted", ), }), execute: async (ctx, { name, color }) => { const trimmed = name.trim(); const [project] = await ctx.db .insert(projects) .values({ name: trimmed, color: color ?? getColorForName(trimmed), organizationId: ctx.organizationId ?? null, }) .returning(); return { success: true, projectId: project.id, name: project.name, message: `Created project "${project.name}"`, }; }, }); export const createClient = defineCapability({ name: "createClient", description: "Create a new client to invoice. Use when the user wants to add a billing client. Returns the new client's ID.", requiredScope: "invoices:write", inputSchema: z.object({ name: z.string().min(1).max(100).describe("Client name"), email: z.string().email().optional(), defaultRate: z .number() .positive() .optional() .describe("Default hourly rate in dollars (e.g. 150 for $150/hr)"), currency: z .string() .optional() .describe("ISO currency code; defaults to USD"), }), execute: async (ctx, { name, email, defaultRate, currency }) => { const trimmed = name.trim(); const [client] = await ctx.db .insert(clients) .values({ userId: ctx.userId, organizationId: ctx.organizationId ?? null, name: trimmed, email: email ?? null, defaultRate: defaultRate != null ? Math.round(defaultRate * 100) : null, currency: currency ?? "USD", }) .returning(); return { success: true, clientId: client.id, name: client.name, message: `Created client "${client.name}"`, }; }, }); export const listClients = defineCapability({ name: "listClients", description: "List clients in your organization. Use when users ask about clients or billing contacts.", requiredScope: "invoices:read", inputSchema: z.object({}), execute: async (ctx) => { const conditions = []; if (ctx.organizationId) { conditions.push(eq(clients.organizationId, ctx.organizationId)); } else { conditions.push(eq(clients.userId, ctx.userId)); } const allClients = await ctx.db .select({ id: clients.id, name: clients.name, email: clients.email, rate: clients.defaultRate, currency: clients.currency, }) .from(clients) .where(and(...conditions)); return { count: allClients.length, clients: allClients.map((c) => ({ id: c.id, name: c.name, email: c.email, rate: c.rate ? `$${(c.rate / 100).toFixed(2)}/hr` : null, currency: c.currency, })), }; }, }); export const updateProject = defineCapability({ name: "updateProject", description: "Update an existing project: rename it, associate it with a client (or clear the association to make it internal), set its hourly rate, change its color, or toggle billable. Use when the user wants to change a project's details or link a project to a client. Call listProjects (and listClients for the client ID) first to get the IDs.", requiredScope: "projects:write", requiredPermission: "projects:edit", inputSchema: z.object({ projectId: z.string().describe("Project ID from listProjects"), name: z.string().min(1).max(100).optional().describe("New project name"), clientId: z .string() .nullable() .optional() .describe( "Client ID (from listClients) to associate with this project; pass null to make the project internal (no client)", ), hourlyRate: z .number() .positive() .nullable() .optional() .describe( "Hourly rate in dollars (e.g. 150 for $150/hr); pass null to clear the project-level override", ), color: z .string() .optional() .describe("Hex color for timeline dots (e.g. #4f46e5)"), billable: z .boolean() .optional() .describe("Whether the project is billable"), }), execute: async ( ctx, { projectId, name, clientId, hourlyRate, color, billable }, ) => { const projConds = [eq(projects.id, projectId)]; if (ctx.organizationId) { projConds.push(eq(projects.organizationId, ctx.organizationId)); } const existing = await ctx.db .select({ id: projects.id }) .from(projects) .where(and(...projConds)) .limit(1); if (existing.length === 0) { return { error: `Project ${projectId} not found in your organization` }; } // Associating with a client: verify it belongs to the same tenant so a // project can never point at another org's client. if (clientId != null) { const clientConds = [eq(clients.id, clientId)]; if (ctx.organizationId) { clientConds.push(eq(clients.organizationId, ctx.organizationId)); } else { clientConds.push(eq(clients.userId, ctx.userId)); } const client = await ctx.db .select({ id: clients.id }) .from(clients) .where(and(...clientConds)) .limit(1); if (client.length === 0) { return { error: `Client ${clientId} not found in your organization` }; } } const updates: Partial = {}; if (name !== undefined) updates.name = name.trim(); if (clientId !== undefined) updates.clientId = clientId; if (hourlyRate !== undefined) { updates.hourlyRate = hourlyRate == null ? null : Math.round(hourlyRate * 100); } if (color !== undefined) updates.color = color; if (billable !== undefined) updates.billable = billable; if (Object.keys(updates).length === 0) { return { error: "No fields to update. Provide at least one of: name, clientId, hourlyRate, color, billable.", }; } const [updated] = await ctx.db .update(projects) .set(updates) .where(eq(projects.id, projectId)) .returning(); return { success: true, projectId: updated.id, name: updated.name, clientId: updated.clientId, message: `Updated project "${updated.name}"`, }; }, }); export const updateClient = defineCapability({ name: "updateClient", description: "Update an existing client: change its name, email, default hourly rate, or currency. Use when the user wants to edit a billing contact's details. Call listClients first to get the client ID.", requiredScope: "invoices:write", inputSchema: z.object({ clientId: z.string().describe("Client ID from listClients"), name: z.string().min(1).max(100).optional().describe("New client name"), email: z .string() .email() .nullable() .optional() .describe("Client email; pass null to clear it"), defaultRate: z .number() .positive() .nullable() .optional() .describe( "Default hourly rate in dollars (e.g. 150 for $150/hr); pass null to clear it", ), currency: z.string().optional().describe("ISO currency code (e.g. USD)"), }), execute: async (ctx, { clientId, name, email, defaultRate, currency }) => { const conds = [eq(clients.id, clientId)]; if (ctx.organizationId) { conds.push(eq(clients.organizationId, ctx.organizationId)); } else { conds.push(eq(clients.userId, ctx.userId)); } const existing = await ctx.db .select({ id: clients.id }) .from(clients) .where(and(...conds)) .limit(1); if (existing.length === 0) { return { error: `Client ${clientId} not found in your organization` }; } const updates: Partial = { updatedAt: new Date(), }; if (name !== undefined) updates.name = name.trim(); if (email !== undefined) updates.email = email; if (defaultRate !== undefined) { updates.defaultRate = defaultRate == null ? null : Math.round(defaultRate * 100); } if (currency !== undefined) updates.currency = currency; if (Object.keys(updates).length === 1) { return { error: "No fields to update. Provide at least one of: name, email, defaultRate, currency.", }; } const [updated] = await ctx.db .update(clients) .set(updates) .where(eq(clients.id, clientId)) .returning(); return { success: true, clientId: updated.id, name: updated.name, message: `Updated client "${updated.name}"`, }; }, }); export const mergeProjects = defineCapability({ name: "mergeProjects", description: "Merge one or more projects INTO a target project to clean up redundant/duplicate projects (the same product split across several, or over-granular splits). The source projects' scanned roots — and thus all their work — move to the target, then the source projects are deleted. Optionally rename the kept project to a canonical name. Call listProjects first to get the IDs; confirm with the user before merging.", requiredScope: "projects:write", requiredPermission: "projects:edit", inputSchema: z.object({ targetProjectId: z .string() .describe("The project to KEEP; everything merges into it"), sourceProjectIds: z .array(z.string()) .min(1) .describe( "Project IDs to merge into the target — their roots move to the target and these projects are deleted", ), targetName: z .string() .min(1) .max(100) .optional() .describe("Optionally rename the kept project to this canonical name"), }), execute: async (ctx, { targetProjectId, sourceProjectIds, targetName }) => { const sources = [...new Set(sourceProjectIds)].filter( (id) => id !== targetProjectId, ); if (sources.length === 0) { return { error: "sourceProjectIds must include at least one project other than the target.", }; } // Verify the target and sources all belong to the caller's org. const conds = [inArray(projects.id, [targetProjectId, ...sources])]; if (ctx.organizationId) { conds.push(eq(projects.organizationId, ctx.organizationId)); } const found = await ctx.db .select({ id: projects.id }) .from(projects) .where(and(...conds)); const foundIds = new Set(found.map((p) => p.id)); if (!foundIds.has(targetProjectId)) { return { error: `Target project ${targetProjectId} not found in your organization.`, }; } const validSources = sources.filter((id) => foundIds.has(id)); if (validSources.length === 0) { return { error: "None of the source projects were found in your organization.", }; } // Move preserved source relationships to the target FIRST (so the cascade // delete below doesn't null them), then delete the source projects. const rootUpdate: { projectId: string; manuallyAssigned?: boolean } = { projectId: targetProjectId, }; // On the hub, mark it a human decision so device sync won't overwrite it. if (ctx.organizationId) rootUpdate.manuallyAssigned = true; await ctx.db .update(projectRoots) .set(rootUpdate) .where(inArray(projectRoots.projectId, validSources)); await ctx.db .update(githubRepositories) .set({ projectId: targetProjectId }) .where(inArray(githubRepositories.projectId, validSources)); await ctx.db .update(liveActivitySessions) .set({ projectId: targetProjectId }) .where(inArray(liveActivitySessions.projectId, validSources)); await ctx.db .update(invoiceLineItems) .set({ projectId: targetProjectId }) .where(inArray(invoiceLineItems.projectId, validSources)); await deleteProjectsCascade(ctx.db, validSources); if (targetName) { await ctx.db .update(projects) .set({ name: targetName.trim() }) .where(eq(projects.id, targetProjectId)); } const [target] = await ctx.db .select({ name: projects.name }) .from(projects) .where(eq(projects.id, targetProjectId)); return { success: true, targetProjectId, merged: validSources.length, name: target?.name, message: `Merged ${validSources.length} project(s) into "${target?.name ?? "the target"}".`, }; }, }); export const deleteProject = defineCapability({ name: "deleteProject", description: "Delete a project to UNGROUP it: the project's scanned roots — and all their work — return to the unassigned pool (no timeline events are lost), then the empty project is removed. Use this to get rid of a project entirely, as opposed to mergeProjects which folds it into another project. Call listProjects first to get the ID; confirm with the user before deleting.", requiredScope: "projects:write", requiredPermission: "projects:edit", inputSchema: z.object({ projectId: z .string() .describe("The project to delete — its roots return to unassigned"), }), execute: async (ctx, { projectId }) => { // Verify the project belongs to the caller's org before deleting. const conds = [eq(projects.id, projectId)]; if (ctx.organizationId) { conds.push(eq(projects.organizationId, ctx.organizationId)); } const [found] = await ctx.db .select({ id: projects.id, name: projects.name }) .from(projects) .where(and(...conds)); if (!found) { return { error: `Project ${projectId} not found in your organization.`, }; } // Cascade re-points roots/github/live-sessions/invoice-lines to null // (work becomes unassigned) and deletes the project's mappings + rollups // before removing the project row itself — same safe path mergeProjects // uses, so no timeline events are lost. await deleteProjectsCascade(ctx.db, [projectId]); return { success: true, projectId, name: found.name, message: `Deleted project "${found.name}". Its work returned to the unassigned pool.`, }; }, });