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>
355 lines
11 KiB
TypeScript
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 };
|
|
}),
|
|
});
|