ubiquitous-invention/apps/web/components/panels/object-detail.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

670 lines
23 KiB
TypeScript

"use client";
import * as React from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
import {
Check,
CheckSquare,
ChevronDown,
ExternalLink,
FileText,
Folder,
Link2,
Plus,
X,
} from "lucide-react";
import { isTRPCClientError } from "@trpc/client";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import {
usePanelStore,
type PanelDetailTab,
} from "@/lib/stores/panel-store";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { AssigneePicker } from "@/components/panels/assignee-picker";
import {
PropertyEditor,
type PropertyFieldType,
} from "@/components/panels/property-editor";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
type StatusValue = "open" | "in_progress" | "done" | "closed";
const STATUS_OPTIONS: {
value: StatusValue;
label: string;
dot: string;
}[] = [
{ value: "open", label: "Open", dot: "bg-slate-400" },
{ value: "in_progress", label: "In progress", dot: "bg-amber-500" },
{ value: "done", label: "Done", dot: "bg-emerald-500" },
{ value: "closed", label: "Closed", dot: "bg-zinc-400" },
];
export interface ObjectDetailData {
id: string;
title: string;
type: string;
status: string;
description: string | null;
assignees: {
user: { id?: string; name: string; avatarUrl?: string | null };
}[];
propertyValues: {
id?: string;
propertyDefinition: {
id: string;
name: string;
fieldType: string;
config?: Record<string, unknown> | null;
};
value: unknown;
}[];
children?: { id: string; title: string; type: string }[];
relations?: { id: string; title: string; type: string }[];
}
function normalizeFieldType(raw: string): PropertyFieldType {
const t = raw.toLowerCase().replace(/[\s-]+/g, "_") as PropertyFieldType;
const allowed: PropertyFieldType[] = [
"text",
"richtext",
"number",
"date",
"select",
"multiselect",
"person",
"relation",
"url",
"file",
"checkbox",
];
return allowed.includes(t) ? t : "text";
}
function typeLabel(type: string) {
const t = type.toLowerCase();
if (t === "task") return "Task";
if (t === "document") return "Document";
return type.charAt(0).toUpperCase() + type.slice(1);
}
function TypeIcon({ type }: { type: string }) {
const t = type.toLowerCase();
if (t === "document") return <FileText className="size-4 text-teal-600 dark:text-teal-400" />;
if (t === "task") return <CheckSquare className="size-4 text-violet-600 dark:text-violet-400" />;
return <Folder className="size-4 text-muted-foreground" />;
}
function initials(name: string) {
return name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
function useObjectDetailQuery(
objectId: string | null,
workspaceHandle: string | undefined,
) {
return api.objects.getById.useQuery(
{ id: objectId as string, workspace: workspaceHandle as string },
{ enabled: Boolean(objectId) && Boolean(workspaceHandle) },
);
}
function DetailSkeleton() {
return (
<div className="flex animate-pulse flex-col gap-3 p-3">
<div className="flex gap-2">
<div className="size-8 rounded-md bg-muted" />
<div className="flex flex-1 flex-col gap-2">
<div className="h-3 w-20 rounded bg-muted" />
<div className="h-6 w-full rounded bg-muted" />
</div>
</div>
<div className="h-9 w-full rounded-md bg-muted" />
<div className="h-24 w-full rounded-md bg-muted" />
<div className="h-32 w-full rounded-md bg-muted" />
</div>
);
}
export function ObjectDetail() {
const objectId = usePanelStore((s) => s.objectId);
const activeTab = usePanelStore((s) => s.activeTab);
const setActiveTab = usePanelStore((s) => s.setActiveTab);
const closePanel = usePanelStore((s) => s.close);
const openPanel = usePanelStore((s) => s.open);
const utils = api.useUtils();
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const workspaceHandle = workspace?.slug ?? workspace?.id;
const { data: workspaceMembersList } = api.workspaces.listMembers.useQuery(
{ workspace: workspaceHandle! },
{ enabled: Boolean(workspaceHandle) },
);
const objectDetailQuery = useObjectDetailQuery(objectId, workspaceHandle);
const data = objectDetailQuery.data as ObjectDetailData | undefined;
const { isPending, isError, error } = objectDetailQuery;
const [titleDraft, setTitleDraft] = React.useState("");
const [editingTitle, setEditingTitle] = React.useState(false);
const [descriptionDraft, setDescriptionDraft] = React.useState("");
const [assigneeOpen, setAssigneeOpen] = React.useState(false);
React.useEffect(() => {
if (data?.title != null) setTitleDraft(data.title);
}, [data?.title]);
React.useEffect(() => {
if (data?.description != null) setDescriptionDraft(data.description ?? "");
}, [data?.description]);
const mergeObjectCache = React.useCallback(
(id: string, patch: Partial<ObjectDetailData>) => {
if (!workspaceHandle) return;
utils.objects.getById.setData({ id, workspace: workspaceHandle }, (old) => {
if (!old) return old;
return {
...(old as ObjectDetailData),
...patch,
} as typeof old;
});
},
[utils, workspaceHandle],
);
const updateObjectMutation = api.objects.update.useMutation({
onMutate: async (variables) => {
mergeObjectCache(variables.id, variables as Partial<ObjectDetailData>);
},
onSuccess: async (_data, variables) => {
if (!workspaceHandle) return;
await utils.objects.getById.invalidate({ id: variables.id, workspace: workspaceHandle });
},
});
const assignMutation = api.objects.assign.useMutation({
onSuccess: async (_data, variables) => {
if (!workspaceHandle) return;
await utils.objects.getById.invalidate({ id: variables.objectId, workspace: workspaceHandle });
},
});
const setPropertyValueMutation = api.properties.setValue.useMutation({
onSuccess: async (_data, variables) => {
if (!workspaceHandle) return;
await utils.objects.getById.invalidate({ id: variables.objectId, workspace: workspaceHandle });
},
});
const handlePropertyChange = (
index: number,
row: ObjectDetailData["propertyValues"][number],
next: unknown,
) => {
if (!data || !workspaceHandle) return;
const nextRows = [...data.propertyValues];
nextRows[index] = { ...row, value: next };
mergeObjectCache(data.id, { propertyValues: nextRows });
setPropertyValueMutation.mutate({
workspace: workspaceHandle,
objectId: data.id,
propertyDefId: row.propertyDefinition.id,
value: next,
});
};
const assignedIds = React.useMemo(() => {
if (!data) return [];
return data.assignees
.map((a) => a.user.id)
.filter((id): id is string => Boolean(id));
}, [data]);
const toggleAssignee = (userId: string) => {
if (!data) return;
const has = assignedIds.includes(userId);
const member = workspaceMembersList?.find((u) => u.id === userId);
const displayName = member
? (member.name ?? member.email)
: "User";
let nextAssignees: ObjectDetailData["assignees"];
if (has) {
nextAssignees = data.assignees.filter((a) => a.user.id !== userId);
} else {
nextAssignees = [
...data.assignees,
{
user: {
id: userId,
name: displayName,
avatarUrl: member?.avatarUrl ?? null,
},
},
];
}
mergeObjectCache(data.id, { assignees: nextAssignees });
if (!workspaceHandle) return;
assignMutation.mutate({
workspace: workspaceHandle,
objectId: data.id,
userId,
action: has ? "remove" : "add",
});
};
const commitTitle = () => {
if (!data || titleDraft.trim() === data.title) {
setEditingTitle(false);
return;
}
if (!workspaceHandle) return;
updateObjectMutation.mutate({ workspace: workspaceHandle, id: data.id, title: titleDraft.trim() });
setEditingTitle(false);
};
const commitDescription = () => {
if (!data || !workspaceHandle) return;
if (descriptionDraft === (data.description ?? "")) return;
updateObjectMutation.mutate({ workspace: workspaceHandle, id: data.id, description: descriptionDraft });
};
const setStatus = (status: StatusValue) => {
if (!data || !workspaceHandle) return;
updateObjectMutation.mutate({ workspace: workspaceHandle, id: data.id, status });
};
const onTabChange = (v: string) => {
setActiveTab(v as PanelDetailTab);
};
if (!objectId) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
<p>No object selected.</p>
<Button variant="outline" size="sm" onClick={() => closePanel()}>
Close panel
</Button>
</div>
);
}
if (isPending) {
return <DetailSkeleton />;
}
if (isError || !data) {
const notFound =
isTRPCClientError(error) && error.data?.code === "NOT_FOUND";
return (
<div className="p-4 text-xs text-destructive">
{isError
? notFound
? "Not found"
: error instanceof Error
? error.message
: "Failed to load object."
: "Not found"}
</div>
);
}
const status =
STATUS_OPTIONS.find((s) => s.value === data.status)?.value ??
(data.status as StatusValue);
const statusMeta =
STATUS_OPTIONS.find((s) => s.value === status) ?? STATUS_OPTIONS[0];
return (
<div className="flex h-full min-h-0 flex-1 flex-col bg-card">
<header className="shrink-0 border-b border-border px-3 pb-2 pt-3">
<div className="flex items-start gap-2">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted/40">
<TypeIcon type={data.type} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"
>
{typeLabel(data.type)}
</Badge>
</div>
{editingTitle ? (
<Input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitTitle();
}
if (e.key === "Escape") {
setTitleDraft(data.title);
setEditingTitle(false);
}
}}
className="h-8 text-sm font-semibold"
/>
) : (
<button
type="button"
onClick={() => setEditingTitle(true)}
className="w-full rounded px-0.5 text-left text-sm font-semibold leading-snug text-foreground hover:bg-muted/60"
>
{data.title}
</button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => closePanel()}
aria-label="Close panel"
>
<X className="size-4" />
</Button>
</div>
<div className="mt-2 flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 border-border px-2 text-xs font-normal"
>
<span
className={cn("size-2 shrink-0 rounded-full", statusMeta.dot)}
/>
{statusMeta.label}
<ChevronDown className="size-3.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
{STATUS_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
className="gap-2 text-xs"
onClick={() => setStatus(opt.value)}
>
<span className={cn("size-2 rounded-full", opt.dot)} />
{opt.label}
{opt.value === status ? (
<Check className="ml-auto size-3.5 text-primary" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<Tabs
value={activeTab}
onValueChange={onTabChange}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="flex shrink-0 gap-0 border-b border-border px-1">
{(
[
["details", "Details"],
["activity", "Activity"],
["comments", "Comments"],
] as const
).map(([value, label]) => (
<TabsTrigger
key={value}
value={value}
className={cn(
"relative flex-1 px-2 py-2 text-center text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors",
"hover:text-foreground",
"data-[state=active]:text-foreground",
"data-[state=active]:after:absolute data-[state=active]:after:inset-x-2 data-[state=active]:after:bottom-0 data-[state=active]:after:h-0.5 data-[state=active]:after:rounded-full data-[state=active]:after:bg-primary",
)}
>
{label}
</TabsTrigger>
))}
</TabsList>
<TabsContent
value="details"
className="flex min-h-0 flex-1 flex-col overflow-hidden animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0 px-3 pb-6 pt-2">
<section className="py-2">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Assignees
</h3>
<div className="flex flex-wrap items-center gap-1.5">
{data.assignees.map((a, i) => (
<Avatar
key={`${a.user.id ?? a.user.name}-${i}`}
className="size-7 border border-border"
title={a.user.name}
>
<AvatarImage src={a.user.avatarUrl ?? undefined} alt="" />
<AvatarFallback className="text-[10px]">
{initials(a.user.name)}
</AvatarFallback>
</Avatar>
))}
<AssigneePicker
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
assignedIds={assignedIds}
onToggle={toggleAssignee}
workspaceHandle={workspaceHandle}
>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-[11px] font-normal"
>
<Plus className="size-3.5" />
Add assignee
</Button>
</AssigneePicker>
</div>
</section>
<Separator className="bg-border/80" />
<section className="py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Properties
</h3>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] font-medium text-primary"
type="button"
>
<Plus className="mr-1 size-3" />
Add property
</Button>
</div>
<div className="rounded-md border border-border/80 bg-muted/20">
{data.propertyValues.length === 0 ? (
<p className="px-3 py-4 text-center text-[11px] text-muted-foreground">
No custom properties yet.
</p>
) : (
data.propertyValues.map((row, index) => (
<div
key={row.id ?? `${row.propertyDefinition.name}-${index}`}
className="border-b border-border/60 px-2 last:border-b-0"
>
<PropertyEditor
definition={{
name: row.propertyDefinition.name,
fieldType: normalizeFieldType(
row.propertyDefinition.fieldType,
),
config: row.propertyDefinition.config as
| { options?: { label: string; value: string }[] }
| undefined,
}}
value={row.value}
onChange={(v) => handlePropertyChange(index, row, v)}
/>
</div>
))
)}
</div>
</section>
<Separator className="bg-border/80" />
<section className="py-2">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Description
</h3>
<textarea
value={descriptionDraft}
onChange={(e) => setDescriptionDraft(e.target.value)}
onBlur={commitDescription}
rows={4}
placeholder="Add a description…"
className="w-full resize-y rounded-md border border-input bg-background px-2 py-1.5 text-xs leading-relaxed ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
/>
</section>
{data.children && data.children.length > 0 ? (
<>
<Separator className="bg-border/80" />
<section className="py-2">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Subtasks
</h3>
<ul className="space-y-1">
{data.children.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => openPanel("object-detail", c.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/70"
>
<CheckSquare className="size-3.5 shrink-0 text-violet-500" />
<span className="min-w-0 flex-1 truncate font-medium">
{c.title}
</span>
<Badge
variant="outline"
className="h-5 shrink-0 px-1.5 text-[9px] font-normal uppercase"
>
{typeLabel(c.type)}
</Badge>
</button>
</li>
))}
</ul>
</section>
</>
) : null}
<Separator className="bg-border/80" />
<section className="py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Relations
</h3>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] font-medium text-primary"
type="button"
>
<Link2 className="mr-1 size-3" />
Add relation
</Button>
</div>
{data.relations && data.relations.length > 0 ? (
<ul className="space-y-1">
{data.relations.map((r) => (
<li key={r.id}>
<button
type="button"
onClick={() => openPanel("object-detail", r.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/70"
>
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{r.title}
</span>
<Badge
variant="secondary"
className="h-5 shrink-0 px-1.5 text-[9px] font-normal uppercase"
>
{typeLabel(r.type)}
</Badge>
</button>
</li>
))}
</ul>
) : (
<p className="py-2 text-[11px] text-muted-foreground">
No relations yet.
</p>
)}
</section>
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="activity"
className="min-h-0 flex-1 animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
>
<div className="p-4 text-xs text-muted-foreground">
<p className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-6 text-center">
Activity feed will appear here (audit log, updates, mentions).
</p>
</div>
</TabsContent>
<TabsContent
value="comments"
className="min-h-0 flex-1 animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
>
<div className="p-4 text-xs text-muted-foreground">
<p className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-6 text-center">
Comments thread coming soon.
</p>
</div>
</TabsContent>
</Tabs>
</div>
);
}