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>
428 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|