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>
125 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|