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 { db } from "../db.js";
import { objects } from "../schema.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
const TREE_TYPES = ["project", "group", "document", "whiteboard"] as const;
@ -18,20 +19,44 @@ type TreeNode = {
export function registerWorkspaceTreeResource(mcp: McpServer): void {
mcp.registerResource(
"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",
},
async (uri, variables) => {
const workspaceId = variables.id;
if (!workspaceId) {
const handleVar = Array.isArray(variables.handle) ? variables.handle[0] : variables.handle;
if (!handleVar) {
return {
contents: [
{
uri: uri.toString(),
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)
.where(
and(
eq(objects.workspaceId, workspaceId),
eq(objects.workspaceId, ws.id),
inArray(objects.type, [...TREE_TYPES]),
isNull(objects.archivedAt),
),
@ -90,7 +115,10 @@ export function registerWorkspaceTreeResource(mcp: McpServer): void {
children: buildTree(r.id, 1),
}));
const payload = { workspaceId, tree };
const payload = {
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
tree,
};
return {
contents: [

View file

@ -3,6 +3,7 @@ import { z } from "zod";
import { db } from "../db.js";
import { objects } from "../schema.js";
import { objectTypes } from "../shared-types.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
const objectTypeSchema = z.enum(objectTypes as unknown as [string, ...string[]]);
@ -11,7 +12,10 @@ const createObjectInputSchema = z.object({
type: objectTypeSchema,
title: z.string().min(1).max(500),
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(),
status: z.string().optional(),
icon: z.string().optional(),
@ -22,19 +26,20 @@ export function registerCreateObjectTool(mcp: McpServer): void {
"create_object",
{
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,
},
async (args) => {
try {
const input = createObjectInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const [created] = await db
.insert(objects)
.values({
type: input.type,
title: input.title,
parentId: input.parentId ?? null,
workspaceId: input.workspaceId,
workspaceId: ws.id,
description: input.description,
status: input.status,
icon: input.icon,
@ -44,7 +49,10 @@ export function registerCreateObjectTool(mcp: McpServer): void {
if (!created) {
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) {
return toolCatch(e);
}

View file

@ -4,12 +4,16 @@ import { z } from "zod";
import { db } from "../db.js";
import { objects } from "../schema.js";
import { objectTypes } from "../shared-types.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolOk } from "./tool-result.js";
const objectTypeSchema = z.enum(objectTypes as unknown as [string, ...string[]]);
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(),
type: objectTypeSchema.optional(),
status: z.string().optional(),
@ -22,16 +26,17 @@ export function registerListObjectsTool(mcp: McpServer): void {
"list_objects",
{
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,
},
async (args) => {
try {
const input = listObjectsInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const limit = input.limit ?? 50;
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) {
conditions.push(isNull(objects.parentId));
@ -55,6 +60,7 @@ export function registerListObjectsTool(mcp: McpServer): void {
.offset(offset);
return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
objects: rows,
count: rows.length,
limit,

View file

@ -3,6 +3,7 @@ import { and, asc, eq, ilike, isNull, or } from "../drizzle.js";
import { z } from "zod";
import { db } from "../db.js";
import { objects } from "../schema.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolOk } from "./tool-result.js";
function escapeLikePattern(q: string): string {
@ -11,7 +12,10 @@ function escapeLikePattern(q: string): string {
const searchObjectsInputSchema = z.object({
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(),
status: z.string().optional(),
limit: z.number().int().positive().max(500).optional(),
@ -22,23 +26,22 @@ export function registerSearchObjectsTool(mcp: McpServer): void {
"search_objects",
{
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,
},
async (args) => {
try {
const input = searchObjectsInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const limit = input.limit ?? 50;
const pattern = `%${escapeLikePattern(input.query)}%`;
const conditions = [
eq(objects.workspaceId, ws.id),
isNull(objects.archivedAt),
or(ilike(objects.title, pattern), ilike(objects.description, pattern)),
];
if (input.workspaceId) {
conditions.push(eq(objects.workspaceId, input.workspaceId));
}
if (input.type !== undefined) {
conditions.push(eq(objects.type, input.type));
}
@ -53,7 +56,11 @@ export function registerSearchObjectsTool(mcp: McpServer): void {
.orderBy(asc(objects.sortOrder), asc(objects.id))
.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) {
return toolCatch(e);
}

View file

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

View file

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

View file

@ -19,19 +19,19 @@ export default function DocsPage() {
const router = useRouter();
const utils = api.useUtils();
const workspaceId =
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const listQuery = api.objects.list.useQuery(
{ workspaceId: workspaceId!, parentId: undefined, limit: 200 },
{ enabled: Boolean(workspaceId) },
{ workspace: workspaceSlug!, parentId: undefined, limit: 200 },
{ enabled: Boolean(workspaceSlug) },
);
const createMutation = api.objects.create.useMutation({
onSuccess: (created) => {
if (workspaceId) {
void utils.objects.list.invalidate({ workspaceId });
router.push(`/${workspaceId}/docs/${created.id}`);
if (workspaceSlug) {
void utils.objects.list.invalidate({ workspace: workspaceSlug });
router.push(`/${workspaceSlug}/docs/${created.id}`);
}
},
});
@ -54,13 +54,13 @@ export default function DocsPage() {
<h1 className="text-3xl font-bold tracking-tight">Documents</h1>
<Button
type="button"
disabled={!workspaceId || createMutation.isPending}
disabled={!workspaceSlug || createMutation.isPending}
onClick={() => {
if (!workspaceId) return;
if (!workspaceSlug) return;
createMutation.mutate({
type: "document",
title: "Untitled",
workspaceId,
workspace: workspaceSlug,
parentId: null,
});
}}
@ -81,7 +81,7 @@ export default function DocsPage() {
{documents.map((doc) => (
<li key={doc.id}>
<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"
>
<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() {
const params = useParams();
const workspaceId =
const workspaceSlug =
typeof params?.workspaceSlug === "string"
? params.workspaceSlug
: undefined;
const formId =
typeof params?.formId === "string" ? params.formId : undefined;
if (!workspaceId || !formId) {
if (!workspaceSlug || !formId) {
return (
<div className="p-10 text-sm text-muted-foreground">
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="mb-6 flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" asChild className="gap-1 px-2">
<Link href={`/${workspaceId}/forms`}>
<Link href={`/${workspaceSlug}/forms`}>
<ArrowLeft className="size-4" />
Forms
</Link>
</Button>
</div>
<FormBuilder formId={formId} workspaceId={workspaceId} />
<FormBuilder formId={formId} workspaceHandle={workspaceSlug} />
</div>
);
}

View file

@ -21,7 +21,10 @@ export default function FormDetailPage() {
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : 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(
() => parseFormFields(formQuery.data?.fields),
@ -100,11 +103,11 @@ export default function FormDetailPage() {
</TabsList>
<TabsContent value="responses" className="outline-none data-[state=inactive]:hidden">
<FormResponses formId={formId} fields={fields} />
<FormResponses formId={formId} workspaceHandle={workspaceSlug} fields={fields} />
</TabsContent>
<TabsContent value="fill" className="outline-none data-[state=inactive]:hidden">
<FormRenderer formId={formId} />
<FormRenderer formId={formId} workspaceHandle={workspaceSlug} />
</TabsContent>
</Tabs>
</div>

View file

@ -20,21 +20,21 @@ export default function FormsListPage() {
const router = useRouter();
const utils = api.useUtils();
const workspaceId =
const workspaceSlug =
typeof params?.workspaceSlug === "string"
? params.workspaceSlug
: undefined;
const listQuery = api.forms.list.useQuery(
{ workspaceId: workspaceId! },
{ enabled: Boolean(workspaceId) },
{ workspace: workspaceSlug! },
{ enabled: Boolean(workspaceSlug) },
);
const createMutation = api.forms.create.useMutation({
onSuccess: (created) => {
if (workspaceId) {
void utils.forms.list.invalidate({ workspaceId });
router.push(`/${workspaceId}/forms/${created.id}/edit`);
if (workspaceSlug) {
void utils.forms.list.invalidate({ workspace: workspaceSlug });
router.push(`/${workspaceSlug}/forms/${created.id}/edit`);
}
},
});
@ -55,11 +55,11 @@ export default function FormsListPage() {
</div>
<Button
type="button"
disabled={!workspaceId || createMutation.isPending}
disabled={!workspaceSlug || createMutation.isPending}
onClick={() => {
if (!workspaceId) return;
if (!workspaceSlug) return;
createMutation.mutate({
workspaceId,
workspace: workspaceSlug,
title: "Untitled form",
});
}}
@ -71,6 +71,8 @@ export default function FormsListPage() {
{listQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading forms</p>
) : !workspaceSlug ? (
<p className="text-sm text-muted-foreground">Missing workspace.</p>
) : forms.length === 0 ? (
<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.
@ -80,7 +82,7 @@ export default function FormsListPage() {
{forms.map((form) => (
<li key={form.id}>
<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"
>
<div className="flex items-start gap-3">

View file

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

View file

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

View file

@ -6,11 +6,11 @@ import { TypeManager } from "@/components/types";
export default function TypesSettingsPage() {
const params = useParams();
const workspaceId =
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
return (
<div className="mx-auto max-w-4xl px-8 py-10">
<TypeManager workspaceId={workspaceId} />
<TypeManager workspaceHandle={workspaceSlug} />
</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() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug;
const workspaceId = typeof workspaceSlug === "string" ? workspaceSlug : undefined;
const rawSlug = params?.workspaceSlug;
const workspaceSlug = typeof rawSlug === "string" ? rawSlug : undefined;
const { data: members, isLoading } = api.workspaces.listMembers.useQuery(
{ workspaceId: workspaceId as string },
{ enabled: Boolean(workspaceId) },
{ workspace: workspaceSlug as string },
{ enabled: Boolean(workspaceSlug) },
);
return (
@ -59,7 +59,7 @@ export default function TeamsPage() {
</Button>
</div>
{!workspaceId ? (
{!workspaceSlug ? (
<p className="text-sm text-muted-foreground">Missing workspace.</p>
) : isLoading ? (
<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 { data: wb } = api.objects.getById.useQuery(
{ id: whiteboardId },
{ enabled: Boolean(whiteboardId) },
{ workspace: workspaceSlug, id: whiteboardId },
{ enabled: Boolean(whiteboardId) && Boolean(workspaceSlug) },
);
return (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,10 +2,17 @@
import type { ReactNode } from "react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
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({
workspaceSlug,
children,
@ -13,20 +20,41 @@ export function WorkspaceSync({
workspaceSlug: string;
children: ReactNode;
}) {
const router = useRouter();
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
const { data } = api.workspaces.getById.useQuery({ id: workspaceSlug });
const { data, isError } = api.workspaces.resolve.useQuery({
handle: workspaceSlug,
});
useEffect(() => {
if (data) {
setWorkspace({
id: data.id,
slug: data.id,
name: data.title,
slug: data.slug,
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);
}, [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}</>;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -194,11 +194,11 @@ function SortablePropertyRow({
export type TemplateEditorProps = {
template?: TemplateRow;
workspaceId: string;
workspaceHandle: string;
onSave: () => void;
};
export function TemplateEditor({ template, workspaceId, onSave }: TemplateEditorProps) {
export function TemplateEditor({ template, workspaceHandle, onSave }: TemplateEditorProps) {
const [name, setName] = React.useState(template?.name ?? "");
const [targetType, setTargetType] = React.useState(
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) {
updateMut.mutate({
workspace: workspaceHandle,
id: template.id,
name: name.trim(),
schema,
});
} else {
createMut.mutate({
workspaceId,
workspace: workspaceHandle,
name: name.trim(),
targetType,
schema,

View file

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

View file

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

View file

@ -60,19 +60,19 @@ function TypeIconDisplay({ icon }: { icon: string | null | undefined }) {
}
export interface TypeManagerProps {
workspaceId: string;
workspaceHandle: string;
}
export function TypeManager({ workspaceId }: TypeManagerProps) {
export function TypeManager({ workspaceHandle }: TypeManagerProps) {
const utils = api.useUtils();
const listQuery = api.types.list.useQuery(
{ workspaceId },
{ enabled: Boolean(workspaceId) },
{ workspace: workspaceHandle },
{ enabled: Boolean(workspaceHandle) },
);
const deleteMutation = api.types.delete.useMutation({
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]) {
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;
deleteMutation.mutate({ id: row.id });
deleteMutation.mutate({ workspace: workspaceHandle, id: row.id });
}
return (
<div className="space-y-10">
<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>
<Button type="button" onClick={openCreate} disabled={!workspaceId}>
<Button type="button" onClick={openCreate} disabled={!workspaceHandle}>
Create Type
</Button>
</div>
@ -248,10 +248,10 @@ export function TypeManager({ workspaceId }: TypeManagerProps) {
</DialogPrimitive.Close>
</div>
<div className="px-4 py-4">
{workspaceId ? (
{workspaceHandle ? (
<TypeEditor
key={editing?.id ?? "new"}
workspaceId={workspaceId}
workspaceHandle={workspaceHandle}
existingType={editing}
onSave={() => setDialogOpen(false)}
onCancel={() => setDialogOpen(false)}

View file

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

View file

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

View file

@ -18,18 +18,19 @@ export interface FormViewProps {
export function FormView({ config, className }: FormViewProps) {
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(
{ workspaceId: workspaceId! },
{ enabled: Boolean(workspaceId) },
{ workspace: workspaceHandle! },
{ enabled: Boolean(workspaceHandle) },
);
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const forms = listQuery.data?.forms ?? [];
if (!workspaceId) {
if (!workspaceHandle) {
return (
<div className={cn("p-6 text-sm text-muted-foreground", className)}>
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">
{selectedId ? (
<FormRenderer key={selectedId} formId={selectedId} />
<FormRenderer key={selectedId} formId={selectedId} workspaceHandle={workspaceHandle} />
) : (
<p className="text-sm text-muted-foreground">
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) {
const params = useParams();
const workspaceId =
const workspaceHandle =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const parentId =
typeof params?.projectId === "string" ? params.projectId : undefined;
@ -223,7 +223,7 @@ export function ListView({ config }: ListViewProps) {
const { items, isLoading, total } = useViewData(
effectiveConfig,
workspaceId,
workspaceHandle,
parentId,
);
@ -398,12 +398,12 @@ export function ListView({ config }: ListViewProps) {
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newTitle.trim() && workspaceId) {
if (e.key === "Enter" && newTitle.trim() && workspaceHandle) {
e.preventDefault();
createObject.mutate({
type: "task",
title: newTitle.trim(),
workspaceId,
workspace: workspaceHandle,
parentId: parentId ?? undefined,
});
}
@ -414,11 +414,11 @@ export function ListView({ config }: ListViewProps) {
}}
onBlur={() => {
if (createObject.isPending) return;
if (newTitle.trim() && workspaceId) {
if (newTitle.trim() && workspaceHandle) {
createObject.mutate({
type: "task",
title: newTitle.trim(),
workspaceId,
workspace: workspaceHandle,
parentId: parentId ?? undefined,
});
} else {

View file

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

View file

@ -347,7 +347,7 @@ export interface TableViewProps {
export function TableView({ config }: TableViewProps) {
const params = useParams();
const workspaceId =
const workspaceHandle =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const parentId =
typeof params?.projectId === "string" ? params.projectId : undefined;
@ -365,7 +365,7 @@ export function TableView({ config }: TableViewProps) {
const { items, isLoading, total } = useViewData(
effectiveConfig,
workspaceId,
workspaceHandle,
parentId,
);
@ -813,12 +813,12 @@ export function TableView({ config }: TableViewProps) {
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newTitle.trim() && workspaceId) {
if (e.key === "Enter" && newTitle.trim() && workspaceHandle) {
e.preventDefault();
createObject.mutate({
type: "task",
title: newTitle.trim(),
workspaceId,
workspace: workspaceHandle,
parentId: parentId ?? undefined,
});
}
@ -829,11 +829,11 @@ export function TableView({ config }: TableViewProps) {
}}
onBlur={() => {
if (createObject.isPending) return;
if (newTitle.trim() && workspaceId) {
if (newTitle.trim() && workspaceHandle) {
createObject.mutate({
type: "task",
title: newTitle.trim(),
workspaceId,
workspace: workspaceHandle,
parentId: parentId ?? undefined,
});
} 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(
config: ViewConfig,
workspaceId?: string,
workspaceHandle?: string,
parentId?: string | null,
) {
const { data, isLoading: queryLoading } = api.objects.list.useQuery(
{ workspaceId: workspaceId!, parentId: parentId ?? undefined, limit: 200 },
{ enabled: Boolean(workspaceId) },
{ workspace: workspaceHandle!, parentId: parentId ?? undefined, limit: 200 },
{ enabled: Boolean(workspaceHandle) },
);
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 { generateText } from "ai";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db as dbInstance } from "@tasks/database";
import { objects } from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc";
import { router, workspaceProcedure } from "@/server/trpc";
type Db = typeof dbInstance;
@ -15,25 +15,30 @@ const messageSchema = z.object({
});
const chatInputSchema = z.object({
workspace: z.string().min(1),
messages: z.array(messageSchema).min(1),
context: z
.object({
workspaceId: z.string().optional(),
objectId: z.string().uuid().optional(),
})
.optional(),
});
const suggestInputSchema = z.object({
workspace: z.string().min(1),
objectId: z.string().uuid().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).`;
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({
where: eq(objects.id, objectId),
where: and(eq(objects.id, objectId), eq(objects.workspaceId, workspaceId)),
columns: {
id: true,
title: true,
@ -133,21 +138,23 @@ function suggestionsForContext(input: z.infer<typeof suggestInputSchema>): strin
}
export const aiRouter = router({
chat: protectedProcedure.input(chatInputSchema).mutation(async ({ ctx, input }) => {
chat: workspaceProcedure.input(chatInputSchema).mutation(async ({ ctx, input }) => {
let system = BASE_SYSTEM;
const ctxParts: string[] = [];
if (input.context?.workspaceId) {
ctxParts.push(`Current workspace context ID: ${input.context.workspaceId}`);
}
ctxParts.push(`Current workspace: ${ctx.workspace.name} (${ctx.workspace.slug})`);
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) {
ctxParts.push("The user is focused on this object:\n" + summary);
} else {
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 };
}),
suggestActions: protectedProcedure.input(suggestInputSchema).query(async ({ ctx, input }) => {
suggestActions: workspaceProcedure.input(suggestInputSchema).query(async ({ ctx, input }) => {
let objectType = input.objectType;
if (input.objectId && !objectType) {
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 },
});
objectType = row?.type;

View file

@ -1,30 +1,76 @@
import { z } from "zod";
import { and, eq, desc } from "drizzle-orm";
import { userFavorites, objects } from "@tasks/database/schema";
import { and, eq, desc, exists } from "drizzle-orm";
import {
userFavorites,
objects,
workspaces,
workspaceMembers,
} from "@tasks/database/schema";
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({
list: protectedProcedure
.query(async ({ ctx }) => {
const rows = await ctx.db
.select({
id: userFavorites.id,
objectId: userFavorites.objectId,
createdAt: userFavorites.createdAt,
objectTitle: objects.title,
objectType: objects.type,
objectIcon: objects.icon,
})
.from(userFavorites)
.innerJoin(objects, eq(userFavorites.objectId, objects.id))
.where(eq(userFavorites.userId, ctx.session.user.id))
.orderBy(desc(userFavorites.createdAt));
return rows;
}),
list: protectedProcedure.query(async ({ ctx }) => {
const rows = await ctx.db
.select({
id: userFavorites.id,
objectId: userFavorites.objectId,
createdAt: userFavorites.createdAt,
objectTitle: objects.title,
objectType: objects.type,
objectIcon: objects.icon,
workspaceId: objects.workspaceId,
})
.from(userFavorites)
.innerJoin(objects, eq(userFavorites.objectId, objects.id))
.where(eq(userFavorites.userId, ctx.session.user.id))
.orderBy(desc(userFavorites.createdAt));
return rows;
}),
toggle: protectedProcedure
.input(z.object({ objectId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
await assertCallerCanSeeObject(ctx.db, input.objectId, ctx.session.user.id);
const existing = await ctx.db
.select({ id: userFavorites.id })
.from(userFavorites)

View file

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

View file

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

View file

@ -1,29 +1,27 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { asc, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import {
objects,
propertyDefinitions,
propertyValues,
} from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc";
import { router, workspaceProcedure } from "@/server/trpc";
export const propertiesRouter = router({
listDefinitions: protectedProcedure
.input(z.object({ workspaceId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const definitions = await ctx.db
.select()
.from(propertyDefinitions)
.where(eq(propertyDefinitions.workspaceId, input.workspaceId))
.orderBy(asc(propertyDefinitions.sortOrder), asc(propertyDefinitions.id));
listDefinitions: workspaceProcedure.query(async ({ ctx }) => {
const definitions = await ctx.db
.select()
.from(propertyDefinitions)
.where(eq(propertyDefinitions.workspaceId, ctx.workspace.id))
.orderBy(asc(propertyDefinitions.sortOrder), asc(propertyDefinitions.id));
return { definitions };
}),
return { definitions };
}),
createDefinition: protectedProcedure
createDefinition: workspaceProcedure
.input(
z.object({
workspaceId: z.string().uuid(),
name: z.string().min(1).max(255),
fieldType: z.string().min(1).max(50),
config: z.any().optional(),
@ -33,7 +31,7 @@ export const propertiesRouter = router({
const [created] = await ctx.db
.insert(propertyDefinitions)
.values({
workspaceId: input.workspaceId,
workspaceId: ctx.workspace.id,
name: input.name,
fieldType: input.fieldType,
config: input.config ?? null,
@ -50,9 +48,19 @@ export const propertiesRouter = router({
return created;
}),
getValues: protectedProcedure
getValues: workspaceProcedure
.input(z.object({ objectId: z.string().uuid() }))
.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
.select({
valueRow: propertyValues,
@ -74,7 +82,7 @@ export const propertiesRouter = router({
};
}),
setValue: protectedProcedure
setValue: workspaceProcedure
.input(
z.object({
objectId: z.string().uuid(),
@ -83,6 +91,30 @@ export const propertiesRouter = router({
}),
)
.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 [row] = await ctx.db

View file

@ -1,11 +1,29 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { and, eq, or } from "drizzle-orm";
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({
list: protectedProcedure
list: workspaceProcedure
.input(
z.object({
objectId: z.string().uuid(),
@ -13,6 +31,7 @@ export const relationsRouter = router({
}),
)
.query(async ({ ctx, input }) => {
await assertObjectsInWorkspace(ctx.db, [input.objectId], ctx.workspace.id);
const dir = input.direction ?? "both";
const baseSelect = {
@ -24,6 +43,7 @@ export const relationsRouter = router({
relatedId: objects.id,
relatedTitle: objects.title,
relatedType: objects.type,
relatedWorkspaceId: objects.workspaceId,
};
const outgoing =
@ -33,7 +53,12 @@ export const relationsRouter = router({
.select(baseSelect)
.from(objectRelations)
.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 =
dir === "outgoing"
@ -42,7 +67,12 @@ export const relationsRouter = router({
.select(baseSelect)
.from(objectRelations)
.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 = [
...outgoing.map((r) => ({
@ -76,7 +106,7 @@ export const relationsRouter = router({
return { relations };
}),
create: protectedProcedure
create: workspaceProcedure
.input(
z.object({
sourceId: z.string().uuid(),
@ -92,6 +122,12 @@ export const relationsRouter = router({
});
}
await assertObjectsInWorkspace(
ctx.db,
[input.sourceId, input.targetId],
ctx.workspace.id,
);
try {
const [created] = await ctx.db
.insert(objectRelations)
@ -131,9 +167,24 @@ export const relationsRouter = router({
}
}),
delete: protectedProcedure
delete: workspaceProcedure
.input(z.object({ id: z.string().uuid() }))
.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
.delete(objectRelations)
.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 { objectTypes } from "@tasks/shared";
import type { Context } from "@/server/trpc";
import { router, protectedProcedure } from "@/server/trpc";
import { router, workspaceProcedure } from "@/server/trpc";
const objectTypeSchema = z.enum(objectTypes);
@ -99,11 +99,10 @@ function parentBreadcrumb(
}
export const searchRouter = router({
search: protectedProcedure
search: workspaceProcedure
.input(
z.object({
query: z.string(),
workspaceId: z.string().uuid().optional(),
type: objectTypeSchema.optional(),
limit: z.number().int().positive().max(100).optional(),
}),
@ -118,11 +117,12 @@ export const searchRouter = router({
const pattern = `%${escapeIlike(raw)}%`;
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) {
conditions.push(eq(objects.type, input.type));
}
@ -172,20 +172,19 @@ export const searchRouter = router({
return { results };
}),
recent: protectedProcedure
recent: workspaceProcedure
.input(
z.object({
workspaceId: z.string().uuid().optional(),
limit: z.number().int().positive().max(50).optional(),
}),
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 10;
const conditions = [isNull(objects.archivedAt)];
if (input.workspaceId !== undefined) {
conditions.push(eq(objects.workspaceId, input.workspaceId));
}
const conditions = [
eq(objects.workspaceId, ctx.workspace.id),
isNull(objects.archivedAt),
];
const rows = await ctx.db
.select({

View file

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

View file

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

View file

@ -1,59 +1,232 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { objects, workspaceMembers, users } from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc";
import { and, desc, eq, isNull, ne } from "drizzle-orm";
import {
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({
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 }) => {
const [row] = await ctx.db
.select({
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) {
const ws = await findWorkspaceByHandle(input.handle, ctx.db);
if (!ws) {
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 }) => {
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
.select({
id: objects.id,
title: objects.title,
icon: objects.icon,
id: users.id,
name: users.name,
email: users.email,
avatarUrl: users.avatarUrl,
role: workspaceMembers.role,
})
.from(workspaceMembers)
.innerJoin(objects, eq(workspaceMembers.workspaceId, objects.id))
.where(eq(workspaceMembers.userId, userId));
.innerJoin(users, eq(workspaceMembers.userId, users.id))
.where(eq(workspaceMembers.workspaceId, ctx.workspace.id));
}),
listMembers: protectedProcedure
.input(z.object({ workspaceId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
return ctx.db
.select({
id: users.id,
name: users.name,
email: users.email,
avatarUrl: users.avatarUrl,
role: workspaceMembers.role,
/**
* Update workspace metadata (name and/or slug). Slug renames are validated
* for uniqueness; the caller must be the workspace owner.
*/
update: workspaceProcedure
.input(
z.object({
name: z.string().min(1).max(200).optional(),
slug: slugSchema.optional(),
}),
)
.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)
.innerJoin(users, eq(workspaceMembers.userId, users.id))
.where(eq(workspaceMembers.workspaceId, input.workspaceId));
.where(eq(workspaces.id, ctx.workspace.id))
.returning();
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 { z } from "zod";
import superjson from "superjson";
import type { Session } from "next-auth";
import { db } from "@tasks/database";
import { auth } from "@/lib/auth";
import { resolveWorkspace, type WorkspaceContext } from "@/server/lib/resolve-workspace";
export type Context = {
db: typeof db;
@ -43,3 +45,39 @@ export const router = t.router;
export const createCallerFactory = t.createCallerFactory;
export const publicProcedure = t.procedure;
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,
"tag": "0002_markdown_backlog_cursor_sync",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1778124738113,
"tag": "0003_damp_green_goblin",
"breakpoints": true
}
]
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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