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

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

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

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

355 lines
11 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { and, asc, desc, eq } from "drizzle-orm";
import {
formResponses,
forms,
objects,
propertyDefinitions,
propertyValues,
} from "@tasks/database/schema";
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;
type FormFieldRow = {
id: string;
type?: string;
mappedProperty?: string | null;
};
async function resolvePropertyDefId(
db: Pick<Context["db"], "select">,
workspaceId: string,
mappedProperty: string,
): Promise<string | null> {
if (UUID_RE.test(mappedProperty)) {
const [def] = await db
.select({ id: propertyDefinitions.id })
.from(propertyDefinitions)
.where(
and(
eq(propertyDefinitions.id, mappedProperty),
eq(propertyDefinitions.workspaceId, workspaceId),
),
)
.limit(1);
return def?.id ?? null;
}
const [def] = await db
.select({ id: propertyDefinitions.id })
.from(propertyDefinitions)
.where(
and(
eq(propertyDefinitions.workspaceId, workspaceId),
eq(propertyDefinitions.name, mappedProperty),
),
)
.limit(1);
return def?.id ?? null;
}
export const formsRouter = router({
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 };
}),
getById: workspaceProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const row = await ctx.db.query.forms.findFirst({
where: and(eq(forms.id, input.id), eq(forms.workspaceId, ctx.workspace.id)),
});
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" });
}
return row;
}),
create: workspaceProcedure
.input(
z.object({
title: z.string().min(1).max(500),
description: z.string().optional(),
coverImage: z.string().optional(),
objectId: z.string().uuid().nullable().optional(),
targetType: z.string().max(50).optional().default("task"),
fields: z.array(z.unknown()).optional().default([]),
settings: z.record(z.unknown()).optional().default({}),
isPublished: z.boolean().optional().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
if (!userId) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id" });
}
const now = new Date();
const [created] = await ctx.db
.insert(forms)
.values({
workspaceId: ctx.workspace.id,
title: input.title,
description: input.description ?? null,
coverImage: input.coverImage ?? null,
objectId: input.objectId ?? null,
targetType: input.targetType ?? "task",
fields: input.fields ?? [],
settings: input.settings ?? {},
isPublished: input.isPublished ?? false,
createdBy: userId,
createdAt: now,
updatedAt: now,
})
.returning();
if (!created) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create form",
});
}
return created;
}),
update: workspaceProcedure
.input(
z.object({
id: z.string().uuid(),
title: z.string().min(1).max(500).optional(),
description: z.string().nullable().optional(),
coverImage: z.string().nullable().optional(),
objectId: z.string().uuid().nullable().optional(),
targetType: z.string().max(50).optional(),
fields: z.array(z.unknown()).optional(),
settings: z.record(z.unknown()).optional(),
isPublished: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...patch } = input;
const now = new Date();
const [updated] = await ctx.db
.update(forms)
.set({
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.description !== undefined ? { description: patch.description } : {}),
...(patch.coverImage !== undefined ? { coverImage: patch.coverImage } : {}),
...(patch.objectId !== undefined ? { objectId: patch.objectId } : {}),
...(patch.targetType !== undefined ? { targetType: patch.targetType } : {}),
...(patch.fields !== undefined ? { fields: patch.fields } : {}),
...(patch.settings !== undefined ? { settings: patch.settings } : {}),
...(patch.isPublished !== undefined ? { isPublished: patch.isPublished } : {}),
updatedAt: now,
})
.where(and(eq(forms.id, id), eq(forms.workspaceId, ctx.workspace.id)))
.returning();
if (!updated) {
throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" });
}
return updated;
}),
delete: workspaceProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const deleted = await ctx.db
.delete(forms)
.where(and(eq(forms.id, input.id), eq(forms.workspaceId, ctx.workspace.id)))
.returning({ id: forms.id });
if (deleted.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" });
}
}),
submit: workspaceProcedure
.input(
z.object({
formId: z.string().uuid(),
data: z.record(z.unknown()),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
if (!userId) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id" });
}
const form = await ctx.db.query.forms.findFirst({
where: and(eq(forms.id, input.formId), eq(forms.workspaceId, ctx.workspace.id)),
});
if (!form) {
throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" });
}
const fieldRows = Array.isArray(form.fields) ? (form.fields as FormFieldRow[]) : [];
const workspaceId = form.workspaceId;
let title = form.title;
let description: string | null | undefined;
let icon: string | null | undefined;
let status: string | null | undefined;
for (const field of fieldRows) {
const key = field.id;
if (!key || !(key in input.data)) continue;
const raw = input.data[key];
const mapKey = field.mappedProperty?.trim();
if (!mapKey) continue;
const lower = mapKey.toLowerCase();
if (lower === "title") {
title = raw == null ? title : String(raw);
continue;
}
if (lower === "description") {
description = raw == null ? null : String(raw);
continue;
}
if (lower === "icon") {
icon = raw == null ? null : String(raw);
continue;
}
if (lower === "status") {
status = raw == null ? null : String(raw);
continue;
}
}
const now = new Date();
const result = await ctx.db.transaction(async (tx) => {
const [createdObject] = await tx
.insert(objects)
.values({
type: form.targetType,
title,
parentId: form.objectId ?? null,
workspaceId,
...(description !== undefined ? { description } : {}),
...(icon !== undefined ? { icon } : {}),
...(status !== undefined ? { status } : {}),
createdBy: userId,
createdAt: now,
updatedAt: now,
})
.returning();
if (!createdObject) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create object from form",
});
}
for (const field of fieldRows) {
const key = field.id;
if (!key || !(key in input.data)) continue;
const skipTypes = new Set(["section_header", "divider"]);
if (field.type && skipTypes.has(field.type)) continue;
const mapKey = field.mappedProperty?.trim();
if (!mapKey) continue;
const lower = mapKey.toLowerCase();
if (["title", "description", "icon", "status"].includes(lower)) {
continue;
}
const propertyDefId = await resolvePropertyDefId(tx, workspaceId, mapKey);
if (!propertyDefId) continue;
const value = input.data[key];
await tx
.insert(propertyValues)
.values({
objectId: createdObject.id,
propertyDefId,
value: value as unknown,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [propertyValues.objectId, propertyValues.propertyDefId],
set: {
value: value as unknown,
updatedAt: now,
},
});
}
const [responseRow] = await tx
.insert(formResponses)
.values({
formId: form.id,
respondentId: userId,
createdObjectId: createdObject.id,
data: input.data,
submittedAt: now,
})
.returning();
if (!responseRow) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to record form response",
});
}
return { object: createdObject, response: responseRow };
});
return result;
}),
listResponses: workspaceProcedure
.input(
z.object({
formId: z.string().uuid(),
limit: z.number().int().positive().max(500).optional(),
offset: z.number().int().nonnegative().optional(),
}),
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 50;
const offset = input.offset ?? 0;
const form = await ctx.db.query.forms.findFirst({
where: and(eq(forms.id, input.formId), eq(forms.workspaceId, ctx.workspace.id)),
columns: { id: true },
});
if (!form) {
throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" });
}
const rows = await ctx.db
.select()
.from(formResponses)
.where(eq(formResponses.formId, input.formId))
.orderBy(desc(formResponses.submittedAt), asc(formResponses.id))
.limit(limit)
.offset(offset);
return { responses: rows };
}),
});