Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
103 lines
3.1 KiB
TypeScript
103 lines
3.1 KiB
TypeScript
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<WorkspaceContext> {
|
|
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 };
|