From c582d621cea566eabeccd29721e76a1edaa66570 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 6 May 2026 23:02:55 -0500 Subject: [PATCH] multi-tenancy: promote workspaces to top-level table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 //... links to //... - 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 --- apps/mcp-server/src/lib/resolve-workspace.ts | 31 + .../src/resources/workspace-tree-resource.ts | 42 +- apps/mcp-server/src/tools/create-object.ts | 16 +- apps/mcp-server/src/tools/list-objects.ts | 12 +- apps/mcp-server/src/tools/search-objects.ts | 19 +- .../[workspaceSlug]/[projectId]/page.tsx | 2 +- .../[workspaceSlug]/docs/[docId]/page.tsx | 21 +- .../app/(app)/[workspaceSlug]/docs/page.tsx | 20 +- .../forms/[formId]/edit/page.tsx | 8 +- .../[workspaceSlug]/forms/[formId]/page.tsx | 9 +- .../app/(app)/[workspaceSlug]/forms/page.tsx | 22 +- .../(app)/[workspaceSlug]/planner/page.tsx | 2 +- .../settings/templates/page.tsx | 23 +- .../[workspaceSlug]/settings/types/page.tsx | 4 +- .../settings/workspace/page.tsx | 251 ++ .../app/(app)/[workspaceSlug]/teams/page.tsx | 10 +- .../whiteboards/[whiteboardId]/page.tsx | 4 +- .../[workspaceSlug]/whiteboards/page.tsx | 6 +- apps/web/components/ai/chat-panel.tsx | 22 +- apps/web/components/forms/form-builder.tsx | 21 +- .../components/forms/form-field-config.tsx | 6 +- .../components/forms/form-mapping-picker.tsx | 8 +- apps/web/components/forms/form-renderer.tsx | 10 +- apps/web/components/forms/form-responses.tsx | 7 +- apps/web/components/layout/workspace-sync.tsx | 36 +- .../objects/create-object-dialog.tsx | 35 +- .../web/components/panels/assignee-picker.tsx | 8 +- apps/web/components/panels/object-detail.tsx | 49 +- apps/web/components/search/search-dialog.tsx | 12 +- apps/web/components/sidebar/nav-tree.tsx | 42 +- .../components/sidebar/workspace-switcher.tsx | 13 +- .../components/templates/template-editor.tsx | 7 +- .../components/templates/template-picker.tsx | 36 +- apps/web/components/types/type-editor.tsx | 11 +- apps/web/components/types/type-manager.tsx | 20 +- apps/web/components/types/type-picker.tsx | 8 +- .../web/components/views/board/board-view.tsx | 8 +- apps/web/components/views/form/form-view.tsx | 11 +- apps/web/components/views/list/list-view.tsx | 12 +- .../views/overview/overview-view.tsx | 12 +- .../web/components/views/table/table-view.tsx | 12 +- .../workspaces/create-workspace-dialog.tsx | 202 ++ apps/web/lib/hooks/use-view-data.ts | 6 +- apps/web/server/lib/resolve-workspace.ts | 103 + apps/web/server/lib/workspace-guard.ts | 46 + apps/web/server/routers/ai.ts | 36 +- apps/web/server/routers/favorites.ts | 84 +- apps/web/server/routers/forms.ts | 55 +- apps/web/server/routers/objects.ts | 100 +- apps/web/server/routers/properties.ts | 66 +- apps/web/server/routers/relations.ts | 65 +- apps/web/server/routers/search.ts | 25 +- apps/web/server/routers/templates.ts | 55 +- apps/web/server/routers/types.ts | 52 +- apps/web/server/routers/workspaces.ts | 245 +- apps/web/server/trpc.ts | 38 + .../migrations/0003_damp_green_goblin.sql | 120 + .../migrations/meta/0003_snapshot.json | 2414 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/src/schema/cursor_sync.ts | 4 +- packages/database/src/schema/forms.ts | 3 +- packages/database/src/schema/index.ts | 1 + .../database/src/schema/markdown_backlog.ts | 4 +- packages/database/src/schema/objects.ts | 11 +- packages/database/src/schema/properties.ts | 4 +- packages/database/src/schema/relations.ts | 47 +- packages/database/src/schema/templates.ts | 5 +- packages/database/src/schema/types.ts | 4 +- packages/database/src/schema/workspaces.ts | 36 + 69 files changed, 4228 insertions(+), 518 deletions(-) create mode 100644 apps/mcp-server/src/lib/resolve-workspace.ts create mode 100644 apps/web/app/(app)/[workspaceSlug]/settings/workspace/page.tsx create mode 100644 apps/web/components/workspaces/create-workspace-dialog.tsx create mode 100644 apps/web/server/lib/resolve-workspace.ts create mode 100644 apps/web/server/lib/workspace-guard.ts create mode 100644 packages/database/migrations/0003_damp_green_goblin.sql create mode 100644 packages/database/migrations/meta/0003_snapshot.json create mode 100644 packages/database/src/schema/workspaces.ts diff --git a/apps/mcp-server/src/lib/resolve-workspace.ts b/apps/mcp-server/src/lib/resolve-workspace.ts new file mode 100644 index 0000000..c04e306 --- /dev/null +++ b/apps/mcp-server/src/lib/resolve-workspace.ts @@ -0,0 +1,31 @@ +import { eq } from "../drizzle.js"; +import { db } from "../db.js"; +import { workspaces } from "../schema.js"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Resolves a workspace handle (UUID or slug) to a `{ id, slug, name }` record. + * MCP tools accept either since agents may have hard-coded either form. Throws + * a friendly Error if no row matches so `toolCatch` can surface the message. + */ +export async function resolveWorkspaceHandle( + handle: string, +): Promise<{ id: string; slug: string; name: string }> { + const cleaned = handle.trim(); + if (!cleaned) { + throw new Error("Workspace handle is required"); + } + const cond = UUID_RE.test(cleaned) + ? eq(workspaces.id, cleaned) + : eq(workspaces.slug, cleaned); + const [row] = await db + .select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name }) + .from(workspaces) + .where(cond) + .limit(1); + if (!row) { + throw new Error(`Workspace not found for handle "${handle}"`); + } + return row; +} diff --git a/apps/mcp-server/src/resources/workspace-tree-resource.ts b/apps/mcp-server/src/resources/workspace-tree-resource.ts index b9600d9..2d88bb7 100644 --- a/apps/mcp-server/src/resources/workspace-tree-resource.ts +++ b/apps/mcp-server/src/resources/workspace-tree-resource.ts @@ -2,6 +2,7 @@ import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/sdk/serv import { and, asc, eq, inArray, isNull } from "../drizzle.js"; import { db } from "../db.js"; import { objects } from "../schema.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; const TREE_TYPES = ["project", "group", "document", "whiteboard"] as const; @@ -18,20 +19,44 @@ type TreeNode = { export function registerWorkspaceTreeResource(mcp: McpServer): void { mcp.registerResource( "workspace_tree", - new ResourceTemplate("workspace://{id}/tree", { list: undefined }), + /** + * The `{handle}` segment accepts either the workspace slug (preferred for + * shareable URIs) or the canonical UUID. The resource resolves it to a + * concrete workspace before walking the tree, so agents may use whichever + * form they were given. + */ + new ResourceTemplate("workspace://{handle}/tree", { list: undefined }), { - description: "Hierarchy tree of projects, groups, documents, and whiteboards in a workspace.", + description: + "Hierarchy tree of projects, groups, documents, and whiteboards in a workspace. Accepts the workspace slug or UUID in the URI.", mimeType: "application/json", }, async (uri, variables) => { - const workspaceId = variables.id; - if (!workspaceId) { + const handleVar = Array.isArray(variables.handle) ? variables.handle[0] : variables.handle; + if (!handleVar) { return { contents: [ { uri: uri.toString(), mimeType: "application/json", - text: JSON.stringify({ error: "Missing workspace id" }), + text: JSON.stringify({ error: "Missing workspace handle" }), + }, + ], + }; + } + + let ws: { id: string; slug: string; name: string }; + try { + ws = await resolveWorkspaceHandle(handleVar); + } catch (e) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify({ + error: e instanceof Error ? e.message : "Workspace not found", + }), }, ], }; @@ -44,7 +69,7 @@ export function registerWorkspaceTreeResource(mcp: McpServer): void { .from(objects) .where( and( - eq(objects.workspaceId, workspaceId), + eq(objects.workspaceId, ws.id), inArray(objects.type, [...TREE_TYPES]), isNull(objects.archivedAt), ), @@ -90,7 +115,10 @@ export function registerWorkspaceTreeResource(mcp: McpServer): void { children: buildTree(r.id, 1), })); - const payload = { workspaceId, tree }; + const payload = { + workspace: { id: ws.id, slug: ws.slug, name: ws.name }, + tree, + }; return { contents: [ diff --git a/apps/mcp-server/src/tools/create-object.ts b/apps/mcp-server/src/tools/create-object.ts index e856b18..79704d8 100644 --- a/apps/mcp-server/src/tools/create-object.ts +++ b/apps/mcp-server/src/tools/create-object.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { db } from "../db.js"; import { objects } from "../schema.js"; import { objectTypes } from "../shared-types.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; import { toolCatch, toolErr, toolOk } from "./tool-result.js"; const objectTypeSchema = z.enum(objectTypes as unknown as [string, ...string[]]); @@ -11,7 +12,10 @@ const createObjectInputSchema = z.object({ type: objectTypeSchema, title: z.string().min(1).max(500), parentId: z.string().uuid().nullable().optional(), - workspaceId: z.string().uuid(), + workspace: z + .string() + .min(1) + .describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"), description: z.string().optional(), status: z.string().optional(), icon: z.string().optional(), @@ -22,19 +26,20 @@ export function registerCreateObjectTool(mcp: McpServer): void { "create_object", { description: - "Create a new object (task, project, document, whiteboard, group, workspace).", + "Create a new object (task, project, document, whiteboard, group). Accepts the workspace slug or UUID.", inputSchema: createObjectInputSchema, }, async (args) => { try { const input = createObjectInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); const [created] = await db .insert(objects) .values({ type: input.type, title: input.title, parentId: input.parentId ?? null, - workspaceId: input.workspaceId, + workspaceId: ws.id, description: input.description, status: input.status, icon: input.icon, @@ -44,7 +49,10 @@ export function registerCreateObjectTool(mcp: McpServer): void { if (!created) { return toolErr("Failed to create object"); } - return toolOk(created); + return toolOk({ + workspace: { id: ws.id, slug: ws.slug, name: ws.name }, + object: created, + }); } catch (e) { return toolCatch(e); } diff --git a/apps/mcp-server/src/tools/list-objects.ts b/apps/mcp-server/src/tools/list-objects.ts index 19ca934..93aefc9 100644 --- a/apps/mcp-server/src/tools/list-objects.ts +++ b/apps/mcp-server/src/tools/list-objects.ts @@ -4,12 +4,16 @@ import { z } from "zod"; import { db } from "../db.js"; import { objects } from "../schema.js"; import { objectTypes } from "../shared-types.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; import { toolCatch, toolOk } from "./tool-result.js"; const objectTypeSchema = z.enum(objectTypes as unknown as [string, ...string[]]); const listObjectsInputSchema = z.object({ - workspaceId: z.string().uuid(), + workspace: z + .string() + .min(1) + .describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"), parentId: z.string().uuid().nullable().optional(), type: objectTypeSchema.optional(), status: z.string().optional(), @@ -22,16 +26,17 @@ export function registerListObjectsTool(mcp: McpServer): void { "list_objects", { description: - "List objects in a workspace with optional filters (parent, type, status) and pagination.", + "List objects in a workspace with optional filters (parent, type, status) and pagination. Accepts the workspace slug or UUID.", inputSchema: listObjectsInputSchema, }, async (args) => { try { const input = listObjectsInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); const limit = input.limit ?? 50; const offset = input.offset ?? 0; - const conditions = [eq(objects.workspaceId, input.workspaceId), isNull(objects.archivedAt)]; + const conditions = [eq(objects.workspaceId, ws.id), isNull(objects.archivedAt)]; if (input.parentId === null) { conditions.push(isNull(objects.parentId)); @@ -55,6 +60,7 @@ export function registerListObjectsTool(mcp: McpServer): void { .offset(offset); return toolOk({ + workspace: { id: ws.id, slug: ws.slug, name: ws.name }, objects: rows, count: rows.length, limit, diff --git a/apps/mcp-server/src/tools/search-objects.ts b/apps/mcp-server/src/tools/search-objects.ts index 4afaba8..19800a4 100644 --- a/apps/mcp-server/src/tools/search-objects.ts +++ b/apps/mcp-server/src/tools/search-objects.ts @@ -3,6 +3,7 @@ import { and, asc, eq, ilike, isNull, or } from "../drizzle.js"; import { z } from "zod"; import { db } from "../db.js"; import { objects } from "../schema.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; import { toolCatch, toolOk } from "./tool-result.js"; function escapeLikePattern(q: string): string { @@ -11,7 +12,10 @@ function escapeLikePattern(q: string): string { const searchObjectsInputSchema = z.object({ query: z.string().min(1), - workspaceId: z.string().uuid().optional(), + workspace: z + .string() + .min(1) + .describe("Workspace UUID or slug. Required so search is tenant-scoped."), type: z.string().optional(), status: z.string().optional(), limit: z.number().int().positive().max(500).optional(), @@ -22,23 +26,22 @@ export function registerSearchObjectsTool(mcp: McpServer): void { "search_objects", { description: - "Search objects by text query (case-insensitive match on title and description) with optional filters.", + "Search objects by text query (case-insensitive match on title and description) within a workspace. Accepts the workspace slug or UUID.", inputSchema: searchObjectsInputSchema, }, async (args) => { try { const input = searchObjectsInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); const limit = input.limit ?? 50; const pattern = `%${escapeLikePattern(input.query)}%`; const conditions = [ + eq(objects.workspaceId, ws.id), isNull(objects.archivedAt), or(ilike(objects.title, pattern), ilike(objects.description, pattern)), ]; - if (input.workspaceId) { - conditions.push(eq(objects.workspaceId, input.workspaceId)); - } if (input.type !== undefined) { conditions.push(eq(objects.type, input.type)); } @@ -53,7 +56,11 @@ export function registerSearchObjectsTool(mcp: McpServer): void { .orderBy(asc(objects.sortOrder), asc(objects.id)) .limit(limit); - return toolOk({ objects: rows, count: rows.length }); + return toolOk({ + workspace: { id: ws.id, slug: ws.slug, name: ws.name }, + objects: rows, + count: rows.length, + }); } catch (e) { return toolCatch(e); } diff --git a/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx b/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx index d2a8d70..6929980 100644 --- a/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx @@ -29,7 +29,7 @@ export default function ProjectPage() { return ; case "overview": return ( - + ); case "form": return ; diff --git a/apps/web/app/(app)/[workspaceSlug]/docs/[docId]/page.tsx b/apps/web/app/(app)/[workspaceSlug]/docs/[docId]/page.tsx index 90eabca..652b428 100644 --- a/apps/web/app/(app)/[workspaceSlug]/docs/[docId]/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/docs/[docId]/page.tsx @@ -34,8 +34,8 @@ export default function DocEditorPage() { const docId = typeof params?.docId === "string" ? params.docId : undefined; const docQuery = api.objects.getById.useQuery( - { id: docId! }, - { enabled: Boolean(docId) }, + { id: docId!, workspace: workspaceSlug! }, + { enabled: Boolean(docId) && Boolean(workspaceSlug) }, ); const doc = docQuery.data as @@ -63,36 +63,35 @@ export default function DocEditorPage() { const updateMutation = api.objects.update.useMutation({ onSuccess: async (_row, variables) => { - await utils.objects.getById.invalidate({ id: variables.id }); - if (workspaceSlug) { - void utils.objects.list.invalidate({ workspaceId: workspaceSlug }); - } + if (!workspaceSlug) return; + await utils.objects.getById.invalidate({ id: variables.id, workspace: workspaceSlug }); + void utils.objects.list.invalidate({ workspace: workspaceSlug }); }, }); const scheduleContentSave = React.useCallback( (html: string) => { - if (!docId) return; + if (!docId || !workspaceSlug) return; if (saveContentTimeoutRef.current) { clearTimeout(saveContentTimeoutRef.current); } saveContentTimeoutRef.current = setTimeout(() => { saveContentTimeoutRef.current = null; - updateMutation.mutate({ id: docId, content: html }); + updateMutation.mutate({ workspace: workspaceSlug, id: docId, content: html }); }, 500); }, - [docId, updateMutation], + [docId, workspaceSlug, updateMutation], ); const handleTitleBlur = () => { - if (!docId || !doc) return; + if (!docId || !doc || !workspaceSlug) return; const next = titleDraft.trim(); if (next.length === 0) { setTitleDraft(doc.title); return; } if (next === doc.title) return; - updateMutation.mutate({ id: docId, title: next }); + updateMutation.mutate({ workspace: workspaceSlug, id: docId, title: next }); }; if (!docId || !workspaceSlug) { diff --git a/apps/web/app/(app)/[workspaceSlug]/docs/page.tsx b/apps/web/app/(app)/[workspaceSlug]/docs/page.tsx index a5663eb..924a13d 100644 --- a/apps/web/app/(app)/[workspaceSlug]/docs/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/docs/page.tsx @@ -19,19 +19,19 @@ export default function DocsPage() { const router = useRouter(); const utils = api.useUtils(); - const workspaceId = + const workspaceSlug = typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; const listQuery = api.objects.list.useQuery( - { workspaceId: workspaceId!, parentId: undefined, limit: 200 }, - { enabled: Boolean(workspaceId) }, + { workspace: workspaceSlug!, parentId: undefined, limit: 200 }, + { enabled: Boolean(workspaceSlug) }, ); const createMutation = api.objects.create.useMutation({ onSuccess: (created) => { - if (workspaceId) { - void utils.objects.list.invalidate({ workspaceId }); - router.push(`/${workspaceId}/docs/${created.id}`); + if (workspaceSlug) { + void utils.objects.list.invalidate({ workspace: workspaceSlug }); + router.push(`/${workspaceSlug}/docs/${created.id}`); } }, }); @@ -54,13 +54,13 @@ export default function DocsPage() {

Documents

- + ); } diff --git a/apps/web/app/(app)/[workspaceSlug]/forms/[formId]/page.tsx b/apps/web/app/(app)/[workspaceSlug]/forms/[formId]/page.tsx index 96ba39e..ff0abd2 100644 --- a/apps/web/app/(app)/[workspaceSlug]/forms/[formId]/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/forms/[formId]/page.tsx @@ -21,7 +21,10 @@ export default function FormDetailPage() { typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; const formId = typeof params?.formId === "string" ? params.formId : undefined; - const formQuery = api.forms.getById.useQuery({ id: formId! }, { enabled: Boolean(formId) }); + const formQuery = api.forms.getById.useQuery( + { workspace: workspaceSlug!, id: formId! }, + { enabled: Boolean(formId) && Boolean(workspaceSlug) }, + ); const fields = React.useMemo( () => parseFormFields(formQuery.data?.fields), @@ -100,11 +103,11 @@ export default function FormDetailPage() { - + - + diff --git a/apps/web/app/(app)/[workspaceSlug]/forms/page.tsx b/apps/web/app/(app)/[workspaceSlug]/forms/page.tsx index 33e97fc..f552120 100644 --- a/apps/web/app/(app)/[workspaceSlug]/forms/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/forms/page.tsx @@ -20,21 +20,21 @@ export default function FormsListPage() { const router = useRouter(); const utils = api.useUtils(); - const workspaceId = + const workspaceSlug = typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; const listQuery = api.forms.list.useQuery( - { workspaceId: workspaceId! }, - { enabled: Boolean(workspaceId) }, + { workspace: workspaceSlug! }, + { enabled: Boolean(workspaceSlug) }, ); const createMutation = api.forms.create.useMutation({ onSuccess: (created) => { - if (workspaceId) { - void utils.forms.list.invalidate({ workspaceId }); - router.push(`/${workspaceId}/forms/${created.id}/edit`); + if (workspaceSlug) { + void utils.forms.list.invalidate({ workspace: workspaceSlug }); + router.push(`/${workspaceSlug}/forms/${created.id}/edit`); } }, }); @@ -55,11 +55,11 @@ export default function FormsListPage() { + + + + + + + +
+

Danger zone

+

+ Archiving hides this workspace from the switcher. Data and members + stay intact and an admin can restore it later. +

+
+ +
+
+ + ); +} diff --git a/apps/web/app/(app)/[workspaceSlug]/teams/page.tsx b/apps/web/app/(app)/[workspaceSlug]/teams/page.tsx index 5eeeffe..46268cb 100644 --- a/apps/web/app/(app)/[workspaceSlug]/teams/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/teams/page.tsx @@ -42,12 +42,12 @@ function MemberCardSkeleton({ className }: { className?: string }) { export default function TeamsPage() { const params = useParams(); - const workspaceSlug = params?.workspaceSlug; - const workspaceId = typeof workspaceSlug === "string" ? workspaceSlug : undefined; + const rawSlug = params?.workspaceSlug; + const workspaceSlug = typeof rawSlug === "string" ? rawSlug : undefined; const { data: members, isLoading } = api.workspaces.listMembers.useQuery( - { workspaceId: workspaceId as string }, - { enabled: Boolean(workspaceId) }, + { workspace: workspaceSlug as string }, + { enabled: Boolean(workspaceSlug) }, ); return ( @@ -59,7 +59,7 @@ export default function TeamsPage() { - {!workspaceId ? ( + {!workspaceSlug ? (

Missing workspace.

) : isLoading ? (
diff --git a/apps/web/app/(app)/[workspaceSlug]/whiteboards/[whiteboardId]/page.tsx b/apps/web/app/(app)/[workspaceSlug]/whiteboards/[whiteboardId]/page.tsx index c8c5d76..bf71980 100644 --- a/apps/web/app/(app)/[workspaceSlug]/whiteboards/[whiteboardId]/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/whiteboards/[whiteboardId]/page.tsx @@ -18,8 +18,8 @@ export default function WhiteboardDetailPage() { const whiteboardId = params.whiteboardId as string; const { data: wb } = api.objects.getById.useQuery( - { id: whiteboardId }, - { enabled: Boolean(whiteboardId) }, + { workspace: workspaceSlug, id: whiteboardId }, + { enabled: Boolean(whiteboardId) && Boolean(workspaceSlug) }, ); return ( diff --git a/apps/web/app/(app)/[workspaceSlug]/whiteboards/page.tsx b/apps/web/app/(app)/[workspaceSlug]/whiteboards/page.tsx index 0c55445..eceba6d 100644 --- a/apps/web/app/(app)/[workspaceSlug]/whiteboards/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/whiteboards/page.tsx @@ -11,7 +11,7 @@ export default function WhiteboardsPage() { const workspaceSlug = params.workspaceSlug as string; const { data, isLoading } = api.objects.list.useQuery( - { workspaceId: workspaceSlug, type: "whiteboard", limit: 200 }, + { workspace: workspaceSlug, type: "whiteboard", limit: 200 }, { enabled: Boolean(workspaceSlug) }, ); @@ -39,7 +39,7 @@ export default function WhiteboardsPage() { createMutation.mutate({ type: "whiteboard", title: "Untitled Whiteboard", - workspaceId: workspaceSlug, + workspace: workspaceSlug, }) } disabled={createMutation.isPending} @@ -66,7 +66,7 @@ export default function WhiteboardsPage() { createMutation.mutate({ type: "whiteboard", title: "Untitled Whiteboard", - workspaceId: workspaceSlug, + workspace: workspaceSlug, }) } > diff --git a/apps/web/components/ai/chat-panel.tsx b/apps/web/components/ai/chat-panel.tsx index 5bfd8e7..86d1f70 100644 --- a/apps/web/components/ai/chat-panel.tsx +++ b/apps/web/components/ai/chat-panel.tsx @@ -31,7 +31,7 @@ type AiOutputs = inferRouterOutputs; const aiTrpc = (api as any).ai as { suggestActions: { useQuery: ( - input: { objectId?: string; objectType?: string }, + input: { workspace: string; objectId?: string; objectType?: string }, opts?: { enabled?: boolean }, ) => { data: { actions: string[] } | undefined }; }; @@ -42,8 +42,9 @@ const aiTrpc = (api as any).ai as { onError?: (err: { message: string }) => void; }) => { mutate: (input: { + workspace: string; messages: { role: "user" | "assistant"; content: string }[]; - context?: { workspaceId?: string; objectId?: string }; + context?: { objectId?: string }; }) => void; isPending: boolean; isError: boolean; @@ -86,19 +87,22 @@ export function AIChatPanel() { const bottomRef = React.useRef(null); const textareaRef = React.useRef(null); + const workspaceHandle = workspace?.slug ?? workspace?.id; + const objectQuery = api.objects.getById.useQuery( - { id: objectId! }, - { enabled: !!objectId }, + { id: objectId!, workspace: workspaceHandle! }, + { enabled: !!objectId && Boolean(workspaceHandle) }, ); const objectSummary = objectQuery.data as ObjectSummary | undefined; const suggestQuery = aiTrpc.suggestActions.useQuery( { + workspace: workspaceHandle!, objectId: objectId ?? undefined, objectType: objectSummary?.type, }, - { enabled: true }, + { enabled: Boolean(workspaceHandle) }, ); const chatMutation = aiTrpc.chat.useMutation({ @@ -143,14 +147,18 @@ export function AIChatPanel() { content: m.content, })); + if (!workspaceHandle) { + setSendError("Select a workspace first."); + return; + } chatMutation.mutate({ + workspace: workspaceHandle, messages: payload, context: { - workspaceId: workspace?.id, objectId: objectId ?? undefined, }, }); - }, [input, isLoading, messages, chatMutation, workspace?.id, objectId]); + }, [input, isLoading, messages, chatMutation, workspaceHandle, objectId]); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { diff --git a/apps/web/components/forms/form-builder.tsx b/apps/web/components/forms/form-builder.tsx index f3de0dd..d4085a0 100644 --- a/apps/web/components/forms/form-builder.tsx +++ b/apps/web/components/forms/form-builder.tsx @@ -111,10 +111,10 @@ function createField(type: string): FormField { export function FormBuilder({ formId, - workspaceId, + workspaceHandle, }: { formId: string; - workspaceId: string; + workspaceHandle: string; }) { const utils = api.useUtils(); const [draft, setDraft] = useState(null); @@ -124,13 +124,13 @@ export function FormBuilder({ const skipSaveRef = useRef(false); const formQuery = api.forms.getById.useQuery( - { id: formId }, - { enabled: Boolean(formId) }, + { workspace: workspaceHandle, id: formId }, + { enabled: Boolean(formId) && Boolean(workspaceHandle) }, ); const updateMutation = api.forms.update.useMutation({ onSuccess: () => { - void utils.forms.getById.invalidate({ id: formId }); + void utils.forms.getById.invalidate({ workspace: workspaceHandle, id: formId }); }, }); @@ -138,7 +138,7 @@ export function FormBuilder({ hydratedRef.current = false; setDraft(null); setSelectedFieldId(null); - }, [formId, workspaceId]); + }, [formId, workspaceHandle]); useEffect(() => { if ( @@ -167,8 +167,9 @@ export function FormBuilder({ return; } - const handle = setTimeout(() => { + const t = setTimeout(() => { updateMutation.mutate({ + workspace: workspaceHandle, id: formId, title: draft.title, description: draft.description, @@ -177,8 +178,8 @@ export function FormBuilder({ }); }, 550); - return () => clearTimeout(handle); - }, [draft, formId, updateMutation]); + return () => clearTimeout(t); + }, [draft, formId, workspaceHandle, updateMutation]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), @@ -363,7 +364,7 @@ export function FormBuilder({ key={selectedField.id} field={selectedField} allFields={draft.fields} - workspaceId={workspaceId} + workspaceHandle={workspaceHandle} onChange={(patch) => updateField(selectedField.id, patch)} /> ) : ( diff --git a/apps/web/components/forms/form-field-config.tsx b/apps/web/components/forms/form-field-config.tsx index 8aa7f69..814c98f 100644 --- a/apps/web/components/forms/form-field-config.tsx +++ b/apps/web/components/forms/form-field-config.tsx @@ -44,12 +44,12 @@ const CHOICE_TYPES = new Set([ export function FormFieldConfig({ field, allFields, - workspaceId, + workspaceHandle, onChange, }: { field: FormField; allFields: FormField[]; - workspaceId: string; + workspaceHandle: string; onChange: (patch: Partial) => void; }) { const otherFields = allFields.filter((f) => f.id !== field.id); @@ -274,7 +274,7 @@ export function FormFieldConfig({ Map to task property onChange({ mappedProperty: next })} /> diff --git a/apps/web/components/forms/form-mapping-picker.tsx b/apps/web/components/forms/form-mapping-picker.tsx index 9b0ab79..7bc2a5a 100644 --- a/apps/web/components/forms/form-mapping-picker.tsx +++ b/apps/web/components/forms/form-mapping-picker.tsx @@ -21,21 +21,21 @@ const BUILTIN = [ ] as const; export function FormMappingPicker({ - workspaceId, + workspaceHandle, value, onChange, disabled, className, }: { - workspaceId: string; + workspaceHandle: string; value: string | null; onChange: (next: string | null) => void; disabled?: boolean; className?: string; }) { const { data, isLoading } = api.properties.listDefinitions.useQuery( - { workspaceId }, - { enabled: Boolean(workspaceId) }, + { workspace: workspaceHandle }, + { enabled: Boolean(workspaceHandle) }, ); const definitions = data?.definitions ?? []; diff --git a/apps/web/components/forms/form-renderer.tsx b/apps/web/components/forms/form-renderer.tsx index b1cbac1..ca80b56 100644 --- a/apps/web/components/forms/form-renderer.tsx +++ b/apps/web/components/forms/form-renderer.tsx @@ -135,12 +135,16 @@ function defaultValueForField(field: FormField): unknown { export interface FormRendererProps { formId: string; + workspaceHandle: string; onSubmitted?: (objectId: string) => void; className?: string; } -export function FormRenderer({ formId, onSubmitted, className }: FormRendererProps) { - const formQuery = api.forms.getById.useQuery({ id: formId }, { enabled: Boolean(formId) }); +export function FormRenderer({ formId, workspaceHandle, onSubmitted, className }: FormRendererProps) { + const formQuery = api.forms.getById.useQuery( + { workspace: workspaceHandle, id: formId }, + { enabled: Boolean(formId) && Boolean(workspaceHandle) }, + ); const fields = React.useMemo( () => parseFields(formQuery.data?.fields), @@ -215,7 +219,7 @@ export function FormRenderer({ formId, onSubmitted, className }: FormRendererPro data[f.id] = values[f.id]; } - submitMutation.mutate({ formId, data }); + submitMutation.mutate({ workspace: workspaceHandle, formId, data }); }; if (formQuery.isPending) { diff --git a/apps/web/components/forms/form-responses.tsx b/apps/web/components/forms/form-responses.tsx index be31ba5..f1195c5 100644 --- a/apps/web/components/forms/form-responses.tsx +++ b/apps/web/components/forms/form-responses.tsx @@ -31,17 +31,18 @@ function tableColumns(fields: FormField[]): FormField[] { export interface FormResponsesProps { formId: string; + workspaceHandle: string; fields: FormField[]; className?: string; } -export function FormResponses({ formId, fields, className }: FormResponsesProps) { +export function FormResponses({ formId, workspaceHandle, fields, className }: FormResponsesProps) { const open = usePanelStore((s) => s.open); const cols = React.useMemo(() => tableColumns(fields), [fields]); const listQuery = api.forms.listResponses.useQuery( - { formId }, - { enabled: Boolean(formId) }, + { workspace: workspaceHandle, formId }, + { enabled: Boolean(formId) && Boolean(workspaceHandle) }, ); if (listQuery.isPending) { diff --git a/apps/web/components/layout/workspace-sync.tsx b/apps/web/components/layout/workspace-sync.tsx index 8371958..426a8a8 100644 --- a/apps/web/components/layout/workspace-sync.tsx +++ b/apps/web/components/layout/workspace-sync.tsx @@ -2,10 +2,17 @@ import type { ReactNode } from "react"; import { useEffect } from "react"; +import { useRouter } from "next/navigation"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { api } from "@/lib/trpc"; +/** + * Resolves the URL workspace handle (slug or UUID) to a full workspace record + * and seeds the global workspace store. Other client components read from the + * store and pass `currentWorkspace.slug` as the `workspace` arg to tenant-scoped + * tRPC procedures. + */ export function WorkspaceSync({ workspaceSlug, children, @@ -13,20 +20,41 @@ export function WorkspaceSync({ workspaceSlug: string; children: ReactNode; }) { + const router = useRouter(); const setWorkspace = useWorkspaceStore((s) => s.setWorkspace); - const { data } = api.workspaces.getById.useQuery({ id: workspaceSlug }); + const { data, isError } = api.workspaces.resolve.useQuery({ + handle: workspaceSlug, + }); useEffect(() => { if (data) { setWorkspace({ id: data.id, - slug: data.id, - name: data.title, + slug: data.slug, + name: data.name, }); + // URL backcompat: if the user landed on //... but the workspace + // has a slug, rewrite the URL to the slug form so future links/share + // surfaces are slug-shaped. + if (workspaceSlug !== data.slug && typeof window !== "undefined") { + const next = window.location.pathname.replace( + `/${workspaceSlug}`, + `/${data.slug}`, + ); + router.replace(next + window.location.search); + } } return () => setWorkspace(null); - }, [workspaceSlug, data, setWorkspace]); + }, [workspaceSlug, data, setWorkspace, router]); + + if (isError) { + return ( +
+ Workspace not found. +
+ ); + } return <>{children}; } diff --git a/apps/web/components/objects/create-object-dialog.tsx b/apps/web/components/objects/create-object-dialog.tsx index 84c6d34..04a8b02 100644 --- a/apps/web/components/objects/create-object-dialog.tsx +++ b/apps/web/components/objects/create-object-dialog.tsx @@ -63,7 +63,8 @@ export interface CreateObjectDialogProps { onOpenChange: (open: boolean) => void; defaultType?: string; defaultParentId?: string; - workspaceId?: string; + /** Workspace UUID or slug. Falls back to current workspace from the store. */ + workspaceHandle?: string; } export function CreateObjectDialog({ @@ -71,11 +72,12 @@ export function CreateObjectDialog({ onOpenChange, defaultType, defaultParentId, - workspaceId: workspaceIdProp, + workspaceHandle: workspaceHandleProp, }: CreateObjectDialogProps) { const router = useRouter(); - const storeWorkspaceId = useWorkspaceStore((s) => s.currentWorkspace?.id); - const resolvedWorkspaceId = workspaceIdProp ?? storeWorkspaceId ?? undefined; + const storeWorkspace = useWorkspaceStore((s) => s.currentWorkspace); + const storeHandle = storeWorkspace?.slug ?? storeWorkspace?.id; + const resolvedWorkspace = workspaceHandleProp ?? storeHandle ?? undefined; const utils = api.useUtils(); const titleInputRef = React.useRef(null); @@ -104,11 +106,11 @@ export function CreateObjectDialog({ const spacesQuery = api.objects.list.useQuery( { - workspaceId: resolvedWorkspaceId!, + workspace: resolvedWorkspace!, type: "space", limit: 500, }, - { enabled: Boolean(open && resolvedWorkspaceId && showParentPicker) }, + { enabled: Boolean(open && resolvedWorkspace && showParentPicker) }, ); const spaces = spacesQuery.data?.objects ?? []; @@ -135,9 +137,10 @@ export function CreateObjectDialog({ const createMutation = api.objects.create.useMutation({ onSuccess: async (newObj) => { const t = selectedTemplateRef.current; - if (t && newObj?.id) { + if (t && newObj?.id && resolvedWorkspace) { try { await applyTemplateMutation.mutateAsync({ + workspace: resolvedWorkspace, templateId: t.id, objectId: newObj.id, }); @@ -155,8 +158,8 @@ export function CreateObjectDialog({ onSuccess: (data) => { utils.objects.getTree.invalidate(); const newId = (data as { id?: string }).id; - if (newId && resolvedWorkspaceId) { - router.push(`/${resolvedWorkspaceId}/forms/${newId}/edit`); + if (newId && resolvedWorkspace) { + router.push(`/${resolvedWorkspace}/forms/${newId}/edit`); } onOpenChange(false); }, @@ -169,14 +172,14 @@ export function CreateObjectDialog({ setTitleError(true); return; } - if (!resolvedWorkspaceId) { + if (!resolvedWorkspace) { return; } setTitleError(false); if (objectType === "form") { createFormMutation.mutate({ - workspaceId: resolvedWorkspaceId, + workspace: resolvedWorkspace, title: trimmed, }); return; @@ -188,9 +191,9 @@ export function CreateObjectDialog({ : null; createMutation.mutate({ + workspace: resolvedWorkspace, type: objectType, title: trimmed, - workspaceId: resolvedWorkspaceId, parentId: parentForCreate, ...(objectType === "task" ? { status: taskStatus } : {}), }); @@ -235,7 +238,7 @@ export function CreateObjectDialog({
- {!resolvedWorkspaceId ? ( + {!resolvedWorkspace ? (

Select a workspace to create objects.

@@ -399,7 +402,7 @@ export function CreateObjectDialog({ @@ -408,11 +411,11 @@ export function CreateObjectDialog({ - {showTemplatePicker && resolvedWorkspaceId && ( + {showTemplatePicker && resolvedWorkspace && ( { setSelectedTemplate(template); diff --git a/apps/web/components/panels/assignee-picker.tsx b/apps/web/components/panels/assignee-picker.tsx index 54c2b21..ca62e20 100644 --- a/apps/web/components/panels/assignee-picker.tsx +++ b/apps/web/components/panels/assignee-picker.tsx @@ -43,7 +43,7 @@ export interface AssigneePickerProps { assignedIds: string[]; onToggle: (userId: string) => void; users?: WorkspaceUser[]; - workspaceId?: string; + workspaceHandle?: string; children: React.ReactNode; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; @@ -55,14 +55,14 @@ export function AssigneePicker({ assignedIds, onToggle, users = WORKSPACE_USERS, - workspaceId, + workspaceHandle, children, side = "bottom", align = "start", }: AssigneePickerProps) { const { data: members } = api.workspaces.listMembers.useQuery( - { workspaceId: workspaceId! }, - { enabled: Boolean(workspaceId) }, + { workspace: workspaceHandle! }, + { enabled: Boolean(workspaceHandle) }, ); const resolvedUsers = useMemo(() => { diff --git a/apps/web/components/panels/object-detail.tsx b/apps/web/components/panels/object-detail.tsx index bfda2c2..c142f7c 100644 --- a/apps/web/components/panels/object-detail.tsx +++ b/apps/web/components/panels/object-detail.tsx @@ -122,10 +122,13 @@ function initials(name: string) { .toUpperCase(); } -function useObjectDetailQuery(objectId: string | null) { +function useObjectDetailQuery( + objectId: string | null, + workspaceHandle: string | undefined, +) { return api.objects.getById.useQuery( - { id: objectId as string }, - { enabled: Boolean(objectId) }, + { id: objectId as string, workspace: workspaceHandle as string }, + { enabled: Boolean(objectId) && Boolean(workspaceHandle) }, ); } @@ -155,14 +158,14 @@ export function ObjectDetail() { const utils = api.useUtils(); const workspace = useWorkspaceStore((s) => s.currentWorkspace); - const workspaceId = workspace?.id; + const workspaceHandle = workspace?.slug ?? workspace?.id; const { data: workspaceMembersList } = api.workspaces.listMembers.useQuery( - { workspaceId: workspaceId! }, - { enabled: Boolean(workspaceId) }, + { workspace: workspaceHandle! }, + { enabled: Boolean(workspaceHandle) }, ); - const objectDetailQuery = useObjectDetailQuery(objectId); + const objectDetailQuery = useObjectDetailQuery(objectId, workspaceHandle); const data = objectDetailQuery.data as ObjectDetailData | undefined; const { isPending, isError, error } = objectDetailQuery; @@ -181,7 +184,8 @@ export function ObjectDetail() { const mergeObjectCache = React.useCallback( (id: string, patch: Partial) => { - utils.objects.getById.setData({ id }, (old) => { + if (!workspaceHandle) return; + utils.objects.getById.setData({ id, workspace: workspaceHandle }, (old) => { if (!old) return old; return { ...(old as ObjectDetailData), @@ -189,7 +193,7 @@ export function ObjectDetail() { } as typeof old; }); }, - [utils], + [utils, workspaceHandle], ); const updateObjectMutation = api.objects.update.useMutation({ @@ -197,19 +201,22 @@ export function ObjectDetail() { mergeObjectCache(variables.id, variables as Partial); }, onSuccess: async (_data, variables) => { - await utils.objects.getById.invalidate({ id: variables.id }); + if (!workspaceHandle) return; + await utils.objects.getById.invalidate({ id: variables.id, workspace: workspaceHandle }); }, }); const assignMutation = api.objects.assign.useMutation({ onSuccess: async (_data, variables) => { - await utils.objects.getById.invalidate({ id: variables.objectId }); + if (!workspaceHandle) return; + await utils.objects.getById.invalidate({ id: variables.objectId, workspace: workspaceHandle }); }, }); const setPropertyValueMutation = api.properties.setValue.useMutation({ onSuccess: async (_data, variables) => { - await utils.objects.getById.invalidate({ id: variables.objectId }); + if (!workspaceHandle) return; + await utils.objects.getById.invalidate({ id: variables.objectId, workspace: workspaceHandle }); }, }); @@ -218,11 +225,12 @@ export function ObjectDetail() { row: ObjectDetailData["propertyValues"][number], next: unknown, ) => { - if (!data) return; + if (!data || !workspaceHandle) return; const nextRows = [...data.propertyValues]; nextRows[index] = { ...row, value: next }; mergeObjectCache(data.id, { propertyValues: nextRows }); setPropertyValueMutation.mutate({ + workspace: workspaceHandle, objectId: data.id, propertyDefId: row.propertyDefinition.id, value: next, @@ -259,7 +267,9 @@ export function ObjectDetail() { ]; } mergeObjectCache(data.id, { assignees: nextAssignees }); + if (!workspaceHandle) return; assignMutation.mutate({ + workspace: workspaceHandle, objectId: data.id, userId, action: has ? "remove" : "add", @@ -271,19 +281,20 @@ export function ObjectDetail() { setEditingTitle(false); return; } - updateObjectMutation.mutate({ id: data.id, title: titleDraft.trim() }); + if (!workspaceHandle) return; + updateObjectMutation.mutate({ workspace: workspaceHandle, id: data.id, title: titleDraft.trim() }); setEditingTitle(false); }; const commitDescription = () => { - if (!data) return; + if (!data || !workspaceHandle) return; if (descriptionDraft === (data.description ?? "")) return; - updateObjectMutation.mutate({ id: data.id, description: descriptionDraft }); + updateObjectMutation.mutate({ workspace: workspaceHandle, id: data.id, description: descriptionDraft }); }; const setStatus = (status: StatusValue) => { - if (!data) return; - updateObjectMutation.mutate({ id: data.id, status }); + if (!data || !workspaceHandle) return; + updateObjectMutation.mutate({ workspace: workspaceHandle, id: data.id, status }); }; const onTabChange = (v: string) => { @@ -472,7 +483,7 @@ export function ObjectDetail() { onOpenChange={setAssigneeOpen} assignedIds={assignedIds} onToggle={toggleAssignee} - workspaceId={workspaceId} + workspaceHandle={workspaceHandle} >
@@ -415,7 +421,7 @@ export function TreeNode({ e.stopPropagation()}> - + ) : null} @@ -436,7 +442,7 @@ export function TreeNode({ e.stopPropagation()}> - + @@ -453,7 +459,7 @@ export function TreeNode({ collapsed={collapsed} base={base} pathname={pathname} - workspaceId={workspaceId} + workspaceHandle={workspaceHandle} /> ))} @@ -481,15 +487,15 @@ export function NavTree({ ? `/${slugParam}` : ""; - const workspaceId = workspace?.id ?? ""; + const workspaceHandle = workspace?.slug ?? workspace?.id ?? ""; const favoritesQuery = api.favorites.list.useQuery(undefined, { - enabled: Boolean(workspaceId), + enabled: Boolean(workspaceHandle), }); const favorites = favoritesQuery.data ?? []; const { data, isLoading, isError } = api.objects.getTree.useQuery( - { workspaceId: workspace?.id! }, - { enabled: Boolean(workspace?.id) }, + { workspace: workspaceHandle }, + { enabled: Boolean(workspaceHandle) }, ); const partitioned = useMemo(() => { @@ -608,7 +614,7 @@ export function NavTree({ collapsed={collapsed} base={base} pathname={pathname} - workspaceId={workspaceId} + workspaceHandle={workspaceHandle} /> ))} diff --git a/apps/web/components/sidebar/workspace-switcher.tsx b/apps/web/components/sidebar/workspace-switcher.tsx index e59a8ba..41ed161 100644 --- a/apps/web/components/sidebar/workspace-switcher.tsx +++ b/apps/web/components/sidebar/workspace-switcher.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Building2, Check, Plus } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -17,6 +18,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { CreateWorkspaceDialog } from "@/components/workspaces/create-workspace-dialog"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; @@ -31,6 +33,7 @@ export function WorkspaceSwitcher({ const router = useRouter(); const current = useWorkspaceStore((s) => s.currentWorkspace); const { data: workspaces } = api.workspaces.listForUser.useQuery(); + const [createOpen, setCreateOpen] = useState(false); const displayName = current?.name ?? "Select workspace..."; @@ -77,11 +80,11 @@ export function WorkspaceSwitcher({ key={ws.id} className="gap-2" onClick={() => { - router.push(`/${ws.id}`); + router.push(`/${ws.slug}`); }} > - {ws.title} + {ws.name} {selected ? ( ) : null} @@ -91,8 +94,9 @@ export function WorkspaceSwitcher({ { - // Conductor: wire create workspace flow + onSelect={(e) => { + e.preventDefault(); + setCreateOpen(true); }} > @@ -100,6 +104,7 @@ export function WorkspaceSwitcher({ + ); } diff --git a/apps/web/components/templates/template-editor.tsx b/apps/web/components/templates/template-editor.tsx index 70bc366..4ba5fe4 100644 --- a/apps/web/components/templates/template-editor.tsx +++ b/apps/web/components/templates/template-editor.tsx @@ -194,11 +194,11 @@ function SortablePropertyRow({ export type TemplateEditorProps = { template?: TemplateRow; - workspaceId: string; + workspaceHandle: string; onSave: () => void; }; -export function TemplateEditor({ template, workspaceId, onSave }: TemplateEditorProps) { +export function TemplateEditor({ template, workspaceHandle, onSave }: TemplateEditorProps) { const [name, setName] = React.useState(template?.name ?? ""); const [targetType, setTargetType] = React.useState( template?.targetType && TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number]) @@ -261,13 +261,14 @@ export function TemplateEditor({ template, workspaceId, onSave }: TemplateEditor if (template?.id) { updateMut.mutate({ + workspace: workspaceHandle, id: template.id, name: name.trim(), schema, }); } else { createMut.mutate({ - workspaceId, + workspace: workspaceHandle, name: name.trim(), targetType, schema, diff --git a/apps/web/components/templates/template-picker.tsx b/apps/web/components/templates/template-picker.tsx index f0790aa..5044dae 100644 --- a/apps/web/components/templates/template-picker.tsx +++ b/apps/web/components/templates/template-picker.tsx @@ -111,12 +111,12 @@ export type TemplatePickerProps = { open: boolean; onOpenChange: (open: boolean) => void; objectType: string; - /** List templates for this workspace when no object exists yet (e.g. create dialog). */ - workspaceId?: string; - /** Resolve workspace from an existing object; ignored when `workspaceId` is set. */ + /** Workspace UUID or slug; pass directly when there's no related object. */ + workspaceHandle?: string; + /** Resolve workspace from an existing object; ignored when `workspaceHandle` is set. */ objectId?: string; onSelect: (template: PickerTemplate) => void; - /** Renders the footer; invoked when the user chooses “Create New Template”. */ + /** Renders the footer; invoked when the user chooses "Create New Template". */ onCreateNew?: () => void; }; @@ -124,7 +124,7 @@ export function TemplatePicker({ open, onOpenChange, objectType, - workspaceId: workspaceIdProp, + workspaceHandle: workspaceHandleProp, objectId, onSelect, onCreateNew, @@ -133,22 +133,21 @@ export function TemplatePicker({ const [showAllTypes, setShowAllTypes] = React.useState(false); const objectQuery = api.objects.getById.useQuery( - { id: objectId! }, - { enabled: open && Boolean(objectId) && !workspaceIdProp }, + { id: objectId!, workspace: workspaceHandleProp! }, + { enabled: open && Boolean(objectId) && Boolean(workspaceHandleProp) }, ); - const objWorkspace = (objectQuery.data as unknown as { workspaceId?: string | null } | undefined) - ?.workspaceId; - const workspaceFromObject = - typeof objWorkspace === "string" && objWorkspace.length > 0 ? objWorkspace : undefined; - const resolvedWorkspaceId = workspaceIdProp ?? workspaceFromObject; + // Templates are workspace-scoped, so we always need a handle. When only an + // object id is passed, the caller must also pass workspaceHandle so we can + // resolve templates without leaking cross-tenant data. + const resolvedWorkspaceHandle = workspaceHandleProp; const listQuery = api.templates.list.useQuery( { - workspaceId: resolvedWorkspaceId!, + workspace: resolvedWorkspaceHandle!, targetType: showAllTypes ? undefined : objectType, }, - { enabled: open && Boolean(resolvedWorkspaceId) }, + { enabled: open && Boolean(resolvedWorkspaceHandle) }, ); const merged = React.useMemo(() => { @@ -256,14 +255,11 @@ export function TemplatePicker({
- {listQuery.isPending && resolvedWorkspaceId ? ( + {listQuery.isPending && resolvedWorkspaceHandle ? (

Loading templates…

) : null} - {!resolvedWorkspaceId && objectId && !workspaceIdProp && objectQuery.isPending ? ( -

Loading object…

- ) : null} - {!resolvedWorkspaceId && objectId && !workspaceIdProp && objectQuery.isError ? ( -

Could not load workspace.

+ {!resolvedWorkspaceHandle ? ( +

Select a workspace first.

) : null} {Array.from(grouped.entries()).map(([typeKey, items], gi) => ( diff --git a/apps/web/components/types/type-editor.tsx b/apps/web/components/types/type-editor.tsx index 1dbefe7..2498ec8 100644 --- a/apps/web/components/types/type-editor.tsx +++ b/apps/web/components/types/type-editor.tsx @@ -36,7 +36,7 @@ function slugFromName(name: string): string { } export interface TypeEditorProps { - workspaceId: string; + workspaceHandle: string; existingType?: { id: string; name: string; @@ -53,7 +53,7 @@ const fieldClass = "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"; export function TypeEditor({ - workspaceId, + workspaceHandle, existingType, onSave, onCancel, @@ -88,14 +88,14 @@ export function TypeEditor({ const createMutation = api.types.create.useMutation({ onSuccess: async () => { - await utils.types.list.invalidate({ workspaceId }); + await utils.types.list.invalidate({ workspace: workspaceHandle }); onSave(); }, }); const updateMutation = api.types.update.useMutation({ onSuccess: async () => { - await utils.types.list.invalidate({ workspaceId }); + await utils.types.list.invalidate({ workspace: workspaceHandle }); onSave(); }, }); @@ -112,6 +112,7 @@ export function TypeEditor({ if (existingType) { await updateMutation.mutateAsync({ + workspace: workspaceHandle, id: existingType.id, name, icon: iconTrim, @@ -120,7 +121,7 @@ export function TypeEditor({ }); } else { await createMutation.mutateAsync({ - workspaceId, + workspace: workspaceHandle, name, slug, icon: iconTrim || undefined, diff --git a/apps/web/components/types/type-manager.tsx b/apps/web/components/types/type-manager.tsx index f4e1736..e62846e 100644 --- a/apps/web/components/types/type-manager.tsx +++ b/apps/web/components/types/type-manager.tsx @@ -60,19 +60,19 @@ function TypeIconDisplay({ icon }: { icon: string | null | undefined }) { } export interface TypeManagerProps { - workspaceId: string; + workspaceHandle: string; } -export function TypeManager({ workspaceId }: TypeManagerProps) { +export function TypeManager({ workspaceHandle }: TypeManagerProps) { const utils = api.useUtils(); const listQuery = api.types.list.useQuery( - { workspaceId }, - { enabled: Boolean(workspaceId) }, + { workspace: workspaceHandle }, + { enabled: Boolean(workspaceHandle) }, ); const deleteMutation = api.types.delete.useMutation({ onSuccess: async () => { - await utils.types.list.invalidate({ workspaceId }); + await utils.types.list.invalidate({ workspace: workspaceHandle }); }, }); @@ -109,16 +109,16 @@ export function TypeManager({ workspaceId }: TypeManagerProps) { } function handleDelete(row: (typeof customTypes)[number]) { - const ok = window.confirm(`Delete type “${row.name}”? This cannot be undone.`); + const ok = window.confirm(`Delete type "${row.name}"? This cannot be undone.`); if (!ok) return; - deleteMutation.mutate({ id: row.id }); + deleteMutation.mutate({ workspace: workspaceHandle, id: row.id }); } return (

Object Types

-
@@ -248,10 +248,10 @@ export function TypeManager({ workspaceId }: TypeManagerProps) {
- {workspaceId ? ( + {workspaceHandle ? ( setDialogOpen(false)} onCancel={() => setDialogOpen(false)} diff --git a/apps/web/components/types/type-picker.tsx b/apps/web/components/types/type-picker.tsx index dbdb04c..e2ffe28 100644 --- a/apps/web/components/types/type-picker.tsx +++ b/apps/web/components/types/type-picker.tsx @@ -30,7 +30,7 @@ const BUILTIN_OPTIONS = [ ] as const; export interface TypePickerProps { - workspaceId?: string; + workspaceHandle?: string; value: string; onChange: (type: string) => void; } @@ -55,10 +55,10 @@ function TypeOptionIcon({ return null; } -export function TypePicker({ workspaceId, value, onChange }: TypePickerProps) { +export function TypePicker({ workspaceHandle, value, onChange }: TypePickerProps) { const listQuery = api.types.list.useQuery( - { workspaceId: workspaceId! }, - { enabled: Boolean(workspaceId) }, + { workspace: workspaceHandle! }, + { enabled: Boolean(workspaceHandle) }, ); const customTypes = listQuery.data ?? []; diff --git a/apps/web/components/views/board/board-view.tsx b/apps/web/components/views/board/board-view.tsx index f6735c7..8f88c7b 100644 --- a/apps/web/components/views/board/board-view.tsx +++ b/apps/web/components/views/board/board-view.tsx @@ -112,7 +112,7 @@ export interface BoardViewProps { export function BoardView({ config, className }: BoardViewProps) { const params = useParams(); - const workspaceId = + const workspaceHandle = typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; const parentId = typeof params?.projectId === "string" ? params.projectId : undefined; @@ -127,7 +127,7 @@ export function BoardView({ config, className }: BoardViewProps) { const { grouped, isLoading, total } = useViewData( effectiveConfig, - workspaceId, + workspaceHandle, parentId, ); const groupField = effectiveConfig.groupBy ?? "status"; @@ -333,11 +333,11 @@ export function BoardView({ config, className }: BoardViewProps) { }, onSubmit: () => { const t = newTitle.trim(); - if (!t || !workspaceId || createObject.isPending) return; + if (!t || !workspaceHandle || createObject.isPending) return; createObject.mutate({ type: "task", title: t, - workspaceId, + workspace: workspaceHandle, parentId: parentId ?? undefined, ...(groupField === "status" ? { status: columnId } : {}), }); diff --git a/apps/web/components/views/form/form-view.tsx b/apps/web/components/views/form/form-view.tsx index a596c2b..0f764e4 100644 --- a/apps/web/components/views/form/form-view.tsx +++ b/apps/web/components/views/form/form-view.tsx @@ -18,18 +18,19 @@ export interface FormViewProps { export function FormView({ config, className }: FormViewProps) { void config; - const workspaceId = useWorkspaceStore((s) => s.currentWorkspace?.id); + const workspace = useWorkspaceStore((s) => s.currentWorkspace); + const workspaceHandle = workspace?.slug ?? workspace?.id; const listQuery = api.forms.list.useQuery( - { workspaceId: workspaceId! }, - { enabled: Boolean(workspaceId) }, + { workspace: workspaceHandle! }, + { enabled: Boolean(workspaceHandle) }, ); const [selectedId, setSelectedId] = React.useState(null); const forms = listQuery.data?.forms ?? []; - if (!workspaceId) { + if (!workspaceHandle) { return (
Select a workspace to use forms. @@ -93,7 +94,7 @@ export function FormView({ config, className }: FormViewProps) {
{selectedId ? ( - + ) : (

Pick a form above to fill it out in this view. diff --git a/apps/web/components/views/list/list-view.tsx b/apps/web/components/views/list/list-view.tsx index eaa47cf..b3d9080 100644 --- a/apps/web/components/views/list/list-view.tsx +++ b/apps/web/components/views/list/list-view.tsx @@ -206,7 +206,7 @@ export interface ListViewProps { export function ListView({ config }: ListViewProps) { const params = useParams(); - const workspaceId = + const workspaceHandle = typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; const parentId = typeof params?.projectId === "string" ? params.projectId : undefined; @@ -223,7 +223,7 @@ export function ListView({ config }: ListViewProps) { const { items, isLoading, total } = useViewData( effectiveConfig, - workspaceId, + workspaceHandle, parentId, ); @@ -398,12 +398,12 @@ export function ListView({ config }: ListViewProps) { value={newTitle} onChange={(e) => setNewTitle(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && newTitle.trim() && workspaceId) { + if (e.key === "Enter" && newTitle.trim() && workspaceHandle) { e.preventDefault(); createObject.mutate({ type: "task", title: newTitle.trim(), - workspaceId, + workspace: workspaceHandle, parentId: parentId ?? undefined, }); } @@ -414,11 +414,11 @@ export function ListView({ config }: ListViewProps) { }} onBlur={() => { if (createObject.isPending) return; - if (newTitle.trim() && workspaceId) { + if (newTitle.trim() && workspaceHandle) { createObject.mutate({ type: "task", title: newTitle.trim(), - workspaceId, + workspace: workspaceHandle, parentId: parentId ?? undefined, }); } else { diff --git a/apps/web/components/views/overview/overview-view.tsx b/apps/web/components/views/overview/overview-view.tsx index 8c265a6..b978378 100644 --- a/apps/web/components/views/overview/overview-view.tsx +++ b/apps/web/components/views/overview/overview-view.tsx @@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"; import { api } from "@/lib/trpc"; export interface OverviewViewProps { - workspaceId?: string; + workspaceHandle?: string; spaceId?: string; } @@ -16,19 +16,19 @@ function formatUpdatedAt(value: Date | string): string { return date.toLocaleString(); } -export function OverviewView({ workspaceId, spaceId }: OverviewViewProps) { +export function OverviewView({ workspaceHandle, spaceId }: OverviewViewProps) { const spaceQuery = api.objects.getById.useQuery( - { id: spaceId! }, - { enabled: Boolean(spaceId) }, + { workspace: workspaceHandle!, id: spaceId! }, + { enabled: Boolean(spaceId) && Boolean(workspaceHandle) }, ); const childrenQuery = api.objects.list.useQuery( { - workspaceId: workspaceId!, + workspace: workspaceHandle!, parentId: spaceId ?? undefined, limit: 200, }, - { enabled: Boolean(workspaceId) }, + { enabled: Boolean(workspaceHandle) }, ); const statusCounts = useMemo(() => { diff --git a/apps/web/components/views/table/table-view.tsx b/apps/web/components/views/table/table-view.tsx index 8a38ff1..801e5b9 100644 --- a/apps/web/components/views/table/table-view.tsx +++ b/apps/web/components/views/table/table-view.tsx @@ -347,7 +347,7 @@ export interface TableViewProps { export function TableView({ config }: TableViewProps) { const params = useParams(); - const workspaceId = + const workspaceHandle = typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; const parentId = typeof params?.projectId === "string" ? params.projectId : undefined; @@ -365,7 +365,7 @@ export function TableView({ config }: TableViewProps) { const { items, isLoading, total } = useViewData( effectiveConfig, - workspaceId, + workspaceHandle, parentId, ); @@ -813,12 +813,12 @@ export function TableView({ config }: TableViewProps) { value={newTitle} onChange={(e) => setNewTitle(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && newTitle.trim() && workspaceId) { + if (e.key === "Enter" && newTitle.trim() && workspaceHandle) { e.preventDefault(); createObject.mutate({ type: "task", title: newTitle.trim(), - workspaceId, + workspace: workspaceHandle, parentId: parentId ?? undefined, }); } @@ -829,11 +829,11 @@ export function TableView({ config }: TableViewProps) { }} onBlur={() => { if (createObject.isPending) return; - if (newTitle.trim() && workspaceId) { + if (newTitle.trim() && workspaceHandle) { createObject.mutate({ type: "task", title: newTitle.trim(), - workspaceId, + workspace: workspaceHandle, parentId: parentId ?? undefined, }); } else { diff --git a/apps/web/components/workspaces/create-workspace-dialog.tsx b/apps/web/components/workspaces/create-workspace-dialog.tsx new file mode 100644 index 0000000..d597965 --- /dev/null +++ b/apps/web/components/workspaces/create-workspace-dialog.tsx @@ -0,0 +1,202 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { useRouter } from "next/navigation"; +import { Loader2, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/trpc"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { cn } from "@/lib/utils"; + +const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +function makeSlug(name: string): string { + return ( + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "" + ); +} + +export function CreateWorkspaceDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (next: boolean) => void; +}) { + const router = useRouter(); + const utils = api.useUtils(); + const setWorkspace = useWorkspaceStore((s) => s.setWorkspace); + + const [name, setName] = React.useState(""); + const [slug, setSlug] = React.useState(""); + const [slugTouched, setSlugTouched] = React.useState(false); + const [error, setError] = React.useState(null); + + const createMut = api.workspaces.create.useMutation({ + onSuccess: async (ws) => { + await utils.workspaces.listForUser.invalidate(); + setWorkspace({ id: ws.id, slug: ws.slug, name: ws.name }); + onOpenChange(false); + router.push(`/${ws.slug}`); + }, + onError: (e) => setError(e.message ?? "Failed to create workspace"), + }); + + React.useEffect(() => { + if (open) { + setName(""); + setSlug(""); + setSlugTouched(false); + setError(null); + } + }, [open]); + + const previewSlug = slugTouched ? slug : makeSlug(name); + const slugInvalid = slugTouched && slug.length > 0 && !SLUG_RE.test(slug); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + if (createMut.isPending) return; + setError(null); + + const trimmed = name.trim(); + if (!trimmed) { + setError("Workspace name is required"); + return; + } + if (slugTouched && slug && !SLUG_RE.test(slug)) { + setError("Slug must be lowercase letters, digits, or hyphens"); + return; + } + createMut.mutate({ + name: trimmed, + ...(slugTouched && slug ? { slug } : {}), + }); + }; + + return ( + + + + +

+
+ + Create workspace + + + Workspaces isolate projects, tasks, and team members. You can + rename or change the slug later. + +
+ +
+ + +
+ + setName(e.target.value)} + autoFocus + /> +
+ +
+ +
+ / + { + setSlug(e.target.value); + setSlugTouched(true); + }} + onFocus={() => { + if (!slugTouched) { + setSlug(previewSlug); + setSlugTouched(true); + } + }} + /> +
+

+ {slugInvalid + ? "Use lowercase letters, digits, or hyphens (no leading/trailing dash)." + : "Used in URLs (e.g. /acme/projects). Auto-generated from name."} +

+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+ + + + + ); +} diff --git a/apps/web/lib/hooks/use-view-data.ts b/apps/web/lib/hooks/use-view-data.ts index 651d6f2..8809dba 100644 --- a/apps/web/lib/hooks/use-view-data.ts +++ b/apps/web/lib/hooks/use-view-data.ts @@ -82,12 +82,12 @@ function applyGroupBy(objects: ViewObject[], groupBy: string | null): Record { diff --git a/apps/web/server/lib/resolve-workspace.ts b/apps/web/server/lib/resolve-workspace.ts new file mode 100644 index 0000000..02022f1 --- /dev/null +++ b/apps/web/server/lib/resolve-workspace.ts @@ -0,0 +1,103 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq, or } from "drizzle-orm"; +import { workspaces, workspaceMembers } from "@tasks/database/schema"; +import { db as defaultDb } from "@tasks/database"; + +/** + * Cheap UUID v4-ish detector. We only need to differentiate "this looks like a + * UUID" from "this looks like a slug" so the resolver can pick the right column. + */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export type WorkspaceContext = { + id: string; + slug: string; + name: string; + ownerUserId: string; + /** Caller's role inside the workspace, or "owner" if they own it directly. */ + role: string; +}; + +/** + * Resolve a workspace handle (UUID or slug) to a full workspace record AND + * authorize the caller against it. Throws NOT_FOUND if the handle doesn't + * resolve, FORBIDDEN if the user isn't a member or owner. + * + * Used by the `workspaceProcedure` middleware and by Server Components at the + * `app/(app)/[workspaceSlug]/...` layout boundary. + */ +export async function resolveWorkspace(args: { + handle: string; + userId: string; + db?: typeof defaultDb; +}): Promise { + const db = args.db ?? defaultDb; + const handle = args.handle.trim(); + if (!handle) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace handle required" }); + } + + const lookupCondition = UUID_RE.test(handle) + ? eq(workspaces.id, handle) + : eq(workspaces.slug, handle); + + const [row] = await db + .select({ + id: workspaces.id, + slug: workspaces.slug, + name: workspaces.name, + ownerUserId: workspaces.ownerUserId, + memberRole: workspaceMembers.role, + }) + .from(workspaces) + .leftJoin( + workspaceMembers, + and( + eq(workspaceMembers.workspaceId, workspaces.id), + eq(workspaceMembers.userId, args.userId), + ), + ) + .where(lookupCondition) + .limit(1); + + if (!row) { + throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found" }); + } + + const isOwner = row.ownerUserId === args.userId; + if (!isOwner && !row.memberRole) { + throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this workspace" }); + } + + return { + id: row.id, + slug: row.slug, + name: row.name, + ownerUserId: row.ownerUserId, + role: isOwner ? "owner" : (row.memberRole ?? "member"), + }; +} + +/** + * Look up a workspace by either UUID or slug WITHOUT authorizing the caller. + * Used for the public form-fill flow and for routes that explicitly want to + * peek at workspace existence (e.g. URL backcompat redirects). + */ +export async function findWorkspaceByHandle( + handle: string, + db = defaultDb, +): Promise<{ id: string; slug: string; name: string } | null> { + const cleaned = handle.trim(); + if (!cleaned) return null; + const cond = UUID_RE.test(cleaned) + ? eq(workspaces.id, cleaned) + : eq(workspaces.slug, cleaned); + const [row] = await db + .select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name }) + .from(workspaces) + .where(cond) + .limit(1); + return row ?? null; +} + +export { UUID_RE as WORKSPACE_HANDLE_UUID_RE }; diff --git a/apps/web/server/lib/workspace-guard.ts b/apps/web/server/lib/workspace-guard.ts new file mode 100644 index 0000000..a69d2fe --- /dev/null +++ b/apps/web/server/lib/workspace-guard.ts @@ -0,0 +1,46 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { + objects, + forms, + propertyDefinitions, + templates, + objectTypeDefs, +} from "@tasks/database/schema"; +import type { db as defaultDb } from "@tasks/database"; + +type Db = typeof defaultDb; + +/** + * Generic "this row belongs to this workspace" guard used by tenant-scoped + * routers when a mutation targets a specific row by id. Throws NOT_FOUND if the + * row either doesn't exist or lives in a different workspace, so callers can't + * use the error code to probe IDs across tenants. + */ +export async function assertRowInWorkspace< + T extends { id: typeof objects.id; workspaceId: typeof objects.workspaceId }, +>(args: { + db: Db; + table: T; + rowId: string; + workspaceId: string; + notFoundMessage?: string; +}): Promise { + const [row] = await args.db + .select({ id: args.table.id }) + .from(args.table as any) + .where(and(eq(args.table.id, args.rowId), eq(args.table.workspaceId, args.workspaceId))) + .limit(1); + if (!row) { + throw new TRPCError({ + code: "NOT_FOUND", + message: args.notFoundMessage ?? "Resource not found", + }); + } +} + +export const tableForms = forms; +export const tablePropertyDefs = propertyDefinitions; +export const tableTemplates = templates; +export const tableObjectTypeDefs = objectTypeDefs; +export const tableObjects = objects; diff --git a/apps/web/server/routers/ai.ts b/apps/web/server/routers/ai.ts index 8364cba..74c2b34 100644 --- a/apps/web/server/routers/ai.ts +++ b/apps/web/server/routers/ai.ts @@ -1,11 +1,11 @@ import { createOpenAI } from "@ai-sdk/openai"; import { generateText } from "ai"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +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, protectedProcedure } from "@/server/trpc"; +import { router, workspaceProcedure } from "@/server/trpc"; type Db = typeof dbInstance; @@ -15,25 +15,30 @@ const messageSchema = z.object({ }); const chatInputSchema = z.object({ + workspace: z.string().min(1), messages: z.array(messageSchema).min(1), context: z .object({ - workspaceId: z.string().optional(), 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): Promise { +async function fetchObjectSummary( + database: Db, + objectId: string, + workspaceId: string, +): Promise { const row = await database.query.objects.findFirst({ - where: eq(objects.id, objectId), + where: and(eq(objects.id, objectId), eq(objects.workspaceId, workspaceId)), columns: { id: true, title: true, @@ -133,21 +138,23 @@ function suggestionsForContext(input: z.infer): strin } export const aiRouter = router({ - chat: protectedProcedure.input(chatInputSchema).mutation(async ({ ctx, input }) => { + chat: workspaceProcedure.input(chatInputSchema).mutation(async ({ ctx, input }) => { let system = BASE_SYSTEM; const ctxParts: string[] = []; - if (input.context?.workspaceId) { - ctxParts.push(`Current workspace context ID: ${input.context.workspaceId}`); - } + ctxParts.push(`Current workspace: ${ctx.workspace.name} (${ctx.workspace.slug})`); if (input.context?.objectId) { - const summary = await fetchObjectSummary(ctx.db, 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.`, + `The user referenced object ID ${input.context.objectId}, but it was not found in this workspace.`, ); } } @@ -164,11 +171,14 @@ export const aiRouter = router({ return { text }; }), - suggestActions: protectedProcedure.input(suggestInputSchema).query(async ({ ctx, input }) => { + 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: eq(objects.id, input.objectId), + where: and( + eq(objects.id, input.objectId), + eq(objects.workspaceId, ctx.workspace.id), + ), columns: { type: true }, }); objectType = row?.type; diff --git a/apps/web/server/routers/favorites.ts b/apps/web/server/routers/favorites.ts index e58ceea..72c8d78 100644 --- a/apps/web/server/routers/favorites.ts +++ b/apps/web/server/routers/favorites.ts @@ -1,30 +1,76 @@ import { z } from "zod"; -import { and, eq, desc } from "drizzle-orm"; -import { userFavorites, objects } from "@tasks/database/schema"; +import { and, eq, desc, exists } from "drizzle-orm"; +import { + userFavorites, + objects, + workspaces, + workspaceMembers, +} from "@tasks/database/schema"; import { router, protectedProcedure } from "@/server/trpc"; +import { TRPCError } from "@trpc/server"; + +/** + * Confirm the caller can see the given object. Cross-workspace favorites + * shouldn't expose object ids the user has no business reading. + */ +async function assertCallerCanSeeObject( + db: typeof import("@tasks/database").db, + objectId: string, + userId: string, +): Promise { + const [row] = await db + .select({ + id: objects.id, + workspaceId: objects.workspaceId, + ownerUserId: workspaces.ownerUserId, + }) + .from(objects) + .innerJoin(workspaces, eq(objects.workspaceId, workspaces.id)) + .where(eq(objects.id, objectId)) + .limit(1); + if (!row) { + throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); + } + if (row.ownerUserId === userId) return; + const [member] = await db + .select({ id: workspaceMembers.id }) + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.workspaceId, row.workspaceId), + eq(workspaceMembers.userId, userId), + ), + ) + .limit(1); + if (!member) { + throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); + } +} export const favoritesRouter = router({ - list: protectedProcedure - .query(async ({ ctx }) => { - const rows = await ctx.db - .select({ - id: userFavorites.id, - objectId: userFavorites.objectId, - createdAt: userFavorites.createdAt, - objectTitle: objects.title, - objectType: objects.type, - objectIcon: objects.icon, - }) - .from(userFavorites) - .innerJoin(objects, eq(userFavorites.objectId, objects.id)) - .where(eq(userFavorites.userId, ctx.session.user.id)) - .orderBy(desc(userFavorites.createdAt)); - return rows; - }), + list: protectedProcedure.query(async ({ ctx }) => { + const rows = await ctx.db + .select({ + id: userFavorites.id, + objectId: userFavorites.objectId, + createdAt: userFavorites.createdAt, + objectTitle: objects.title, + objectType: objects.type, + objectIcon: objects.icon, + workspaceId: objects.workspaceId, + }) + .from(userFavorites) + .innerJoin(objects, eq(userFavorites.objectId, objects.id)) + .where(eq(userFavorites.userId, ctx.session.user.id)) + .orderBy(desc(userFavorites.createdAt)); + return rows; + }), toggle: protectedProcedure .input(z.object({ objectId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { + await assertCallerCanSeeObject(ctx.db, input.objectId, ctx.session.user.id); + const existing = await ctx.db .select({ id: userFavorites.id }) .from(userFavorites) diff --git a/apps/web/server/routers/forms.ts b/apps/web/server/routers/forms.ts index ab05208..abda60b 100644 --- a/apps/web/server/routers/forms.ts +++ b/apps/web/server/routers/forms.ts @@ -8,7 +8,7 @@ import { propertyDefinitions, propertyValues, } from "@tasks/database/schema"; -import { type Context, router, protectedProcedure } from "@/server/trpc"; +import { type Context, router, workspaceProcedure } from "@/server/trpc"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -52,23 +52,21 @@ async function resolvePropertyDefId( } export const formsRouter = router({ - list: protectedProcedure - .input(z.object({ workspaceId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const rows = await ctx.db - .select() - .from(forms) - .where(eq(forms.workspaceId, input.workspaceId)) - .orderBy(desc(forms.updatedAt), asc(forms.id)); + list: workspaceProcedure.query(async ({ ctx }) => { + const rows = await ctx.db + .select() + .from(forms) + .where(eq(forms.workspaceId, ctx.workspace.id)) + .orderBy(desc(forms.updatedAt), asc(forms.id)); - return { forms: rows }; - }), + return { forms: rows }; + }), - getById: protectedProcedure + getById: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const row = await ctx.db.query.forms.findFirst({ - where: eq(forms.id, input.id), + where: and(eq(forms.id, input.id), eq(forms.workspaceId, ctx.workspace.id)), }); if (!row) { @@ -78,10 +76,9 @@ export const formsRouter = router({ return row; }), - create: protectedProcedure + create: workspaceProcedure .input( z.object({ - workspaceId: z.string().uuid(), title: z.string().min(1).max(500), description: z.string().optional(), coverImage: z.string().optional(), @@ -95,17 +92,14 @@ export const formsRouter = router({ .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; if (!userId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Missing user id", - }); + throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id" }); } const now = new Date(); const [created] = await ctx.db .insert(forms) .values({ - workspaceId: input.workspaceId, + workspaceId: ctx.workspace.id, title: input.title, description: input.description ?? null, coverImage: input.coverImage ?? null, @@ -130,7 +124,7 @@ export const formsRouter = router({ return created; }), - update: protectedProcedure + update: workspaceProcedure .input( z.object({ id: z.string().uuid(), @@ -161,7 +155,7 @@ export const formsRouter = router({ ...(patch.isPublished !== undefined ? { isPublished: patch.isPublished } : {}), updatedAt: now, }) - .where(eq(forms.id, id)) + .where(and(eq(forms.id, id), eq(forms.workspaceId, ctx.workspace.id))) .returning(); if (!updated) { @@ -171,12 +165,12 @@ export const formsRouter = router({ return updated; }), - delete: protectedProcedure + delete: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const deleted = await ctx.db .delete(forms) - .where(eq(forms.id, input.id)) + .where(and(eq(forms.id, input.id), eq(forms.workspaceId, ctx.workspace.id))) .returning({ id: forms.id }); if (deleted.length === 0) { @@ -184,7 +178,7 @@ export const formsRouter = router({ } }), - submit: protectedProcedure + submit: workspaceProcedure .input( z.object({ formId: z.string().uuid(), @@ -194,14 +188,11 @@ export const formsRouter = router({ .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; if (!userId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Missing user id", - }); + throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id" }); } const form = await ctx.db.query.forms.findFirst({ - where: eq(forms.id, input.formId), + where: and(eq(forms.id, input.formId), eq(forms.workspaceId, ctx.workspace.id)), }); if (!form) { @@ -330,7 +321,7 @@ export const formsRouter = router({ return result; }), - listResponses: protectedProcedure + listResponses: workspaceProcedure .input( z.object({ formId: z.string().uuid(), @@ -343,7 +334,7 @@ export const formsRouter = router({ const offset = input.offset ?? 0; const form = await ctx.db.query.forms.findFirst({ - where: eq(forms.id, input.formId), + where: and(eq(forms.id, input.formId), eq(forms.workspaceId, ctx.workspace.id)), columns: { id: true }, }); diff --git a/apps/web/server/routers/objects.ts b/apps/web/server/routers/objects.ts index 15442ae..0ae4fa1 100644 --- a/apps/web/server/routers/objects.ts +++ b/apps/web/server/routers/objects.ts @@ -10,11 +10,8 @@ import { sql, } from "drizzle-orm"; import { objectTypes } from "@tasks/shared"; -import { - objectAssignees, - objects, -} from "@tasks/database/schema"; -import { router, protectedProcedure } from "@/server/trpc"; +import { objectAssignees, objects } from "@tasks/database/schema"; +import { router, workspaceProcedure } from "@/server/trpc"; const objectTypeSchema = z.enum(objectTypes); @@ -36,11 +33,29 @@ export type ObjectTreeNode = { children: ObjectTreeNode[]; }; +/** + * Reusable: confirm a given object id belongs to the resolved workspace, throwing + * NOT_FOUND otherwise. Prevents cross-tenant ID guessing on per-id mutations. + */ +async function assertObjectInWorkspace( + db: typeof import("@tasks/database").db, + objectId: string, + workspaceId: string, +): Promise { + const [row] = await db + .select({ id: objects.id }) + .from(objects) + .where(and(eq(objects.id, objectId), eq(objects.workspaceId, workspaceId))) + .limit(1); + if (!row) { + throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); + } +} + export const objectsRouter = router({ - list: protectedProcedure + list: workspaceProcedure .input( z.object({ - workspaceId: z.string().uuid(), parentId: z.string().uuid().nullable().optional(), type: objectTypeSchema.optional(), status: z.string().optional(), @@ -53,7 +68,7 @@ export const objectsRouter = router({ const offset = input.offset ?? 0; const conditions = [ - eq(objects.workspaceId, input.workspaceId), + eq(objects.workspaceId, ctx.workspace.id), isNull(objects.archivedAt), ]; @@ -87,23 +102,18 @@ export const objectsRouter = router({ return { objects: rows }; }), - getById: protectedProcedure + getById: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const obj = await ctx.db.query.objects.findFirst({ - where: eq(objects.id, input.id), + where: and( + eq(objects.id, input.id), + eq(objects.workspaceId, ctx.workspace.id), + ), with: { children: true, - assignees: { - with: { - user: true, - }, - }, - propertyValues: { - with: { - propertyDefinition: true, - }, - }, + assignees: { with: { user: true } }, + propertyValues: { with: { propertyDefinition: true } }, }, }); @@ -121,10 +131,9 @@ export const objectsRouter = router({ return { ...obj, children }; }), - getTree: protectedProcedure + getTree: workspaceProcedure .input( z.object({ - workspaceId: z.string().uuid(), maxDepth: z.number().int().positive().max(100).optional(), }), ) @@ -136,7 +145,7 @@ export const objectsRouter = router({ .from(objects) .where( and( - eq(objects.workspaceId, input.workspaceId), + eq(objects.workspaceId, ctx.workspace.id), inArray(objects.type, [...TREE_TYPES]), isNull(objects.archivedAt), ), @@ -159,9 +168,7 @@ export const objectsRouter = router({ if (depth > maxDepth) { return []; } - const directChildren = rows.filter((r) => r.parentId === parentId); - return directChildren.map((r) => ({ id: r.id, title: r.title, @@ -190,13 +197,12 @@ export const objectsRouter = router({ return { tree }; }), - create: protectedProcedure + create: workspaceProcedure .input( z.object({ type: objectTypeSchema, title: z.string().min(1).max(500), parentId: z.string().uuid().nullable().optional(), - workspaceId: z.string().uuid(), description: z.string().optional(), icon: z.string().optional(), status: z.string().optional(), @@ -206,10 +212,11 @@ export const objectsRouter = router({ .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; if (!userId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Missing user id", - }); + throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id" }); + } + + if (input.parentId) { + await assertObjectInWorkspace(ctx.db, input.parentId, ctx.workspace.id); } const [created] = await ctx.db @@ -218,7 +225,7 @@ export const objectsRouter = router({ type: input.type, title: input.title, parentId: input.parentId ?? null, - workspaceId: input.workspaceId, + workspaceId: ctx.workspace.id, description: input.description, icon: input.icon, status: input.status, @@ -237,7 +244,7 @@ export const objectsRouter = router({ return created; }), - update: protectedProcedure + update: workspaceProcedure .input( z.object({ id: z.string().uuid(), @@ -251,6 +258,7 @@ export const objectsRouter = router({ ) .mutation(async ({ ctx, input }) => { const { id, ...patch } = input; + await assertObjectInWorkspace(ctx.db, id, ctx.workspace.id); const updatedAt = new Date(); const [updated] = await ctx.db @@ -274,9 +282,10 @@ export const objectsRouter = router({ return updated; }), - archive: protectedProcedure + archive: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { + await assertObjectInWorkspace(ctx.db, input.id, ctx.workspace.id); const archivedAt = new Date(); const [row] = await ctx.db .update(objects) @@ -287,13 +296,13 @@ export const objectsRouter = router({ if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } - return row; }), - delete: protectedProcedure + delete: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { + await assertObjectInWorkspace(ctx.db, input.id, ctx.workspace.id); const deleted = await ctx.db .delete(objects) .where(eq(objects.id, input.id)) @@ -302,11 +311,10 @@ export const objectsRouter = router({ if (deleted.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } - return deleted[0]; }), - reorder: protectedProcedure + reorder: workspaceProcedure .input( z.object({ id: z.string().uuid(), @@ -315,6 +323,11 @@ export const objectsRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + await assertObjectInWorkspace(ctx.db, input.id, ctx.workspace.id); + if (input.newParentId) { + await assertObjectInWorkspace(ctx.db, input.newParentId, ctx.workspace.id); + } + const updates: { sortOrder: number; updatedAt: Date; @@ -337,11 +350,10 @@ export const objectsRouter = router({ if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } - return row; }), - assign: protectedProcedure + assign: workspaceProcedure .input( z.object({ objectId: z.string().uuid(), @@ -351,6 +363,8 @@ export const objectsRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + await assertObjectInWorkspace(ctx.db, input.objectId, ctx.workspace.id); + if (input.action === "remove") { const deleted = await ctx.db .delete(objectAssignees) @@ -363,12 +377,8 @@ export const objectsRouter = router({ .returning({ id: objectAssignees.id }); if (deleted.length === 0) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Assignee not found", - }); + throw new TRPCError({ code: "NOT_FOUND", message: "Assignee not found" }); } - return { ok: true as const, action: "remove" as const }; } diff --git a/apps/web/server/routers/properties.ts b/apps/web/server/routers/properties.ts index 6ec94db..5b214c3 100644 --- a/apps/web/server/routers/properties.ts +++ b/apps/web/server/routers/properties.ts @@ -1,29 +1,27 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { asc, eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { + objects, propertyDefinitions, propertyValues, } from "@tasks/database/schema"; -import { router, protectedProcedure } from "@/server/trpc"; +import { router, workspaceProcedure } from "@/server/trpc"; export const propertiesRouter = router({ - listDefinitions: protectedProcedure - .input(z.object({ workspaceId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const definitions = await ctx.db - .select() - .from(propertyDefinitions) - .where(eq(propertyDefinitions.workspaceId, input.workspaceId)) - .orderBy(asc(propertyDefinitions.sortOrder), asc(propertyDefinitions.id)); + listDefinitions: workspaceProcedure.query(async ({ ctx }) => { + const definitions = await ctx.db + .select() + .from(propertyDefinitions) + .where(eq(propertyDefinitions.workspaceId, ctx.workspace.id)) + .orderBy(asc(propertyDefinitions.sortOrder), asc(propertyDefinitions.id)); - return { definitions }; - }), + return { definitions }; + }), - createDefinition: protectedProcedure + createDefinition: workspaceProcedure .input( z.object({ - workspaceId: z.string().uuid(), name: z.string().min(1).max(255), fieldType: z.string().min(1).max(50), config: z.any().optional(), @@ -33,7 +31,7 @@ export const propertiesRouter = router({ const [created] = await ctx.db .insert(propertyDefinitions) .values({ - workspaceId: input.workspaceId, + workspaceId: ctx.workspace.id, name: input.name, fieldType: input.fieldType, config: input.config ?? null, @@ -50,9 +48,19 @@ export const propertiesRouter = router({ return created; }), - getValues: protectedProcedure + getValues: workspaceProcedure .input(z.object({ objectId: z.string().uuid() })) .query(async ({ ctx, input }) => { + // Confirm the target object lives in this workspace. + const [obj] = await ctx.db + .select({ id: objects.id }) + .from(objects) + .where(and(eq(objects.id, input.objectId), eq(objects.workspaceId, ctx.workspace.id))) + .limit(1); + if (!obj) { + throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); + } + const rows = await ctx.db .select({ valueRow: propertyValues, @@ -74,7 +82,7 @@ export const propertiesRouter = router({ }; }), - setValue: protectedProcedure + setValue: workspaceProcedure .input( z.object({ objectId: z.string().uuid(), @@ -83,6 +91,30 @@ export const propertiesRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + // Verify both the target object and the property definition belong to + // the resolved workspace before writing. + const [obj] = await ctx.db + .select({ id: objects.id }) + .from(objects) + .where(and(eq(objects.id, input.objectId), eq(objects.workspaceId, ctx.workspace.id))) + .limit(1); + if (!obj) { + throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); + } + const [def] = await ctx.db + .select({ id: propertyDefinitions.id }) + .from(propertyDefinitions) + .where( + and( + eq(propertyDefinitions.id, input.propertyDefId), + eq(propertyDefinitions.workspaceId, ctx.workspace.id), + ), + ) + .limit(1); + if (!def) { + throw new TRPCError({ code: "NOT_FOUND", message: "Property definition not found" }); + } + const now = new Date(); const [row] = await ctx.db diff --git a/apps/web/server/routers/relations.ts b/apps/web/server/routers/relations.ts index 877bd65..cc4e606 100644 --- a/apps/web/server/routers/relations.ts +++ b/apps/web/server/routers/relations.ts @@ -1,11 +1,29 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { eq } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { objectRelations, objects } from "@tasks/database/schema"; -import { router, protectedProcedure } from "@/server/trpc"; +import { router, workspaceProcedure } from "@/server/trpc"; + +/** + * Confirm both endpoints of a relation live in the resolved workspace. Without + * this guard, callers could relate cross-tenant objects to leak titles/types. + */ +async function assertObjectsInWorkspace( + db: typeof import("@tasks/database").db, + ids: string[], + workspaceId: string, +): Promise { + const rows = await db + .select({ id: objects.id }) + .from(objects) + .where(and(eq(objects.workspaceId, workspaceId), or(...ids.map((id) => eq(objects.id, id))))); + if (rows.length !== ids.length) { + throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); + } +} export const relationsRouter = router({ - list: protectedProcedure + list: workspaceProcedure .input( z.object({ objectId: z.string().uuid(), @@ -13,6 +31,7 @@ export const relationsRouter = router({ }), ) .query(async ({ ctx, input }) => { + await assertObjectsInWorkspace(ctx.db, [input.objectId], ctx.workspace.id); const dir = input.direction ?? "both"; const baseSelect = { @@ -24,6 +43,7 @@ export const relationsRouter = router({ relatedId: objects.id, relatedTitle: objects.title, relatedType: objects.type, + relatedWorkspaceId: objects.workspaceId, }; const outgoing = @@ -33,7 +53,12 @@ export const relationsRouter = router({ .select(baseSelect) .from(objectRelations) .innerJoin(objects, eq(objectRelations.targetId, objects.id)) - .where(eq(objectRelations.sourceId, input.objectId)); + .where( + and( + eq(objectRelations.sourceId, input.objectId), + eq(objects.workspaceId, ctx.workspace.id), + ), + ); const incoming = dir === "outgoing" @@ -42,7 +67,12 @@ export const relationsRouter = router({ .select(baseSelect) .from(objectRelations) .innerJoin(objects, eq(objectRelations.sourceId, objects.id)) - .where(eq(objectRelations.targetId, input.objectId)); + .where( + and( + eq(objectRelations.targetId, input.objectId), + eq(objects.workspaceId, ctx.workspace.id), + ), + ); const relations = [ ...outgoing.map((r) => ({ @@ -76,7 +106,7 @@ export const relationsRouter = router({ return { relations }; }), - create: protectedProcedure + create: workspaceProcedure .input( z.object({ sourceId: z.string().uuid(), @@ -92,6 +122,12 @@ export const relationsRouter = router({ }); } + await assertObjectsInWorkspace( + ctx.db, + [input.sourceId, input.targetId], + ctx.workspace.id, + ); + try { const [created] = await ctx.db .insert(objectRelations) @@ -131,9 +167,24 @@ export const relationsRouter = router({ } }), - delete: protectedProcedure + delete: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { + // Confirm the relation's source object lives in this workspace before + // deleting (cheap guard against cross-tenant ID guessing). + const [rel] = await ctx.db + .select({ + id: objectRelations.id, + sourceWorkspaceId: objects.workspaceId, + }) + .from(objectRelations) + .innerJoin(objects, eq(objectRelations.sourceId, objects.id)) + .where(eq(objectRelations.id, input.id)) + .limit(1); + if (!rel || rel.sourceWorkspaceId !== ctx.workspace.id) { + throw new TRPCError({ code: "NOT_FOUND", message: "Relation not found" }); + } + const deleted = await ctx.db .delete(objectRelations) .where(eq(objectRelations.id, input.id)) diff --git a/apps/web/server/routers/search.ts b/apps/web/server/routers/search.ts index 85a30b8..4d4652c 100644 --- a/apps/web/server/routers/search.ts +++ b/apps/web/server/routers/search.ts @@ -3,7 +3,7 @@ import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { objects } from "@tasks/database/schema"; import { objectTypes } from "@tasks/shared"; import type { Context } from "@/server/trpc"; -import { router, protectedProcedure } from "@/server/trpc"; +import { router, workspaceProcedure } from "@/server/trpc"; const objectTypeSchema = z.enum(objectTypes); @@ -99,11 +99,10 @@ function parentBreadcrumb( } export const searchRouter = router({ - search: protectedProcedure + search: workspaceProcedure .input( z.object({ query: z.string(), - workspaceId: z.string().uuid().optional(), type: objectTypeSchema.optional(), limit: z.number().int().positive().max(100).optional(), }), @@ -118,11 +117,12 @@ export const searchRouter = router({ const pattern = `%${escapeIlike(raw)}%`; const matchCondition = sql`(${objects.title} ILIKE ${pattern} ESCAPE '\\' OR ${objects.description} ILIKE ${pattern} ESCAPE '\\')`; - const conditions = [isNull(objects.archivedAt), matchCondition]; + const conditions = [ + eq(objects.workspaceId, ctx.workspace.id), + isNull(objects.archivedAt), + matchCondition, + ]; - if (input.workspaceId !== undefined) { - conditions.push(eq(objects.workspaceId, input.workspaceId)); - } if (input.type !== undefined) { conditions.push(eq(objects.type, input.type)); } @@ -172,20 +172,19 @@ export const searchRouter = router({ return { results }; }), - recent: protectedProcedure + recent: workspaceProcedure .input( z.object({ - workspaceId: z.string().uuid().optional(), limit: z.number().int().positive().max(50).optional(), }), ) .query(async ({ ctx, input }) => { const limit = input.limit ?? 10; - const conditions = [isNull(objects.archivedAt)]; - if (input.workspaceId !== undefined) { - conditions.push(eq(objects.workspaceId, input.workspaceId)); - } + const conditions = [ + eq(objects.workspaceId, ctx.workspace.id), + isNull(objects.archivedAt), + ]; const rows = await ctx.db .select({ diff --git a/apps/web/server/routers/templates.ts b/apps/web/server/routers/templates.ts index e09f53f..547dcab 100644 --- a/apps/web/server/routers/templates.ts +++ b/apps/web/server/routers/templates.ts @@ -7,7 +7,7 @@ import { propertyValues, templates, } from "@tasks/database/schema"; -import { type Context, router, protectedProcedure } from "@/server/trpc"; +import { type Context, router, workspaceProcedure } from "@/server/trpc"; const templatePropertySchema = z.object({ name: z.string().min(1).max(255), @@ -57,15 +57,14 @@ async function getMaxPropertySortOrder( } export const templatesRouter = router({ - list: protectedProcedure + list: workspaceProcedure .input( z.object({ - workspaceId: z.string().uuid(), targetType: z.string().max(50).optional(), }), ) .query(async ({ ctx, input }) => { - const conditions = [eq(templates.workspaceId, input.workspaceId)]; + const conditions = [eq(templates.workspaceId, ctx.workspace.id)]; if (input.targetType !== undefined) { conditions.push(eq(templates.targetType, input.targetType)); } @@ -79,11 +78,14 @@ export const templatesRouter = router({ return { templates: rows }; }), - getById: protectedProcedure + getById: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const row = await ctx.db.query.templates.findFirst({ - where: eq(templates.id, input.id), + where: and( + eq(templates.id, input.id), + eq(templates.workspaceId, ctx.workspace.id), + ), }); if (!row) { @@ -93,10 +95,9 @@ export const templatesRouter = router({ return row; }), - create: protectedProcedure + create: workspaceProcedure .input( z.object({ - workspaceId: z.string().uuid(), name: z.string().min(1).max(255), targetType: z.string().min(1).max(50), schema: templateSchemaJson, @@ -107,7 +108,7 @@ export const templatesRouter = router({ const [created] = await ctx.db .insert(templates) .values({ - workspaceId: input.workspaceId, + workspaceId: ctx.workspace.id, name: input.name, targetType: input.targetType, schema: input.schema ?? null, @@ -126,7 +127,7 @@ export const templatesRouter = router({ return created; }), - update: protectedProcedure + update: workspaceProcedure .input( z.object({ id: z.string().uuid(), @@ -145,7 +146,9 @@ export const templatesRouter = router({ ...(patch.schema !== undefined ? { schema: patch.schema } : {}), updatedAt: now, }) - .where(eq(templates.id, id)) + .where( + and(eq(templates.id, id), eq(templates.workspaceId, ctx.workspace.id)), + ) .returning(); if (!updated) { @@ -155,12 +158,17 @@ export const templatesRouter = router({ return updated; }), - delete: protectedProcedure + delete: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const deleted = await ctx.db .delete(templates) - .where(eq(templates.id, input.id)) + .where( + and( + eq(templates.id, input.id), + eq(templates.workspaceId, ctx.workspace.id), + ), + ) .returning({ id: templates.id }); if (deleted.length === 0) { @@ -168,7 +176,7 @@ export const templatesRouter = router({ } }), - applyTemplate: protectedProcedure + applyTemplate: workspaceProcedure .input( z.object({ objectId: z.string().uuid(), @@ -177,7 +185,10 @@ export const templatesRouter = router({ ) .mutation(async ({ ctx, input }) => { const template = await ctx.db.query.templates.findFirst({ - where: eq(templates.id, input.templateId), + where: and( + eq(templates.id, input.templateId), + eq(templates.workspaceId, ctx.workspace.id), + ), }); if (!template) { @@ -185,26 +196,22 @@ export const templatesRouter = router({ } const obj = await ctx.db.query.objects.findFirst({ - where: eq(objects.id, input.objectId), + where: and( + eq(objects.id, input.objectId), + eq(objects.workspaceId, ctx.workspace.id), + ), }); if (!obj) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } - if (obj.workspaceId !== template.workspaceId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Template belongs to a different workspace than the object", - }); - } - const schema = (template.schema ?? {}) as { properties?: { name: string; fieldType: string; defaultValue?: unknown }[]; defaultContent?: string; }; - const workspaceId = template.workspaceId; + const workspaceId = ctx.workspace.id; let nextSort = (await getMaxPropertySortOrder(ctx.db, workspaceId)) + 1; const now = new Date(); diff --git a/apps/web/server/routers/types.ts b/apps/web/server/routers/types.ts index 8562c97..2c2101d 100644 --- a/apps/web/server/routers/types.ts +++ b/apps/web/server/routers/types.ts @@ -1,36 +1,38 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { asc, eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { objectTypeDefs } from "@tasks/database/schema"; -import { router, protectedProcedure } from "@/server/trpc"; +import { router, workspaceProcedure } from "@/server/trpc"; export const typesRouter = router({ - list: protectedProcedure - .input(z.object({ workspaceId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - return ctx.db - .select() - .from(objectTypeDefs) - .where(eq(objectTypeDefs.workspaceId, input.workspaceId)) - .orderBy(asc(objectTypeDefs.name)); - }), + list: workspaceProcedure.query(async ({ ctx }) => { + return ctx.db + .select() + .from(objectTypeDefs) + .where(eq(objectTypeDefs.workspaceId, ctx.workspace.id)) + .orderBy(asc(objectTypeDefs.name)); + }), - getById: protectedProcedure + getById: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const [row] = await ctx.db .select() .from(objectTypeDefs) - .where(eq(objectTypeDefs.id, input.id)) + .where( + and( + eq(objectTypeDefs.id, input.id), + eq(objectTypeDefs.workspaceId, ctx.workspace.id), + ), + ) .limit(1); if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" }); return row; }), - create: protectedProcedure + create: workspaceProcedure .input( z.object({ - workspaceId: z.string().uuid(), name: z.string().min(1).max(255), slug: z.string().min(1).max(100), icon: z.string().optional(), @@ -43,7 +45,7 @@ export const typesRouter = router({ const [created] = await ctx.db .insert(objectTypeDefs) .values({ - workspaceId: input.workspaceId, + workspaceId: ctx.workspace.id, name: input.name, slug: input.slug, icon: input.icon ?? null, @@ -60,7 +62,7 @@ export const typesRouter = router({ return created; }), - update: protectedProcedure + update: workspaceProcedure .input( z.object({ id: z.string().uuid(), @@ -83,18 +85,28 @@ export const typesRouter = router({ const [updated] = await ctx.db .update(objectTypeDefs) .set(updates) - .where(eq(objectTypeDefs.id, id)) + .where( + and( + eq(objectTypeDefs.id, id), + eq(objectTypeDefs.workspaceId, ctx.workspace.id), + ), + ) .returning(); if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" }); return updated; }), - delete: protectedProcedure + delete: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const [deleted] = await ctx.db .delete(objectTypeDefs) - .where(eq(objectTypeDefs.id, input.id)) + .where( + and( + eq(objectTypeDefs.id, input.id), + eq(objectTypeDefs.workspaceId, ctx.workspace.id), + ), + ) .returning(); if (!deleted) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" }); return { success: true }; diff --git a/apps/web/server/routers/workspaces.ts b/apps/web/server/routers/workspaces.ts index 2c7bc62..c4ef8f6 100644 --- a/apps/web/server/routers/workspaces.ts +++ b/apps/web/server/routers/workspaces.ts @@ -1,59 +1,232 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { and, eq } from "drizzle-orm"; -import { objects, workspaceMembers, users } from "@tasks/database/schema"; -import { router, protectedProcedure } from "@/server/trpc"; +import { and, desc, eq, isNull, ne } from "drizzle-orm"; +import { + workspaces, + workspaceMembers, + users, +} from "@tasks/database/schema"; +import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc"; +import { findWorkspaceByHandle } from "@/server/lib/resolve-workspace"; + +const slugSchema = z + .string() + .min(2) + .max(60) + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "Slug must be lowercase, alphanumeric, hyphen-separated"); + +function makeSlug(name: string): string { + return ( + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "workspace" + ); +} export const workspacesRouter = router({ - getById: protectedProcedure - .input(z.object({ id: z.string().uuid() })) + /** + * Resolve a UUID-or-slug handle to a workspace the caller can see. Used by + * the app shell to redirect / hydrate the workspace switcher. + */ + resolve: protectedProcedure + .input(z.object({ handle: z.string().min(1) })) .query(async ({ ctx, input }) => { - const [row] = await ctx.db - .select({ - id: objects.id, - title: objects.title, - type: objects.type, - icon: objects.icon, - }) - .from(objects) - .where(and(eq(objects.id, input.id), eq(objects.type, "workspace"))) - .limit(1); - - if (!row) { + const ws = await findWorkspaceByHandle(input.handle, ctx.db); + if (!ws) { throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found" }); } - return row; + const userId = ctx.session.user.id; + const [membership] = await ctx.db + .select({ role: workspaceMembers.role }) + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.workspaceId, ws.id), + eq(workspaceMembers.userId, userId), + ), + ) + .limit(1); + + const [owner] = await ctx.db + .select({ ownerUserId: workspaces.ownerUserId }) + .from(workspaces) + .where(eq(workspaces.id, ws.id)) + .limit(1); + + if (!membership && owner?.ownerUserId !== userId) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + + return ws; }), + /** + * Create a new workspace owned by the caller. Auto-mints a slug from `name` + * unless one is provided. Caller is added as the owner+initial member. + */ + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1).max(200), + slug: slugSchema.optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + let slug = input.slug ?? makeSlug(input.name); + + const [collision] = await ctx.db + .select({ id: workspaces.id }) + .from(workspaces) + .where(eq(workspaces.slug, slug)) + .limit(1); + if (collision) { + if (input.slug) { + throw new TRPCError({ code: "CONFLICT", message: "Slug already in use" }); + } + slug = `${slug}-${Math.random().toString(36).slice(2, 8)}`; + } + + const [ws] = await ctx.db + .insert(workspaces) + .values({ + name: input.name, + slug, + ownerUserId: userId, + }) + .returning(); + + await ctx.db.insert(workspaceMembers).values({ + workspaceId: ws.id, + userId, + role: "owner", + }); + + return ws; + }), + + /** All workspaces the caller owns or is a member of, owned-first then alpha. */ listForUser: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.session.user.id; + const owned = await ctx.db + .select({ + id: workspaces.id, + slug: workspaces.slug, + name: workspaces.name, + role: workspaceMembers.role, + archivedAt: workspaces.archivedAt, + }) + .from(workspaces) + .leftJoin( + workspaceMembers, + and( + eq(workspaceMembers.workspaceId, workspaces.id), + eq(workspaceMembers.userId, userId), + ), + ) + .where( + and(eq(workspaces.ownerUserId, userId), isNull(workspaces.archivedAt)), + ) + .orderBy(workspaces.name); + + const memberOnly = await ctx.db + .select({ + id: workspaces.id, + slug: workspaces.slug, + name: workspaces.name, + role: workspaceMembers.role, + archivedAt: workspaces.archivedAt, + }) + .from(workspaceMembers) + .innerJoin(workspaces, eq(workspaceMembers.workspaceId, workspaces.id)) + .where( + and( + eq(workspaceMembers.userId, userId), + ne(workspaces.ownerUserId, userId), + isNull(workspaces.archivedAt), + ), + ) + .orderBy(workspaces.name); + + return [...owned, ...memberOnly].map((row) => ({ + id: row.id, + slug: row.slug, + name: row.name, + role: row.role ?? "owner", + archivedAt: row.archivedAt, + })); + }), + + /** Members of a workspace the caller can see. */ + listMembers: workspaceProcedure.query(async ({ ctx }) => { return ctx.db .select({ - id: objects.id, - title: objects.title, - icon: objects.icon, + id: users.id, + name: users.name, + email: users.email, + avatarUrl: users.avatarUrl, role: workspaceMembers.role, }) .from(workspaceMembers) - .innerJoin(objects, eq(workspaceMembers.workspaceId, objects.id)) - .where(eq(workspaceMembers.userId, userId)); + .innerJoin(users, eq(workspaceMembers.userId, users.id)) + .where(eq(workspaceMembers.workspaceId, ctx.workspace.id)); }), - listMembers: protectedProcedure - .input(z.object({ workspaceId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - return ctx.db - .select({ - id: users.id, - name: users.name, - email: users.email, - avatarUrl: users.avatarUrl, - role: workspaceMembers.role, + /** + * Update workspace metadata (name and/or slug). Slug renames are validated + * for uniqueness; the caller must be the workspace owner. + */ + update: workspaceProcedure + .input( + z.object({ + name: z.string().min(1).max(200).optional(), + slug: slugSchema.optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + if (ctx.workspace.role !== "owner") { + throw new TRPCError({ code: "FORBIDDEN", message: "Only the owner can rename the workspace" }); + } + + if (input.slug && input.slug !== ctx.workspace.slug) { + const [collision] = await ctx.db + .select({ id: workspaces.id }) + .from(workspaces) + .where(eq(workspaces.slug, input.slug)) + .limit(1); + if (collision) { + throw new TRPCError({ code: "CONFLICT", message: "Slug already in use" }); + } + } + + const [updated] = await ctx.db + .update(workspaces) + .set({ + ...(input.name ? { name: input.name } : {}), + ...(input.slug ? { slug: input.slug } : {}), + updatedAt: new Date(), }) - .from(workspaceMembers) - .innerJoin(users, eq(workspaceMembers.userId, users.id)) - .where(eq(workspaceMembers.workspaceId, input.workspaceId)); + .where(eq(workspaces.id, ctx.workspace.id)) + .returning(); + + return updated; }), + + /** Owner-only soft archive. */ + archive: workspaceProcedure.mutation(async ({ ctx }) => { + if (ctx.workspace.role !== "owner") { + throw new TRPCError({ code: "FORBIDDEN" }); + } + const [updated] = await ctx.db + .update(workspaces) + .set({ archivedAt: new Date() }) + .where(eq(workspaces.id, ctx.workspace.id)) + .returning(); + return updated; + }), }); diff --git a/apps/web/server/trpc.ts b/apps/web/server/trpc.ts index f95f8fe..bc20ca0 100644 --- a/apps/web/server/trpc.ts +++ b/apps/web/server/trpc.ts @@ -1,8 +1,10 @@ import { initTRPC, TRPCError } from "@trpc/server"; +import { z } from "zod"; import superjson from "superjson"; import type { Session } from "next-auth"; import { db } from "@tasks/database"; import { auth } from "@/lib/auth"; +import { resolveWorkspace, type WorkspaceContext } from "@/server/lib/resolve-workspace"; export type Context = { db: typeof db; @@ -43,3 +45,39 @@ export const router = t.router; export const createCallerFactory = t.createCallerFactory; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(isAuthed); + +/** + * Procedure for any tenant-scoped operation. Caller must: + * - Be authenticated. + * - Pass `workspace` (UUID or slug) in the input. The middleware resolves it + * to a full `WorkspaceContext` (id, slug, name, owner, role) and exposes it + * on `ctx.workspace`. Procedures can then scope queries by `ctx.workspace.id`. + * + * Example: + * workspaceProcedure + * .input(z.object({ workspace: z.string(), title: z.string() })) + * .mutation(({ ctx, input }) => { + * return ctx.db.insert(objects).values({ + * workspaceId: ctx.workspace.id, + * title: input.title, + * type: "task", + * }); + * }); + */ +export const workspaceProcedure = protectedProcedure + .input(z.object({ workspace: z.string().min(1) })) + .use(async ({ ctx, input, next }) => { + const ws = await resolveWorkspace({ + handle: input.workspace, + userId: ctx.session.user.id, + db: ctx.db, + }); + return next({ + ctx: { + ...ctx, + workspace: ws, + }, + }); + }); + +export type WorkspaceProcedureContext = Context & { session: Session; workspace: WorkspaceContext }; diff --git a/packages/database/migrations/0003_damp_green_goblin.sql b/packages/database/migrations/0003_damp_green_goblin.sql new file mode 100644 index 0000000..ac93b96 --- /dev/null +++ b/packages/database/migrations/0003_damp_green_goblin.sql @@ -0,0 +1,120 @@ +-- ============================================================================ +-- 0003 — Promote workspaces to a top-level table. +-- ============================================================================ +-- Block A of the EchoDo commercial plan. We: +-- 1. Create the new `workspaces` table. +-- 2. Copy every existing `objects` row of type='workspace' INTO `workspaces`, +-- preserving the same UUID so existing FKs (which all point at +-- objects.id today) remain valid mid-migration. +-- 3. Mint a `slug` for each workspace from its title (or falling back to a +-- short UUID prefix if the slug would collide / be empty). +-- 4. Drop the old objects→objects FK on every anchor table and re-add a new +-- FK pointing at workspaces.id. +-- 5. Delete the now-redundant type='workspace' rows from `objects` and add +-- NOT NULL on objects.workspace_id (it was nullable for self-references). +-- ============================================================================ + +-- 1. New workspaces table +CREATE TABLE "workspaces" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" varchar(60) NOT NULL, + "name" varchar(200) NOT NULL, + "owner_user_id" uuid NOT NULL, + "plan_tier" varchar(20) DEFAULT 'free' NOT NULL, + "archived_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint + +-- 2. Copy existing workspaces (objects WHERE type='workspace') into the new +-- table. Owner is best-effort: prefer created_by, otherwise the first +-- workspace_member that exists, otherwise the first user in the system +-- (single-tenant homelab fallback). +INSERT INTO "workspaces" ("id", "slug", "name", "owner_user_id", "plan_tier", "archived_at", "created_at", "updated_at") +SELECT + o.id, + -- slug: lowercased, alphanum+dash, fallback to first 8 chars of UUID. + COALESCE( + NULLIF( + regexp_replace(lower(trim(o.title)), '[^a-z0-9]+', '-', 'g'), + '' + ), + substring(o.id::text from 1 for 8) + ), + COALESCE(NULLIF(trim(o.title), ''), 'Workspace'), + COALESCE( + o.created_by, + (SELECT wm.user_id FROM "workspace_members" wm WHERE wm.workspace_id = o.id ORDER BY wm.created_at ASC LIMIT 1), + (SELECT u.id FROM "users" u ORDER BY u.created_at ASC LIMIT 1) + ), + 'free', + o.archived_at, + o.created_at, + o.updated_at +FROM "objects" o +WHERE o.type = 'workspace'; +--> statement-breakpoint + +-- 2b. Disambiguate any duplicate slugs (e.g. two workspaces both titled "Tasks") +-- by suffixing with the short UUID. Only touches collisions. +UPDATE "workspaces" w +SET "slug" = w.slug || '-' || substring(w.id::text from 1 for 6) +WHERE w.id IN ( + SELECT id FROM ( + SELECT id, row_number() OVER (PARTITION BY slug ORDER BY created_at) AS rn + FROM "workspaces" + ) ranked + WHERE ranked.rn > 1 +); +--> statement-breakpoint + +-- 2c. Safety net: if there is at least one user but NO workspace exists yet +-- (fresh DB on a brand-new Coolify deploy), seed a default one so the +-- NOT NULL FK swap below cannot fail at runtime. +INSERT INTO "workspaces" ("slug", "name", "owner_user_id") +SELECT 'default', 'Default Workspace', u.id +FROM "users" u +WHERE NOT EXISTS (SELECT 1 FROM "workspaces") +ORDER BY u.created_at ASC +LIMIT 1; +--> statement-breakpoint + +-- 3. Drop old objects.id-based FKs on every anchor table. +ALTER TABLE "objects" DROP CONSTRAINT "objects_workspace_id_objects_id_fk";--> statement-breakpoint +ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_workspace_id_objects_id_fk";--> statement-breakpoint +ALTER TABLE "object_type_defs" DROP CONSTRAINT "object_type_defs_workspace_id_objects_id_fk";--> statement-breakpoint +ALTER TABLE "property_definitions" DROP CONSTRAINT "property_definitions_workspace_id_objects_id_fk";--> statement-breakpoint +ALTER TABLE "templates" DROP CONSTRAINT "templates_workspace_id_objects_id_fk";--> statement-breakpoint +ALTER TABLE "forms" DROP CONSTRAINT "forms_workspace_id_objects_id_fk";--> statement-breakpoint +ALTER TABLE "markdown_backlog_items" DROP CONSTRAINT "markdown_backlog_items_workspace_id_objects_id_fk";--> statement-breakpoint +ALTER TABLE "cursor_sync_mappings" DROP CONSTRAINT "cursor_sync_mappings_workspace_id_objects_id_fk";--> statement-breakpoint + +-- 4. Backfill any orphan `workspace_id` values (rows whose old workspace +-- object was deleted before this migration ran). Reassign to the first +-- available workspace so the NOT NULL constraint below holds. +UPDATE "objects" +SET "workspace_id" = (SELECT id FROM "workspaces" ORDER BY created_at ASC LIMIT 1) +WHERE "workspace_id" IS NULL + AND EXISTS (SELECT 1 FROM "workspaces"); +--> statement-breakpoint + +-- 5. Now objects.workspace_id is fully populated → flip to NOT NULL. +ALTER TABLE "objects" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint + +-- 6. Remove the obsolete type='workspace' rows from `objects` (they live in +-- `workspaces` now). Use a guard so this is a no-op on a fresh DB. +DELETE FROM "objects" WHERE "type" = 'workspace';--> statement-breakpoint + +-- 7. New FK constraints + indexes. +ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "workspaces_slug_unique" ON "workspaces" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "workspaces_owner_user_id_idx" ON "workspaces" USING btree ("owner_user_id");--> statement-breakpoint +ALTER TABLE "objects" ADD CONSTRAINT "objects_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "object_type_defs" ADD CONSTRAINT "object_type_defs_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "property_definitions" ADD CONSTRAINT "property_definitions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "templates" ADD CONSTRAINT "templates_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "forms" ADD CONSTRAINT "forms_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "markdown_backlog_items" ADD CONSTRAINT "markdown_backlog_items_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cursor_sync_mappings" ADD CONSTRAINT "cursor_sync_mappings_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action; diff --git a/packages/database/migrations/meta/0003_snapshot.json b/packages/database/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..0fe075b --- /dev/null +++ b/packages/database/migrations/meta/0003_snapshot.json @@ -0,0 +1,2414 @@ +{ + "id": "1dc79026-77bc-459d-a448-b4a42bb815c0", + "prevId": "46c4f6fc-18a6-4700-bd76-46268c905a81", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan_tier": { + "name": "plan_tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_user_id_idx": { + "name": "workspaces_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_user_id_users_id_fk": { + "name": "workspaces_owner_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_assignees": { + "name": "object_assignees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'assignee'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_assignees_object_id_user_id_unique": { + "name": "object_assignees_object_id_user_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_object_id_idx": { + "name": "object_assignees_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_user_id_idx": { + "name": "object_assignees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_assignees_object_id_objects_id_fk": { + "name": "object_assignees_object_id_objects_id_fk", + "tableFrom": "object_assignees", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_assignees_user_id_users_id_fk": { + "name": "object_assignees_user_id_users_id_fk", + "tableFrom": "object_assignees", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.objects": { + "name": "objects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "objects_parent_id_idx": { + "name": "objects_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_idx": { + "name": "objects_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_workspace_id_idx": { + "name": "objects_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_template_id_idx": { + "name": "objects_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_created_by_idx": { + "name": "objects_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_workspace_id_idx": { + "name": "objects_type_workspace_id_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "objects_workspace_id_workspaces_id_fk": { + "name": "objects_workspace_id_workspaces_id_fk", + "tableFrom": "objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "objects_created_by_users_id_fk": { + "name": "objects_created_by_users_id_fk", + "tableFrom": "objects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_parent_id_objects_id_fk": { + "name": "objects_parent_id_objects_id_fk", + "tableFrom": "objects", + "tableTo": "objects", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_template_id_templates_id_fk": { + "name": "objects_template_id_templates_id_fk", + "tableFrom": "objects", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_workspace_id_user_id_unique": { + "name": "workspace_members_workspace_id_user_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_workspace_id_idx": { + "name": "workspace_members_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_users_id_fk": { + "name": "workspace_members_user_id_users_id_fk", + "tableFrom": "workspace_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_type_defs": { + "name": "object_type_defs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "default_properties": { + "name": "default_properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_type_defs_workspace_id_idx": { + "name": "object_type_defs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_type_defs_slug_idx": { + "name": "object_type_defs_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_type_defs_workspace_id_workspaces_id_fk": { + "name": "object_type_defs_workspace_id_workspaces_id_fk", + "tableFrom": "object_type_defs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_definitions": { + "name": "property_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "property_definitions_workspace_id_idx": { + "name": "property_definitions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_definitions_workspace_id_name_idx": { + "name": "property_definitions_workspace_id_name_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_definitions_workspace_id_workspaces_id_fk": { + "name": "property_definitions_workspace_id_workspaces_id_fk", + "tableFrom": "property_definitions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_values": { + "name": "property_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "property_def_id": { + "name": "property_def_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "property_values_object_id_property_def_id_unique": { + "name": "property_values_object_id_property_def_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_object_id_idx": { + "name": "property_values_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_property_def_id_idx": { + "name": "property_values_property_def_id_idx", + "columns": [ + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_values_object_id_objects_id_fk": { + "name": "property_values_object_id_objects_id_fk", + "tableFrom": "property_values", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_values_property_def_id_property_definitions_id_fk": { + "name": "property_values_property_def_id_property_definitions_id_fk", + "tableFrom": "property_values", + "tableTo": "property_definitions", + "columnsFrom": [ + "property_def_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.views": { + "name": "views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "view_type": { + "name": "view_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "views_object_id_idx": { + "name": "views_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "views_object_id_objects_id_fk": { + "name": "views_object_id_objects_id_fk", + "tableFrom": "views", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_provider_account_id_unique": { + "name": "accounts_provider_provider_account_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_token": { + "name": "session_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_session_token_unique": { + "name": "sessions_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_relations": { + "name": "object_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relation_type": { + "name": "relation_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_relations_source_id_idx": { + "name": "object_relations_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_target_id_idx": { + "name": "object_relations_target_id_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_relation_type_idx": { + "name": "object_relations_relation_type_idx", + "columns": [ + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_source_target_type_unique": { + "name": "object_relations_source_target_type_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_relations_source_id_objects_id_fk": { + "name": "object_relations_source_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_relations_target_id_objects_id_fk": { + "name": "object_relations_target_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_workspace_id_idx": { + "name": "templates_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workspace_id_workspaces_id_fk": { + "name": "templates_workspace_id_workspaces_id_fk", + "tableFrom": "templates", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form_responses": { + "name": "form_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "respondent_id": { + "name": "respondent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_object_id": { + "name": "created_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_responses_form_id_idx": { + "name": "form_responses_form_id_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_responses_respondent_id_idx": { + "name": "form_responses_respondent_id_idx", + "columns": [ + { + "expression": "respondent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_responses_form_id_forms_id_fk": { + "name": "form_responses_form_id_forms_id_fk", + "tableFrom": "form_responses", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_responses_respondent_id_users_id_fk": { + "name": "form_responses_respondent_id_users_id_fk", + "tableFrom": "form_responses", + "tableTo": "users", + "columnsFrom": [ + "respondent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "form_responses_created_object_id_objects_id_fk": { + "name": "form_responses_created_object_id_objects_id_fk", + "tableFrom": "form_responses", + "tableTo": "objects", + "columnsFrom": [ + "created_object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forms": { + "name": "forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forms_workspace_id_idx": { + "name": "forms_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forms_object_id_idx": { + "name": "forms_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forms_workspace_id_workspaces_id_fk": { + "name": "forms_workspace_id_workspaces_id_fk", + "tableFrom": "forms", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forms_object_id_objects_id_fk": { + "name": "forms_object_id_objects_id_fk", + "tableFrom": "forms", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "forms_created_by_users_id_fk": { + "name": "forms_created_by_users_id_fk", + "tableFrom": "forms", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_favorites": { + "name": "user_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_favorites_user_object_idx": { + "name": "user_favorites_user_object_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_favorites_user_id_users_id_fk": { + "name": "user_favorites_user_id_users_id_fk", + "tableFrom": "user_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_favorites_object_id_objects_id_fk": { + "name": "user_favorites_object_id_objects_id_fk", + "tableFrom": "user_favorites", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.markdown_backlog_items": { + "name": "markdown_backlog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "plan_slug": { + "name": "plan_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "epic_slug": { + "name": "epic_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frontmatter": { + "name": "frontmatter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "body_markdown": { + "name": "body_markdown", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "markdown_backlog_workspace_repo_path_unique": { + "name": "markdown_backlog_workspace_repo_path_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_plan_idx": { + "name": "markdown_backlog_workspace_plan_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_parent_id_idx": { + "name": "markdown_backlog_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_kind_idx": { + "name": "markdown_backlog_workspace_kind_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "markdown_backlog_items_workspace_id_workspaces_id_fk": { + "name": "markdown_backlog_items_workspace_id_workspaces_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk": { + "name": "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cursor_sync_mappings": { + "name": "cursor_sync_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "backlog_item_id": { + "name": "backlog_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cursor_plan_id": { + "name": "cursor_plan_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "cursor_item_id": { + "name": "cursor_item_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_pulled_at": { + "name": "last_pulled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_pushed_at": { + "name": "last_pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_content_hash": { + "name": "sync_content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cursor_sync_mappings_backlog_item_id_unique": { + "name": "cursor_sync_mappings_backlog_item_id_unique", + "columns": [ + { + "expression": "backlog_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cursor_sync_mappings_workspace_id_idx": { + "name": "cursor_sync_mappings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cursor_sync_mappings_workspace_id_workspaces_id_fk": { + "name": "cursor_sync_mappings_workspace_id_workspaces_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk": { + "name": "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "backlog_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 9b71eea..7f41d0b 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1777225115319, "tag": "0002_markdown_backlog_cursor_sync", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1778124738113, + "tag": "0003_damp_green_goblin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema/cursor_sync.ts b/packages/database/src/schema/cursor_sync.ts index ddf89bc..c79af15 100644 --- a/packages/database/src/schema/cursor_sync.ts +++ b/packages/database/src/schema/cursor_sync.ts @@ -6,7 +6,7 @@ import { index, uniqueIndex, } from "drizzle-orm/pg-core"; -import { objects } from "./objects"; +import { workspaces } from "./workspaces"; import { markdownBacklogItems } from "./markdown_backlog"; /** @@ -19,7 +19,7 @@ export const cursorSyncMappings = pgTable( id: uuid("id").primaryKey().defaultRandom(), workspaceId: uuid("workspace_id") .notNull() - .references(() => objects.id, { onDelete: "cascade" }), + .references(() => workspaces.id, { onDelete: "cascade" }), backlogItemId: uuid("backlog_item_id") .notNull() .references(() => markdownBacklogItems.id, { onDelete: "cascade" }), diff --git a/packages/database/src/schema/forms.ts b/packages/database/src/schema/forms.ts index cf6c305..6c65994 100644 --- a/packages/database/src/schema/forms.ts +++ b/packages/database/src/schema/forms.ts @@ -10,6 +10,7 @@ import { } from "drizzle-orm/pg-core"; import { objects } from "./objects"; import { users } from "./users"; +import { workspaces } from "./workspaces"; export const forms = pgTable( "forms", @@ -17,7 +18,7 @@ export const forms = pgTable( id: uuid("id").primaryKey().defaultRandom(), workspaceId: uuid("workspace_id") .notNull() - .references(() => objects.id, { onDelete: "cascade" }), + .references(() => workspaces.id, { onDelete: "cascade" }), title: varchar("title", { length: 500 }).notNull(), description: text("description"), coverImage: text("cover_image"), diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 86f2d3c..a631eac 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -1,3 +1,4 @@ +export * from "./workspaces"; export * from "./objects"; export * from "./types"; export * from "./properties"; diff --git a/packages/database/src/schema/markdown_backlog.ts b/packages/database/src/schema/markdown_backlog.ts index 7109940..eaa3a5e 100644 --- a/packages/database/src/schema/markdown_backlog.ts +++ b/packages/database/src/schema/markdown_backlog.ts @@ -9,7 +9,7 @@ import { uniqueIndex, foreignKey, } from "drizzle-orm/pg-core"; -import { objects } from "./objects"; +import { workspaces } from "./workspaces"; /** * Imported plan / epic / task rows sourced from repo markdown under `plans/`. @@ -21,7 +21,7 @@ export const markdownBacklogItems = pgTable( id: uuid("id").primaryKey().defaultRandom(), workspaceId: uuid("workspace_id") .notNull() - .references(() => objects.id, { onDelete: "cascade" }), + .references(() => workspaces.id, { onDelete: "cascade" }), kind: varchar("kind", { length: 20 }).notNull(), slug: varchar("slug", { length: 200 }).notNull(), planSlug: varchar("plan_slug", { length: 200 }).notNull(), diff --git a/packages/database/src/schema/objects.ts b/packages/database/src/schema/objects.ts index 0936dd4..f3f4a4e 100644 --- a/packages/database/src/schema/objects.ts +++ b/packages/database/src/schema/objects.ts @@ -13,6 +13,7 @@ import { } from "drizzle-orm/pg-core"; import { users } from "./users"; import { templates } from "./templates"; +import { workspaces } from "./workspaces"; export const objects = pgTable( "objects", @@ -28,7 +29,9 @@ export const objects = pgTable( status: varchar("status", { length: 50 }), sortOrder: integer("sort_order").notNull().default(0), templateId: uuid("template_id"), - workspaceId: uuid("workspace_id"), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), @@ -39,10 +42,6 @@ export const objects = pgTable( columns: [table.parentId], foreignColumns: [table.id], }).onDelete("set null"), - workspaceFk: foreignKey({ - columns: [table.workspaceId], - foreignColumns: [table.id], - }).onDelete("cascade"), templateFk: foreignKey({ columns: [table.templateId], foreignColumns: [templates.id], @@ -85,7 +84,7 @@ export const workspaceMembers = pgTable( id: uuid("id").primaryKey().defaultRandom(), workspaceId: uuid("workspace_id") .notNull() - .references(() => objects.id, { onDelete: "cascade" }), + .references(() => workspaces.id, { onDelete: "cascade" }), userId: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), diff --git a/packages/database/src/schema/properties.ts b/packages/database/src/schema/properties.ts index 0fdee31..b49cf2b 100644 --- a/packages/database/src/schema/properties.ts +++ b/packages/database/src/schema/properties.ts @@ -7,7 +7,7 @@ import { timestamp, index, } from "drizzle-orm/pg-core"; -import { objects } from "./objects"; +import { workspaces } from "./workspaces"; export const propertyDefinitions = pgTable( "property_definitions", @@ -15,7 +15,7 @@ export const propertyDefinitions = pgTable( id: uuid("id").primaryKey().defaultRandom(), workspaceId: uuid("workspace_id") .notNull() - .references(() => objects.id, { onDelete: "cascade" }), + .references(() => workspaces.id, { onDelete: "cascade" }), name: varchar("name", { length: 255 }).notNull(), fieldType: varchar("field_type", { length: 50 }).notNull(), config: jsonb("config"), diff --git a/packages/database/src/schema/relations.ts b/packages/database/src/schema/relations.ts index c2652cd..3683c75 100644 --- a/packages/database/src/schema/relations.ts +++ b/packages/database/src/schema/relations.ts @@ -16,6 +16,7 @@ import { templates } from "./templates"; import { objectTypeDefs } from "./types"; import { markdownBacklogItems } from "./markdown_backlog"; import { cursorSyncMappings } from "./cursor_sync"; +import { workspaces } from "./workspaces"; export const objectRelations = pgTable( "object_relations", @@ -47,11 +48,25 @@ export const objectRelations = pgTable( export const usersRelations = relations(users, ({ many }) => ({ objectsCreated: many(objects), workspaceMemberships: many(workspaceMembers), + ownedWorkspaces: many(workspaces), objectAssignees: many(objectAssignees), accounts: many(accounts), sessions: many(sessions), })); +export const workspacesRelations = relations(workspaces, ({ one, many }) => ({ + owner: one(users, { + fields: [workspaces.ownerUserId], + references: [users.id], + }), + members: many(workspaceMembers), + objects: many(objects), + templates: many(templates), + objectTypeDefs: many(objectTypeDefs), + propertyDefinitions: many(propertyDefinitions), + markdownBacklogItems: many(markdownBacklogItems), +})); + export const objectsRelations = relations(objects, ({ one, many }) => ({ parent: one(objects, { fields: [objects.parentId], @@ -59,12 +74,10 @@ export const objectsRelations = relations(objects, ({ one, many }) => ({ relationName: "objectHierarchy", }), children: many(objects, { relationName: "objectHierarchy" }), - workspace: one(objects, { + workspace: one(workspaces, { fields: [objects.workspaceId], - references: [objects.id], - relationName: "workspaceRoot", + references: [workspaces.id], }), - workspaceContents: many(objects, { relationName: "workspaceRoot" }), template: one(templates, { fields: [objects.templateId], references: [templates.id], @@ -76,10 +89,8 @@ export const objectsRelations = relations(objects, ({ one, many }) => ({ propertyValues: many(propertyValues), views: many(views), assignees: many(objectAssignees), - workspaceMembers: many(workspaceMembers), outgoingRelations: many(objectRelations, { relationName: "relationSource" }), incomingRelations: many(objectRelations, { relationName: "relationTarget" }), - markdownBacklogItems: many(markdownBacklogItems), })); export const objectAssigneesRelations = relations(objectAssignees, ({ one }) => ({ @@ -94,9 +105,9 @@ export const objectAssigneesRelations = relations(objectAssignees, ({ one }) => })); export const workspaceMembersRelations = relations(workspaceMembers, ({ one }) => ({ - workspace: one(objects, { + workspace: one(workspaces, { fields: [workspaceMembers.workspaceId], - references: [objects.id], + references: [workspaces.id], }), user: one(users, { fields: [workspaceMembers.userId], @@ -105,9 +116,9 @@ export const workspaceMembersRelations = relations(workspaceMembers, ({ one }) = })); export const propertyDefinitionsRelations = relations(propertyDefinitions, ({ one, many }) => ({ - workspace: one(objects, { + workspace: one(workspaces, { fields: [propertyDefinitions.workspaceId], - references: [objects.id], + references: [workspaces.id], }), values: many(propertyValues), })); @@ -131,17 +142,17 @@ export const viewsRelations = relations(views, ({ one }) => ({ })); export const templatesRelations = relations(templates, ({ one, many }) => ({ - workspace: one(objects, { + workspace: one(workspaces, { fields: [templates.workspaceId], - references: [objects.id], + references: [workspaces.id], }), objects: many(objects), })); export const objectTypeDefsRelations = relations(objectTypeDefs, ({ one }) => ({ - workspace: one(objects, { + workspace: one(workspaces, { fields: [objectTypeDefs.workspaceId], - references: [objects.id], + references: [workspaces.id], }), })); @@ -175,9 +186,9 @@ export const objectRelationsRelations = relations(objectRelations, ({ one }) => export const markdownBacklogItemsRelations = relations( markdownBacklogItems, ({ one, many }) => ({ - workspace: one(objects, { + workspace: one(workspaces, { fields: [markdownBacklogItems.workspaceId], - references: [objects.id], + references: [workspaces.id], }), parent: one(markdownBacklogItems, { fields: [markdownBacklogItems.parentId], @@ -193,9 +204,9 @@ export const markdownBacklogItemsRelations = relations( ); export const cursorSyncMappingsRelations = relations(cursorSyncMappings, ({ one }) => ({ - workspace: one(objects, { + workspace: one(workspaces, { fields: [cursorSyncMappings.workspaceId], - references: [objects.id], + references: [workspaces.id], }), backlogItem: one(markdownBacklogItems, { fields: [cursorSyncMappings.backlogItemId], diff --git a/packages/database/src/schema/templates.ts b/packages/database/src/schema/templates.ts index 3141df9..fe5f08a 100644 --- a/packages/database/src/schema/templates.ts +++ b/packages/database/src/schema/templates.ts @@ -1,4 +1,3 @@ -// @ts-nocheck — circular inference with objects.workspaceId FK import { pgTable, uuid, @@ -7,7 +6,7 @@ import { timestamp, index, } from "drizzle-orm/pg-core"; -import { objects } from "./objects"; +import { workspaces } from "./workspaces"; export const templates = pgTable( "templates", @@ -15,7 +14,7 @@ export const templates = pgTable( id: uuid("id").primaryKey().defaultRandom(), workspaceId: uuid("workspace_id") .notNull() - .references(() => objects.id, { onDelete: "cascade" }), + .references(() => workspaces.id, { onDelete: "cascade" }), name: varchar("name", { length: 255 }).notNull(), targetType: varchar("target_type", { length: 50 }).notNull(), schema: jsonb("schema"), diff --git a/packages/database/src/schema/types.ts b/packages/database/src/schema/types.ts index f2cb6cd..2cef184 100644 --- a/packages/database/src/schema/types.ts +++ b/packages/database/src/schema/types.ts @@ -7,7 +7,7 @@ import { timestamp, index, } from "drizzle-orm/pg-core"; -import { objects } from "./objects"; +import { workspaces } from "./workspaces"; export const objectTypeDefs = pgTable( "object_type_defs", @@ -15,7 +15,7 @@ export const objectTypeDefs = pgTable( id: uuid("id").primaryKey().defaultRandom(), workspaceId: uuid("workspace_id") .notNull() - .references(() => objects.id, { onDelete: "cascade" }), + .references(() => workspaces.id, { onDelete: "cascade" }), name: varchar("name", { length: 255 }).notNull(), slug: varchar("slug", { length: 100 }).notNull(), icon: text("icon"), diff --git a/packages/database/src/schema/workspaces.ts b/packages/database/src/schema/workspaces.ts new file mode 100644 index 0000000..5d45f5a --- /dev/null +++ b/packages/database/src/schema/workspaces.ts @@ -0,0 +1,36 @@ +import { + pgTable, + uuid, + varchar, + timestamp, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { users } from "./users"; + +/** + * Top-level tenant boundary. Every multitenant table FK's into this. + * Promoted out of `objects` (where workspaces used to live as `type='workspace'`) + * to give us a real, RLS-friendly anchor for Block A of the EchoDo plan. + */ +export const workspaces = pgTable( + "workspaces", + { + id: uuid("id").primaryKey().defaultRandom(), + /** URL-safe, human-readable, unique workspace-wide. Mutable; URL re-routes on rename. */ + slug: varchar("slug", { length: 60 }).notNull(), + name: varchar("name", { length: 200 }).notNull(), + ownerUserId: uuid("owner_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + /** free | pro | team — billing/scope hook for Phase 2. */ + planTier: varchar("plan_tier", { length: 20 }).notNull().default("free"), + archivedAt: timestamp("archived_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + slugUnique: uniqueIndex("workspaces_slug_unique").on(table.slug), + ownerIdx: index("workspaces_owner_user_id_idx").on(table.ownerUserId), + }), +);