ubiquitous-invention/apps/web/components/views/overview/overview-view.tsx
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

125 lines
4.2 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { api } from "@/lib/trpc";
export interface OverviewViewProps {
workspaceHandle?: string;
spaceId?: string;
}
function formatUpdatedAt(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
export function OverviewView({ workspaceHandle, spaceId }: OverviewViewProps) {
const spaceQuery = api.objects.getById.useQuery(
{ workspace: workspaceHandle!, id: spaceId! },
{ enabled: Boolean(spaceId) && Boolean(workspaceHandle) },
);
const childrenQuery = api.objects.list.useQuery(
{
workspace: workspaceHandle!,
parentId: spaceId ?? undefined,
limit: 200,
},
{ enabled: Boolean(workspaceHandle) },
);
const statusCounts = useMemo(() => {
const counts = { open: 0, in_progress: 0, done: 0 };
for (const row of childrenQuery.data?.objects ?? []) {
const s = row.status;
if (s === "in_progress") counts.in_progress += 1;
else if (s === "done") counts.done += 1;
else counts.open += 1;
}
return counts;
}, [childrenQuery.data?.objects]);
const recentChildren = useMemo(() => {
const rows = childrenQuery.data?.objects ?? [];
return [...rows]
.sort((a, b) => {
const ta = new Date(a.updatedAt).getTime();
const tb = new Date(b.updatedAt).getTime();
return tb - ta;
})
.slice(0, 10);
}, [childrenQuery.data?.objects]);
const isLoading = spaceQuery.isLoading || childrenQuery.isLoading;
const children = childrenQuery.data?.objects ?? [];
const hasChildren = children.length > 0;
const space = spaceQuery.data as { title: string } | undefined;
return (
<div className="h-full overflow-auto p-6">
{spaceQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading space</div>
) : space ? (
<h1 className="text-2xl font-semibold tracking-tight">{space.title}</h1>
) : null}
<div className="mt-8 space-y-8">
<section>
<h2 className="text-sm font-semibold">By status</h2>
<div className="mt-3 grid grid-cols-3 gap-4">
{(
[
{ key: "open" as const, label: "Open" },
{ key: "in_progress" as const, label: "In progress" },
{ key: "done" as const, label: "Done" },
] as const
).map(({ key, label }) => (
<div
key={key}
className="rounded-lg border border-border bg-card p-4 shadow-sm"
>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">
{isLoading ? "—" : statusCounts[key]}
</div>
</div>
))}
</div>
</section>
<section>
<h2 className="text-sm font-semibold">Recent activity</h2>
{childrenQuery.isLoading ? (
<p className="mt-3 text-sm text-muted-foreground">Loading</p>
) : !hasChildren ? (
<div className="mt-4 rounded-lg border border-dashed border-border bg-muted/30 p-10 text-center text-sm text-muted-foreground">
No items in this space yet.
</div>
) : (
<ul className="mt-3 divide-y divide-border rounded-lg border border-border bg-card shadow-sm">
{recentChildren.map((obj) => (
<li
key={obj.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<span className="min-w-0 truncate font-medium">
{obj.title}
</span>
<div className="flex shrink-0 items-center gap-2">
<Badge variant="outline">{obj.type}</Badge>
<span className="text-xs text-muted-foreground tabular-nums">
{formatUpdatedAt(obj.updatedAt)}
</span>
</div>
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
}