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>
112 lines
3.2 KiB
TypeScript
112 lines
3.2 KiB
TypeScript
import { z } from "zod";
|
|
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<void> {
|
|
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,
|
|
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)
|
|
.where(
|
|
and(
|
|
eq(userFavorites.userId, ctx.session.user.id),
|
|
eq(userFavorites.objectId, input.objectId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
if (existing.length > 0) {
|
|
await ctx.db.delete(userFavorites).where(eq(userFavorites.id, existing[0].id));
|
|
return { favorited: false };
|
|
}
|
|
|
|
await ctx.db.insert(userFavorites).values({
|
|
userId: ctx.session.user.id,
|
|
objectId: input.objectId,
|
|
});
|
|
return { favorited: true };
|
|
}),
|
|
|
|
isFavorited: protectedProcedure
|
|
.input(z.object({ objectId: z.string().uuid() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const rows = await ctx.db
|
|
.select({ id: userFavorites.id })
|
|
.from(userFavorites)
|
|
.where(
|
|
and(
|
|
eq(userFavorites.userId, ctx.session.user.id),
|
|
eq(userFavorites.objectId, input.objectId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
return { favorited: rows.length > 0 };
|
|
}),
|
|
});
|