ubiquitous-invention/apps/web/server/trpc.ts

100 lines
3 KiB
TypeScript
Raw Normal View History

import { initTRPC, TRPCError } from "@trpc/server";
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-07 00:02:55 -04:00
import { z } from "zod";
import superjson from "superjson";
import type { Session } from "next-auth";
import { db } from "@tasks/database";
import { auth } from "@/lib/auth";
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-07 00:02:55 -04:00
import { resolveWorkspace, type WorkspaceContext } from "@/server/lib/resolve-workspace";
export type Context = {
db: typeof db;
session: Session | null;
};
export async function createContext(): Promise<Context> {
try {
const session = await auth();
return { db, session };
} catch (error) {
console.error("[trpc] createContext failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create request context",
cause: error,
});
}
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) Closes Task-workspace-invites-and-roles end-to-end. Builds on the schema + procedures from 7a55d6d (Task 2, part 1/2). apps/web/components/teams/invite-dialog.tsx (new): * Owner/admin-only sheet that wraps invites.create. Email input + role select (member/admin; owner deliberately excluded — single-owner model means ownership transfer is a separate flow, not a fresh invite). On success surfaces the accept URL with a copy-to-clipboard affordance and a "your email isn't wired up yet, paste this directly" hint. Plain text input in this commit; the smart recipient autocomplete combobox from Task 3 will swap it in via a follow-up edit to this same file (subagent is working that in parallel). apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite): * Replaced the placeholder "Invite coming soon" button with the new InviteDialog. Adds: - Pending invites section (admin/owner only) listing each open invite with email, role, expiry-relative time, and Copy link / Revoke actions. - Per-member kebab menu with role-change actions and Remove. Only owners can promote anyone to owner; admins can move people between admin/member only. The "demote to member" item disables on the last-owner row (the server enforces this anyway with a clear error; UI just avoids surfacing a click that'd 400). - "You're a member, not a manager" footer hint for non-owners/admins. * Caller's role is derived from the members query (no extra round-trip) — the membership row IS the source of truth for who can manage what. * Mutation errors surface inline at the page level with a Dismiss action — kebab/copy actions that hit the last-owner guard, expired- token error, etc. don't fail silently. apps/web/app/invite/[token]/page.tsx (new): * Public-by-token redeem page. Four phases handled cleanly: 1. No session yet -> "Sign in to continue" with callbackUrl set so the user lands back here after auth. 2. Authenticated, accepting -> spinner. 3. Success -> redirect to the workspace's slug-rooted URL. 4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated explainer page showing both the invited email AND the user's current sign-in email, with deep links to link the invited email via OAuth and try again. (This is the Task 1 invariant surfacing through the UI: we never silently accept an invite under a mismatched identity.) * All other accept errors (not found / revoked / expired) render the message verbatim with a "Go home" button. apps/web/server/trpc.ts: * Added a small errorFormatter that exposes `error.cause` to the client when it's a plain object. Required for the invite-accept explainer page to read `cause.invitedEmail` off the TRPCError. The cause-payload contract is "small, pure data, no secrets" — anything the server throws as a cause is also visible client-side. End-to-end behavior verified statically: type-check clean across all 6 packages. Smoke test path: 1. As admin@tasks.dev, open /<workspace>/teams. 2. Click Invite -> dialog opens -> enter an email, pick member, send. 3. See the success state with the accept URL. Copy it. 4. Open the URL in a different browser (or incognito). With no session -> sign-in prompt. After auth -> invite accepts and you land in the workspace. With a session whose email doesn't match -> the email-mismatch explainer renders. Note: the test runner shows three new tests in packages/shared (invite-suggestions.test.ts) from the in-progress Task-3 subagent. Those land with their own commit when the subagent finishes — they're visible here only because they share the working tree. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
// Expose `error.cause` to the client. Procedures that need to surface
// structured failure modes (e.g. `invites.accept` returning the invited
// email so the explainer page can render) pass a `{ reason, ... }` object
// as the cause and the client reads it from `error.shape.data.cause`.
// Anything thrown should still be safe to serialize (no class instances,
// no DB rows, no secrets) — keep cause payloads small and pure data.
errorFormatter: ({ shape, error }) => ({
...shape,
data: {
...shape.data,
cause:
error.cause && typeof error.cause === "object" && !(error.cause instanceof Error)
? (error.cause as Record<string, unknown>)
: undefined,
},
}),
});
export const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
...ctx,
session: ctx.session,
},
});
});
export const router = t.router;
export const createCallerFactory = t.createCallerFactory;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
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-07 00:02:55 -04:00
/**
* 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 };