ubiquitous-invention/apps/web/app/(app)/[workspaceSlug]/planner/page.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

251 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo } from "react";
import { useParams } from "next/navigation";
import type { inferRouterOutputs } from "@trpc/server";
import { Calendar } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/lib/trpc";
import type { AppRouter } from "@/server/root";
import { cn } from "@/lib/utils";
type ListObject = inferRouterOutputs<AppRouter>["objects"]["list"]["objects"][number];
type PlannerTask = ListObject & {
dueDate?: unknown;
properties?: unknown;
};
function readDueDateString(task: PlannerTask): string | null {
if (typeof task.dueDate === "string" && task.dueDate.trim()) {
return task.dueDate.trim();
}
const props = task.properties;
if (props && typeof props === "object" && props !== null) {
const v = (props as Record<string, unknown>).dueDate;
if (typeof v === "string" && v.trim()) return v.trim();
}
const content = task.content;
if (content && typeof content === "object" && content !== null) {
const v = (content as Record<string, unknown>).dueDate;
if (typeof v === "string" && v.trim()) return v.trim();
}
return null;
}
function toLocalDateKey(iso: string): string | null {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatGroupHeading(dateKey: string): string {
const [y, mo, da] = dateKey.split("-").map(Number);
const date = new Date(y, mo - 1, da);
return date.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
}
function formatWeekRangeLabel(dateKey: string): string {
const [y, mo, da] = dateKey.split("-").map(Number);
const start = new Date(y, mo - 1, da);
const day = start.getDay();
const diff = start.getDate() - day + (day === 0 ? -6 : 1);
const weekStart = new Date(start);
weekStart.setDate(diff);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
const a = weekStart.toLocaleDateString(undefined, opts);
const b = weekEnd.toLocaleDateString(undefined, {
...opts,
year: weekEnd.getFullYear() !== weekStart.getFullYear() ? "numeric" : undefined,
});
return `Week of ${a} ${b}`;
}
const NO_DATE_KEY = "__no_date__";
function statusLabel(status: string | null | undefined): string {
switch (status) {
case "in_progress":
return "In progress";
case "done":
return "Done";
case "closed":
return "Closed";
case "open":
default:
return "Open";
}
}
function StatusBadge({ status }: { status: string | null | undefined }) {
const s = status ?? "open";
if (s === "in_progress") {
return (
<Badge
className={cn(
"shrink-0 border-transparent bg-blue-500/15 font-medium text-blue-700",
"hover:bg-blue-500/20 dark:text-blue-300",
)}
>
{statusLabel(s)}
</Badge>
);
}
if (s === "done") {
return (
<Badge
className={cn(
"shrink-0 border-transparent bg-green-500/15 font-medium text-green-700",
"hover:bg-green-500/20 dark:text-green-300",
)}
>
{statusLabel(s)}
</Badge>
);
}
return (
<Badge variant="outline" className="shrink-0 font-medium">
{statusLabel(s)}
</Badge>
);
}
export default function PlannerPage() {
const params = useParams();
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const listQuery = api.objects.list.useQuery(
{ workspace: workspaceSlug!, type: "task", limit: 200 },
{ enabled: Boolean(workspaceSlug) },
);
const grouped = useMemo(() => {
const rows = (listQuery.data?.objects ?? []) as PlannerTask[];
const map = new Map<string, PlannerTask[]>();
for (const task of rows) {
const raw = readDueDateString(task);
const key = raw ? toLocalDateKey(raw) : null;
const groupKey = key ?? NO_DATE_KEY;
const list = map.get(groupKey) ?? [];
list.push(task);
map.set(groupKey, list);
}
const keys = [...map.keys()].sort((a, b) => {
if (a === NO_DATE_KEY) return 1;
if (b === NO_DATE_KEY) return -1;
return a.localeCompare(b);
});
for (const k of keys) {
const list = map.get(k)!;
list.sort((a, b) => a.title.localeCompare(b.title));
}
return { keys, map };
}, [listQuery.data?.objects]);
const taskCount = listQuery.data?.objects?.length ?? 0;
const isEmpty =
Boolean(workspaceSlug) && !listQuery.isLoading && taskCount === 0;
if (!workspaceSlug) {
return (
<div className="mx-auto max-w-3xl px-8 py-10">
<p className="text-sm text-muted-foreground">No workspace selected.</p>
</div>
);
}
return (
<div className="mx-auto flex h-full max-w-3xl flex-col px-8 py-10">
<header className="flex flex-wrap items-start gap-4 border-b border-border pb-6">
<div
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl",
"border border-border bg-muted/40 text-muted-foreground",
)}
>
<Calendar className="size-5" strokeWidth={1.75} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">Planner</h1>
<Badge variant="secondary" className="font-normal text-muted-foreground">
Calendar view coming soon
</Badge>
</div>
</div>
</header>
{listQuery.isLoading ? (
<p className="mt-8 text-sm text-muted-foreground">Loading tasks</p>
) : isEmpty ? (
<div
className={cn(
"mt-8 rounded-lg border border-dashed border-border bg-muted/30",
"p-12 text-center text-sm text-muted-foreground",
)}
>
No tasks in this workspace yet. Create tasks in a space to see them here.
</div>
) : (
<ScrollArea className="mt-6 min-h-[min(480px,calc(100vh-14rem))] flex-1 pr-3">
<div className="space-y-8 pb-6">
{grouped.keys.map((key) => {
const tasks = grouped.map.get(key)!;
const isNoDate = key === NO_DATE_KEY;
return (
<section key={key} className="space-y-3">
<div>
<h2 className="text-sm font-semibold tracking-tight text-foreground">
{isNoDate ? "No date" : formatGroupHeading(key)}
</h2>
{!isNoDate ? (
<p className="text-xs text-muted-foreground">
{formatWeekRangeLabel(key)}
</p>
) : null}
</div>
<ul
className={cn(
"divide-y divide-border overflow-hidden rounded-lg border border-border",
"bg-card shadow-sm",
)}
>
{tasks.map((task) => (
<li
key={task.id}
className="flex items-center gap-3 px-4 py-3 text-sm"
>
<span className="min-w-0 flex-1 truncate font-medium">
{task.title || "Untitled"}
</span>
<StatusBadge status={task.status} />
</li>
))}
</ul>
</section>
);
})}
</div>
</ScrollArea>
)}
</div>
);
}