ubiquitous-invention/apps/web/server/routers/ai.ts
Randall Stillwell c582d621ce multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:

- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
  property_definitions, templates, forms, markdown_backlog_items,
  cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
  with a hand-augmented data-copy migration that preserves IDs and slug-collision-
  proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
  that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
  tenant-scoped routers (objects, types, properties, templates, forms, search,
  ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
  of the old `workspaceId`, including a workspace-sync layer that rewrites
  /<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
  workspace://{handle}/tree resource to accept either a slug or UUID so existing
  agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
  rename with redirect, owner-only archive).

Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:02:55 -05:00

188 lines
5.6 KiB
TypeScript

import { createOpenAI } from "@ai-sdk/openai";
import { generateText } from "ai";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db as dbInstance } from "@tasks/database";
import { objects } from "@tasks/database/schema";
import { router, workspaceProcedure } from "@/server/trpc";
type Db = typeof dbInstance;
const messageSchema = z.object({
role: z.enum(["user", "assistant"]),
content: z.string(),
});
const chatInputSchema = z.object({
workspace: z.string().min(1),
messages: z.array(messageSchema).min(1),
context: z
.object({
objectId: z.string().uuid().optional(),
})
.optional(),
});
const suggestInputSchema = z.object({
workspace: z.string().min(1),
objectId: z.string().uuid().optional(),
objectType: z.string().optional(),
});
const BASE_SYSTEM = `You are a helpful AI assistant embedded in a project management and collaboration app. Users organize work in workspaces with objects such as projects, tasks, documents, and groups. You help them plan work, clarify requirements, break down tasks, summarize content, and suggest next steps. Be concise, actionable, and friendly. Use markdown when it improves readability (bold, lists, short code snippets).`;
async function fetchObjectSummary(
database: Db,
objectId: string,
workspaceId: string,
): Promise<string | null> {
const row = await database.query.objects.findFirst({
where: and(eq(objects.id, objectId), eq(objects.workspaceId, workspaceId)),
columns: {
id: true,
title: true,
type: true,
status: true,
description: true,
workspaceId: true,
},
});
if (!row) return null;
const bits = [
`Object: ${row.title} (${row.type})`,
row.status ? `Status: ${row.status}` : null,
row.description ? `Description:\n${row.description}` : null,
row.workspaceId ? `Workspace ID: ${row.workspaceId}` : null,
].filter(Boolean);
return bits.join("\n");
}
function mockChatResponse(lastUser: string): string {
return [
"**Demo mode** — set `OPENAI_API_KEY` in your environment to use live AI.",
"",
"Here is a mock reply based on what you sent:",
"",
`> ${lastUser.slice(0, 280)}${lastUser.length > 280 ? "…" : ""}`,
"",
"In the real app, I would help you plan tasks, refine descriptions, and suggest next steps in your workspace.",
].join("\n");
}
async function callOpenAIChat(params: {
system: string;
messages: { role: "user" | "assistant"; content: string }[];
}): Promise<string> {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
const lastUser = [...params.messages].reverse().find((m) => m.role === "user");
return mockChatResponse(lastUser?.content ?? "");
}
try {
const openai = createOpenAI({ apiKey });
const { text } = await generateText({
model: openai("gpt-4o"),
system: params.system,
messages: params.messages,
temperature: 0.7,
maxTokens: 4096,
});
const trimmed = text.trim();
if (!trimmed) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Empty AI response",
});
}
return trimmed;
} catch (e) {
if (e instanceof TRPCError) throw e;
console.error("[ai.chat] generateText failed:", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get AI response",
});
}
}
function suggestionsForContext(input: z.infer<typeof suggestInputSchema>): string[] {
const t = (input.objectType ?? "").toLowerCase();
const base = [
"Create subtasks",
"Set due date",
"Add description",
"Summarize open questions",
"List blockers and next steps",
];
if (t === "task" || t === "project") {
return [
"Break this into subtasks",
"Suggest acceptance criteria",
"Estimate effort",
"Draft a checklist",
...base,
];
}
if (t === "document" || t === "whiteboard") {
return [
"Summarize this document",
"Extract action items",
"Outline key sections",
"Generate task descriptions",
...base,
];
}
return ["Create a project plan", "Summarize this document", "Generate task descriptions", ...base];
}
export const aiRouter = router({
chat: workspaceProcedure.input(chatInputSchema).mutation(async ({ ctx, input }) => {
let system = BASE_SYSTEM;
const ctxParts: string[] = [];
ctxParts.push(`Current workspace: ${ctx.workspace.name} (${ctx.workspace.slug})`);
if (input.context?.objectId) {
const summary = await fetchObjectSummary(
ctx.db,
input.context.objectId,
ctx.workspace.id,
);
if (summary) {
ctxParts.push("The user is focused on this object:\n" + summary);
} else {
ctxParts.push(
`The user referenced object ID ${input.context.objectId}, but it was not found in this workspace.`,
);
}
}
if (ctxParts.length) {
system += "\n\n---\n" + ctxParts.join("\n\n");
}
const text = await callOpenAIChat({
system,
messages: input.messages,
});
return { text };
}),
suggestActions: workspaceProcedure.input(suggestInputSchema).query(async ({ ctx, input }) => {
let objectType = input.objectType;
if (input.objectId && !objectType) {
const row = await ctx.db.query.objects.findFirst({
where: and(
eq(objects.id, input.objectId),
eq(objects.workspaceId, ctx.workspace.id),
),
columns: { type: true },
});
objectType = row?.type;
}
return { actions: suggestionsForContext({ ...input, objectType }) };
}),
});