feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:
- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)
Made-with: Cursor
2026-03-26 23:39:16 -04:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import * as React from "react";
|
|
|
|
|
import {
|
|
|
|
|
DndContext,
|
|
|
|
|
type DragEndEvent,
|
|
|
|
|
KeyboardSensor,
|
|
|
|
|
PointerSensor,
|
|
|
|
|
closestCenter,
|
|
|
|
|
useSensor,
|
|
|
|
|
useSensors,
|
|
|
|
|
} from "@dnd-kit/core";
|
|
|
|
|
import {
|
|
|
|
|
SortableContext,
|
|
|
|
|
arrayMove,
|
|
|
|
|
sortableKeyboardCoordinates,
|
|
|
|
|
useSortable,
|
|
|
|
|
verticalListSortingStrategy,
|
|
|
|
|
} from "@dnd-kit/sortable";
|
|
|
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
|
|
|
import { GripVertical, Trash2 } from "lucide-react";
|
|
|
|
|
import type { inferRouterOutputs } from "@trpc/server";
|
|
|
|
|
|
|
|
|
|
import type { AppRouter } from "@/server/root";
|
|
|
|
|
import { api } from "@/lib/trpc";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
|
|
|
|
|
|
import type { TemplateSchemaJson } from "./template-picker";
|
|
|
|
|
|
|
|
|
|
type TemplateRow = inferRouterOutputs<AppRouter>["templates"]["getById"];
|
|
|
|
|
|
|
|
|
|
const TARGET_TYPES = ["task", "document", "project"] as const;
|
|
|
|
|
|
|
|
|
|
const FIELD_TYPES = [
|
|
|
|
|
"text",
|
|
|
|
|
"textarea",
|
|
|
|
|
"number",
|
|
|
|
|
"date",
|
|
|
|
|
"select",
|
|
|
|
|
"checkbox",
|
|
|
|
|
"url",
|
|
|
|
|
"email",
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
type PropertyRow = {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
fieldType: string;
|
|
|
|
|
defaultValue: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function newPropertyRow(): PropertyRow {
|
|
|
|
|
return {
|
|
|
|
|
id:
|
|
|
|
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
|
|
|
? crypto.randomUUID()
|
|
|
|
|
: `p-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
|
|
|
name: "",
|
|
|
|
|
fieldType: "text",
|
|
|
|
|
defaultValue: "",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function schemaToRows(schema: TemplateSchemaJson | null | undefined): PropertyRow[] {
|
|
|
|
|
const props = schema?.properties ?? [];
|
|
|
|
|
return props.map((p, i) => ({
|
|
|
|
|
id:
|
|
|
|
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
|
|
|
? crypto.randomUUID()
|
|
|
|
|
: `p-${i}`,
|
|
|
|
|
name: p.name,
|
|
|
|
|
fieldType: p.fieldType,
|
|
|
|
|
defaultValue:
|
|
|
|
|
p.defaultValue === undefined || p.defaultValue === null
|
|
|
|
|
? ""
|
|
|
|
|
: typeof p.defaultValue === "string"
|
|
|
|
|
? p.defaultValue
|
|
|
|
|
: JSON.stringify(p.defaultValue),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rowsToSchema(
|
|
|
|
|
rows: PropertyRow[],
|
|
|
|
|
defaultContent: string,
|
|
|
|
|
): TemplateSchemaJson {
|
|
|
|
|
return {
|
|
|
|
|
properties: rows
|
|
|
|
|
.filter((r) => r.name.trim() !== "")
|
|
|
|
|
.map((r) => {
|
|
|
|
|
let defaultValue: unknown = r.defaultValue;
|
|
|
|
|
if (r.fieldType === "number" && r.defaultValue.trim() !== "") {
|
|
|
|
|
const n = Number(r.defaultValue);
|
|
|
|
|
defaultValue = Number.isFinite(n) ? n : r.defaultValue;
|
|
|
|
|
} else if (r.fieldType === "checkbox") {
|
|
|
|
|
defaultValue = r.defaultValue === "true" || r.defaultValue === "1";
|
|
|
|
|
} else if (r.defaultValue.trim() === "") {
|
|
|
|
|
defaultValue = undefined;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
name: r.name.trim(),
|
|
|
|
|
fieldType: r.fieldType,
|
|
|
|
|
...(defaultValue !== undefined ? { defaultValue } : {}),
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
...(defaultContent.trim() !== "" ? { defaultContent } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SortablePropertyRow({
|
|
|
|
|
row,
|
|
|
|
|
onChange,
|
|
|
|
|
onRemove,
|
|
|
|
|
}: {
|
|
|
|
|
row: PropertyRow;
|
|
|
|
|
onChange: (id: string, patch: Partial<PropertyRow>) => void;
|
|
|
|
|
onRemove: (id: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
|
|
|
useSortable({ id: row.id });
|
|
|
|
|
|
|
|
|
|
const style = {
|
|
|
|
|
transform: CSS.Transform.toString(transform),
|
|
|
|
|
transition,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
style={style}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex flex-col gap-2 rounded-md border bg-card p-3 sm:flex-row sm:items-end",
|
|
|
|
|
isDragging && "z-10 opacity-90 shadow-md",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-dashed text-muted-foreground hover:bg-muted"
|
|
|
|
|
{...attributes}
|
|
|
|
|
{...listeners}
|
|
|
|
|
aria-label="Reorder property"
|
|
|
|
|
>
|
|
|
|
|
<GripVertical className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
<div className="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
|
|
|
|
<Input
|
|
|
|
|
value={row.name}
|
|
|
|
|
onChange={(e) => onChange(row.id, { name: e.target.value })}
|
|
|
|
|
placeholder="Property name"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<label className="text-xs font-medium text-muted-foreground">Field type</label>
|
|
|
|
|
<select
|
|
|
|
|
className={cn(
|
|
|
|
|
"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",
|
|
|
|
|
)}
|
|
|
|
|
value={row.fieldType}
|
|
|
|
|
onChange={(e) => onChange(row.id, { fieldType: e.target.value })}
|
|
|
|
|
>
|
|
|
|
|
{FIELD_TYPES.map((ft) => (
|
|
|
|
|
<option key={ft} value={ft}>
|
|
|
|
|
{ft}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<label className="text-xs font-medium text-muted-foreground">Default value</label>
|
|
|
|
|
<Input
|
|
|
|
|
value={row.defaultValue}
|
|
|
|
|
onChange={(e) => onChange(row.id, { defaultValue: e.target.value })}
|
|
|
|
|
placeholder="Optional"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="shrink-0 text-destructive hover:text-destructive"
|
|
|
|
|
onClick={() => onRemove(row.id)}
|
|
|
|
|
aria-label="Remove property"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type TemplateEditorProps = {
|
|
|
|
|
template?: TemplateRow;
|
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-07 00:02:55 -04:00
|
|
|
workspaceHandle: string;
|
feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:
- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)
Made-with: Cursor
2026-03-26 23:39:16 -04:00
|
|
|
onSave: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
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-07 00:02:55 -04:00
|
|
|
export function TemplateEditor({ template, workspaceHandle, onSave }: TemplateEditorProps) {
|
feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:
- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)
Made-with: Cursor
2026-03-26 23:39:16 -04:00
|
|
|
const [name, setName] = React.useState(template?.name ?? "");
|
|
|
|
|
const [targetType, setTargetType] = React.useState(
|
|
|
|
|
template?.targetType && TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number])
|
|
|
|
|
? template.targetType
|
|
|
|
|
: "task",
|
|
|
|
|
);
|
|
|
|
|
const [defaultContent, setDefaultContent] = React.useState(
|
|
|
|
|
(template?.schema as TemplateSchemaJson | null | undefined)?.defaultContent ?? "",
|
|
|
|
|
);
|
|
|
|
|
const [rows, setRows] = React.useState<PropertyRow[]>(() =>
|
|
|
|
|
template?.schema ? schemaToRows(template.schema as TemplateSchemaJson) : [newPropertyRow()],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!template) return;
|
|
|
|
|
setName(template.name);
|
|
|
|
|
setTargetType(
|
|
|
|
|
TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number])
|
|
|
|
|
? template.targetType
|
|
|
|
|
: "task",
|
|
|
|
|
);
|
|
|
|
|
const sch = template.schema as TemplateSchemaJson | null | undefined;
|
|
|
|
|
setDefaultContent(sch?.defaultContent ?? "");
|
|
|
|
|
setRows(schemaToRows(sch));
|
|
|
|
|
}, [template]);
|
|
|
|
|
|
|
|
|
|
const sensors = useSensors(
|
|
|
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
|
|
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const createMut = api.templates.create.useMutation({ onSuccess: onSave });
|
|
|
|
|
const updateMut = api.templates.update.useMutation({ onSuccess: onSave });
|
|
|
|
|
|
|
|
|
|
const pending = createMut.isPending || updateMut.isPending;
|
|
|
|
|
|
|
|
|
|
const updateRow = React.useCallback((id: string, patch: Partial<PropertyRow>) => {
|
|
|
|
|
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const removeRow = React.useCallback((id: string) => {
|
|
|
|
|
setRows((prev) => (prev.length <= 1 ? prev : prev.filter((r) => r.id !== id)));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const onDragEnd = React.useCallback((event: DragEndEvent) => {
|
|
|
|
|
const { active, over } = event;
|
|
|
|
|
if (!over || active.id === over.id) return;
|
|
|
|
|
setRows((items) => {
|
|
|
|
|
const oldIndex = items.findIndex((i) => i.id === active.id);
|
|
|
|
|
const newIndex = items.findIndex((i) => i.id === over.id);
|
|
|
|
|
if (oldIndex < 0 || newIndex < 0) return items;
|
|
|
|
|
return arrayMove(items, oldIndex, newIndex);
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const schema = rowsToSchema(rows, defaultContent);
|
|
|
|
|
if (!name.trim()) return;
|
|
|
|
|
|
|
|
|
|
if (template?.id) {
|
|
|
|
|
updateMut.mutate({
|
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-07 00:02:55 -04:00
|
|
|
workspace: workspaceHandle,
|
feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:
- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)
Made-with: Cursor
2026-03-26 23:39:16 -04:00
|
|
|
id: template.id,
|
|
|
|
|
name: name.trim(),
|
|
|
|
|
schema,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
createMut.mutate({
|
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-07 00:02:55 -04:00
|
|
|
workspace: workspaceHandle,
|
feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:
- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)
Made-with: Cursor
2026-03-26 23:39:16 -04:00
|
|
|
name: name.trim(),
|
|
|
|
|
targetType,
|
|
|
|
|
schema,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<label className="text-sm font-medium">Name</label>
|
|
|
|
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Template name" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<label className="text-sm font-medium">Target type</label>
|
|
|
|
|
<select
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
|
|
|
|
|
)}
|
|
|
|
|
value={targetType}
|
|
|
|
|
onChange={(e) => setTargetType(e.target.value)}
|
|
|
|
|
disabled={Boolean(template?.id)}
|
|
|
|
|
>
|
|
|
|
|
{TARGET_TYPES.map((t) => (
|
|
|
|
|
<option key={t} value={t}>
|
|
|
|
|
{t}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
{template?.id ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">Target type cannot be changed after creation.</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<span className="text-sm font-medium">Properties</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setRows((r) => [...r, newPropertyRow()])}
|
|
|
|
|
>
|
|
|
|
|
Add property
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
|
|
|
<SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{rows.map((row) => (
|
|
|
|
|
<SortablePropertyRow
|
|
|
|
|
key={row.id}
|
|
|
|
|
row={row}
|
|
|
|
|
onChange={updateRow}
|
|
|
|
|
onRemove={removeRow}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</DndContext>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<label className="text-sm font-medium">Default content</label>
|
|
|
|
|
<textarea
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
|
|
|
)}
|
|
|
|
|
value={defaultContent}
|
|
|
|
|
onChange={(e) => setDefaultContent(e.target.value)}
|
|
|
|
|
placeholder="Initial body text or outline for new objects using this template"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
disabled={pending}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (template) {
|
|
|
|
|
setName(template.name);
|
|
|
|
|
setTargetType(
|
|
|
|
|
TARGET_TYPES.includes(
|
|
|
|
|
template.targetType as (typeof TARGET_TYPES)[number],
|
|
|
|
|
)
|
|
|
|
|
? template.targetType
|
|
|
|
|
: "task",
|
|
|
|
|
);
|
|
|
|
|
const sch = template.schema as TemplateSchemaJson | null | undefined;
|
|
|
|
|
setDefaultContent(sch?.defaultContent ?? "");
|
|
|
|
|
setRows(schemaToRows(sch));
|
|
|
|
|
} else {
|
|
|
|
|
setName("");
|
|
|
|
|
setTargetType("task");
|
|
|
|
|
setDefaultContent("");
|
|
|
|
|
setRows([newPropertyRow()]);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="submit" disabled={pending || !name.trim()}>
|
|
|
|
|
Save
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
);
|
|
|
|
|
}
|