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>
This commit is contained in:
Randall Stillwell 2026-05-06 23:02:55 -05:00
parent 5f2f1e34c0
commit c582d621ce
69 changed files with 4228 additions and 518 deletions

View file

@ -0,0 +1,31 @@
import { eq } from "../drizzle.js";
import { db } from "../db.js";
import { workspaces } from "../schema.js";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Resolves a workspace handle (UUID or slug) to a `{ id, slug, name }` record.
* MCP tools accept either since agents may have hard-coded either form. Throws
* a friendly Error if no row matches so `toolCatch` can surface the message.
*/
export async function resolveWorkspaceHandle(
handle: string,
): Promise<{ id: string; slug: string; name: string }> {
const cleaned = handle.trim();
if (!cleaned) {
throw new Error("Workspace handle is required");
}
const cond = UUID_RE.test(cleaned)
? eq(workspaces.id, cleaned)
: eq(workspaces.slug, cleaned);
const [row] = await db
.select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name })
.from(workspaces)
.where(cond)
.limit(1);
if (!row) {
throw new Error(`Workspace not found for handle "${handle}"`);
}
return row;
}

View file

@ -2,6 +2,7 @@ import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/sdk/serv
import { and, asc, eq, inArray, isNull } from "../drizzle.js"; import { and, asc, eq, inArray, isNull } from "../drizzle.js";
import { db } from "../db.js"; import { db } from "../db.js";
import { objects } from "../schema.js"; import { objects } from "../schema.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
const TREE_TYPES = ["project", "group", "document", "whiteboard"] as const; const TREE_TYPES = ["project", "group", "document", "whiteboard"] as const;
@ -18,20 +19,44 @@ type TreeNode = {
export function registerWorkspaceTreeResource(mcp: McpServer): void { export function registerWorkspaceTreeResource(mcp: McpServer): void {
mcp.registerResource( mcp.registerResource(
"workspace_tree", "workspace_tree",
new ResourceTemplate("workspace://{id}/tree", { list: undefined }), /**
* The `{handle}` segment accepts either the workspace slug (preferred for
* shareable URIs) or the canonical UUID. The resource resolves it to a
* concrete workspace before walking the tree, so agents may use whichever
* form they were given.
*/
new ResourceTemplate("workspace://{handle}/tree", { list: undefined }),
{ {
description: "Hierarchy tree of projects, groups, documents, and whiteboards in a workspace.", description:
"Hierarchy tree of projects, groups, documents, and whiteboards in a workspace. Accepts the workspace slug or UUID in the URI.",
mimeType: "application/json", mimeType: "application/json",
}, },
async (uri, variables) => { async (uri, variables) => {
const workspaceId = variables.id; const handleVar = Array.isArray(variables.handle) ? variables.handle[0] : variables.handle;
if (!workspaceId) { if (!handleVar) {
return { return {
contents: [ contents: [
{ {
uri: uri.toString(), uri: uri.toString(),
mimeType: "application/json", mimeType: "application/json",
text: JSON.stringify({ error: "Missing workspace id" }), text: JSON.stringify({ error: "Missing workspace handle" }),
},
],
};
}
let ws: { id: string; slug: string; name: string };
try {
ws = await resolveWorkspaceHandle(handleVar);
} catch (e) {
return {
contents: [
{
uri: uri.toString(),
mimeType: "application/json",
text: JSON.stringify({
error: e instanceof Error ? e.message : "Workspace not found",
}),
}, },
], ],
}; };
@ -44,7 +69,7 @@ export function registerWorkspaceTreeResource(mcp: McpServer): void {
.from(objects) .from(objects)
.where( .where(
and( and(
eq(objects.workspaceId, workspaceId), eq(objects.workspaceId, ws.id),
inArray(objects.type, [...TREE_TYPES]), inArray(objects.type, [...TREE_TYPES]),
isNull(objects.archivedAt), isNull(objects.archivedAt),
), ),
@ -90,7 +115,10 @@ export function registerWorkspaceTreeResource(mcp: McpServer): void {
children: buildTree(r.id, 1), children: buildTree(r.id, 1),
})); }));
const payload = { workspaceId, tree }; const payload = {
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
tree,
};
return { return {
contents: [ contents: [

View file

@ -3,6 +3,7 @@ import { z } from "zod";
import { db } from "../db.js"; import { db } from "../db.js";
import { objects } from "../schema.js"; import { objects } from "../schema.js";
import { objectTypes } from "../shared-types.js"; import { objectTypes } from "../shared-types.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js"; import { toolCatch, toolErr, toolOk } from "./tool-result.js";
const objectTypeSchema = z.enum(objectTypes as unknown as [string, ...string[]]); const objectTypeSchema = z.enum(objectTypes as unknown as [string, ...string[]]);
@ -11,7 +12,10 @@ const createObjectInputSchema = z.object({
type: objectTypeSchema, type: objectTypeSchema,
title: z.string().min(1).max(500), title: z.string().min(1).max(500),
parentId: z.string().uuid().nullable().optional(), parentId: z.string().uuid().nullable().optional(),
workspaceId: z.string().uuid(), workspace: z
.string()
.min(1)
.describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"),
description: z.string().optional(), description: z.string().optional(),
status: z.string().optional(), status: z.string().optional(),
icon: z.string().optional(), icon: z.string().optional(),
@ -22,19 +26,20 @@ export function registerCreateObjectTool(mcp: McpServer): void {
"create_object", "create_object",
{ {
description: description:
"Create a new object (task, project, document, whiteboard, group, workspace).", "Create a new object (task, project, document, whiteboard, group). Accepts the workspace slug or UUID.",
inputSchema: createObjectInputSchema, inputSchema: createObjectInputSchema,
}, },
async (args) => { async (args) => {
try { try {
const input = createObjectInputSchema.parse(args); const input = createObjectInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const [created] = await db const [created] = await db
.insert(objects) .insert(objects)
.values({ .values({
type: input.type, type: input.type,
title: input.title, title: input.title,
parentId: input.parentId ?? null, parentId: input.parentId ?? null,
workspaceId: input.workspaceId, workspaceId: ws.id,
description: input.description, description: input.description,
status: input.status, status: input.status,
icon: input.icon, icon: input.icon,
@ -44,7 +49,10 @@ export function registerCreateObjectTool(mcp: McpServer): void {
if (!created) { if (!created) {
return toolErr("Failed to create object"); return toolErr("Failed to create object");
} }
return toolOk(created); return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
object: created,
});
} catch (e) { } catch (e) {
return toolCatch(e); return toolCatch(e);
} }

View file

@ -4,12 +4,16 @@ import { z } from "zod";
import { db } from "../db.js"; import { db } from "../db.js";
import { objects } from "../schema.js"; import { objects } from "../schema.js";
import { objectTypes } from "../shared-types.js"; import { objectTypes } from "../shared-types.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolOk } from "./tool-result.js"; import { toolCatch, toolOk } from "./tool-result.js";
const objectTypeSchema = z.enum(objectTypes as unknown as [string, ...string[]]); const objectTypeSchema = z.enum(objectTypes as unknown as [string, ...string[]]);
const listObjectsInputSchema = z.object({ const listObjectsInputSchema = z.object({
workspaceId: z.string().uuid(), workspace: z
.string()
.min(1)
.describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"),
parentId: z.string().uuid().nullable().optional(), parentId: z.string().uuid().nullable().optional(),
type: objectTypeSchema.optional(), type: objectTypeSchema.optional(),
status: z.string().optional(), status: z.string().optional(),
@ -22,16 +26,17 @@ export function registerListObjectsTool(mcp: McpServer): void {
"list_objects", "list_objects",
{ {
description: description:
"List objects in a workspace with optional filters (parent, type, status) and pagination.", "List objects in a workspace with optional filters (parent, type, status) and pagination. Accepts the workspace slug or UUID.",
inputSchema: listObjectsInputSchema, inputSchema: listObjectsInputSchema,
}, },
async (args) => { async (args) => {
try { try {
const input = listObjectsInputSchema.parse(args); const input = listObjectsInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const limit = input.limit ?? 50; const limit = input.limit ?? 50;
const offset = input.offset ?? 0; const offset = input.offset ?? 0;
const conditions = [eq(objects.workspaceId, input.workspaceId), isNull(objects.archivedAt)]; const conditions = [eq(objects.workspaceId, ws.id), isNull(objects.archivedAt)];
if (input.parentId === null) { if (input.parentId === null) {
conditions.push(isNull(objects.parentId)); conditions.push(isNull(objects.parentId));
@ -55,6 +60,7 @@ export function registerListObjectsTool(mcp: McpServer): void {
.offset(offset); .offset(offset);
return toolOk({ return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
objects: rows, objects: rows,
count: rows.length, count: rows.length,
limit, limit,

View file

@ -3,6 +3,7 @@ import { and, asc, eq, ilike, isNull, or } from "../drizzle.js";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db.js"; import { db } from "../db.js";
import { objects } from "../schema.js"; import { objects } from "../schema.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolOk } from "./tool-result.js"; import { toolCatch, toolOk } from "./tool-result.js";
function escapeLikePattern(q: string): string { function escapeLikePattern(q: string): string {
@ -11,7 +12,10 @@ function escapeLikePattern(q: string): string {
const searchObjectsInputSchema = z.object({ const searchObjectsInputSchema = z.object({
query: z.string().min(1), query: z.string().min(1),
workspaceId: z.string().uuid().optional(), workspace: z
.string()
.min(1)
.describe("Workspace UUID or slug. Required so search is tenant-scoped."),
type: z.string().optional(), type: z.string().optional(),
status: z.string().optional(), status: z.string().optional(),
limit: z.number().int().positive().max(500).optional(), limit: z.number().int().positive().max(500).optional(),
@ -22,23 +26,22 @@ export function registerSearchObjectsTool(mcp: McpServer): void {
"search_objects", "search_objects",
{ {
description: description:
"Search objects by text query (case-insensitive match on title and description) with optional filters.", "Search objects by text query (case-insensitive match on title and description) within a workspace. Accepts the workspace slug or UUID.",
inputSchema: searchObjectsInputSchema, inputSchema: searchObjectsInputSchema,
}, },
async (args) => { async (args) => {
try { try {
const input = searchObjectsInputSchema.parse(args); const input = searchObjectsInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const limit = input.limit ?? 50; const limit = input.limit ?? 50;
const pattern = `%${escapeLikePattern(input.query)}%`; const pattern = `%${escapeLikePattern(input.query)}%`;
const conditions = [ const conditions = [
eq(objects.workspaceId, ws.id),
isNull(objects.archivedAt), isNull(objects.archivedAt),
or(ilike(objects.title, pattern), ilike(objects.description, pattern)), or(ilike(objects.title, pattern), ilike(objects.description, pattern)),
]; ];
if (input.workspaceId) {
conditions.push(eq(objects.workspaceId, input.workspaceId));
}
if (input.type !== undefined) { if (input.type !== undefined) {
conditions.push(eq(objects.type, input.type)); conditions.push(eq(objects.type, input.type));
} }
@ -53,7 +56,11 @@ export function registerSearchObjectsTool(mcp: McpServer): void {
.orderBy(asc(objects.sortOrder), asc(objects.id)) .orderBy(asc(objects.sortOrder), asc(objects.id))
.limit(limit); .limit(limit);
return toolOk({ objects: rows, count: rows.length }); return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
objects: rows,
count: rows.length,
});
} catch (e) { } catch (e) {
return toolCatch(e); return toolCatch(e);
} }

View file

@ -29,7 +29,7 @@ export default function ProjectPage() {
return <EmbedView config={config} />; return <EmbedView config={config} />;
case "overview": case "overview":
return ( return (
<OverviewView workspaceId={workspaceSlug} spaceId={projectId} /> <OverviewView workspaceHandle={workspaceSlug} spaceId={projectId} />
); );
case "form": case "form":
return <FormView config={config} />; return <FormView config={config} />;

View file

@ -34,8 +34,8 @@ export default function DocEditorPage() {
const docId = typeof params?.docId === "string" ? params.docId : undefined; const docId = typeof params?.docId === "string" ? params.docId : undefined;
const docQuery = api.objects.getById.useQuery( const docQuery = api.objects.getById.useQuery(
{ id: docId! }, { id: docId!, workspace: workspaceSlug! },
{ enabled: Boolean(docId) }, { enabled: Boolean(docId) && Boolean(workspaceSlug) },
); );
const doc = docQuery.data as const doc = docQuery.data as
@ -63,36 +63,35 @@ export default function DocEditorPage() {
const updateMutation = api.objects.update.useMutation({ const updateMutation = api.objects.update.useMutation({
onSuccess: async (_row, variables) => { onSuccess: async (_row, variables) => {
await utils.objects.getById.invalidate({ id: variables.id }); if (!workspaceSlug) return;
if (workspaceSlug) { await utils.objects.getById.invalidate({ id: variables.id, workspace: workspaceSlug });
void utils.objects.list.invalidate({ workspaceId: workspaceSlug }); void utils.objects.list.invalidate({ workspace: workspaceSlug });
}
}, },
}); });
const scheduleContentSave = React.useCallback( const scheduleContentSave = React.useCallback(
(html: string) => { (html: string) => {
if (!docId) return; if (!docId || !workspaceSlug) return;
if (saveContentTimeoutRef.current) { if (saveContentTimeoutRef.current) {
clearTimeout(saveContentTimeoutRef.current); clearTimeout(saveContentTimeoutRef.current);
} }
saveContentTimeoutRef.current = setTimeout(() => { saveContentTimeoutRef.current = setTimeout(() => {
saveContentTimeoutRef.current = null; saveContentTimeoutRef.current = null;
updateMutation.mutate({ id: docId, content: html }); updateMutation.mutate({ workspace: workspaceSlug, id: docId, content: html });
}, 500); }, 500);
}, },
[docId, updateMutation], [docId, workspaceSlug, updateMutation],
); );
const handleTitleBlur = () => { const handleTitleBlur = () => {
if (!docId || !doc) return; if (!docId || !doc || !workspaceSlug) return;
const next = titleDraft.trim(); const next = titleDraft.trim();
if (next.length === 0) { if (next.length === 0) {
setTitleDraft(doc.title); setTitleDraft(doc.title);
return; return;
} }
if (next === doc.title) return; if (next === doc.title) return;
updateMutation.mutate({ id: docId, title: next }); updateMutation.mutate({ workspace: workspaceSlug, id: docId, title: next });
}; };
if (!docId || !workspaceSlug) { if (!docId || !workspaceSlug) {

View file

@ -19,19 +19,19 @@ export default function DocsPage() {
const router = useRouter(); const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
const workspaceId = const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const listQuery = api.objects.list.useQuery( const listQuery = api.objects.list.useQuery(
{ workspaceId: workspaceId!, parentId: undefined, limit: 200 }, { workspace: workspaceSlug!, parentId: undefined, limit: 200 },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceSlug) },
); );
const createMutation = api.objects.create.useMutation({ const createMutation = api.objects.create.useMutation({
onSuccess: (created) => { onSuccess: (created) => {
if (workspaceId) { if (workspaceSlug) {
void utils.objects.list.invalidate({ workspaceId }); void utils.objects.list.invalidate({ workspace: workspaceSlug });
router.push(`/${workspaceId}/docs/${created.id}`); router.push(`/${workspaceSlug}/docs/${created.id}`);
} }
}, },
}); });
@ -54,13 +54,13 @@ export default function DocsPage() {
<h1 className="text-3xl font-bold tracking-tight">Documents</h1> <h1 className="text-3xl font-bold tracking-tight">Documents</h1>
<Button <Button
type="button" type="button"
disabled={!workspaceId || createMutation.isPending} disabled={!workspaceSlug || createMutation.isPending}
onClick={() => { onClick={() => {
if (!workspaceId) return; if (!workspaceSlug) return;
createMutation.mutate({ createMutation.mutate({
type: "document", type: "document",
title: "Untitled", title: "Untitled",
workspaceId, workspace: workspaceSlug,
parentId: null, parentId: null,
}); });
}} }}
@ -81,7 +81,7 @@ export default function DocsPage() {
{documents.map((doc) => ( {documents.map((doc) => (
<li key={doc.id}> <li key={doc.id}>
<Link <Link
href={`/${workspaceId}/docs/${doc.id}`} href={`/${workspaceSlug}/docs/${doc.id}`}
className="flex items-center justify-between gap-4 px-4 py-4 transition-colors hover:bg-muted/40" className="flex items-center justify-between gap-4 px-4 py-4 transition-colors hover:bg-muted/40"
> >
<span className="flex min-w-0 items-center gap-3"> <span className="flex min-w-0 items-center gap-3">

View file

@ -10,14 +10,14 @@ import { Button } from "@/components/ui/button";
export default function FormEditPage() { export default function FormEditPage() {
const params = useParams(); const params = useParams();
const workspaceId = const workspaceSlug =
typeof params?.workspaceSlug === "string" typeof params?.workspaceSlug === "string"
? params.workspaceSlug ? params.workspaceSlug
: undefined; : undefined;
const formId = const formId =
typeof params?.formId === "string" ? params.formId : undefined; typeof params?.formId === "string" ? params.formId : undefined;
if (!workspaceId || !formId) { if (!workspaceSlug || !formId) {
return ( return (
<div className="p-10 text-sm text-muted-foreground"> <div className="p-10 text-sm text-muted-foreground">
Missing workspace or form. Missing workspace or form.
@ -29,13 +29,13 @@ export default function FormEditPage() {
<div className="mx-auto max-w-6xl px-6 py-8 sm:px-10"> <div className="mx-auto max-w-6xl px-6 py-8 sm:px-10">
<div className="mb-6 flex flex-wrap items-center gap-3"> <div className="mb-6 flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" asChild className="gap-1 px-2"> <Button variant="ghost" size="sm" asChild className="gap-1 px-2">
<Link href={`/${workspaceId}/forms`}> <Link href={`/${workspaceSlug}/forms`}>
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Forms Forms
</Link> </Link>
</Button> </Button>
</div> </div>
<FormBuilder formId={formId} workspaceId={workspaceId} /> <FormBuilder formId={formId} workspaceHandle={workspaceSlug} />
</div> </div>
); );
} }

View file

@ -21,7 +21,10 @@ export default function FormDetailPage() {
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const formId = typeof params?.formId === "string" ? params.formId : undefined; const formId = typeof params?.formId === "string" ? params.formId : undefined;
const formQuery = api.forms.getById.useQuery({ id: formId! }, { enabled: Boolean(formId) }); const formQuery = api.forms.getById.useQuery(
{ workspace: workspaceSlug!, id: formId! },
{ enabled: Boolean(formId) && Boolean(workspaceSlug) },
);
const fields = React.useMemo( const fields = React.useMemo(
() => parseFormFields(formQuery.data?.fields), () => parseFormFields(formQuery.data?.fields),
@ -100,11 +103,11 @@ export default function FormDetailPage() {
</TabsList> </TabsList>
<TabsContent value="responses" className="outline-none data-[state=inactive]:hidden"> <TabsContent value="responses" className="outline-none data-[state=inactive]:hidden">
<FormResponses formId={formId} fields={fields} /> <FormResponses formId={formId} workspaceHandle={workspaceSlug} fields={fields} />
</TabsContent> </TabsContent>
<TabsContent value="fill" className="outline-none data-[state=inactive]:hidden"> <TabsContent value="fill" className="outline-none data-[state=inactive]:hidden">
<FormRenderer formId={formId} /> <FormRenderer formId={formId} workspaceHandle={workspaceSlug} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View file

@ -20,21 +20,21 @@ export default function FormsListPage() {
const router = useRouter(); const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
const workspaceId = const workspaceSlug =
typeof params?.workspaceSlug === "string" typeof params?.workspaceSlug === "string"
? params.workspaceSlug ? params.workspaceSlug
: undefined; : undefined;
const listQuery = api.forms.list.useQuery( const listQuery = api.forms.list.useQuery(
{ workspaceId: workspaceId! }, { workspace: workspaceSlug! },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceSlug) },
); );
const createMutation = api.forms.create.useMutation({ const createMutation = api.forms.create.useMutation({
onSuccess: (created) => { onSuccess: (created) => {
if (workspaceId) { if (workspaceSlug) {
void utils.forms.list.invalidate({ workspaceId }); void utils.forms.list.invalidate({ workspace: workspaceSlug });
router.push(`/${workspaceId}/forms/${created.id}/edit`); router.push(`/${workspaceSlug}/forms/${created.id}/edit`);
} }
}, },
}); });
@ -55,11 +55,11 @@ export default function FormsListPage() {
</div> </div>
<Button <Button
type="button" type="button"
disabled={!workspaceId || createMutation.isPending} disabled={!workspaceSlug || createMutation.isPending}
onClick={() => { onClick={() => {
if (!workspaceId) return; if (!workspaceSlug) return;
createMutation.mutate({ createMutation.mutate({
workspaceId, workspace: workspaceSlug,
title: "Untitled form", title: "Untitled form",
}); });
}} }}
@ -71,6 +71,8 @@ export default function FormsListPage() {
{listQuery.isLoading ? ( {listQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading forms</p> <p className="text-sm text-muted-foreground">Loading forms</p>
) : !workspaceSlug ? (
<p className="text-sm text-muted-foreground">Missing workspace.</p>
) : forms.length === 0 ? ( ) : forms.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-12 text-center text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed border-border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
No forms yet. Create one to open the form builder. No forms yet. Create one to open the form builder.
@ -80,7 +82,7 @@ export default function FormsListPage() {
{forms.map((form) => ( {forms.map((form) => (
<li key={form.id}> <li key={form.id}>
<Link <Link
href={`/${workspaceId}/forms/${form.id}/edit`} href={`/${workspaceSlug}/forms/${form.id}/edit`}
className="flex h-full flex-col rounded-xl border border-border bg-card p-5 shadow-sm transition-colors hover:border-primary/30 hover:bg-muted/20" className="flex h-full flex-col rounded-xl border border-border bg-card p-5 shadow-sm transition-colors hover:border-primary/30 hover:bg-muted/20"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

View file

@ -129,7 +129,7 @@ export default function PlannerPage() {
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const listQuery = api.objects.list.useQuery( const listQuery = api.objects.list.useQuery(
{ workspaceId: workspaceSlug!, type: "task", limit: 200 }, { workspace: workspaceSlug!, type: "task", limit: 200 },
{ enabled: Boolean(workspaceSlug) }, { enabled: Boolean(workspaceSlug) },
); );

View file

@ -10,30 +10,30 @@ import { TemplateEditor } from "@/components/templates";
export default function TemplatesSettingsPage() { export default function TemplatesSettingsPage() {
const params = useParams(); const params = useParams();
const workspaceId = const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : ""; typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const utils = api.useUtils(); const utils = api.useUtils();
const { data, isLoading } = api.templates.list.useQuery( const { data, isLoading } = api.templates.list.useQuery(
{ workspaceId }, { workspace: workspaceSlug },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceSlug) },
); );
const templates = data?.templates ?? []; const templates = data?.templates ?? [];
const createMutation = api.templates.create.useMutation({ const createMutation = api.templates.create.useMutation({
onSuccess: (newTemplate) => { onSuccess: (newTemplate) => {
setSelectedId(newTemplate.id); setSelectedId(newTemplate.id);
void utils.templates.list.invalidate({ workspaceId }); void utils.templates.list.invalidate({ workspace: workspaceSlug });
}, },
}); });
const getByIdQuery = api.templates.getById.useQuery( const getByIdQuery = api.templates.getById.useQuery(
{ id: selectedId! }, { workspace: workspaceSlug, id: selectedId! },
{ enabled: Boolean(workspaceId && selectedId) }, { enabled: Boolean(workspaceSlug && selectedId) },
); );
if (!workspaceId) { if (!workspaceSlug) {
return ( return (
<div className="p-8 text-sm text-muted-foreground"> <div className="p-8 text-sm text-muted-foreground">
No workspace selected. No workspace selected.
@ -42,8 +42,9 @@ export default function TemplatesSettingsPage() {
} }
const invalidateAfterSave = () => { const invalidateAfterSave = () => {
void utils.templates.list.invalidate({ workspaceId }); void utils.templates.list.invalidate({ workspace: workspaceSlug });
if (selectedId) void utils.templates.getById.invalidate({ id: selectedId }); if (selectedId)
void utils.templates.getById.invalidate({ workspace: workspaceSlug, id: selectedId });
}; };
return ( return (
@ -60,7 +61,7 @@ export default function TemplatesSettingsPage() {
className="gap-1.5" className="gap-1.5"
onClick={() => onClick={() =>
createMutation.mutate({ createMutation.mutate({
workspaceId, workspace: workspaceSlug,
name: "Untitled Template", name: "Untitled Template",
targetType: "task", targetType: "task",
schema: { properties: [], defaultContent: "" }, schema: { properties: [], defaultContent: "" },
@ -117,7 +118,7 @@ export default function TemplatesSettingsPage() {
<TemplateEditor <TemplateEditor
key={selectedId} key={selectedId}
template={getByIdQuery.data} template={getByIdQuery.data}
workspaceId={workspaceId} workspaceHandle={workspaceSlug}
onSave={invalidateAfterSave} onSave={invalidateAfterSave}
/> />
) : ( ) : (

View file

@ -6,11 +6,11 @@ import { TypeManager } from "@/components/types";
export default function TypesSettingsPage() { export default function TypesSettingsPage() {
const params = useParams(); const params = useParams();
const workspaceId = const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : ""; typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
return ( return (
<div className="mx-auto max-w-4xl px-8 py-10"> <div className="mx-auto max-w-4xl px-8 py-10">
<TypeManager workspaceId={workspaceId} /> <TypeManager workspaceHandle={workspaceSlug} />
</div> </div>
); );
} }

View file

@ -0,0 +1,251 @@
"use client";
import * as React from "react";
import { useParams, useRouter } from "next/navigation";
import { Building2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
export default function WorkspaceSettingsPage() {
const params = useParams();
const router = useRouter();
const utils = api.useUtils();
const setStoreWorkspace = useWorkspaceStore((s) => s.setWorkspace);
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const resolveQuery = api.workspaces.resolve.useQuery(
{ handle: workspaceSlug! },
{ enabled: Boolean(workspaceSlug) },
);
const [name, setName] = React.useState("");
const [slug, setSlug] = React.useState("");
const [savedMessage, setSavedMessage] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
// Hydrate form once we have the workspace.
React.useEffect(() => {
if (!resolveQuery.data) return;
setName(resolveQuery.data.name);
setSlug(resolveQuery.data.slug);
}, [resolveQuery.data?.id, resolveQuery.data?.name, resolveQuery.data?.slug]);
const updateMut = api.workspaces.update.useMutation({
onSuccess: async (ws) => {
setSavedMessage("Saved.");
setError(null);
// Refresh the listing & resolve cache so other surfaces see the rename.
await Promise.all([
utils.workspaces.listForUser.invalidate(),
utils.workspaces.resolve.invalidate(),
]);
setStoreWorkspace({ id: ws.id, slug: ws.slug, name: ws.name });
// If the slug changed, redirect to the new URL form.
if (workspaceSlug && ws.slug !== workspaceSlug) {
router.replace(`/${ws.slug}/settings/workspace`);
}
},
onError: (e) => {
setSavedMessage(null);
setError(e.message ?? "Failed to update workspace");
},
});
const archiveMut = api.workspaces.archive.useMutation({
onSuccess: async () => {
await utils.workspaces.listForUser.invalidate();
router.replace(`/`);
},
onError: (e) => setError(e.message ?? "Failed to archive workspace"),
});
if (!workspaceSlug) {
return (
<div className="p-10 text-sm text-muted-foreground">No workspace selected.</div>
);
}
if (resolveQuery.isLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="size-6 animate-spin" aria-hidden />
</div>
);
}
if (resolveQuery.isError || !resolveQuery.data) {
return (
<div className="p-10 text-sm text-muted-foreground">
Workspace not found or you don&apos;t have access.
</div>
);
}
const slugInvalid = slug.length > 0 && !SLUG_RE.test(slug);
const dirty =
name.trim() !== resolveQuery.data.name || slug !== resolveQuery.data.slug;
const submit = (e: React.FormEvent) => {
e.preventDefault();
setSavedMessage(null);
setError(null);
if (!name.trim()) {
setError("Name is required");
return;
}
if (slugInvalid) {
setError("Slug must be lowercase letters, digits, or hyphens");
return;
}
updateMut.mutate({
workspace: workspaceSlug,
...(name.trim() !== resolveQuery.data!.name ? { name: name.trim() } : {}),
...(slug !== resolveQuery.data!.slug ? { slug } : {}),
});
};
return (
<div className="mx-auto max-w-2xl px-8 py-10">
<div className="mb-8 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Building2 className="size-5 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">Workspace settings</h1>
<p className="text-xs text-muted-foreground">
Rename or move this workspace. Slug changes update the URL but old
UUID-based links keep working.
</p>
</div>
</div>
<form
onSubmit={submit}
className="rounded-lg border border-border bg-card p-6 shadow-sm"
>
<div className="space-y-5">
<div className="space-y-1.5">
<label htmlFor="ws-name" className="text-xs font-medium">
Name
</label>
<Input
id="ws-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Acme Inc."
/>
</div>
<div className="space-y-1.5">
<label htmlFor="ws-slug" className="text-xs font-medium">
Slug
</label>
<div className="flex items-center gap-2 rounded-md border border-input bg-background px-2 focus-within:ring-2 focus-within:ring-ring">
<span className="select-none text-xs text-muted-foreground">/</span>
<input
id="ws-slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
className="h-9 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="acme"
/>
</div>
<p
className={cn(
"text-xs",
slugInvalid ? "text-destructive" : "text-muted-foreground",
)}
>
{slugInvalid
? "Use lowercase letters, digits, or hyphens (no leading/trailing dash)."
: `URL: /${slug || "<slug>"}`}
</p>
</div>
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : savedMessage ? (
<p className="text-sm text-emerald-600" role="status">
{savedMessage}
</p>
) : null}
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
disabled={!dirty || updateMut.isPending}
onClick={() => {
setName(resolveQuery.data!.name);
setSlug(resolveQuery.data!.slug);
setError(null);
setSavedMessage(null);
}}
>
Reset
</Button>
<Button type="submit" disabled={!dirty || updateMut.isPending}>
{updateMut.isPending ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden />
Saving
</>
) : (
"Save changes"
)}
</Button>
</div>
</div>
</form>
<Separator className="my-10" />
<section className="rounded-lg border border-destructive/30 bg-destructive/5 p-6">
<h2 className="text-sm font-semibold text-destructive">Danger zone</h2>
<p className="mt-1 text-xs text-muted-foreground">
Archiving hides this workspace from the switcher. Data and members
stay intact and an admin can restore it later.
</p>
<div className="mt-4 flex justify-end">
<Button
type="button"
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10"
disabled={archiveMut.isPending}
onClick={() => {
if (
typeof window !== "undefined" &&
!window.confirm(
"Archive this workspace? It will disappear from your sidebar.",
)
) {
return;
}
archiveMut.mutate({ workspace: workspaceSlug });
}}
>
{archiveMut.isPending ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden />
Archiving
</>
) : (
"Archive workspace"
)}
</Button>
</div>
</section>
</div>
);
}

View file

@ -42,12 +42,12 @@ function MemberCardSkeleton({ className }: { className?: string }) {
export default function TeamsPage() { export default function TeamsPage() {
const params = useParams(); const params = useParams();
const workspaceSlug = params?.workspaceSlug; const rawSlug = params?.workspaceSlug;
const workspaceId = typeof workspaceSlug === "string" ? workspaceSlug : undefined; const workspaceSlug = typeof rawSlug === "string" ? rawSlug : undefined;
const { data: members, isLoading } = api.workspaces.listMembers.useQuery( const { data: members, isLoading } = api.workspaces.listMembers.useQuery(
{ workspaceId: workspaceId as string }, { workspace: workspaceSlug as string },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceSlug) },
); );
return ( return (
@ -59,7 +59,7 @@ export default function TeamsPage() {
</Button> </Button>
</div> </div>
{!workspaceId ? ( {!workspaceSlug ? (
<p className="text-sm text-muted-foreground">Missing workspace.</p> <p className="text-sm text-muted-foreground">Missing workspace.</p>
) : isLoading ? ( ) : isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">

View file

@ -18,8 +18,8 @@ export default function WhiteboardDetailPage() {
const whiteboardId = params.whiteboardId as string; const whiteboardId = params.whiteboardId as string;
const { data: wb } = api.objects.getById.useQuery( const { data: wb } = api.objects.getById.useQuery(
{ id: whiteboardId }, { workspace: workspaceSlug, id: whiteboardId },
{ enabled: Boolean(whiteboardId) }, { enabled: Boolean(whiteboardId) && Boolean(workspaceSlug) },
); );
return ( return (

View file

@ -11,7 +11,7 @@ export default function WhiteboardsPage() {
const workspaceSlug = params.workspaceSlug as string; const workspaceSlug = params.workspaceSlug as string;
const { data, isLoading } = api.objects.list.useQuery( const { data, isLoading } = api.objects.list.useQuery(
{ workspaceId: workspaceSlug, type: "whiteboard", limit: 200 }, { workspace: workspaceSlug, type: "whiteboard", limit: 200 },
{ enabled: Boolean(workspaceSlug) }, { enabled: Boolean(workspaceSlug) },
); );
@ -39,7 +39,7 @@ export default function WhiteboardsPage() {
createMutation.mutate({ createMutation.mutate({
type: "whiteboard", type: "whiteboard",
title: "Untitled Whiteboard", title: "Untitled Whiteboard",
workspaceId: workspaceSlug, workspace: workspaceSlug,
}) })
} }
disabled={createMutation.isPending} disabled={createMutation.isPending}
@ -66,7 +66,7 @@ export default function WhiteboardsPage() {
createMutation.mutate({ createMutation.mutate({
type: "whiteboard", type: "whiteboard",
title: "Untitled Whiteboard", title: "Untitled Whiteboard",
workspaceId: workspaceSlug, workspace: workspaceSlug,
}) })
} }
> >

View file

@ -31,7 +31,7 @@ type AiOutputs = inferRouterOutputs<typeof aiRouter>;
const aiTrpc = (api as any).ai as { const aiTrpc = (api as any).ai as {
suggestActions: { suggestActions: {
useQuery: ( useQuery: (
input: { objectId?: string; objectType?: string }, input: { workspace: string; objectId?: string; objectType?: string },
opts?: { enabled?: boolean }, opts?: { enabled?: boolean },
) => { data: { actions: string[] } | undefined }; ) => { data: { actions: string[] } | undefined };
}; };
@ -42,8 +42,9 @@ const aiTrpc = (api as any).ai as {
onError?: (err: { message: string }) => void; onError?: (err: { message: string }) => void;
}) => { }) => {
mutate: (input: { mutate: (input: {
workspace: string;
messages: { role: "user" | "assistant"; content: string }[]; messages: { role: "user" | "assistant"; content: string }[];
context?: { workspaceId?: string; objectId?: string }; context?: { objectId?: string };
}) => void; }) => void;
isPending: boolean; isPending: boolean;
isError: boolean; isError: boolean;
@ -86,19 +87,22 @@ export function AIChatPanel() {
const bottomRef = React.useRef<HTMLDivElement>(null); const bottomRef = React.useRef<HTMLDivElement>(null);
const textareaRef = React.useRef<HTMLTextAreaElement>(null); const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const workspaceHandle = workspace?.slug ?? workspace?.id;
const objectQuery = api.objects.getById.useQuery( const objectQuery = api.objects.getById.useQuery(
{ id: objectId! }, { id: objectId!, workspace: workspaceHandle! },
{ enabled: !!objectId }, { enabled: !!objectId && Boolean(workspaceHandle) },
); );
const objectSummary = objectQuery.data as ObjectSummary | undefined; const objectSummary = objectQuery.data as ObjectSummary | undefined;
const suggestQuery = aiTrpc.suggestActions.useQuery( const suggestQuery = aiTrpc.suggestActions.useQuery(
{ {
workspace: workspaceHandle!,
objectId: objectId ?? undefined, objectId: objectId ?? undefined,
objectType: objectSummary?.type, objectType: objectSummary?.type,
}, },
{ enabled: true }, { enabled: Boolean(workspaceHandle) },
); );
const chatMutation = aiTrpc.chat.useMutation({ const chatMutation = aiTrpc.chat.useMutation({
@ -143,14 +147,18 @@ export function AIChatPanel() {
content: m.content, content: m.content,
})); }));
if (!workspaceHandle) {
setSendError("Select a workspace first.");
return;
}
chatMutation.mutate({ chatMutation.mutate({
workspace: workspaceHandle,
messages: payload, messages: payload,
context: { context: {
workspaceId: workspace?.id,
objectId: objectId ?? undefined, objectId: objectId ?? undefined,
}, },
}); });
}, [input, isLoading, messages, chatMutation, workspace?.id, objectId]); }, [input, isLoading, messages, chatMutation, workspaceHandle, objectId]);
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {

View file

@ -111,10 +111,10 @@ function createField(type: string): FormField {
export function FormBuilder({ export function FormBuilder({
formId, formId,
workspaceId, workspaceHandle,
}: { }: {
formId: string; formId: string;
workspaceId: string; workspaceHandle: string;
}) { }) {
const utils = api.useUtils(); const utils = api.useUtils();
const [draft, setDraft] = useState<Draft | null>(null); const [draft, setDraft] = useState<Draft | null>(null);
@ -124,13 +124,13 @@ export function FormBuilder({
const skipSaveRef = useRef(false); const skipSaveRef = useRef(false);
const formQuery = api.forms.getById.useQuery( const formQuery = api.forms.getById.useQuery(
{ id: formId }, { workspace: workspaceHandle, id: formId },
{ enabled: Boolean(formId) }, { enabled: Boolean(formId) && Boolean(workspaceHandle) },
); );
const updateMutation = api.forms.update.useMutation({ const updateMutation = api.forms.update.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.forms.getById.invalidate({ id: formId }); void utils.forms.getById.invalidate({ workspace: workspaceHandle, id: formId });
}, },
}); });
@ -138,7 +138,7 @@ export function FormBuilder({
hydratedRef.current = false; hydratedRef.current = false;
setDraft(null); setDraft(null);
setSelectedFieldId(null); setSelectedFieldId(null);
}, [formId, workspaceId]); }, [formId, workspaceHandle]);
useEffect(() => { useEffect(() => {
if ( if (
@ -167,8 +167,9 @@ export function FormBuilder({
return; return;
} }
const handle = setTimeout(() => { const t = setTimeout(() => {
updateMutation.mutate({ updateMutation.mutate({
workspace: workspaceHandle,
id: formId, id: formId,
title: draft.title, title: draft.title,
description: draft.description, description: draft.description,
@ -177,8 +178,8 @@ export function FormBuilder({
}); });
}, 550); }, 550);
return () => clearTimeout(handle); return () => clearTimeout(t);
}, [draft, formId, updateMutation]); }, [draft, formId, workspaceHandle, updateMutation]);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
@ -363,7 +364,7 @@ export function FormBuilder({
key={selectedField.id} key={selectedField.id}
field={selectedField} field={selectedField}
allFields={draft.fields} allFields={draft.fields}
workspaceId={workspaceId} workspaceHandle={workspaceHandle}
onChange={(patch) => updateField(selectedField.id, patch)} onChange={(patch) => updateField(selectedField.id, patch)}
/> />
) : ( ) : (

View file

@ -44,12 +44,12 @@ const CHOICE_TYPES = new Set([
export function FormFieldConfig({ export function FormFieldConfig({
field, field,
allFields, allFields,
workspaceId, workspaceHandle,
onChange, onChange,
}: { }: {
field: FormField; field: FormField;
allFields: FormField[]; allFields: FormField[];
workspaceId: string; workspaceHandle: string;
onChange: (patch: Partial<FormField>) => void; onChange: (patch: Partial<FormField>) => void;
}) { }) {
const otherFields = allFields.filter((f) => f.id !== field.id); const otherFields = allFields.filter((f) => f.id !== field.id);
@ -274,7 +274,7 @@ export function FormFieldConfig({
Map to task property Map to task property
</span> </span>
<FormMappingPicker <FormMappingPicker
workspaceId={workspaceId} workspaceHandle={workspaceHandle}
value={field.mappedProperty} value={field.mappedProperty}
onChange={(next) => onChange({ mappedProperty: next })} onChange={(next) => onChange({ mappedProperty: next })}
/> />

View file

@ -21,21 +21,21 @@ const BUILTIN = [
] as const; ] as const;
export function FormMappingPicker({ export function FormMappingPicker({
workspaceId, workspaceHandle,
value, value,
onChange, onChange,
disabled, disabled,
className, className,
}: { }: {
workspaceId: string; workspaceHandle: string;
value: string | null; value: string | null;
onChange: (next: string | null) => void; onChange: (next: string | null) => void;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
}) { }) {
const { data, isLoading } = api.properties.listDefinitions.useQuery( const { data, isLoading } = api.properties.listDefinitions.useQuery(
{ workspaceId }, { workspace: workspaceHandle },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceHandle) },
); );
const definitions = data?.definitions ?? []; const definitions = data?.definitions ?? [];

View file

@ -135,12 +135,16 @@ function defaultValueForField(field: FormField): unknown {
export interface FormRendererProps { export interface FormRendererProps {
formId: string; formId: string;
workspaceHandle: string;
onSubmitted?: (objectId: string) => void; onSubmitted?: (objectId: string) => void;
className?: string; className?: string;
} }
export function FormRenderer({ formId, onSubmitted, className }: FormRendererProps) { export function FormRenderer({ formId, workspaceHandle, onSubmitted, className }: FormRendererProps) {
const formQuery = api.forms.getById.useQuery({ id: formId }, { enabled: Boolean(formId) }); const formQuery = api.forms.getById.useQuery(
{ workspace: workspaceHandle, id: formId },
{ enabled: Boolean(formId) && Boolean(workspaceHandle) },
);
const fields = React.useMemo( const fields = React.useMemo(
() => parseFields(formQuery.data?.fields), () => parseFields(formQuery.data?.fields),
@ -215,7 +219,7 @@ export function FormRenderer({ formId, onSubmitted, className }: FormRendererPro
data[f.id] = values[f.id]; data[f.id] = values[f.id];
} }
submitMutation.mutate({ formId, data }); submitMutation.mutate({ workspace: workspaceHandle, formId, data });
}; };
if (formQuery.isPending) { if (formQuery.isPending) {

View file

@ -31,17 +31,18 @@ function tableColumns(fields: FormField[]): FormField[] {
export interface FormResponsesProps { export interface FormResponsesProps {
formId: string; formId: string;
workspaceHandle: string;
fields: FormField[]; fields: FormField[];
className?: string; className?: string;
} }
export function FormResponses({ formId, fields, className }: FormResponsesProps) { export function FormResponses({ formId, workspaceHandle, fields, className }: FormResponsesProps) {
const open = usePanelStore((s) => s.open); const open = usePanelStore((s) => s.open);
const cols = React.useMemo(() => tableColumns(fields), [fields]); const cols = React.useMemo(() => tableColumns(fields), [fields]);
const listQuery = api.forms.listResponses.useQuery( const listQuery = api.forms.listResponses.useQuery(
{ formId }, { workspace: workspaceHandle, formId },
{ enabled: Boolean(formId) }, { enabled: Boolean(formId) && Boolean(workspaceHandle) },
); );
if (listQuery.isPending) { if (listQuery.isPending) {

View file

@ -2,10 +2,17 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
/**
* Resolves the URL workspace handle (slug or UUID) to a full workspace record
* and seeds the global workspace store. Other client components read from the
* store and pass `currentWorkspace.slug` as the `workspace` arg to tenant-scoped
* tRPC procedures.
*/
export function WorkspaceSync({ export function WorkspaceSync({
workspaceSlug, workspaceSlug,
children, children,
@ -13,20 +20,41 @@ export function WorkspaceSync({
workspaceSlug: string; workspaceSlug: string;
children: ReactNode; children: ReactNode;
}) { }) {
const router = useRouter();
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace); const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
const { data } = api.workspaces.getById.useQuery({ id: workspaceSlug }); const { data, isError } = api.workspaces.resolve.useQuery({
handle: workspaceSlug,
});
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setWorkspace({ setWorkspace({
id: data.id, id: data.id,
slug: data.id, slug: data.slug,
name: data.title, name: data.name,
}); });
// URL backcompat: if the user landed on /<UUID>/... but the workspace
// has a slug, rewrite the URL to the slug form so future links/share
// surfaces are slug-shaped.
if (workspaceSlug !== data.slug && typeof window !== "undefined") {
const next = window.location.pathname.replace(
`/${workspaceSlug}`,
`/${data.slug}`,
);
router.replace(next + window.location.search);
}
} }
return () => setWorkspace(null); return () => setWorkspace(null);
}, [workspaceSlug, data, setWorkspace]); }, [workspaceSlug, data, setWorkspace, router]);
if (isError) {
return (
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
Workspace not found.
</div>
);
}
return <>{children}</>; return <>{children}</>;
} }

View file

@ -63,7 +63,8 @@ export interface CreateObjectDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
defaultType?: string; defaultType?: string;
defaultParentId?: string; defaultParentId?: string;
workspaceId?: string; /** Workspace UUID or slug. Falls back to current workspace from the store. */
workspaceHandle?: string;
} }
export function CreateObjectDialog({ export function CreateObjectDialog({
@ -71,11 +72,12 @@ export function CreateObjectDialog({
onOpenChange, onOpenChange,
defaultType, defaultType,
defaultParentId, defaultParentId,
workspaceId: workspaceIdProp, workspaceHandle: workspaceHandleProp,
}: CreateObjectDialogProps) { }: CreateObjectDialogProps) {
const router = useRouter(); const router = useRouter();
const storeWorkspaceId = useWorkspaceStore((s) => s.currentWorkspace?.id); const storeWorkspace = useWorkspaceStore((s) => s.currentWorkspace);
const resolvedWorkspaceId = workspaceIdProp ?? storeWorkspaceId ?? undefined; const storeHandle = storeWorkspace?.slug ?? storeWorkspace?.id;
const resolvedWorkspace = workspaceHandleProp ?? storeHandle ?? undefined;
const utils = api.useUtils(); const utils = api.useUtils();
const titleInputRef = React.useRef<HTMLInputElement>(null); const titleInputRef = React.useRef<HTMLInputElement>(null);
@ -104,11 +106,11 @@ export function CreateObjectDialog({
const spacesQuery = api.objects.list.useQuery( const spacesQuery = api.objects.list.useQuery(
{ {
workspaceId: resolvedWorkspaceId!, workspace: resolvedWorkspace!,
type: "space", type: "space",
limit: 500, limit: 500,
}, },
{ enabled: Boolean(open && resolvedWorkspaceId && showParentPicker) }, { enabled: Boolean(open && resolvedWorkspace && showParentPicker) },
); );
const spaces = spacesQuery.data?.objects ?? []; const spaces = spacesQuery.data?.objects ?? [];
@ -135,9 +137,10 @@ export function CreateObjectDialog({
const createMutation = api.objects.create.useMutation({ const createMutation = api.objects.create.useMutation({
onSuccess: async (newObj) => { onSuccess: async (newObj) => {
const t = selectedTemplateRef.current; const t = selectedTemplateRef.current;
if (t && newObj?.id) { if (t && newObj?.id && resolvedWorkspace) {
try { try {
await applyTemplateMutation.mutateAsync({ await applyTemplateMutation.mutateAsync({
workspace: resolvedWorkspace,
templateId: t.id, templateId: t.id,
objectId: newObj.id, objectId: newObj.id,
}); });
@ -155,8 +158,8 @@ export function CreateObjectDialog({
onSuccess: (data) => { onSuccess: (data) => {
utils.objects.getTree.invalidate(); utils.objects.getTree.invalidate();
const newId = (data as { id?: string }).id; const newId = (data as { id?: string }).id;
if (newId && resolvedWorkspaceId) { if (newId && resolvedWorkspace) {
router.push(`/${resolvedWorkspaceId}/forms/${newId}/edit`); router.push(`/${resolvedWorkspace}/forms/${newId}/edit`);
} }
onOpenChange(false); onOpenChange(false);
}, },
@ -169,14 +172,14 @@ export function CreateObjectDialog({
setTitleError(true); setTitleError(true);
return; return;
} }
if (!resolvedWorkspaceId) { if (!resolvedWorkspace) {
return; return;
} }
setTitleError(false); setTitleError(false);
if (objectType === "form") { if (objectType === "form") {
createFormMutation.mutate({ createFormMutation.mutate({
workspaceId: resolvedWorkspaceId, workspace: resolvedWorkspace,
title: trimmed, title: trimmed,
}); });
return; return;
@ -188,9 +191,9 @@ export function CreateObjectDialog({
: null; : null;
createMutation.mutate({ createMutation.mutate({
workspace: resolvedWorkspace,
type: objectType, type: objectType,
title: trimmed, title: trimmed,
workspaceId: resolvedWorkspaceId,
parentId: parentForCreate, parentId: parentForCreate,
...(objectType === "task" ? { status: taskStatus } : {}), ...(objectType === "task" ? { status: taskStatus } : {}),
}); });
@ -235,7 +238,7 @@ export function CreateObjectDialog({
</DialogPrimitive.Close> </DialogPrimitive.Close>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
{!resolvedWorkspaceId ? ( {!resolvedWorkspace ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Select a workspace to create objects. Select a workspace to create objects.
</p> </p>
@ -399,7 +402,7 @@ export function CreateObjectDialog({
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={!resolvedWorkspaceId || isSubmitting} disabled={!resolvedWorkspace || isSubmitting}
> >
{isSubmitting ? "Creating…" : "Create"} {isSubmitting ? "Creating…" : "Create"}
</Button> </Button>
@ -408,11 +411,11 @@ export function CreateObjectDialog({
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPrimitive.Portal> </DialogPrimitive.Portal>
{showTemplatePicker && resolvedWorkspaceId && ( {showTemplatePicker && resolvedWorkspace && (
<TemplatePicker <TemplatePicker
open={showTemplatePicker} open={showTemplatePicker}
onOpenChange={setShowTemplatePicker} onOpenChange={setShowTemplatePicker}
workspaceId={resolvedWorkspaceId} workspaceHandle={resolvedWorkspace}
objectType={objectType} objectType={objectType}
onSelect={(template) => { onSelect={(template) => {
setSelectedTemplate(template); setSelectedTemplate(template);

View file

@ -43,7 +43,7 @@ export interface AssigneePickerProps {
assignedIds: string[]; assignedIds: string[];
onToggle: (userId: string) => void; onToggle: (userId: string) => void;
users?: WorkspaceUser[]; users?: WorkspaceUser[];
workspaceId?: string; workspaceHandle?: string;
children: React.ReactNode; children: React.ReactNode;
side?: "top" | "right" | "bottom" | "left"; side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end"; align?: "start" | "center" | "end";
@ -55,14 +55,14 @@ export function AssigneePicker({
assignedIds, assignedIds,
onToggle, onToggle,
users = WORKSPACE_USERS, users = WORKSPACE_USERS,
workspaceId, workspaceHandle,
children, children,
side = "bottom", side = "bottom",
align = "start", align = "start",
}: AssigneePickerProps) { }: AssigneePickerProps) {
const { data: members } = api.workspaces.listMembers.useQuery( const { data: members } = api.workspaces.listMembers.useQuery(
{ workspaceId: workspaceId! }, { workspace: workspaceHandle! },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceHandle) },
); );
const resolvedUsers = useMemo(() => { const resolvedUsers = useMemo(() => {

View file

@ -122,10 +122,13 @@ function initials(name: string) {
.toUpperCase(); .toUpperCase();
} }
function useObjectDetailQuery(objectId: string | null) { function useObjectDetailQuery(
objectId: string | null,
workspaceHandle: string | undefined,
) {
return api.objects.getById.useQuery( return api.objects.getById.useQuery(
{ id: objectId as string }, { id: objectId as string, workspace: workspaceHandle as string },
{ enabled: Boolean(objectId) }, { enabled: Boolean(objectId) && Boolean(workspaceHandle) },
); );
} }
@ -155,14 +158,14 @@ export function ObjectDetail() {
const utils = api.useUtils(); const utils = api.useUtils();
const workspace = useWorkspaceStore((s) => s.currentWorkspace); const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const workspaceId = workspace?.id; const workspaceHandle = workspace?.slug ?? workspace?.id;
const { data: workspaceMembersList } = api.workspaces.listMembers.useQuery( const { data: workspaceMembersList } = api.workspaces.listMembers.useQuery(
{ workspaceId: workspaceId! }, { workspace: workspaceHandle! },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceHandle) },
); );
const objectDetailQuery = useObjectDetailQuery(objectId); const objectDetailQuery = useObjectDetailQuery(objectId, workspaceHandle);
const data = objectDetailQuery.data as ObjectDetailData | undefined; const data = objectDetailQuery.data as ObjectDetailData | undefined;
const { isPending, isError, error } = objectDetailQuery; const { isPending, isError, error } = objectDetailQuery;
@ -181,7 +184,8 @@ export function ObjectDetail() {
const mergeObjectCache = React.useCallback( const mergeObjectCache = React.useCallback(
(id: string, patch: Partial<ObjectDetailData>) => { (id: string, patch: Partial<ObjectDetailData>) => {
utils.objects.getById.setData({ id }, (old) => { if (!workspaceHandle) return;
utils.objects.getById.setData({ id, workspace: workspaceHandle }, (old) => {
if (!old) return old; if (!old) return old;
return { return {
...(old as ObjectDetailData), ...(old as ObjectDetailData),
@ -189,7 +193,7 @@ export function ObjectDetail() {
} as typeof old; } as typeof old;
}); });
}, },
[utils], [utils, workspaceHandle],
); );
const updateObjectMutation = api.objects.update.useMutation({ const updateObjectMutation = api.objects.update.useMutation({
@ -197,19 +201,22 @@ export function ObjectDetail() {
mergeObjectCache(variables.id, variables as Partial<ObjectDetailData>); mergeObjectCache(variables.id, variables as Partial<ObjectDetailData>);
}, },
onSuccess: async (_data, variables) => { onSuccess: async (_data, variables) => {
await utils.objects.getById.invalidate({ id: variables.id }); if (!workspaceHandle) return;
await utils.objects.getById.invalidate({ id: variables.id, workspace: workspaceHandle });
}, },
}); });
const assignMutation = api.objects.assign.useMutation({ const assignMutation = api.objects.assign.useMutation({
onSuccess: async (_data, variables) => { onSuccess: async (_data, variables) => {
await utils.objects.getById.invalidate({ id: variables.objectId }); if (!workspaceHandle) return;
await utils.objects.getById.invalidate({ id: variables.objectId, workspace: workspaceHandle });
}, },
}); });
const setPropertyValueMutation = api.properties.setValue.useMutation({ const setPropertyValueMutation = api.properties.setValue.useMutation({
onSuccess: async (_data, variables) => { onSuccess: async (_data, variables) => {
await utils.objects.getById.invalidate({ id: variables.objectId }); if (!workspaceHandle) return;
await utils.objects.getById.invalidate({ id: variables.objectId, workspace: workspaceHandle });
}, },
}); });
@ -218,11 +225,12 @@ export function ObjectDetail() {
row: ObjectDetailData["propertyValues"][number], row: ObjectDetailData["propertyValues"][number],
next: unknown, next: unknown,
) => { ) => {
if (!data) return; if (!data || !workspaceHandle) return;
const nextRows = [...data.propertyValues]; const nextRows = [...data.propertyValues];
nextRows[index] = { ...row, value: next }; nextRows[index] = { ...row, value: next };
mergeObjectCache(data.id, { propertyValues: nextRows }); mergeObjectCache(data.id, { propertyValues: nextRows });
setPropertyValueMutation.mutate({ setPropertyValueMutation.mutate({
workspace: workspaceHandle,
objectId: data.id, objectId: data.id,
propertyDefId: row.propertyDefinition.id, propertyDefId: row.propertyDefinition.id,
value: next, value: next,
@ -259,7 +267,9 @@ export function ObjectDetail() {
]; ];
} }
mergeObjectCache(data.id, { assignees: nextAssignees }); mergeObjectCache(data.id, { assignees: nextAssignees });
if (!workspaceHandle) return;
assignMutation.mutate({ assignMutation.mutate({
workspace: workspaceHandle,
objectId: data.id, objectId: data.id,
userId, userId,
action: has ? "remove" : "add", action: has ? "remove" : "add",
@ -271,19 +281,20 @@ export function ObjectDetail() {
setEditingTitle(false); setEditingTitle(false);
return; return;
} }
updateObjectMutation.mutate({ id: data.id, title: titleDraft.trim() }); if (!workspaceHandle) return;
updateObjectMutation.mutate({ workspace: workspaceHandle, id: data.id, title: titleDraft.trim() });
setEditingTitle(false); setEditingTitle(false);
}; };
const commitDescription = () => { const commitDescription = () => {
if (!data) return; if (!data || !workspaceHandle) return;
if (descriptionDraft === (data.description ?? "")) return; if (descriptionDraft === (data.description ?? "")) return;
updateObjectMutation.mutate({ id: data.id, description: descriptionDraft }); updateObjectMutation.mutate({ workspace: workspaceHandle, id: data.id, description: descriptionDraft });
}; };
const setStatus = (status: StatusValue) => { const setStatus = (status: StatusValue) => {
if (!data) return; if (!data || !workspaceHandle) return;
updateObjectMutation.mutate({ id: data.id, status }); updateObjectMutation.mutate({ workspace: workspaceHandle, id: data.id, status });
}; };
const onTabChange = (v: string) => { const onTabChange = (v: string) => {
@ -472,7 +483,7 @@ export function ObjectDetail() {
onOpenChange={setAssigneeOpen} onOpenChange={setAssigneeOpen}
assignedIds={assignedIds} assignedIds={assignedIds}
onToggle={toggleAssignee} onToggle={toggleAssignee}
workspaceId={workspaceId} workspaceHandle={workspaceHandle}
> >
<Button <Button
variant="outline" variant="outline"

View file

@ -157,19 +157,19 @@ export function SearchDialog({
const workspace = useWorkspaceStore((s) => s.currentWorkspace); const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const openPanel = usePanelStore((s) => s.open); const openPanel = usePanelStore((s) => s.open);
const workspaceId = const workspaceHandle = workspace?.slug ?? workspace?.id;
workspace?.id && isUuid(workspace.id) ? workspace.id : undefined;
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const debounced = useDebouncedValue(query, DEBOUNCE_MS); const debounced = useDebouncedValue(query, DEBOUNCE_MS);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const searchEnabled = open && debounced.trim().length > 0; const searchEnabled =
open && debounced.trim().length > 0 && Boolean(workspaceHandle);
const searchQuery = api.search.search.useQuery( const searchQuery = api.search.search.useQuery(
{ {
workspace: workspaceHandle!,
query: debounced.trim(), query: debounced.trim(),
workspaceId,
limit: 20, limit: 20,
}, },
{ {
@ -179,9 +179,9 @@ export function SearchDialog({
); );
const recentQuery = api.search.recent.useQuery( const recentQuery = api.search.recent.useQuery(
{ workspaceId, limit: 10 }, { workspace: workspaceHandle!, limit: 10 },
{ {
enabled: open, enabled: open && Boolean(workspaceHandle),
retry: false, retry: false,
}, },
); );

View file

@ -163,11 +163,11 @@ function hrefForNode(base: string, nodeId: string, nodeType: string): string {
function MoreMenuItems({ function MoreMenuItems({
node, node,
href, href,
workspaceId, workspaceHandle,
}: { }: {
node: TreeNodeData; node: TreeNodeData;
href: string; href: string;
workspaceId: string; workspaceHandle: string;
}) { }) {
const utils = api.useUtils(); const utils = api.useUtils();
const archiveObj = api.objects.archive.useMutation({ const archiveObj = api.objects.archive.useMutation({
@ -214,9 +214,9 @@ function MoreMenuItems({
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
duplicateObj.mutate({ duplicateObj.mutate({
workspace: workspaceHandle,
type: node.type as ObjectType, type: node.type as ObjectType,
title: `${node.title} (copy)`, title: `${node.title} (copy)`,
workspaceId,
parentId: node.parentId ?? undefined, parentId: node.parentId ?? undefined,
}); });
}} }}
@ -225,7 +225,7 @@ function MoreMenuItems({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
archiveObj.mutate({ id: node.id }); archiveObj.mutate({ workspace: workspaceHandle, id: node.id });
}} }}
> >
Archive Archive
@ -234,7 +234,7 @@ function MoreMenuItems({
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
onSelect={() => { onSelect={() => {
if (window.confirm(`Delete "${node.title}"?`)) { if (window.confirm(`Delete "${node.title}"?`)) {
deleteObj.mutate({ id: node.id }); deleteObj.mutate({ workspace: workspaceHandle, id: node.id });
} }
}} }}
> >
@ -244,7 +244,13 @@ function MoreMenuItems({
); );
} }
function CreateChildMenu({ parentId, workspaceId }: { parentId: string; workspaceId: string }) { function CreateChildMenu({
parentId,
workspaceHandle,
}: {
parentId: string;
workspaceHandle: string;
}) {
const utils = api.useUtils(); const utils = api.useUtils();
const create = api.objects.create.useMutation({ const create = api.objects.create.useMutation({
onSuccess: () => { onSuccess: () => {
@ -252,7 +258,7 @@ function CreateChildMenu({ parentId, workspaceId }: { parentId: string; workspac
}, },
}); });
const handleCreate = (type: ObjectType, title: string) => { const handleCreate = (type: ObjectType, title: string) => {
create.mutate({ type, title, workspaceId, parentId }); create.mutate({ workspace: workspaceHandle, type, title, parentId });
}; };
return ( return (
<> <>
@ -272,14 +278,14 @@ export function TreeNode({
collapsed, collapsed,
base, base,
pathname, pathname,
workspaceId, workspaceHandle,
}: { }: {
node: TreeNodeData; node: TreeNodeData;
level: number; level: number;
collapsed: boolean; collapsed: boolean;
base: string; base: string;
pathname: string | null; pathname: string | null;
workspaceId: string; workspaceHandle: string;
}) { }) {
const expandedNodes = useSidebarStore((s) => s.expandedNodes); const expandedNodes = useSidebarStore((s) => s.expandedNodes);
const toggleNode = useSidebarStore((s) => s.toggleNode); const toggleNode = useSidebarStore((s) => s.toggleNode);
@ -332,7 +338,7 @@ export function TreeNode({
collapsed={collapsed} collapsed={collapsed}
base={base} base={base}
pathname={pathname} pathname={pathname}
workspaceId={workspaceId} workspaceHandle={workspaceHandle}
/> />
))} ))}
</div> </div>
@ -415,7 +421,7 @@ export function TreeNode({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}> <DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
<CreateChildMenu parentId={node.id} workspaceId={workspaceId} /> <CreateChildMenu parentId={node.id} workspaceHandle={workspaceHandle} />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null} ) : null}
@ -436,7 +442,7 @@ export function TreeNode({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}> <DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}>
<MoreMenuItems node={node} href={href} workspaceId={workspaceId} /> <MoreMenuItems node={node} href={href} workspaceHandle={workspaceHandle} />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@ -453,7 +459,7 @@ export function TreeNode({
collapsed={collapsed} collapsed={collapsed}
base={base} base={base}
pathname={pathname} pathname={pathname}
workspaceId={workspaceId} workspaceHandle={workspaceHandle}
/> />
))} ))}
</div> </div>
@ -481,15 +487,15 @@ export function NavTree({
? `/${slugParam}` ? `/${slugParam}`
: ""; : "";
const workspaceId = workspace?.id ?? ""; const workspaceHandle = workspace?.slug ?? workspace?.id ?? "";
const favoritesQuery = api.favorites.list.useQuery(undefined, { const favoritesQuery = api.favorites.list.useQuery(undefined, {
enabled: Boolean(workspaceId), enabled: Boolean(workspaceHandle),
}); });
const favorites = favoritesQuery.data ?? []; const favorites = favoritesQuery.data ?? [];
const { data, isLoading, isError } = api.objects.getTree.useQuery( const { data, isLoading, isError } = api.objects.getTree.useQuery(
{ workspaceId: workspace?.id! }, { workspace: workspaceHandle },
{ enabled: Boolean(workspace?.id) }, { enabled: Boolean(workspaceHandle) },
); );
const partitioned = useMemo(() => { const partitioned = useMemo(() => {
@ -608,7 +614,7 @@ export function NavTree({
collapsed={collapsed} collapsed={collapsed}
base={base} base={base}
pathname={pathname} pathname={pathname}
workspaceId={workspaceId} workspaceHandle={workspaceHandle}
/> />
))} ))}
</div> </div>

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from "react";
import { Building2, Check, Plus } from "lucide-react"; import { Building2, Check, Plus } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -17,6 +18,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { CreateWorkspaceDialog } from "@/components/workspaces/create-workspace-dialog";
import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -31,6 +33,7 @@ export function WorkspaceSwitcher({
const router = useRouter(); const router = useRouter();
const current = useWorkspaceStore((s) => s.currentWorkspace); const current = useWorkspaceStore((s) => s.currentWorkspace);
const { data: workspaces } = api.workspaces.listForUser.useQuery(); const { data: workspaces } = api.workspaces.listForUser.useQuery();
const [createOpen, setCreateOpen] = useState(false);
const displayName = current?.name ?? "Select workspace..."; const displayName = current?.name ?? "Select workspace...";
@ -77,11 +80,11 @@ export function WorkspaceSwitcher({
key={ws.id} key={ws.id}
className="gap-2" className="gap-2"
onClick={() => { onClick={() => {
router.push(`/${ws.id}`); router.push(`/${ws.slug}`);
}} }}
> >
<Building2 className="size-4 shrink-0 opacity-70" /> <Building2 className="size-4 shrink-0 opacity-70" />
<span className="flex-1 truncate">{ws.title}</span> <span className="flex-1 truncate">{ws.name}</span>
{selected ? ( {selected ? (
<Check className="size-4 shrink-0 text-primary" /> <Check className="size-4 shrink-0 text-primary" />
) : null} ) : null}
@ -91,8 +94,9 @@ export function WorkspaceSwitcher({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="gap-2 text-muted-foreground focus:text-foreground" className="gap-2 text-muted-foreground focus:text-foreground"
onClick={() => { onSelect={(e) => {
// Conductor: wire create workspace flow e.preventDefault();
setCreateOpen(true);
}} }}
> >
<Plus className="size-4" /> <Plus className="size-4" />
@ -100,6 +104,7 @@ export function WorkspaceSwitcher({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<CreateWorkspaceDialog open={createOpen} onOpenChange={setCreateOpen} />
</div> </div>
); );
} }

View file

@ -194,11 +194,11 @@ function SortablePropertyRow({
export type TemplateEditorProps = { export type TemplateEditorProps = {
template?: TemplateRow; template?: TemplateRow;
workspaceId: string; workspaceHandle: string;
onSave: () => void; onSave: () => void;
}; };
export function TemplateEditor({ template, workspaceId, onSave }: TemplateEditorProps) { export function TemplateEditor({ template, workspaceHandle, onSave }: TemplateEditorProps) {
const [name, setName] = React.useState(template?.name ?? ""); const [name, setName] = React.useState(template?.name ?? "");
const [targetType, setTargetType] = React.useState( const [targetType, setTargetType] = React.useState(
template?.targetType && TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number]) template?.targetType && TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number])
@ -261,13 +261,14 @@ export function TemplateEditor({ template, workspaceId, onSave }: TemplateEditor
if (template?.id) { if (template?.id) {
updateMut.mutate({ updateMut.mutate({
workspace: workspaceHandle,
id: template.id, id: template.id,
name: name.trim(), name: name.trim(),
schema, schema,
}); });
} else { } else {
createMut.mutate({ createMut.mutate({
workspaceId, workspace: workspaceHandle,
name: name.trim(), name: name.trim(),
targetType, targetType,
schema, schema,

View file

@ -111,12 +111,12 @@ export type TemplatePickerProps = {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
objectType: string; objectType: string;
/** List templates for this workspace when no object exists yet (e.g. create dialog). */ /** Workspace UUID or slug; pass directly when there's no related object. */
workspaceId?: string; workspaceHandle?: string;
/** Resolve workspace from an existing object; ignored when `workspaceId` is set. */ /** Resolve workspace from an existing object; ignored when `workspaceHandle` is set. */
objectId?: string; objectId?: string;
onSelect: (template: PickerTemplate) => void; onSelect: (template: PickerTemplate) => void;
/** Renders the footer; invoked when the user chooses “Create New Template”. */ /** Renders the footer; invoked when the user chooses "Create New Template". */
onCreateNew?: () => void; onCreateNew?: () => void;
}; };
@ -124,7 +124,7 @@ export function TemplatePicker({
open, open,
onOpenChange, onOpenChange,
objectType, objectType,
workspaceId: workspaceIdProp, workspaceHandle: workspaceHandleProp,
objectId, objectId,
onSelect, onSelect,
onCreateNew, onCreateNew,
@ -133,22 +133,21 @@ export function TemplatePicker({
const [showAllTypes, setShowAllTypes] = React.useState(false); const [showAllTypes, setShowAllTypes] = React.useState(false);
const objectQuery = api.objects.getById.useQuery( const objectQuery = api.objects.getById.useQuery(
{ id: objectId! }, { id: objectId!, workspace: workspaceHandleProp! },
{ enabled: open && Boolean(objectId) && !workspaceIdProp }, { enabled: open && Boolean(objectId) && Boolean(workspaceHandleProp) },
); );
const objWorkspace = (objectQuery.data as unknown as { workspaceId?: string | null } | undefined) // Templates are workspace-scoped, so we always need a handle. When only an
?.workspaceId; // object id is passed, the caller must also pass workspaceHandle so we can
const workspaceFromObject = // resolve templates without leaking cross-tenant data.
typeof objWorkspace === "string" && objWorkspace.length > 0 ? objWorkspace : undefined; const resolvedWorkspaceHandle = workspaceHandleProp;
const resolvedWorkspaceId = workspaceIdProp ?? workspaceFromObject;
const listQuery = api.templates.list.useQuery( const listQuery = api.templates.list.useQuery(
{ {
workspaceId: resolvedWorkspaceId!, workspace: resolvedWorkspaceHandle!,
targetType: showAllTypes ? undefined : objectType, targetType: showAllTypes ? undefined : objectType,
}, },
{ enabled: open && Boolean(resolvedWorkspaceId) }, { enabled: open && Boolean(resolvedWorkspaceHandle) },
); );
const merged = React.useMemo(() => { const merged = React.useMemo(() => {
@ -256,14 +255,11 @@ export function TemplatePicker({
<ScrollArea className="max-h-[min(420px,55vh)] px-4"> <ScrollArea className="max-h-[min(420px,55vh)] px-4">
<div className="space-y-4 pb-3 pr-3"> <div className="space-y-4 pb-3 pr-3">
{listQuery.isPending && resolvedWorkspaceId ? ( {listQuery.isPending && resolvedWorkspaceHandle ? (
<p className="text-sm text-muted-foreground">Loading templates</p> <p className="text-sm text-muted-foreground">Loading templates</p>
) : null} ) : null}
{!resolvedWorkspaceId && objectId && !workspaceIdProp && objectQuery.isPending ? ( {!resolvedWorkspaceHandle ? (
<p className="text-sm text-muted-foreground">Loading object</p> <p className="text-sm text-muted-foreground">Select a workspace first.</p>
) : null}
{!resolvedWorkspaceId && objectId && !workspaceIdProp && objectQuery.isError ? (
<p className="text-sm text-destructive">Could not load workspace.</p>
) : null} ) : null}
{Array.from(grouped.entries()).map(([typeKey, items], gi) => ( {Array.from(grouped.entries()).map(([typeKey, items], gi) => (

View file

@ -36,7 +36,7 @@ function slugFromName(name: string): string {
} }
export interface TypeEditorProps { export interface TypeEditorProps {
workspaceId: string; workspaceHandle: string;
existingType?: { existingType?: {
id: string; id: string;
name: string; name: string;
@ -53,7 +53,7 @@ const fieldClass =
"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"; "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";
export function TypeEditor({ export function TypeEditor({
workspaceId, workspaceHandle,
existingType, existingType,
onSave, onSave,
onCancel, onCancel,
@ -88,14 +88,14 @@ export function TypeEditor({
const createMutation = api.types.create.useMutation({ const createMutation = api.types.create.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.types.list.invalidate({ workspaceId }); await utils.types.list.invalidate({ workspace: workspaceHandle });
onSave(); onSave();
}, },
}); });
const updateMutation = api.types.update.useMutation({ const updateMutation = api.types.update.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.types.list.invalidate({ workspaceId }); await utils.types.list.invalidate({ workspace: workspaceHandle });
onSave(); onSave();
}, },
}); });
@ -112,6 +112,7 @@ export function TypeEditor({
if (existingType) { if (existingType) {
await updateMutation.mutateAsync({ await updateMutation.mutateAsync({
workspace: workspaceHandle,
id: existingType.id, id: existingType.id,
name, name,
icon: iconTrim, icon: iconTrim,
@ -120,7 +121,7 @@ export function TypeEditor({
}); });
} else { } else {
await createMutation.mutateAsync({ await createMutation.mutateAsync({
workspaceId, workspace: workspaceHandle,
name, name,
slug, slug,
icon: iconTrim || undefined, icon: iconTrim || undefined,

View file

@ -60,19 +60,19 @@ function TypeIconDisplay({ icon }: { icon: string | null | undefined }) {
} }
export interface TypeManagerProps { export interface TypeManagerProps {
workspaceId: string; workspaceHandle: string;
} }
export function TypeManager({ workspaceId }: TypeManagerProps) { export function TypeManager({ workspaceHandle }: TypeManagerProps) {
const utils = api.useUtils(); const utils = api.useUtils();
const listQuery = api.types.list.useQuery( const listQuery = api.types.list.useQuery(
{ workspaceId }, { workspace: workspaceHandle },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceHandle) },
); );
const deleteMutation = api.types.delete.useMutation({ const deleteMutation = api.types.delete.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.types.list.invalidate({ workspaceId }); await utils.types.list.invalidate({ workspace: workspaceHandle });
}, },
}); });
@ -109,16 +109,16 @@ export function TypeManager({ workspaceId }: TypeManagerProps) {
} }
function handleDelete(row: (typeof customTypes)[number]) { function handleDelete(row: (typeof customTypes)[number]) {
const ok = window.confirm(`Delete type ${row.name}? This cannot be undone.`); const ok = window.confirm(`Delete type "${row.name}"? This cannot be undone.`);
if (!ok) return; if (!ok) return;
deleteMutation.mutate({ id: row.id }); deleteMutation.mutate({ workspace: workspaceHandle, id: row.id });
} }
return ( return (
<div className="space-y-10"> <div className="space-y-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-semibold tracking-tight">Object Types</h1> <h1 className="text-2xl font-semibold tracking-tight">Object Types</h1>
<Button type="button" onClick={openCreate} disabled={!workspaceId}> <Button type="button" onClick={openCreate} disabled={!workspaceHandle}>
Create Type Create Type
</Button> </Button>
</div> </div>
@ -248,10 +248,10 @@ export function TypeManager({ workspaceId }: TypeManagerProps) {
</DialogPrimitive.Close> </DialogPrimitive.Close>
</div> </div>
<div className="px-4 py-4"> <div className="px-4 py-4">
{workspaceId ? ( {workspaceHandle ? (
<TypeEditor <TypeEditor
key={editing?.id ?? "new"} key={editing?.id ?? "new"}
workspaceId={workspaceId} workspaceHandle={workspaceHandle}
existingType={editing} existingType={editing}
onSave={() => setDialogOpen(false)} onSave={() => setDialogOpen(false)}
onCancel={() => setDialogOpen(false)} onCancel={() => setDialogOpen(false)}

View file

@ -30,7 +30,7 @@ const BUILTIN_OPTIONS = [
] as const; ] as const;
export interface TypePickerProps { export interface TypePickerProps {
workspaceId?: string; workspaceHandle?: string;
value: string; value: string;
onChange: (type: string) => void; onChange: (type: string) => void;
} }
@ -55,10 +55,10 @@ function TypeOptionIcon({
return null; return null;
} }
export function TypePicker({ workspaceId, value, onChange }: TypePickerProps) { export function TypePicker({ workspaceHandle, value, onChange }: TypePickerProps) {
const listQuery = api.types.list.useQuery( const listQuery = api.types.list.useQuery(
{ workspaceId: workspaceId! }, { workspace: workspaceHandle! },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceHandle) },
); );
const customTypes = listQuery.data ?? []; const customTypes = listQuery.data ?? [];

View file

@ -112,7 +112,7 @@ export interface BoardViewProps {
export function BoardView({ config, className }: BoardViewProps) { export function BoardView({ config, className }: BoardViewProps) {
const params = useParams(); const params = useParams();
const workspaceId = const workspaceHandle =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const parentId = const parentId =
typeof params?.projectId === "string" ? params.projectId : undefined; typeof params?.projectId === "string" ? params.projectId : undefined;
@ -127,7 +127,7 @@ export function BoardView({ config, className }: BoardViewProps) {
const { grouped, isLoading, total } = useViewData( const { grouped, isLoading, total } = useViewData(
effectiveConfig, effectiveConfig,
workspaceId, workspaceHandle,
parentId, parentId,
); );
const groupField = effectiveConfig.groupBy ?? "status"; const groupField = effectiveConfig.groupBy ?? "status";
@ -333,11 +333,11 @@ export function BoardView({ config, className }: BoardViewProps) {
}, },
onSubmit: () => { onSubmit: () => {
const t = newTitle.trim(); const t = newTitle.trim();
if (!t || !workspaceId || createObject.isPending) return; if (!t || !workspaceHandle || createObject.isPending) return;
createObject.mutate({ createObject.mutate({
type: "task", type: "task",
title: t, title: t,
workspaceId, workspace: workspaceHandle,
parentId: parentId ?? undefined, parentId: parentId ?? undefined,
...(groupField === "status" ? { status: columnId } : {}), ...(groupField === "status" ? { status: columnId } : {}),
}); });

View file

@ -18,18 +18,19 @@ export interface FormViewProps {
export function FormView({ config, className }: FormViewProps) { export function FormView({ config, className }: FormViewProps) {
void config; void config;
const workspaceId = useWorkspaceStore((s) => s.currentWorkspace?.id); const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const workspaceHandle = workspace?.slug ?? workspace?.id;
const listQuery = api.forms.list.useQuery( const listQuery = api.forms.list.useQuery(
{ workspaceId: workspaceId! }, { workspace: workspaceHandle! },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceHandle) },
); );
const [selectedId, setSelectedId] = React.useState<string | null>(null); const [selectedId, setSelectedId] = React.useState<string | null>(null);
const forms = listQuery.data?.forms ?? []; const forms = listQuery.data?.forms ?? [];
if (!workspaceId) { if (!workspaceHandle) {
return ( return (
<div className={cn("p-6 text-sm text-muted-foreground", className)}> <div className={cn("p-6 text-sm text-muted-foreground", className)}>
Select a workspace to use forms. Select a workspace to use forms.
@ -93,7 +94,7 @@ export function FormView({ config, className }: FormViewProps) {
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border bg-card p-6"> <div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border bg-card p-6">
{selectedId ? ( {selectedId ? (
<FormRenderer key={selectedId} formId={selectedId} /> <FormRenderer key={selectedId} formId={selectedId} workspaceHandle={workspaceHandle} />
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Pick a form above to fill it out in this view. Pick a form above to fill it out in this view.

View file

@ -206,7 +206,7 @@ export interface ListViewProps {
export function ListView({ config }: ListViewProps) { export function ListView({ config }: ListViewProps) {
const params = useParams(); const params = useParams();
const workspaceId = const workspaceHandle =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const parentId = const parentId =
typeof params?.projectId === "string" ? params.projectId : undefined; typeof params?.projectId === "string" ? params.projectId : undefined;
@ -223,7 +223,7 @@ export function ListView({ config }: ListViewProps) {
const { items, isLoading, total } = useViewData( const { items, isLoading, total } = useViewData(
effectiveConfig, effectiveConfig,
workspaceId, workspaceHandle,
parentId, parentId,
); );
@ -398,12 +398,12 @@ export function ListView({ config }: ListViewProps) {
value={newTitle} value={newTitle}
onChange={(e) => setNewTitle(e.target.value)} onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && newTitle.trim() && workspaceId) { if (e.key === "Enter" && newTitle.trim() && workspaceHandle) {
e.preventDefault(); e.preventDefault();
createObject.mutate({ createObject.mutate({
type: "task", type: "task",
title: newTitle.trim(), title: newTitle.trim(),
workspaceId, workspace: workspaceHandle,
parentId: parentId ?? undefined, parentId: parentId ?? undefined,
}); });
} }
@ -414,11 +414,11 @@ export function ListView({ config }: ListViewProps) {
}} }}
onBlur={() => { onBlur={() => {
if (createObject.isPending) return; if (createObject.isPending) return;
if (newTitle.trim() && workspaceId) { if (newTitle.trim() && workspaceHandle) {
createObject.mutate({ createObject.mutate({
type: "task", type: "task",
title: newTitle.trim(), title: newTitle.trim(),
workspaceId, workspace: workspaceHandle,
parentId: parentId ?? undefined, parentId: parentId ?? undefined,
}); });
} else { } else {

View file

@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
export interface OverviewViewProps { export interface OverviewViewProps {
workspaceId?: string; workspaceHandle?: string;
spaceId?: string; spaceId?: string;
} }
@ -16,19 +16,19 @@ function formatUpdatedAt(value: Date | string): string {
return date.toLocaleString(); return date.toLocaleString();
} }
export function OverviewView({ workspaceId, spaceId }: OverviewViewProps) { export function OverviewView({ workspaceHandle, spaceId }: OverviewViewProps) {
const spaceQuery = api.objects.getById.useQuery( const spaceQuery = api.objects.getById.useQuery(
{ id: spaceId! }, { workspace: workspaceHandle!, id: spaceId! },
{ enabled: Boolean(spaceId) }, { enabled: Boolean(spaceId) && Boolean(workspaceHandle) },
); );
const childrenQuery = api.objects.list.useQuery( const childrenQuery = api.objects.list.useQuery(
{ {
workspaceId: workspaceId!, workspace: workspaceHandle!,
parentId: spaceId ?? undefined, parentId: spaceId ?? undefined,
limit: 200, limit: 200,
}, },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceHandle) },
); );
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {

View file

@ -347,7 +347,7 @@ export interface TableViewProps {
export function TableView({ config }: TableViewProps) { export function TableView({ config }: TableViewProps) {
const params = useParams(); const params = useParams();
const workspaceId = const workspaceHandle =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const parentId = const parentId =
typeof params?.projectId === "string" ? params.projectId : undefined; typeof params?.projectId === "string" ? params.projectId : undefined;
@ -365,7 +365,7 @@ export function TableView({ config }: TableViewProps) {
const { items, isLoading, total } = useViewData( const { items, isLoading, total } = useViewData(
effectiveConfig, effectiveConfig,
workspaceId, workspaceHandle,
parentId, parentId,
); );
@ -813,12 +813,12 @@ export function TableView({ config }: TableViewProps) {
value={newTitle} value={newTitle}
onChange={(e) => setNewTitle(e.target.value)} onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && newTitle.trim() && workspaceId) { if (e.key === "Enter" && newTitle.trim() && workspaceHandle) {
e.preventDefault(); e.preventDefault();
createObject.mutate({ createObject.mutate({
type: "task", type: "task",
title: newTitle.trim(), title: newTitle.trim(),
workspaceId, workspace: workspaceHandle,
parentId: parentId ?? undefined, parentId: parentId ?? undefined,
}); });
} }
@ -829,11 +829,11 @@ export function TableView({ config }: TableViewProps) {
}} }}
onBlur={() => { onBlur={() => {
if (createObject.isPending) return; if (createObject.isPending) return;
if (newTitle.trim() && workspaceId) { if (newTitle.trim() && workspaceHandle) {
createObject.mutate({ createObject.mutate({
type: "task", type: "task",
title: newTitle.trim(), title: newTitle.trim(),
workspaceId, workspace: workspaceHandle,
parentId: parentId ?? undefined, parentId: parentId ?? undefined,
}); });
} else { } else {

View file

@ -0,0 +1,202 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { useRouter } from "next/navigation";
import { Loader2, X } from "lucide-react";
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 SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
function makeSlug(name: string): string {
return (
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || ""
);
}
export function CreateWorkspaceDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (next: boolean) => void;
}) {
const router = useRouter();
const utils = api.useUtils();
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
const [name, setName] = React.useState("");
const [slug, setSlug] = React.useState("");
const [slugTouched, setSlugTouched] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const createMut = api.workspaces.create.useMutation({
onSuccess: async (ws) => {
await utils.workspaces.listForUser.invalidate();
setWorkspace({ id: ws.id, slug: ws.slug, name: ws.name });
onOpenChange(false);
router.push(`/${ws.slug}`);
},
onError: (e) => setError(e.message ?? "Failed to create workspace"),
});
React.useEffect(() => {
if (open) {
setName("");
setSlug("");
setSlugTouched(false);
setError(null);
}
}, [open]);
const previewSlug = slugTouched ? slug : makeSlug(name);
const slugInvalid = slugTouched && slug.length > 0 && !SLUG_RE.test(slug);
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (createMut.isPending) return;
setError(null);
const trimmed = name.trim();
if (!trimmed) {
setError("Workspace name is required");
return;
}
if (slugTouched && slug && !SLUG_RE.test(slug)) {
setError("Slug must be lowercase letters, digits, or hyphens");
return;
}
createMut.mutate({
name: trimmed,
...(slugTouched && slug ? { slug } : {}),
});
};
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-[200] bg-background/80 backdrop-blur-sm",
"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-[18vh] z-[201] w-[min(480px,calc(100vw-1.5rem))] -translate-x-1/2 rounded-xl border border-border bg-popover p-6 shadow-2xl outline-none",
"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",
)}
>
<div className="flex items-start justify-between gap-3 pb-4">
<div>
<DialogPrimitive.Title className="text-base font-semibold">
Create workspace
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-xs text-muted-foreground">
Workspaces isolate projects, tasks, and team members. You can
rename or change the slug later.
</DialogPrimitive.Description>
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 shrink-0 text-muted-foreground"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X className="size-4" />
</Button>
</div>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="ws-name" className="text-xs font-medium">
Name
</label>
<Input
id="ws-name"
value={name}
placeholder="Acme Inc."
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-1.5">
<label htmlFor="ws-slug" className="text-xs font-medium">
Slug
</label>
<div className="flex items-center gap-2 rounded-md border border-input bg-background px-2 focus-within:ring-2 focus-within:ring-ring">
<span className="select-none text-xs text-muted-foreground">/</span>
<input
id="ws-slug"
className="h-9 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
value={slugTouched ? slug : previewSlug}
placeholder="acme"
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
onFocus={() => {
if (!slugTouched) {
setSlug(previewSlug);
setSlugTouched(true);
}
}}
/>
</div>
<p
className={cn(
"text-xs",
slugInvalid ? "text-destructive" : "text-muted-foreground",
)}
>
{slugInvalid
? "Use lowercase letters, digits, or hyphens (no leading/trailing dash)."
: "Used in URLs (e.g. /acme/projects). Auto-generated from name."}
</p>
</div>
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={createMut.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={createMut.isPending || !name.trim()}>
{createMut.isPending ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden />
Creating
</>
) : (
"Create workspace"
)}
</Button>
</div>
</form>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View file

@ -82,12 +82,12 @@ function applyGroupBy(objects: ViewObject[], groupBy: string | null): Record<str
export function useViewData( export function useViewData(
config: ViewConfig, config: ViewConfig,
workspaceId?: string, workspaceHandle?: string,
parentId?: string | null, parentId?: string | null,
) { ) {
const { data, isLoading: queryLoading } = api.objects.list.useQuery( const { data, isLoading: queryLoading } = api.objects.list.useQuery(
{ workspaceId: workspaceId!, parentId: parentId ?? undefined, limit: 200 }, { workspace: workspaceHandle!, parentId: parentId ?? undefined, limit: 200 },
{ enabled: Boolean(workspaceId) }, { enabled: Boolean(workspaceHandle) },
); );
const objects: ViewObject[] = useMemo(() => { const objects: ViewObject[] = useMemo(() => {

View file

@ -0,0 +1,103 @@
import { TRPCError } from "@trpc/server";
import { and, eq, or } from "drizzle-orm";
import { workspaces, workspaceMembers } from "@tasks/database/schema";
import { db as defaultDb } from "@tasks/database";
/**
* Cheap UUID v4-ish detector. We only need to differentiate "this looks like a
* UUID" from "this looks like a slug" so the resolver can pick the right column.
*/
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export type WorkspaceContext = {
id: string;
slug: string;
name: string;
ownerUserId: string;
/** Caller's role inside the workspace, or "owner" if they own it directly. */
role: string;
};
/**
* Resolve a workspace handle (UUID or slug) to a full workspace record AND
* authorize the caller against it. Throws NOT_FOUND if the handle doesn't
* resolve, FORBIDDEN if the user isn't a member or owner.
*
* Used by the `workspaceProcedure` middleware and by Server Components at the
* `app/(app)/[workspaceSlug]/...` layout boundary.
*/
export async function resolveWorkspace(args: {
handle: string;
userId: string;
db?: typeof defaultDb;
}): Promise<WorkspaceContext> {
const db = args.db ?? defaultDb;
const handle = args.handle.trim();
if (!handle) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace handle required" });
}
const lookupCondition = UUID_RE.test(handle)
? eq(workspaces.id, handle)
: eq(workspaces.slug, handle);
const [row] = await db
.select({
id: workspaces.id,
slug: workspaces.slug,
name: workspaces.name,
ownerUserId: workspaces.ownerUserId,
memberRole: workspaceMembers.role,
})
.from(workspaces)
.leftJoin(
workspaceMembers,
and(
eq(workspaceMembers.workspaceId, workspaces.id),
eq(workspaceMembers.userId, args.userId),
),
)
.where(lookupCondition)
.limit(1);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found" });
}
const isOwner = row.ownerUserId === args.userId;
if (!isOwner && !row.memberRole) {
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this workspace" });
}
return {
id: row.id,
slug: row.slug,
name: row.name,
ownerUserId: row.ownerUserId,
role: isOwner ? "owner" : (row.memberRole ?? "member"),
};
}
/**
* Look up a workspace by either UUID or slug WITHOUT authorizing the caller.
* Used for the public form-fill flow and for routes that explicitly want to
* peek at workspace existence (e.g. URL backcompat redirects).
*/
export async function findWorkspaceByHandle(
handle: string,
db = defaultDb,
): Promise<{ id: string; slug: string; name: string } | null> {
const cleaned = handle.trim();
if (!cleaned) return null;
const cond = UUID_RE.test(cleaned)
? eq(workspaces.id, cleaned)
: eq(workspaces.slug, cleaned);
const [row] = await db
.select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name })
.from(workspaces)
.where(cond)
.limit(1);
return row ?? null;
}
export { UUID_RE as WORKSPACE_HANDLE_UUID_RE };

View file

@ -0,0 +1,46 @@
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import {
objects,
forms,
propertyDefinitions,
templates,
objectTypeDefs,
} from "@tasks/database/schema";
import type { db as defaultDb } from "@tasks/database";
type Db = typeof defaultDb;
/**
* Generic "this row belongs to this workspace" guard used by tenant-scoped
* routers when a mutation targets a specific row by id. Throws NOT_FOUND if the
* row either doesn't exist or lives in a different workspace, so callers can't
* use the error code to probe IDs across tenants.
*/
export async function assertRowInWorkspace<
T extends { id: typeof objects.id; workspaceId: typeof objects.workspaceId },
>(args: {
db: Db;
table: T;
rowId: string;
workspaceId: string;
notFoundMessage?: string;
}): Promise<void> {
const [row] = await args.db
.select({ id: args.table.id })
.from(args.table as any)
.where(and(eq(args.table.id, args.rowId), eq(args.table.workspaceId, args.workspaceId)))
.limit(1);
if (!row) {
throw new TRPCError({
code: "NOT_FOUND",
message: args.notFoundMessage ?? "Resource not found",
});
}
}
export const tableForms = forms;
export const tablePropertyDefs = propertyDefinitions;
export const tableTemplates = templates;
export const tableObjectTypeDefs = objectTypeDefs;
export const tableObjects = objects;

View file

@ -1,11 +1,11 @@
import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAI } from "@ai-sdk/openai";
import { generateText } from "ai"; import { generateText } from "ai";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db as dbInstance } from "@tasks/database"; import { db as dbInstance } from "@tasks/database";
import { objects } from "@tasks/database/schema"; import { objects } from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc"; import { router, workspaceProcedure } from "@/server/trpc";
type Db = typeof dbInstance; type Db = typeof dbInstance;
@ -15,25 +15,30 @@ const messageSchema = z.object({
}); });
const chatInputSchema = z.object({ const chatInputSchema = z.object({
workspace: z.string().min(1),
messages: z.array(messageSchema).min(1), messages: z.array(messageSchema).min(1),
context: z context: z
.object({ .object({
workspaceId: z.string().optional(),
objectId: z.string().uuid().optional(), objectId: z.string().uuid().optional(),
}) })
.optional(), .optional(),
}); });
const suggestInputSchema = z.object({ const suggestInputSchema = z.object({
workspace: z.string().min(1),
objectId: z.string().uuid().optional(), objectId: z.string().uuid().optional(),
objectType: z.string().optional(), objectType: z.string().optional(),
}); });
const BASE_SYSTEM = `You are a helpful AI assistant embedded in a project management and collaboration app. Users organize work in workspaces with objects such as projects, tasks, documents, and groups. You help them plan work, clarify requirements, break down tasks, summarize content, and suggest next steps. Be concise, actionable, and friendly. Use markdown when it improves readability (bold, lists, short code snippets).`; const BASE_SYSTEM = `You are a helpful AI assistant embedded in a project management and collaboration app. Users organize work in workspaces with objects such as projects, tasks, documents, and groups. You help them plan work, clarify requirements, break down tasks, summarize content, and suggest next steps. Be concise, actionable, and friendly. Use markdown when it improves readability (bold, lists, short code snippets).`;
async function fetchObjectSummary(database: Db, objectId: string): Promise<string | null> { async function fetchObjectSummary(
database: Db,
objectId: string,
workspaceId: string,
): Promise<string | null> {
const row = await database.query.objects.findFirst({ const row = await database.query.objects.findFirst({
where: eq(objects.id, objectId), where: and(eq(objects.id, objectId), eq(objects.workspaceId, workspaceId)),
columns: { columns: {
id: true, id: true,
title: true, title: true,
@ -133,21 +138,23 @@ function suggestionsForContext(input: z.infer<typeof suggestInputSchema>): strin
} }
export const aiRouter = router({ export const aiRouter = router({
chat: protectedProcedure.input(chatInputSchema).mutation(async ({ ctx, input }) => { chat: workspaceProcedure.input(chatInputSchema).mutation(async ({ ctx, input }) => {
let system = BASE_SYSTEM; let system = BASE_SYSTEM;
const ctxParts: string[] = []; const ctxParts: string[] = [];
if (input.context?.workspaceId) { ctxParts.push(`Current workspace: ${ctx.workspace.name} (${ctx.workspace.slug})`);
ctxParts.push(`Current workspace context ID: ${input.context.workspaceId}`);
}
if (input.context?.objectId) { if (input.context?.objectId) {
const summary = await fetchObjectSummary(ctx.db, input.context.objectId); const summary = await fetchObjectSummary(
ctx.db,
input.context.objectId,
ctx.workspace.id,
);
if (summary) { if (summary) {
ctxParts.push("The user is focused on this object:\n" + summary); ctxParts.push("The user is focused on this object:\n" + summary);
} else { } else {
ctxParts.push( ctxParts.push(
`The user referenced object ID ${input.context.objectId}, but it was not found.`, `The user referenced object ID ${input.context.objectId}, but it was not found in this workspace.`,
); );
} }
} }
@ -164,11 +171,14 @@ export const aiRouter = router({
return { text }; return { text };
}), }),
suggestActions: protectedProcedure.input(suggestInputSchema).query(async ({ ctx, input }) => { suggestActions: workspaceProcedure.input(suggestInputSchema).query(async ({ ctx, input }) => {
let objectType = input.objectType; let objectType = input.objectType;
if (input.objectId && !objectType) { if (input.objectId && !objectType) {
const row = await ctx.db.query.objects.findFirst({ const row = await ctx.db.query.objects.findFirst({
where: eq(objects.id, input.objectId), where: and(
eq(objects.id, input.objectId),
eq(objects.workspaceId, ctx.workspace.id),
),
columns: { type: true }, columns: { type: true },
}); });
objectType = row?.type; objectType = row?.type;

View file

@ -1,30 +1,76 @@
import { z } from "zod"; import { z } from "zod";
import { and, eq, desc } from "drizzle-orm"; import { and, eq, desc, exists } from "drizzle-orm";
import { userFavorites, objects } from "@tasks/database/schema"; import {
userFavorites,
objects,
workspaces,
workspaceMembers,
} from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc"; import { router, protectedProcedure } from "@/server/trpc";
import { TRPCError } from "@trpc/server";
/**
* Confirm the caller can see the given object. Cross-workspace favorites
* shouldn't expose object ids the user has no business reading.
*/
async function assertCallerCanSeeObject(
db: typeof import("@tasks/database").db,
objectId: string,
userId: string,
): Promise<void> {
const [row] = await db
.select({
id: objects.id,
workspaceId: objects.workspaceId,
ownerUserId: workspaces.ownerUserId,
})
.from(objects)
.innerJoin(workspaces, eq(objects.workspaceId, workspaces.id))
.where(eq(objects.id, objectId))
.limit(1);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
}
if (row.ownerUserId === userId) return;
const [member] = await db
.select({ id: workspaceMembers.id })
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, row.workspaceId),
eq(workspaceMembers.userId, userId),
),
)
.limit(1);
if (!member) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
}
}
export const favoritesRouter = router({ export const favoritesRouter = router({
list: protectedProcedure list: protectedProcedure.query(async ({ ctx }) => {
.query(async ({ ctx }) => { const rows = await ctx.db
const rows = await ctx.db .select({
.select({ id: userFavorites.id,
id: userFavorites.id, objectId: userFavorites.objectId,
objectId: userFavorites.objectId, createdAt: userFavorites.createdAt,
createdAt: userFavorites.createdAt, objectTitle: objects.title,
objectTitle: objects.title, objectType: objects.type,
objectType: objects.type, objectIcon: objects.icon,
objectIcon: objects.icon, workspaceId: objects.workspaceId,
}) })
.from(userFavorites) .from(userFavorites)
.innerJoin(objects, eq(userFavorites.objectId, objects.id)) .innerJoin(objects, eq(userFavorites.objectId, objects.id))
.where(eq(userFavorites.userId, ctx.session.user.id)) .where(eq(userFavorites.userId, ctx.session.user.id))
.orderBy(desc(userFavorites.createdAt)); .orderBy(desc(userFavorites.createdAt));
return rows; return rows;
}), }),
toggle: protectedProcedure toggle: protectedProcedure
.input(z.object({ objectId: z.string().uuid() })) .input(z.object({ objectId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await assertCallerCanSeeObject(ctx.db, input.objectId, ctx.session.user.id);
const existing = await ctx.db const existing = await ctx.db
.select({ id: userFavorites.id }) .select({ id: userFavorites.id })
.from(userFavorites) .from(userFavorites)

View file

@ -8,7 +8,7 @@ import {
propertyDefinitions, propertyDefinitions,
propertyValues, propertyValues,
} from "@tasks/database/schema"; } from "@tasks/database/schema";
import { type Context, router, protectedProcedure } from "@/server/trpc"; import { type Context, router, workspaceProcedure } from "@/server/trpc";
const UUID_RE = const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@ -52,23 +52,21 @@ async function resolvePropertyDefId(
} }
export const formsRouter = router({ export const formsRouter = router({
list: protectedProcedure list: workspaceProcedure.query(async ({ ctx }) => {
.input(z.object({ workspaceId: z.string().uuid() })) const rows = await ctx.db
.query(async ({ ctx, input }) => { .select()
const rows = await ctx.db .from(forms)
.select() .where(eq(forms.workspaceId, ctx.workspace.id))
.from(forms) .orderBy(desc(forms.updatedAt), asc(forms.id));
.where(eq(forms.workspaceId, input.workspaceId))
.orderBy(desc(forms.updatedAt), asc(forms.id));
return { forms: rows }; return { forms: rows };
}), }),
getById: protectedProcedure getById: workspaceProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const row = await ctx.db.query.forms.findFirst({ const row = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.id), where: and(eq(forms.id, input.id), eq(forms.workspaceId, ctx.workspace.id)),
}); });
if (!row) { if (!row) {
@ -78,10 +76,9 @@ export const formsRouter = router({
return row; return row;
}), }),
create: protectedProcedure create: workspaceProcedure
.input( .input(
z.object({ z.object({
workspaceId: z.string().uuid(),
title: z.string().min(1).max(500), title: z.string().min(1).max(500),
description: z.string().optional(), description: z.string().optional(),
coverImage: z.string().optional(), coverImage: z.string().optional(),
@ -95,17 +92,14 @@ export const formsRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
if (!userId) { if (!userId) {
throw new TRPCError({ throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id" });
code: "UNAUTHORIZED",
message: "Missing user id",
});
} }
const now = new Date(); const now = new Date();
const [created] = await ctx.db const [created] = await ctx.db
.insert(forms) .insert(forms)
.values({ .values({
workspaceId: input.workspaceId, workspaceId: ctx.workspace.id,
title: input.title, title: input.title,
description: input.description ?? null, description: input.description ?? null,
coverImage: input.coverImage ?? null, coverImage: input.coverImage ?? null,
@ -130,7 +124,7 @@ export const formsRouter = router({
return created; return created;
}), }),
update: protectedProcedure update: workspaceProcedure
.input( .input(
z.object({ z.object({
id: z.string().uuid(), id: z.string().uuid(),
@ -161,7 +155,7 @@ export const formsRouter = router({
...(patch.isPublished !== undefined ? { isPublished: patch.isPublished } : {}), ...(patch.isPublished !== undefined ? { isPublished: patch.isPublished } : {}),
updatedAt: now, updatedAt: now,
}) })
.where(eq(forms.id, id)) .where(and(eq(forms.id, id), eq(forms.workspaceId, ctx.workspace.id)))
.returning(); .returning();
if (!updated) { if (!updated) {
@ -171,12 +165,12 @@ export const formsRouter = router({
return updated; return updated;
}), }),
delete: protectedProcedure delete: workspaceProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const deleted = await ctx.db const deleted = await ctx.db
.delete(forms) .delete(forms)
.where(eq(forms.id, input.id)) .where(and(eq(forms.id, input.id), eq(forms.workspaceId, ctx.workspace.id)))
.returning({ id: forms.id }); .returning({ id: forms.id });
if (deleted.length === 0) { if (deleted.length === 0) {
@ -184,7 +178,7 @@ export const formsRouter = router({
} }
}), }),
submit: protectedProcedure submit: workspaceProcedure
.input( .input(
z.object({ z.object({
formId: z.string().uuid(), formId: z.string().uuid(),
@ -194,14 +188,11 @@ export const formsRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
if (!userId) { if (!userId) {
throw new TRPCError({ throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id" });
code: "UNAUTHORIZED",
message: "Missing user id",
});
} }
const form = await ctx.db.query.forms.findFirst({ const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.formId), where: and(eq(forms.id, input.formId), eq(forms.workspaceId, ctx.workspace.id)),
}); });
if (!form) { if (!form) {
@ -330,7 +321,7 @@ export const formsRouter = router({
return result; return result;
}), }),
listResponses: protectedProcedure listResponses: workspaceProcedure
.input( .input(
z.object({ z.object({
formId: z.string().uuid(), formId: z.string().uuid(),
@ -343,7 +334,7 @@ export const formsRouter = router({
const offset = input.offset ?? 0; const offset = input.offset ?? 0;
const form = await ctx.db.query.forms.findFirst({ const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.formId), where: and(eq(forms.id, input.formId), eq(forms.workspaceId, ctx.workspace.id)),
columns: { id: true }, columns: { id: true },
}); });

View file

@ -10,11 +10,8 @@ import {
sql, sql,
} from "drizzle-orm"; } from "drizzle-orm";
import { objectTypes } from "@tasks/shared"; import { objectTypes } from "@tasks/shared";
import { import { objectAssignees, objects } from "@tasks/database/schema";
objectAssignees, import { router, workspaceProcedure } from "@/server/trpc";
objects,
} from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc";
const objectTypeSchema = z.enum(objectTypes); const objectTypeSchema = z.enum(objectTypes);
@ -36,11 +33,29 @@ export type ObjectTreeNode = {
children: ObjectTreeNode[]; children: ObjectTreeNode[];
}; };
/**
* Reusable: confirm a given object id belongs to the resolved workspace, throwing
* NOT_FOUND otherwise. Prevents cross-tenant ID guessing on per-id mutations.
*/
async function assertObjectInWorkspace(
db: typeof import("@tasks/database").db,
objectId: string,
workspaceId: string,
): Promise<void> {
const [row] = await db
.select({ id: objects.id })
.from(objects)
.where(and(eq(objects.id, objectId), eq(objects.workspaceId, workspaceId)))
.limit(1);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
}
}
export const objectsRouter = router({ export const objectsRouter = router({
list: protectedProcedure list: workspaceProcedure
.input( .input(
z.object({ z.object({
workspaceId: z.string().uuid(),
parentId: z.string().uuid().nullable().optional(), parentId: z.string().uuid().nullable().optional(),
type: objectTypeSchema.optional(), type: objectTypeSchema.optional(),
status: z.string().optional(), status: z.string().optional(),
@ -53,7 +68,7 @@ export const objectsRouter = router({
const offset = input.offset ?? 0; const offset = input.offset ?? 0;
const conditions = [ const conditions = [
eq(objects.workspaceId, input.workspaceId), eq(objects.workspaceId, ctx.workspace.id),
isNull(objects.archivedAt), isNull(objects.archivedAt),
]; ];
@ -87,23 +102,18 @@ export const objectsRouter = router({
return { objects: rows }; return { objects: rows };
}), }),
getById: protectedProcedure getById: workspaceProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const obj = await ctx.db.query.objects.findFirst({ const obj = await ctx.db.query.objects.findFirst({
where: eq(objects.id, input.id), where: and(
eq(objects.id, input.id),
eq(objects.workspaceId, ctx.workspace.id),
),
with: { with: {
children: true, children: true,
assignees: { assignees: { with: { user: true } },
with: { propertyValues: { with: { propertyDefinition: true } },
user: true,
},
},
propertyValues: {
with: {
propertyDefinition: true,
},
},
}, },
}); });
@ -121,10 +131,9 @@ export const objectsRouter = router({
return { ...obj, children }; return { ...obj, children };
}), }),
getTree: protectedProcedure getTree: workspaceProcedure
.input( .input(
z.object({ z.object({
workspaceId: z.string().uuid(),
maxDepth: z.number().int().positive().max(100).optional(), maxDepth: z.number().int().positive().max(100).optional(),
}), }),
) )
@ -136,7 +145,7 @@ export const objectsRouter = router({
.from(objects) .from(objects)
.where( .where(
and( and(
eq(objects.workspaceId, input.workspaceId), eq(objects.workspaceId, ctx.workspace.id),
inArray(objects.type, [...TREE_TYPES]), inArray(objects.type, [...TREE_TYPES]),
isNull(objects.archivedAt), isNull(objects.archivedAt),
), ),
@ -159,9 +168,7 @@ export const objectsRouter = router({
if (depth > maxDepth) { if (depth > maxDepth) {
return []; return [];
} }
const directChildren = rows.filter((r) => r.parentId === parentId); const directChildren = rows.filter((r) => r.parentId === parentId);
return directChildren.map((r) => ({ return directChildren.map((r) => ({
id: r.id, id: r.id,
title: r.title, title: r.title,
@ -190,13 +197,12 @@ export const objectsRouter = router({
return { tree }; return { tree };
}), }),
create: protectedProcedure create: workspaceProcedure
.input( .input(
z.object({ z.object({
type: objectTypeSchema, type: objectTypeSchema,
title: z.string().min(1).max(500), title: z.string().min(1).max(500),
parentId: z.string().uuid().nullable().optional(), parentId: z.string().uuid().nullable().optional(),
workspaceId: z.string().uuid(),
description: z.string().optional(), description: z.string().optional(),
icon: z.string().optional(), icon: z.string().optional(),
status: z.string().optional(), status: z.string().optional(),
@ -206,10 +212,11 @@ export const objectsRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
if (!userId) { if (!userId) {
throw new TRPCError({ throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id" });
code: "UNAUTHORIZED", }
message: "Missing user id",
}); if (input.parentId) {
await assertObjectInWorkspace(ctx.db, input.parentId, ctx.workspace.id);
} }
const [created] = await ctx.db const [created] = await ctx.db
@ -218,7 +225,7 @@ export const objectsRouter = router({
type: input.type, type: input.type,
title: input.title, title: input.title,
parentId: input.parentId ?? null, parentId: input.parentId ?? null,
workspaceId: input.workspaceId, workspaceId: ctx.workspace.id,
description: input.description, description: input.description,
icon: input.icon, icon: input.icon,
status: input.status, status: input.status,
@ -237,7 +244,7 @@ export const objectsRouter = router({
return created; return created;
}), }),
update: protectedProcedure update: workspaceProcedure
.input( .input(
z.object({ z.object({
id: z.string().uuid(), id: z.string().uuid(),
@ -251,6 +258,7 @@ export const objectsRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { id, ...patch } = input; const { id, ...patch } = input;
await assertObjectInWorkspace(ctx.db, id, ctx.workspace.id);
const updatedAt = new Date(); const updatedAt = new Date();
const [updated] = await ctx.db const [updated] = await ctx.db
@ -274,9 +282,10 @@ export const objectsRouter = router({
return updated; return updated;
}), }),
archive: protectedProcedure archive: workspaceProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await assertObjectInWorkspace(ctx.db, input.id, ctx.workspace.id);
const archivedAt = new Date(); const archivedAt = new Date();
const [row] = await ctx.db const [row] = await ctx.db
.update(objects) .update(objects)
@ -287,13 +296,13 @@ export const objectsRouter = router({
if (!row) { if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
} }
return row; return row;
}), }),
delete: protectedProcedure delete: workspaceProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await assertObjectInWorkspace(ctx.db, input.id, ctx.workspace.id);
const deleted = await ctx.db const deleted = await ctx.db
.delete(objects) .delete(objects)
.where(eq(objects.id, input.id)) .where(eq(objects.id, input.id))
@ -302,11 +311,10 @@ export const objectsRouter = router({
if (deleted.length === 0) { if (deleted.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
} }
return deleted[0]; return deleted[0];
}), }),
reorder: protectedProcedure reorder: workspaceProcedure
.input( .input(
z.object({ z.object({
id: z.string().uuid(), id: z.string().uuid(),
@ -315,6 +323,11 @@ export const objectsRouter = router({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await assertObjectInWorkspace(ctx.db, input.id, ctx.workspace.id);
if (input.newParentId) {
await assertObjectInWorkspace(ctx.db, input.newParentId, ctx.workspace.id);
}
const updates: { const updates: {
sortOrder: number; sortOrder: number;
updatedAt: Date; updatedAt: Date;
@ -337,11 +350,10 @@ export const objectsRouter = router({
if (!row) { if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
} }
return row; return row;
}), }),
assign: protectedProcedure assign: workspaceProcedure
.input( .input(
z.object({ z.object({
objectId: z.string().uuid(), objectId: z.string().uuid(),
@ -351,6 +363,8 @@ export const objectsRouter = router({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await assertObjectInWorkspace(ctx.db, input.objectId, ctx.workspace.id);
if (input.action === "remove") { if (input.action === "remove") {
const deleted = await ctx.db const deleted = await ctx.db
.delete(objectAssignees) .delete(objectAssignees)
@ -363,12 +377,8 @@ export const objectsRouter = router({
.returning({ id: objectAssignees.id }); .returning({ id: objectAssignees.id });
if (deleted.length === 0) { if (deleted.length === 0) {
throw new TRPCError({ throw new TRPCError({ code: "NOT_FOUND", message: "Assignee not found" });
code: "NOT_FOUND",
message: "Assignee not found",
});
} }
return { ok: true as const, action: "remove" as const }; return { ok: true as const, action: "remove" as const };
} }

View file

@ -1,29 +1,27 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { import {
objects,
propertyDefinitions, propertyDefinitions,
propertyValues, propertyValues,
} from "@tasks/database/schema"; } from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc"; import { router, workspaceProcedure } from "@/server/trpc";
export const propertiesRouter = router({ export const propertiesRouter = router({
listDefinitions: protectedProcedure listDefinitions: workspaceProcedure.query(async ({ ctx }) => {
.input(z.object({ workspaceId: z.string().uuid() })) const definitions = await ctx.db
.query(async ({ ctx, input }) => { .select()
const definitions = await ctx.db .from(propertyDefinitions)
.select() .where(eq(propertyDefinitions.workspaceId, ctx.workspace.id))
.from(propertyDefinitions) .orderBy(asc(propertyDefinitions.sortOrder), asc(propertyDefinitions.id));
.where(eq(propertyDefinitions.workspaceId, input.workspaceId))
.orderBy(asc(propertyDefinitions.sortOrder), asc(propertyDefinitions.id));
return { definitions }; return { definitions };
}), }),
createDefinition: protectedProcedure createDefinition: workspaceProcedure
.input( .input(
z.object({ z.object({
workspaceId: z.string().uuid(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
fieldType: z.string().min(1).max(50), fieldType: z.string().min(1).max(50),
config: z.any().optional(), config: z.any().optional(),
@ -33,7 +31,7 @@ export const propertiesRouter = router({
const [created] = await ctx.db const [created] = await ctx.db
.insert(propertyDefinitions) .insert(propertyDefinitions)
.values({ .values({
workspaceId: input.workspaceId, workspaceId: ctx.workspace.id,
name: input.name, name: input.name,
fieldType: input.fieldType, fieldType: input.fieldType,
config: input.config ?? null, config: input.config ?? null,
@ -50,9 +48,19 @@ export const propertiesRouter = router({
return created; return created;
}), }),
getValues: protectedProcedure getValues: workspaceProcedure
.input(z.object({ objectId: z.string().uuid() })) .input(z.object({ objectId: z.string().uuid() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
// Confirm the target object lives in this workspace.
const [obj] = await ctx.db
.select({ id: objects.id })
.from(objects)
.where(and(eq(objects.id, input.objectId), eq(objects.workspaceId, ctx.workspace.id)))
.limit(1);
if (!obj) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
}
const rows = await ctx.db const rows = await ctx.db
.select({ .select({
valueRow: propertyValues, valueRow: propertyValues,
@ -74,7 +82,7 @@ export const propertiesRouter = router({
}; };
}), }),
setValue: protectedProcedure setValue: workspaceProcedure
.input( .input(
z.object({ z.object({
objectId: z.string().uuid(), objectId: z.string().uuid(),
@ -83,6 +91,30 @@ export const propertiesRouter = router({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Verify both the target object and the property definition belong to
// the resolved workspace before writing.
const [obj] = await ctx.db
.select({ id: objects.id })
.from(objects)
.where(and(eq(objects.id, input.objectId), eq(objects.workspaceId, ctx.workspace.id)))
.limit(1);
if (!obj) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
}
const [def] = await ctx.db
.select({ id: propertyDefinitions.id })
.from(propertyDefinitions)
.where(
and(
eq(propertyDefinitions.id, input.propertyDefId),
eq(propertyDefinitions.workspaceId, ctx.workspace.id),
),
)
.limit(1);
if (!def) {
throw new TRPCError({ code: "NOT_FOUND", message: "Property definition not found" });
}
const now = new Date(); const now = new Date();
const [row] = await ctx.db const [row] = await ctx.db

View file

@ -1,11 +1,29 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { eq } from "drizzle-orm"; import { and, eq, or } from "drizzle-orm";
import { objectRelations, objects } from "@tasks/database/schema"; import { objectRelations, objects } from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc"; import { router, workspaceProcedure } from "@/server/trpc";
/**
* Confirm both endpoints of a relation live in the resolved workspace. Without
* this guard, callers could relate cross-tenant objects to leak titles/types.
*/
async function assertObjectsInWorkspace(
db: typeof import("@tasks/database").db,
ids: string[],
workspaceId: string,
): Promise<void> {
const rows = await db
.select({ id: objects.id })
.from(objects)
.where(and(eq(objects.workspaceId, workspaceId), or(...ids.map((id) => eq(objects.id, id)))));
if (rows.length !== ids.length) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
}
}
export const relationsRouter = router({ export const relationsRouter = router({
list: protectedProcedure list: workspaceProcedure
.input( .input(
z.object({ z.object({
objectId: z.string().uuid(), objectId: z.string().uuid(),
@ -13,6 +31,7 @@ export const relationsRouter = router({
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
await assertObjectsInWorkspace(ctx.db, [input.objectId], ctx.workspace.id);
const dir = input.direction ?? "both"; const dir = input.direction ?? "both";
const baseSelect = { const baseSelect = {
@ -24,6 +43,7 @@ export const relationsRouter = router({
relatedId: objects.id, relatedId: objects.id,
relatedTitle: objects.title, relatedTitle: objects.title,
relatedType: objects.type, relatedType: objects.type,
relatedWorkspaceId: objects.workspaceId,
}; };
const outgoing = const outgoing =
@ -33,7 +53,12 @@ export const relationsRouter = router({
.select(baseSelect) .select(baseSelect)
.from(objectRelations) .from(objectRelations)
.innerJoin(objects, eq(objectRelations.targetId, objects.id)) .innerJoin(objects, eq(objectRelations.targetId, objects.id))
.where(eq(objectRelations.sourceId, input.objectId)); .where(
and(
eq(objectRelations.sourceId, input.objectId),
eq(objects.workspaceId, ctx.workspace.id),
),
);
const incoming = const incoming =
dir === "outgoing" dir === "outgoing"
@ -42,7 +67,12 @@ export const relationsRouter = router({
.select(baseSelect) .select(baseSelect)
.from(objectRelations) .from(objectRelations)
.innerJoin(objects, eq(objectRelations.sourceId, objects.id)) .innerJoin(objects, eq(objectRelations.sourceId, objects.id))
.where(eq(objectRelations.targetId, input.objectId)); .where(
and(
eq(objectRelations.targetId, input.objectId),
eq(objects.workspaceId, ctx.workspace.id),
),
);
const relations = [ const relations = [
...outgoing.map((r) => ({ ...outgoing.map((r) => ({
@ -76,7 +106,7 @@ export const relationsRouter = router({
return { relations }; return { relations };
}), }),
create: protectedProcedure create: workspaceProcedure
.input( .input(
z.object({ z.object({
sourceId: z.string().uuid(), sourceId: z.string().uuid(),
@ -92,6 +122,12 @@ export const relationsRouter = router({
}); });
} }
await assertObjectsInWorkspace(
ctx.db,
[input.sourceId, input.targetId],
ctx.workspace.id,
);
try { try {
const [created] = await ctx.db const [created] = await ctx.db
.insert(objectRelations) .insert(objectRelations)
@ -131,9 +167,24 @@ export const relationsRouter = router({
} }
}), }),
delete: protectedProcedure delete: workspaceProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Confirm the relation's source object lives in this workspace before
// deleting (cheap guard against cross-tenant ID guessing).
const [rel] = await ctx.db
.select({
id: objectRelations.id,
sourceWorkspaceId: objects.workspaceId,
})
.from(objectRelations)
.innerJoin(objects, eq(objectRelations.sourceId, objects.id))
.where(eq(objectRelations.id, input.id))
.limit(1);
if (!rel || rel.sourceWorkspaceId !== ctx.workspace.id) {
throw new TRPCError({ code: "NOT_FOUND", message: "Relation not found" });
}
const deleted = await ctx.db const deleted = await ctx.db
.delete(objectRelations) .delete(objectRelations)
.where(eq(objectRelations.id, input.id)) .where(eq(objectRelations.id, input.id))

View file

@ -3,7 +3,7 @@ import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { objects } from "@tasks/database/schema"; import { objects } from "@tasks/database/schema";
import { objectTypes } from "@tasks/shared"; import { objectTypes } from "@tasks/shared";
import type { Context } from "@/server/trpc"; import type { Context } from "@/server/trpc";
import { router, protectedProcedure } from "@/server/trpc"; import { router, workspaceProcedure } from "@/server/trpc";
const objectTypeSchema = z.enum(objectTypes); const objectTypeSchema = z.enum(objectTypes);
@ -99,11 +99,10 @@ function parentBreadcrumb(
} }
export const searchRouter = router({ export const searchRouter = router({
search: protectedProcedure search: workspaceProcedure
.input( .input(
z.object({ z.object({
query: z.string(), query: z.string(),
workspaceId: z.string().uuid().optional(),
type: objectTypeSchema.optional(), type: objectTypeSchema.optional(),
limit: z.number().int().positive().max(100).optional(), limit: z.number().int().positive().max(100).optional(),
}), }),
@ -118,11 +117,12 @@ export const searchRouter = router({
const pattern = `%${escapeIlike(raw)}%`; const pattern = `%${escapeIlike(raw)}%`;
const matchCondition = sql`(${objects.title} ILIKE ${pattern} ESCAPE '\\' OR ${objects.description} ILIKE ${pattern} ESCAPE '\\')`; const matchCondition = sql`(${objects.title} ILIKE ${pattern} ESCAPE '\\' OR ${objects.description} ILIKE ${pattern} ESCAPE '\\')`;
const conditions = [isNull(objects.archivedAt), matchCondition]; const conditions = [
eq(objects.workspaceId, ctx.workspace.id),
isNull(objects.archivedAt),
matchCondition,
];
if (input.workspaceId !== undefined) {
conditions.push(eq(objects.workspaceId, input.workspaceId));
}
if (input.type !== undefined) { if (input.type !== undefined) {
conditions.push(eq(objects.type, input.type)); conditions.push(eq(objects.type, input.type));
} }
@ -172,20 +172,19 @@ export const searchRouter = router({
return { results }; return { results };
}), }),
recent: protectedProcedure recent: workspaceProcedure
.input( .input(
z.object({ z.object({
workspaceId: z.string().uuid().optional(),
limit: z.number().int().positive().max(50).optional(), limit: z.number().int().positive().max(50).optional(),
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const limit = input.limit ?? 10; const limit = input.limit ?? 10;
const conditions = [isNull(objects.archivedAt)]; const conditions = [
if (input.workspaceId !== undefined) { eq(objects.workspaceId, ctx.workspace.id),
conditions.push(eq(objects.workspaceId, input.workspaceId)); isNull(objects.archivedAt),
} ];
const rows = await ctx.db const rows = await ctx.db
.select({ .select({

View file

@ -7,7 +7,7 @@ import {
propertyValues, propertyValues,
templates, templates,
} from "@tasks/database/schema"; } from "@tasks/database/schema";
import { type Context, router, protectedProcedure } from "@/server/trpc"; import { type Context, router, workspaceProcedure } from "@/server/trpc";
const templatePropertySchema = z.object({ const templatePropertySchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
@ -57,15 +57,14 @@ async function getMaxPropertySortOrder(
} }
export const templatesRouter = router({ export const templatesRouter = router({
list: protectedProcedure list: workspaceProcedure
.input( .input(
z.object({ z.object({
workspaceId: z.string().uuid(),
targetType: z.string().max(50).optional(), targetType: z.string().max(50).optional(),
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const conditions = [eq(templates.workspaceId, input.workspaceId)]; const conditions = [eq(templates.workspaceId, ctx.workspace.id)];
if (input.targetType !== undefined) { if (input.targetType !== undefined) {
conditions.push(eq(templates.targetType, input.targetType)); conditions.push(eq(templates.targetType, input.targetType));
} }
@ -79,11 +78,14 @@ export const templatesRouter = router({
return { templates: rows }; return { templates: rows };
}), }),
getById: protectedProcedure getById: workspaceProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const row = await ctx.db.query.templates.findFirst({ const row = await ctx.db.query.templates.findFirst({
where: eq(templates.id, input.id), where: and(
eq(templates.id, input.id),
eq(templates.workspaceId, ctx.workspace.id),
),
}); });
if (!row) { if (!row) {
@ -93,10 +95,9 @@ export const templatesRouter = router({
return row; return row;
}), }),
create: protectedProcedure create: workspaceProcedure
.input( .input(
z.object({ z.object({
workspaceId: z.string().uuid(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
targetType: z.string().min(1).max(50), targetType: z.string().min(1).max(50),
schema: templateSchemaJson, schema: templateSchemaJson,
@ -107,7 +108,7 @@ export const templatesRouter = router({
const [created] = await ctx.db const [created] = await ctx.db
.insert(templates) .insert(templates)
.values({ .values({
workspaceId: input.workspaceId, workspaceId: ctx.workspace.id,
name: input.name, name: input.name,
targetType: input.targetType, targetType: input.targetType,
schema: input.schema ?? null, schema: input.schema ?? null,
@ -126,7 +127,7 @@ export const templatesRouter = router({
return created; return created;
}), }),
update: protectedProcedure update: workspaceProcedure
.input( .input(
z.object({ z.object({
id: z.string().uuid(), id: z.string().uuid(),
@ -145,7 +146,9 @@ export const templatesRouter = router({
...(patch.schema !== undefined ? { schema: patch.schema } : {}), ...(patch.schema !== undefined ? { schema: patch.schema } : {}),
updatedAt: now, updatedAt: now,
}) })
.where(eq(templates.id, id)) .where(
and(eq(templates.id, id), eq(templates.workspaceId, ctx.workspace.id)),
)
.returning(); .returning();
if (!updated) { if (!updated) {
@ -155,12 +158,17 @@ export const templatesRouter = router({
return updated; return updated;
}), }),
delete: protectedProcedure delete: workspaceProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const deleted = await ctx.db const deleted = await ctx.db
.delete(templates) .delete(templates)
.where(eq(templates.id, input.id)) .where(
and(
eq(templates.id, input.id),
eq(templates.workspaceId, ctx.workspace.id),
),
)
.returning({ id: templates.id }); .returning({ id: templates.id });
if (deleted.length === 0) { if (deleted.length === 0) {
@ -168,7 +176,7 @@ export const templatesRouter = router({
} }
}), }),
applyTemplate: protectedProcedure applyTemplate: workspaceProcedure
.input( .input(
z.object({ z.object({
objectId: z.string().uuid(), objectId: z.string().uuid(),
@ -177,7 +185,10 @@ export const templatesRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const template = await ctx.db.query.templates.findFirst({ const template = await ctx.db.query.templates.findFirst({
where: eq(templates.id, input.templateId), where: and(
eq(templates.id, input.templateId),
eq(templates.workspaceId, ctx.workspace.id),
),
}); });
if (!template) { if (!template) {
@ -185,26 +196,22 @@ export const templatesRouter = router({
} }
const obj = await ctx.db.query.objects.findFirst({ const obj = await ctx.db.query.objects.findFirst({
where: eq(objects.id, input.objectId), where: and(
eq(objects.id, input.objectId),
eq(objects.workspaceId, ctx.workspace.id),
),
}); });
if (!obj) { if (!obj) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
} }
if (obj.workspaceId !== template.workspaceId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Template belongs to a different workspace than the object",
});
}
const schema = (template.schema ?? {}) as { const schema = (template.schema ?? {}) as {
properties?: { name: string; fieldType: string; defaultValue?: unknown }[]; properties?: { name: string; fieldType: string; defaultValue?: unknown }[];
defaultContent?: string; defaultContent?: string;
}; };
const workspaceId = template.workspaceId; const workspaceId = ctx.workspace.id;
let nextSort = (await getMaxPropertySortOrder(ctx.db, workspaceId)) + 1; let nextSort = (await getMaxPropertySortOrder(ctx.db, workspaceId)) + 1;
const now = new Date(); const now = new Date();

View file

@ -1,36 +1,38 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { objectTypeDefs } from "@tasks/database/schema"; import { objectTypeDefs } from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc"; import { router, workspaceProcedure } from "@/server/trpc";
export const typesRouter = router({ export const typesRouter = router({
list: protectedProcedure list: workspaceProcedure.query(async ({ ctx }) => {
.input(z.object({ workspaceId: z.string().uuid() })) return ctx.db
.query(async ({ ctx, input }) => { .select()
return ctx.db .from(objectTypeDefs)
.select() .where(eq(objectTypeDefs.workspaceId, ctx.workspace.id))
.from(objectTypeDefs) .orderBy(asc(objectTypeDefs.name));
.where(eq(objectTypeDefs.workspaceId, input.workspaceId)) }),
.orderBy(asc(objectTypeDefs.name));
}),
getById: protectedProcedure getById: workspaceProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const [row] = await ctx.db const [row] = await ctx.db
.select() .select()
.from(objectTypeDefs) .from(objectTypeDefs)
.where(eq(objectTypeDefs.id, input.id)) .where(
and(
eq(objectTypeDefs.id, input.id),
eq(objectTypeDefs.workspaceId, ctx.workspace.id),
),
)
.limit(1); .limit(1);
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" }); if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" });
return row; return row;
}), }),
create: protectedProcedure create: workspaceProcedure
.input( .input(
z.object({ z.object({
workspaceId: z.string().uuid(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
slug: z.string().min(1).max(100), slug: z.string().min(1).max(100),
icon: z.string().optional(), icon: z.string().optional(),
@ -43,7 +45,7 @@ export const typesRouter = router({
const [created] = await ctx.db const [created] = await ctx.db
.insert(objectTypeDefs) .insert(objectTypeDefs)
.values({ .values({
workspaceId: input.workspaceId, workspaceId: ctx.workspace.id,
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
icon: input.icon ?? null, icon: input.icon ?? null,
@ -60,7 +62,7 @@ export const typesRouter = router({
return created; return created;
}), }),
update: protectedProcedure update: workspaceProcedure
.input( .input(
z.object({ z.object({
id: z.string().uuid(), id: z.string().uuid(),
@ -83,18 +85,28 @@ export const typesRouter = router({
const [updated] = await ctx.db const [updated] = await ctx.db
.update(objectTypeDefs) .update(objectTypeDefs)
.set(updates) .set(updates)
.where(eq(objectTypeDefs.id, id)) .where(
and(
eq(objectTypeDefs.id, id),
eq(objectTypeDefs.workspaceId, ctx.workspace.id),
),
)
.returning(); .returning();
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" }); if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" });
return updated; return updated;
}), }),
delete: protectedProcedure delete: workspaceProcedure
.input(z.object({ id: z.string().uuid() })) .input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const [deleted] = await ctx.db const [deleted] = await ctx.db
.delete(objectTypeDefs) .delete(objectTypeDefs)
.where(eq(objectTypeDefs.id, input.id)) .where(
and(
eq(objectTypeDefs.id, input.id),
eq(objectTypeDefs.workspaceId, ctx.workspace.id),
),
)
.returning(); .returning();
if (!deleted) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" }); if (!deleted) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" });
return { success: true }; return { success: true };

View file

@ -1,59 +1,232 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { and, eq } from "drizzle-orm"; import { and, desc, eq, isNull, ne } from "drizzle-orm";
import { objects, workspaceMembers, users } from "@tasks/database/schema"; import {
import { router, protectedProcedure } from "@/server/trpc"; workspaces,
workspaceMembers,
users,
} from "@tasks/database/schema";
import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc";
import { findWorkspaceByHandle } from "@/server/lib/resolve-workspace";
const slugSchema = z
.string()
.min(2)
.max(60)
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "Slug must be lowercase, alphanumeric, hyphen-separated");
function makeSlug(name: string): string {
return (
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "workspace"
);
}
export const workspacesRouter = router({ export const workspacesRouter = router({
getById: protectedProcedure /**
.input(z.object({ id: z.string().uuid() })) * Resolve a UUID-or-slug handle to a workspace the caller can see. Used by
* the app shell to redirect / hydrate the workspace switcher.
*/
resolve: protectedProcedure
.input(z.object({ handle: z.string().min(1) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const [row] = await ctx.db const ws = await findWorkspaceByHandle(input.handle, ctx.db);
.select({ if (!ws) {
id: objects.id,
title: objects.title,
type: objects.type,
icon: objects.icon,
})
.from(objects)
.where(and(eq(objects.id, input.id), eq(objects.type, "workspace")))
.limit(1);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found" });
} }
return row; const userId = ctx.session.user.id;
const [membership] = await ctx.db
.select({ role: workspaceMembers.role })
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, ws.id),
eq(workspaceMembers.userId, userId),
),
)
.limit(1);
const [owner] = await ctx.db
.select({ ownerUserId: workspaces.ownerUserId })
.from(workspaces)
.where(eq(workspaces.id, ws.id))
.limit(1);
if (!membership && owner?.ownerUserId !== userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return ws;
}), }),
/**
* Create a new workspace owned by the caller. Auto-mints a slug from `name`
* unless one is provided. Caller is added as the owner+initial member.
*/
create: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(200),
slug: slugSchema.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
let slug = input.slug ?? makeSlug(input.name);
const [collision] = await ctx.db
.select({ id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.slug, slug))
.limit(1);
if (collision) {
if (input.slug) {
throw new TRPCError({ code: "CONFLICT", message: "Slug already in use" });
}
slug = `${slug}-${Math.random().toString(36).slice(2, 8)}`;
}
const [ws] = await ctx.db
.insert(workspaces)
.values({
name: input.name,
slug,
ownerUserId: userId,
})
.returning();
await ctx.db.insert(workspaceMembers).values({
workspaceId: ws.id,
userId,
role: "owner",
});
return ws;
}),
/** All workspaces the caller owns or is a member of, owned-first then alpha. */
listForUser: protectedProcedure.query(async ({ ctx }) => { listForUser: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const owned = await ctx.db
.select({
id: workspaces.id,
slug: workspaces.slug,
name: workspaces.name,
role: workspaceMembers.role,
archivedAt: workspaces.archivedAt,
})
.from(workspaces)
.leftJoin(
workspaceMembers,
and(
eq(workspaceMembers.workspaceId, workspaces.id),
eq(workspaceMembers.userId, userId),
),
)
.where(
and(eq(workspaces.ownerUserId, userId), isNull(workspaces.archivedAt)),
)
.orderBy(workspaces.name);
const memberOnly = await ctx.db
.select({
id: workspaces.id,
slug: workspaces.slug,
name: workspaces.name,
role: workspaceMembers.role,
archivedAt: workspaces.archivedAt,
})
.from(workspaceMembers)
.innerJoin(workspaces, eq(workspaceMembers.workspaceId, workspaces.id))
.where(
and(
eq(workspaceMembers.userId, userId),
ne(workspaces.ownerUserId, userId),
isNull(workspaces.archivedAt),
),
)
.orderBy(workspaces.name);
return [...owned, ...memberOnly].map((row) => ({
id: row.id,
slug: row.slug,
name: row.name,
role: row.role ?? "owner",
archivedAt: row.archivedAt,
}));
}),
/** Members of a workspace the caller can see. */
listMembers: workspaceProcedure.query(async ({ ctx }) => {
return ctx.db return ctx.db
.select({ .select({
id: objects.id, id: users.id,
title: objects.title, name: users.name,
icon: objects.icon, email: users.email,
avatarUrl: users.avatarUrl,
role: workspaceMembers.role, role: workspaceMembers.role,
}) })
.from(workspaceMembers) .from(workspaceMembers)
.innerJoin(objects, eq(workspaceMembers.workspaceId, objects.id)) .innerJoin(users, eq(workspaceMembers.userId, users.id))
.where(eq(workspaceMembers.userId, userId)); .where(eq(workspaceMembers.workspaceId, ctx.workspace.id));
}), }),
listMembers: protectedProcedure /**
.input(z.object({ workspaceId: z.string().uuid() })) * Update workspace metadata (name and/or slug). Slug renames are validated
.query(async ({ ctx, input }) => { * for uniqueness; the caller must be the workspace owner.
return ctx.db */
.select({ update: workspaceProcedure
id: users.id, .input(
name: users.name, z.object({
email: users.email, name: z.string().min(1).max(200).optional(),
avatarUrl: users.avatarUrl, slug: slugSchema.optional(),
role: workspaceMembers.role, }),
)
.mutation(async ({ ctx, input }) => {
if (ctx.workspace.role !== "owner") {
throw new TRPCError({ code: "FORBIDDEN", message: "Only the owner can rename the workspace" });
}
if (input.slug && input.slug !== ctx.workspace.slug) {
const [collision] = await ctx.db
.select({ id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.slug, input.slug))
.limit(1);
if (collision) {
throw new TRPCError({ code: "CONFLICT", message: "Slug already in use" });
}
}
const [updated] = await ctx.db
.update(workspaces)
.set({
...(input.name ? { name: input.name } : {}),
...(input.slug ? { slug: input.slug } : {}),
updatedAt: new Date(),
}) })
.from(workspaceMembers) .where(eq(workspaces.id, ctx.workspace.id))
.innerJoin(users, eq(workspaceMembers.userId, users.id)) .returning();
.where(eq(workspaceMembers.workspaceId, input.workspaceId));
return updated;
}), }),
/** Owner-only soft archive. */
archive: workspaceProcedure.mutation(async ({ ctx }) => {
if (ctx.workspace.role !== "owner") {
throw new TRPCError({ code: "FORBIDDEN" });
}
const [updated] = await ctx.db
.update(workspaces)
.set({ archivedAt: new Date() })
.where(eq(workspaces.id, ctx.workspace.id))
.returning();
return updated;
}),
}); });

View file

@ -1,8 +1,10 @@
import { initTRPC, TRPCError } from "@trpc/server"; import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
import superjson from "superjson"; import superjson from "superjson";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { db } from "@tasks/database"; import { db } from "@tasks/database";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { resolveWorkspace, type WorkspaceContext } from "@/server/lib/resolve-workspace";
export type Context = { export type Context = {
db: typeof db; db: typeof db;
@ -43,3 +45,39 @@ export const router = t.router;
export const createCallerFactory = t.createCallerFactory; export const createCallerFactory = t.createCallerFactory;
export const publicProcedure = t.procedure; export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed); export const protectedProcedure = t.procedure.use(isAuthed);
/**
* Procedure for any tenant-scoped operation. Caller must:
* - Be authenticated.
* - Pass `workspace` (UUID or slug) in the input. The middleware resolves it
* to a full `WorkspaceContext` (id, slug, name, owner, role) and exposes it
* on `ctx.workspace`. Procedures can then scope queries by `ctx.workspace.id`.
*
* Example:
* workspaceProcedure
* .input(z.object({ workspace: z.string(), title: z.string() }))
* .mutation(({ ctx, input }) => {
* return ctx.db.insert(objects).values({
* workspaceId: ctx.workspace.id,
* title: input.title,
* type: "task",
* });
* });
*/
export const workspaceProcedure = protectedProcedure
.input(z.object({ workspace: z.string().min(1) }))
.use(async ({ ctx, input, next }) => {
const ws = await resolveWorkspace({
handle: input.workspace,
userId: ctx.session.user.id,
db: ctx.db,
});
return next({
ctx: {
...ctx,
workspace: ws,
},
});
});
export type WorkspaceProcedureContext = Context & { session: Session; workspace: WorkspaceContext };

View file

@ -0,0 +1,120 @@
-- ============================================================================
-- 0003 — Promote workspaces to a top-level table.
-- ============================================================================
-- Block A of the EchoDo commercial plan. We:
-- 1. Create the new `workspaces` table.
-- 2. Copy every existing `objects` row of type='workspace' INTO `workspaces`,
-- preserving the same UUID so existing FKs (which all point at
-- objects.id today) remain valid mid-migration.
-- 3. Mint a `slug` for each workspace from its title (or falling back to a
-- short UUID prefix if the slug would collide / be empty).
-- 4. Drop the old objects→objects FK on every anchor table and re-add a new
-- FK pointing at workspaces.id.
-- 5. Delete the now-redundant type='workspace' rows from `objects` and add
-- NOT NULL on objects.workspace_id (it was nullable for self-references).
-- ============================================================================
-- 1. New workspaces table
CREATE TABLE "workspaces" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" varchar(60) NOT NULL,
"name" varchar(200) NOT NULL,
"owner_user_id" uuid NOT NULL,
"plan_tier" varchar(20) DEFAULT 'free' NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
-- 2. Copy existing workspaces (objects WHERE type='workspace') into the new
-- table. Owner is best-effort: prefer created_by, otherwise the first
-- workspace_member that exists, otherwise the first user in the system
-- (single-tenant homelab fallback).
INSERT INTO "workspaces" ("id", "slug", "name", "owner_user_id", "plan_tier", "archived_at", "created_at", "updated_at")
SELECT
o.id,
-- slug: lowercased, alphanum+dash, fallback to first 8 chars of UUID.
COALESCE(
NULLIF(
regexp_replace(lower(trim(o.title)), '[^a-z0-9]+', '-', 'g'),
''
),
substring(o.id::text from 1 for 8)
),
COALESCE(NULLIF(trim(o.title), ''), 'Workspace'),
COALESCE(
o.created_by,
(SELECT wm.user_id FROM "workspace_members" wm WHERE wm.workspace_id = o.id ORDER BY wm.created_at ASC LIMIT 1),
(SELECT u.id FROM "users" u ORDER BY u.created_at ASC LIMIT 1)
),
'free',
o.archived_at,
o.created_at,
o.updated_at
FROM "objects" o
WHERE o.type = 'workspace';
--> statement-breakpoint
-- 2b. Disambiguate any duplicate slugs (e.g. two workspaces both titled "Tasks")
-- by suffixing with the short UUID. Only touches collisions.
UPDATE "workspaces" w
SET "slug" = w.slug || '-' || substring(w.id::text from 1 for 6)
WHERE w.id IN (
SELECT id FROM (
SELECT id, row_number() OVER (PARTITION BY slug ORDER BY created_at) AS rn
FROM "workspaces"
) ranked
WHERE ranked.rn > 1
);
--> statement-breakpoint
-- 2c. Safety net: if there is at least one user but NO workspace exists yet
-- (fresh DB on a brand-new Coolify deploy), seed a default one so the
-- NOT NULL FK swap below cannot fail at runtime.
INSERT INTO "workspaces" ("slug", "name", "owner_user_id")
SELECT 'default', 'Default Workspace', u.id
FROM "users" u
WHERE NOT EXISTS (SELECT 1 FROM "workspaces")
ORDER BY u.created_at ASC
LIMIT 1;
--> statement-breakpoint
-- 3. Drop old objects.id-based FKs on every anchor table.
ALTER TABLE "objects" DROP CONSTRAINT "objects_workspace_id_objects_id_fk";--> statement-breakpoint
ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_workspace_id_objects_id_fk";--> statement-breakpoint
ALTER TABLE "object_type_defs" DROP CONSTRAINT "object_type_defs_workspace_id_objects_id_fk";--> statement-breakpoint
ALTER TABLE "property_definitions" DROP CONSTRAINT "property_definitions_workspace_id_objects_id_fk";--> statement-breakpoint
ALTER TABLE "templates" DROP CONSTRAINT "templates_workspace_id_objects_id_fk";--> statement-breakpoint
ALTER TABLE "forms" DROP CONSTRAINT "forms_workspace_id_objects_id_fk";--> statement-breakpoint
ALTER TABLE "markdown_backlog_items" DROP CONSTRAINT "markdown_backlog_items_workspace_id_objects_id_fk";--> statement-breakpoint
ALTER TABLE "cursor_sync_mappings" DROP CONSTRAINT "cursor_sync_mappings_workspace_id_objects_id_fk";--> statement-breakpoint
-- 4. Backfill any orphan `workspace_id` values (rows whose old workspace
-- object was deleted before this migration ran). Reassign to the first
-- available workspace so the NOT NULL constraint below holds.
UPDATE "objects"
SET "workspace_id" = (SELECT id FROM "workspaces" ORDER BY created_at ASC LIMIT 1)
WHERE "workspace_id" IS NULL
AND EXISTS (SELECT 1 FROM "workspaces");
--> statement-breakpoint
-- 5. Now objects.workspace_id is fully populated → flip to NOT NULL.
ALTER TABLE "objects" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint
-- 6. Remove the obsolete type='workspace' rows from `objects` (they live in
-- `workspaces` now). Use a guard so this is a no-op on a fresh DB.
DELETE FROM "objects" WHERE "type" = 'workspace';--> statement-breakpoint
-- 7. New FK constraints + indexes.
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "workspaces_slug_unique" ON "workspaces" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "workspaces_owner_user_id_idx" ON "workspaces" USING btree ("owner_user_id");--> statement-breakpoint
ALTER TABLE "objects" ADD CONSTRAINT "objects_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "object_type_defs" ADD CONSTRAINT "object_type_defs_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_definitions" ADD CONSTRAINT "property_definitions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "templates" ADD CONSTRAINT "templates_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "forms" ADD CONSTRAINT "forms_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "markdown_backlog_items" ADD CONSTRAINT "markdown_backlog_items_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cursor_sync_mappings" ADD CONSTRAINT "cursor_sync_mappings_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,13 @@
"when": 1777225115319, "when": 1777225115319,
"tag": "0002_markdown_backlog_cursor_sync", "tag": "0002_markdown_backlog_cursor_sync",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1778124738113,
"tag": "0003_damp_green_goblin",
"breakpoints": true
} }
] ]
} }

View file

@ -6,7 +6,7 @@ import {
index, index,
uniqueIndex, uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { objects } from "./objects"; import { workspaces } from "./workspaces";
import { markdownBacklogItems } from "./markdown_backlog"; import { markdownBacklogItems } from "./markdown_backlog";
/** /**
@ -19,7 +19,7 @@ export const cursorSyncMappings = pgTable(
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id") workspaceId: uuid("workspace_id")
.notNull() .notNull()
.references(() => objects.id, { onDelete: "cascade" }), .references(() => workspaces.id, { onDelete: "cascade" }),
backlogItemId: uuid("backlog_item_id") backlogItemId: uuid("backlog_item_id")
.notNull() .notNull()
.references(() => markdownBacklogItems.id, { onDelete: "cascade" }), .references(() => markdownBacklogItems.id, { onDelete: "cascade" }),

View file

@ -10,6 +10,7 @@ import {
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { objects } from "./objects"; import { objects } from "./objects";
import { users } from "./users"; import { users } from "./users";
import { workspaces } from "./workspaces";
export const forms = pgTable( export const forms = pgTable(
"forms", "forms",
@ -17,7 +18,7 @@ export const forms = pgTable(
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id") workspaceId: uuid("workspace_id")
.notNull() .notNull()
.references(() => objects.id, { onDelete: "cascade" }), .references(() => workspaces.id, { onDelete: "cascade" }),
title: varchar("title", { length: 500 }).notNull(), title: varchar("title", { length: 500 }).notNull(),
description: text("description"), description: text("description"),
coverImage: text("cover_image"), coverImage: text("cover_image"),

View file

@ -1,3 +1,4 @@
export * from "./workspaces";
export * from "./objects"; export * from "./objects";
export * from "./types"; export * from "./types";
export * from "./properties"; export * from "./properties";

View file

@ -9,7 +9,7 @@ import {
uniqueIndex, uniqueIndex,
foreignKey, foreignKey,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { objects } from "./objects"; import { workspaces } from "./workspaces";
/** /**
* Imported plan / epic / task rows sourced from repo markdown under `plans/`. * Imported plan / epic / task rows sourced from repo markdown under `plans/`.
@ -21,7 +21,7 @@ export const markdownBacklogItems = pgTable(
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id") workspaceId: uuid("workspace_id")
.notNull() .notNull()
.references(() => objects.id, { onDelete: "cascade" }), .references(() => workspaces.id, { onDelete: "cascade" }),
kind: varchar("kind", { length: 20 }).notNull(), kind: varchar("kind", { length: 20 }).notNull(),
slug: varchar("slug", { length: 200 }).notNull(), slug: varchar("slug", { length: 200 }).notNull(),
planSlug: varchar("plan_slug", { length: 200 }).notNull(), planSlug: varchar("plan_slug", { length: 200 }).notNull(),

View file

@ -13,6 +13,7 @@ import {
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { users } from "./users"; import { users } from "./users";
import { templates } from "./templates"; import { templates } from "./templates";
import { workspaces } from "./workspaces";
export const objects = pgTable( export const objects = pgTable(
"objects", "objects",
@ -28,7 +29,9 @@ export const objects = pgTable(
status: varchar("status", { length: 50 }), status: varchar("status", { length: 50 }),
sortOrder: integer("sort_order").notNull().default(0), sortOrder: integer("sort_order").notNull().default(0),
templateId: uuid("template_id"), templateId: uuid("template_id"),
workspaceId: uuid("workspace_id"), workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
@ -39,10 +42,6 @@ export const objects = pgTable(
columns: [table.parentId], columns: [table.parentId],
foreignColumns: [table.id], foreignColumns: [table.id],
}).onDelete("set null"), }).onDelete("set null"),
workspaceFk: foreignKey({
columns: [table.workspaceId],
foreignColumns: [table.id],
}).onDelete("cascade"),
templateFk: foreignKey({ templateFk: foreignKey({
columns: [table.templateId], columns: [table.templateId],
foreignColumns: [templates.id], foreignColumns: [templates.id],
@ -85,7 +84,7 @@ export const workspaceMembers = pgTable(
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id") workspaceId: uuid("workspace_id")
.notNull() .notNull()
.references(() => objects.id, { onDelete: "cascade" }), .references(() => workspaces.id, { onDelete: "cascade" }),
userId: uuid("user_id") userId: uuid("user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),

View file

@ -7,7 +7,7 @@ import {
timestamp, timestamp,
index, index,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { objects } from "./objects"; import { workspaces } from "./workspaces";
export const propertyDefinitions = pgTable( export const propertyDefinitions = pgTable(
"property_definitions", "property_definitions",
@ -15,7 +15,7 @@ export const propertyDefinitions = pgTable(
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id") workspaceId: uuid("workspace_id")
.notNull() .notNull()
.references(() => objects.id, { onDelete: "cascade" }), .references(() => workspaces.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
fieldType: varchar("field_type", { length: 50 }).notNull(), fieldType: varchar("field_type", { length: 50 }).notNull(),
config: jsonb("config"), config: jsonb("config"),

View file

@ -16,6 +16,7 @@ import { templates } from "./templates";
import { objectTypeDefs } from "./types"; import { objectTypeDefs } from "./types";
import { markdownBacklogItems } from "./markdown_backlog"; import { markdownBacklogItems } from "./markdown_backlog";
import { cursorSyncMappings } from "./cursor_sync"; import { cursorSyncMappings } from "./cursor_sync";
import { workspaces } from "./workspaces";
export const objectRelations = pgTable( export const objectRelations = pgTable(
"object_relations", "object_relations",
@ -47,11 +48,25 @@ export const objectRelations = pgTable(
export const usersRelations = relations(users, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({
objectsCreated: many(objects), objectsCreated: many(objects),
workspaceMemberships: many(workspaceMembers), workspaceMemberships: many(workspaceMembers),
ownedWorkspaces: many(workspaces),
objectAssignees: many(objectAssignees), objectAssignees: many(objectAssignees),
accounts: many(accounts), accounts: many(accounts),
sessions: many(sessions), sessions: many(sessions),
})); }));
export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
owner: one(users, {
fields: [workspaces.ownerUserId],
references: [users.id],
}),
members: many(workspaceMembers),
objects: many(objects),
templates: many(templates),
objectTypeDefs: many(objectTypeDefs),
propertyDefinitions: many(propertyDefinitions),
markdownBacklogItems: many(markdownBacklogItems),
}));
export const objectsRelations = relations(objects, ({ one, many }) => ({ export const objectsRelations = relations(objects, ({ one, many }) => ({
parent: one(objects, { parent: one(objects, {
fields: [objects.parentId], fields: [objects.parentId],
@ -59,12 +74,10 @@ export const objectsRelations = relations(objects, ({ one, many }) => ({
relationName: "objectHierarchy", relationName: "objectHierarchy",
}), }),
children: many(objects, { relationName: "objectHierarchy" }), children: many(objects, { relationName: "objectHierarchy" }),
workspace: one(objects, { workspace: one(workspaces, {
fields: [objects.workspaceId], fields: [objects.workspaceId],
references: [objects.id], references: [workspaces.id],
relationName: "workspaceRoot",
}), }),
workspaceContents: many(objects, { relationName: "workspaceRoot" }),
template: one(templates, { template: one(templates, {
fields: [objects.templateId], fields: [objects.templateId],
references: [templates.id], references: [templates.id],
@ -76,10 +89,8 @@ export const objectsRelations = relations(objects, ({ one, many }) => ({
propertyValues: many(propertyValues), propertyValues: many(propertyValues),
views: many(views), views: many(views),
assignees: many(objectAssignees), assignees: many(objectAssignees),
workspaceMembers: many(workspaceMembers),
outgoingRelations: many(objectRelations, { relationName: "relationSource" }), outgoingRelations: many(objectRelations, { relationName: "relationSource" }),
incomingRelations: many(objectRelations, { relationName: "relationTarget" }), incomingRelations: many(objectRelations, { relationName: "relationTarget" }),
markdownBacklogItems: many(markdownBacklogItems),
})); }));
export const objectAssigneesRelations = relations(objectAssignees, ({ one }) => ({ export const objectAssigneesRelations = relations(objectAssignees, ({ one }) => ({
@ -94,9 +105,9 @@ export const objectAssigneesRelations = relations(objectAssignees, ({ one }) =>
})); }));
export const workspaceMembersRelations = relations(workspaceMembers, ({ one }) => ({ export const workspaceMembersRelations = relations(workspaceMembers, ({ one }) => ({
workspace: one(objects, { workspace: one(workspaces, {
fields: [workspaceMembers.workspaceId], fields: [workspaceMembers.workspaceId],
references: [objects.id], references: [workspaces.id],
}), }),
user: one(users, { user: one(users, {
fields: [workspaceMembers.userId], fields: [workspaceMembers.userId],
@ -105,9 +116,9 @@ export const workspaceMembersRelations = relations(workspaceMembers, ({ one }) =
})); }));
export const propertyDefinitionsRelations = relations(propertyDefinitions, ({ one, many }) => ({ export const propertyDefinitionsRelations = relations(propertyDefinitions, ({ one, many }) => ({
workspace: one(objects, { workspace: one(workspaces, {
fields: [propertyDefinitions.workspaceId], fields: [propertyDefinitions.workspaceId],
references: [objects.id], references: [workspaces.id],
}), }),
values: many(propertyValues), values: many(propertyValues),
})); }));
@ -131,17 +142,17 @@ export const viewsRelations = relations(views, ({ one }) => ({
})); }));
export const templatesRelations = relations(templates, ({ one, many }) => ({ export const templatesRelations = relations(templates, ({ one, many }) => ({
workspace: one(objects, { workspace: one(workspaces, {
fields: [templates.workspaceId], fields: [templates.workspaceId],
references: [objects.id], references: [workspaces.id],
}), }),
objects: many(objects), objects: many(objects),
})); }));
export const objectTypeDefsRelations = relations(objectTypeDefs, ({ one }) => ({ export const objectTypeDefsRelations = relations(objectTypeDefs, ({ one }) => ({
workspace: one(objects, { workspace: one(workspaces, {
fields: [objectTypeDefs.workspaceId], fields: [objectTypeDefs.workspaceId],
references: [objects.id], references: [workspaces.id],
}), }),
})); }));
@ -175,9 +186,9 @@ export const objectRelationsRelations = relations(objectRelations, ({ one }) =>
export const markdownBacklogItemsRelations = relations( export const markdownBacklogItemsRelations = relations(
markdownBacklogItems, markdownBacklogItems,
({ one, many }) => ({ ({ one, many }) => ({
workspace: one(objects, { workspace: one(workspaces, {
fields: [markdownBacklogItems.workspaceId], fields: [markdownBacklogItems.workspaceId],
references: [objects.id], references: [workspaces.id],
}), }),
parent: one(markdownBacklogItems, { parent: one(markdownBacklogItems, {
fields: [markdownBacklogItems.parentId], fields: [markdownBacklogItems.parentId],
@ -193,9 +204,9 @@ export const markdownBacklogItemsRelations = relations(
); );
export const cursorSyncMappingsRelations = relations(cursorSyncMappings, ({ one }) => ({ export const cursorSyncMappingsRelations = relations(cursorSyncMappings, ({ one }) => ({
workspace: one(objects, { workspace: one(workspaces, {
fields: [cursorSyncMappings.workspaceId], fields: [cursorSyncMappings.workspaceId],
references: [objects.id], references: [workspaces.id],
}), }),
backlogItem: one(markdownBacklogItems, { backlogItem: one(markdownBacklogItems, {
fields: [cursorSyncMappings.backlogItemId], fields: [cursorSyncMappings.backlogItemId],

View file

@ -1,4 +1,3 @@
// @ts-nocheck — circular inference with objects.workspaceId FK
import { import {
pgTable, pgTable,
uuid, uuid,
@ -7,7 +6,7 @@ import {
timestamp, timestamp,
index, index,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { objects } from "./objects"; import { workspaces } from "./workspaces";
export const templates = pgTable( export const templates = pgTable(
"templates", "templates",
@ -15,7 +14,7 @@ export const templates = pgTable(
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id") workspaceId: uuid("workspace_id")
.notNull() .notNull()
.references(() => objects.id, { onDelete: "cascade" }), .references(() => workspaces.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
targetType: varchar("target_type", { length: 50 }).notNull(), targetType: varchar("target_type", { length: 50 }).notNull(),
schema: jsonb("schema"), schema: jsonb("schema"),

View file

@ -7,7 +7,7 @@ import {
timestamp, timestamp,
index, index,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { objects } from "./objects"; import { workspaces } from "./workspaces";
export const objectTypeDefs = pgTable( export const objectTypeDefs = pgTable(
"object_type_defs", "object_type_defs",
@ -15,7 +15,7 @@ export const objectTypeDefs = pgTable(
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id") workspaceId: uuid("workspace_id")
.notNull() .notNull()
.references(() => objects.id, { onDelete: "cascade" }), .references(() => workspaces.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 100 }).notNull(), slug: varchar("slug", { length: 100 }).notNull(),
icon: text("icon"), icon: text("icon"),

View file

@ -0,0 +1,36 @@
import {
pgTable,
uuid,
varchar,
timestamp,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { users } from "./users";
/**
* Top-level tenant boundary. Every multitenant table FK's into this.
* Promoted out of `objects` (where workspaces used to live as `type='workspace'`)
* to give us a real, RLS-friendly anchor for Block A of the EchoDo plan.
*/
export const workspaces = pgTable(
"workspaces",
{
id: uuid("id").primaryKey().defaultRandom(),
/** URL-safe, human-readable, unique workspace-wide. Mutable; URL re-routes on rename. */
slug: varchar("slug", { length: 60 }).notNull(),
name: varchar("name", { length: 200 }).notNull(),
ownerUserId: uuid("owner_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
/** free | pro | team — billing/scope hook for Phase 2. */
planTier: varchar("plan_tier", { length: 20 }).notNull().default("free"),
archivedAt: timestamp("archived_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
slugUnique: uniqueIndex("workspaces_slug_unique").on(table.slug),
ownerIdx: index("workspaces_owner_user_id_idx").on(table.ownerUserId),
}),
);