ubiquitous-invention/apps/web/components/objects/create-object-dialog.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

428 lines
14 KiB
TypeScript

"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { useRouter } from "next/navigation";
import {
CheckSquare,
ClipboardList,
FileStack,
FileText,
LayoutGrid,
Presentation,
X,
} from "lucide-react";
import { TemplatePicker, type PickerTemplate } from "@/components/templates";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
const CREATE_TYPES = ["task", "document", "space", "whiteboard", "form"] as const;
type CreateObjectType = (typeof CREATE_TYPES)[number];
const TYPE_OPTIONS: {
value: CreateObjectType;
label: string;
icon: React.ComponentType<{ className?: string }>;
}[] = [
{ value: "task", label: "Task", icon: CheckSquare },
{ value: "document", label: "Document", icon: FileText },
{ value: "space", label: "Space", icon: LayoutGrid },
{ value: "whiteboard", label: "Whiteboard", icon: Presentation },
{ value: "form", label: "Form", icon: ClipboardList },
];
const TASK_STATUSES = [
{ value: "open", label: "Open" },
{ value: "in_progress", label: "In Progress" },
{ value: "done", label: "Done" },
] as const;
const selectFieldClass =
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
function normalizeDefaultType(raw?: string): CreateObjectType {
if (!raw) return "task";
const v = raw.toLowerCase().trim().replace(/\s+/g, "_");
if (v === "doc") return "document";
if (v === "white_board") return "whiteboard";
if (v === "list") return "task";
if (v === "form") return "form";
if ((CREATE_TYPES as readonly string[]).includes(v)) {
return v as CreateObjectType;
}
return "task";
}
export interface CreateObjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultType?: string;
defaultParentId?: string;
/** Workspace UUID or slug. Falls back to current workspace from the store. */
workspaceHandle?: string;
}
export function CreateObjectDialog({
open,
onOpenChange,
defaultType,
defaultParentId,
workspaceHandle: workspaceHandleProp,
}: CreateObjectDialogProps) {
const router = useRouter();
const storeWorkspace = useWorkspaceStore((s) => s.currentWorkspace);
const storeHandle = storeWorkspace?.slug ?? storeWorkspace?.id;
const resolvedWorkspace = workspaceHandleProp ?? storeHandle ?? undefined;
const utils = api.useUtils();
const titleInputRef = React.useRef<HTMLInputElement>(null);
const selectedTemplateRef = React.useRef<PickerTemplate | null>(null);
const [objectType, setObjectType] = React.useState<CreateObjectType>(() =>
normalizeDefaultType(defaultType),
);
const [title, setTitle] = React.useState("");
const [titleError, setTitleError] = React.useState(false);
const [parentId, setParentId] = React.useState("");
const [taskStatus, setTaskStatus] = React.useState<
(typeof TASK_STATUSES)[number]["value"]
>("open");
const [selectedTemplate, setSelectedTemplate] = React.useState<PickerTemplate | null>(
null,
);
const [showTemplatePicker, setShowTemplatePicker] = React.useState(false);
React.useEffect(() => {
selectedTemplateRef.current = selectedTemplate;
}, [selectedTemplate]);
const showParentPicker =
objectType === "task" || objectType === "document";
const spacesQuery = api.objects.list.useQuery(
{
workspace: resolvedWorkspace!,
type: "space",
limit: 500,
},
{ enabled: Boolean(open && resolvedWorkspace && showParentPicker) },
);
const spaces = spacesQuery.data?.objects ?? [];
React.useEffect(() => {
if (!open) return;
setObjectType(normalizeDefaultType(defaultType));
setTitle("");
setTitleError(false);
setParentId(defaultParentId ?? "");
setTaskStatus("open");
setSelectedTemplate(null);
setShowTemplatePicker(false);
}, [open, defaultType, defaultParentId]);
React.useEffect(() => {
if (!open) return;
const t = window.setTimeout(() => titleInputRef.current?.focus(), 0);
return () => window.clearTimeout(t);
}, [open]);
const applyTemplateMutation = api.templates.applyTemplate.useMutation();
const createMutation = api.objects.create.useMutation({
onSuccess: async (newObj) => {
const t = selectedTemplateRef.current;
if (t && newObj?.id && resolvedWorkspace) {
try {
await applyTemplateMutation.mutateAsync({
workspace: resolvedWorkspace,
templateId: t.id,
objectId: newObj.id,
});
} catch {
// Template application failure shouldn't block creation
}
}
await utils.objects.list.invalidate();
await utils.objects.getTree.invalidate();
onOpenChange(false);
},
});
const createFormMutation = api.forms.create.useMutation({
onSuccess: (data) => {
utils.objects.getTree.invalidate();
const newId = (data as { id?: string }).id;
if (newId && resolvedWorkspace) {
router.push(`/${resolvedWorkspace}/forms/${newId}/edit`);
}
onOpenChange(false);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) {
setTitleError(true);
return;
}
if (!resolvedWorkspace) {
return;
}
setTitleError(false);
if (objectType === "form") {
createFormMutation.mutate({
workspace: resolvedWorkspace,
title: trimmed,
});
return;
}
const parentForCreate =
objectType === "task" || objectType === "document"
? parentId || null
: null;
createMutation.mutate({
workspace: resolvedWorkspace,
type: objectType,
title: trimmed,
parentId: parentForCreate,
...(objectType === "task" ? { status: taskStatus } : {}),
});
};
const isSubmitting =
createMutation.isPending ||
createFormMutation.isPending ||
applyTemplateMutation.isPending;
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
)}
/>
<DialogPrimitive.Content
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"rounded-lg",
)}
onOpenAutoFocus={(ev) => ev.preventDefault()}
>
<div className="flex flex-col gap-1.5 pr-8 text-left">
<DialogPrimitive.Title className="text-lg font-semibold leading-none tracking-tight">
Create object
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Choose a type, enter a title, and optional parent space or task
status.
</DialogPrimitive.Description>
</div>
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
aria-label="Close"
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{!resolvedWorkspace ? (
<p className="text-sm text-muted-foreground">
Select a workspace to create objects.
</p>
) : null}
<div className="space-y-2">
<span className="text-sm font-medium">Type</span>
<div
className="grid grid-cols-2 gap-2 sm:grid-cols-5"
role="radiogroup"
aria-label="Object type"
>
{TYPE_OPTIONS.map(({ value, label, icon: Icon }) => {
const selected = objectType === value;
return (
<button
key={value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => setObjectType(value)}
className={cn(
"flex flex-col items-center gap-2 rounded-md border p-3 text-center transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
selected
? "border-primary bg-accent"
: "border-input bg-background hover:bg-accent/50",
)}
>
<Badge
variant={selected ? "default" : "outline"}
className="gap-1 px-2 py-1 font-normal"
>
<Icon className="size-4" />
<span>{label}</span>
</Badge>
</button>
);
})}
</div>
</div>
{(objectType === "task" || objectType === "document") && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Template</span>
{selectedTemplate ? (
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => setSelectedTemplate(null)}
>
Clear
</button>
) : null}
</div>
{selectedTemplate ? (
<div className="flex items-center gap-2 rounded-md border bg-accent/50 px-3 py-2 text-sm">
<Badge variant="secondary" className="gap-1">
<FileStack className="size-3" />
{selectedTemplate.name}
</Badge>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="w-full gap-1.5"
onClick={() => setShowTemplatePicker(true)}
>
<FileStack className="size-4" />
Use Template
</Button>
)}
</div>
)}
<div className="space-y-2">
<label htmlFor="create-object-title" className="text-sm font-medium">
Title
</label>
<Input
id="create-object-title"
ref={titleInputRef}
value={title}
onChange={(ev) => {
setTitle(ev.target.value);
if (titleError) setTitleError(false);
}}
placeholder="Name"
autoComplete="off"
aria-invalid={titleError}
/>
{titleError ? (
<p className="text-sm text-destructive" role="alert">
Title is required
</p>
) : null}
</div>
{showParentPicker ? (
<div className="space-y-2">
<label
htmlFor="create-object-parent"
className="text-sm font-medium"
>
Parent space
</label>
<select
id="create-object-parent"
className={selectFieldClass}
value={parentId}
onChange={(e) => setParentId(e.target.value)}
disabled={spacesQuery.isLoading}
>
<option value="">Workspace root</option>
{spaces.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
</div>
) : null}
{objectType === "task" ? (
<div className="space-y-2">
<label
htmlFor="create-object-status"
className="text-sm font-medium"
>
Status
</label>
<select
id="create-object-status"
className={selectFieldClass}
value={taskStatus}
onChange={(e) =>
setTaskStatus(
e.target.value as (typeof TASK_STATUSES)[number]["value"],
)
}
>
{TASK_STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
) : null}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={!resolvedWorkspace || isSubmitting}
>
{isSubmitting ? "Creating…" : "Create"}
</Button>
</div>
</form>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
{showTemplatePicker && resolvedWorkspace && (
<TemplatePicker
open={showTemplatePicker}
onOpenChange={setShowTemplatePicker}
workspaceHandle={resolvedWorkspace}
objectType={objectType}
onSelect={(template) => {
setSelectedTemplate(template);
setShowTemplatePicker(false);
}}
/>
)}
</DialogPrimitive.Root>
);
}