Select a workspace to use forms.
@@ -93,7 +94,7 @@ export function FormView({ config, className }: FormViewProps) {
{selectedId ? (
-
+
) : (
Pick a form above to fill it out in this view.
diff --git a/apps/web/components/views/list/list-view.tsx b/apps/web/components/views/list/list-view.tsx
index eaa47cf..b3d9080 100644
--- a/apps/web/components/views/list/list-view.tsx
+++ b/apps/web/components/views/list/list-view.tsx
@@ -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 {
diff --git a/apps/web/components/views/overview/overview-view.tsx b/apps/web/components/views/overview/overview-view.tsx
index 8c265a6..b978378 100644
--- a/apps/web/components/views/overview/overview-view.tsx
+++ b/apps/web/components/views/overview/overview-view.tsx
@@ -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(() => {
diff --git a/apps/web/components/views/table/table-view.tsx b/apps/web/components/views/table/table-view.tsx
index 8a38ff1..801e5b9 100644
--- a/apps/web/components/views/table/table-view.tsx
+++ b/apps/web/components/views/table/table-view.tsx
@@ -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 {
diff --git a/apps/web/components/workspaces/create-workspace-dialog.tsx b/apps/web/components/workspaces/create-workspace-dialog.tsx
new file mode 100644
index 0000000..d597965
--- /dev/null
+++ b/apps/web/components/workspaces/create-workspace-dialog.tsx
@@ -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(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 (
+
+
+
+
+
+
+
+ Create workspace
+
+
+ Workspaces isolate projects, tasks, and team members. You can
+ rename or change the slug later.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/lib/hooks/use-view-data.ts b/apps/web/lib/hooks/use-view-data.ts
index 651d6f2..8809dba 100644
--- a/apps/web/lib/hooks/use-view-data.ts
+++ b/apps/web/lib/hooks/use-view-data.ts
@@ -82,12 +82,12 @@ function applyGroupBy(objects: ViewObject[], groupBy: string | null): Record {
diff --git a/apps/web/server/lib/resolve-workspace.ts b/apps/web/server/lib/resolve-workspace.ts
new file mode 100644
index 0000000..02022f1
--- /dev/null
+++ b/apps/web/server/lib/resolve-workspace.ts
@@ -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 {
+ 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 };
diff --git a/apps/web/server/lib/workspace-guard.ts b/apps/web/server/lib/workspace-guard.ts
new file mode 100644
index 0000000..a69d2fe
--- /dev/null
+++ b/apps/web/server/lib/workspace-guard.ts
@@ -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 {
+ 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;
diff --git a/apps/web/server/routers/ai.ts b/apps/web/server/routers/ai.ts
index 8364cba..74c2b34 100644
--- a/apps/web/server/routers/ai.ts
+++ b/apps/web/server/routers/ai.ts
@@ -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 {
+async function fetchObjectSummary(
+ database: Db,
+ objectId: string,
+ workspaceId: string,
+): Promise {
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): 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;
diff --git a/apps/web/server/routers/favorites.ts b/apps/web/server/routers/favorites.ts
index e58ceea..72c8d78 100644
--- a/apps/web/server/routers/favorites.ts
+++ b/apps/web/server/routers/favorites.ts
@@ -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 {
+ 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)
diff --git a/apps/web/server/routers/forms.ts b/apps/web/server/routers/forms.ts
index ab05208..abda60b 100644
--- a/apps/web/server/routers/forms.ts
+++ b/apps/web/server/routers/forms.ts
@@ -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 },
});
diff --git a/apps/web/server/routers/objects.ts b/apps/web/server/routers/objects.ts
index 15442ae..0ae4fa1 100644
--- a/apps/web/server/routers/objects.ts
+++ b/apps/web/server/routers/objects.ts
@@ -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 {
+ 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 };
}
diff --git a/apps/web/server/routers/properties.ts b/apps/web/server/routers/properties.ts
index 6ec94db..5b214c3 100644
--- a/apps/web/server/routers/properties.ts
+++ b/apps/web/server/routers/properties.ts
@@ -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
diff --git a/apps/web/server/routers/relations.ts b/apps/web/server/routers/relations.ts
index 877bd65..cc4e606 100644
--- a/apps/web/server/routers/relations.ts
+++ b/apps/web/server/routers/relations.ts
@@ -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 {
+ 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))
diff --git a/apps/web/server/routers/search.ts b/apps/web/server/routers/search.ts
index 85a30b8..4d4652c 100644
--- a/apps/web/server/routers/search.ts
+++ b/apps/web/server/routers/search.ts
@@ -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({
diff --git a/apps/web/server/routers/templates.ts b/apps/web/server/routers/templates.ts
index e09f53f..547dcab 100644
--- a/apps/web/server/routers/templates.ts
+++ b/apps/web/server/routers/templates.ts
@@ -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();
diff --git a/apps/web/server/routers/types.ts b/apps/web/server/routers/types.ts
index 8562c97..2c2101d 100644
--- a/apps/web/server/routers/types.ts
+++ b/apps/web/server/routers/types.ts
@@ -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 };
diff --git a/apps/web/server/routers/workspaces.ts b/apps/web/server/routers/workspaces.ts
index 2c7bc62..c4ef8f6 100644
--- a/apps/web/server/routers/workspaces.ts
+++ b/apps/web/server/routers/workspaces.ts
@@ -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;
+ }),
});
diff --git a/apps/web/server/trpc.ts b/apps/web/server/trpc.ts
index f95f8fe..bc20ca0 100644
--- a/apps/web/server/trpc.ts
+++ b/apps/web/server/trpc.ts
@@ -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 };
diff --git a/packages/database/migrations/0003_damp_green_goblin.sql b/packages/database/migrations/0003_damp_green_goblin.sql
new file mode 100644
index 0000000..ac93b96
--- /dev/null
+++ b/packages/database/migrations/0003_damp_green_goblin.sql
@@ -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;
diff --git a/packages/database/migrations/meta/0003_snapshot.json b/packages/database/migrations/meta/0003_snapshot.json
new file mode 100644
index 0000000..0fe075b
--- /dev/null
+++ b/packages/database/migrations/meta/0003_snapshot.json
@@ -0,0 +1,2414 @@
+{
+ "id": "1dc79026-77bc-459d-a448-b4a42bb815c0",
+ "prevId": "46c4f6fc-18a6-4700-bd76-46268c905a81",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.workspaces": {
+ "name": "workspaces",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(60)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plan_tier": {
+ "name": "plan_tier",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'free'"
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workspaces_slug_unique": {
+ "name": "workspaces_slug_unique",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspaces_owner_user_id_idx": {
+ "name": "workspaces_owner_user_id_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspaces_owner_user_id_users_id_fk": {
+ "name": "workspaces_owner_user_id_users_id_fk",
+ "tableFrom": "workspaces",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.object_assignees": {
+ "name": "object_assignees",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "object_id": {
+ "name": "object_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'assignee'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "object_assignees_object_id_user_id_unique": {
+ "name": "object_assignees_object_id_user_id_unique",
+ "columns": [
+ {
+ "expression": "object_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "object_assignees_object_id_idx": {
+ "name": "object_assignees_object_id_idx",
+ "columns": [
+ {
+ "expression": "object_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "object_assignees_user_id_idx": {
+ "name": "object_assignees_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "object_assignees_object_id_objects_id_fk": {
+ "name": "object_assignees_object_id_objects_id_fk",
+ "tableFrom": "object_assignees",
+ "tableTo": "objects",
+ "columnsFrom": [
+ "object_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "object_assignees_user_id_users_id_fk": {
+ "name": "object_assignees_user_id_users_id_fk",
+ "tableFrom": "object_assignees",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.objects": {
+ "name": "objects",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_image": {
+ "name": "cover_image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "objects_parent_id_idx": {
+ "name": "objects_parent_id_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "objects_type_idx": {
+ "name": "objects_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "objects_workspace_id_idx": {
+ "name": "objects_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "objects_template_id_idx": {
+ "name": "objects_template_id_idx",
+ "columns": [
+ {
+ "expression": "template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "objects_created_by_idx": {
+ "name": "objects_created_by_idx",
+ "columns": [
+ {
+ "expression": "created_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "objects_type_workspace_id_idx": {
+ "name": "objects_type_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "objects_workspace_id_workspaces_id_fk": {
+ "name": "objects_workspace_id_workspaces_id_fk",
+ "tableFrom": "objects",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "objects_created_by_users_id_fk": {
+ "name": "objects_created_by_users_id_fk",
+ "tableFrom": "objects",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "objects_parent_id_objects_id_fk": {
+ "name": "objects_parent_id_objects_id_fk",
+ "tableFrom": "objects",
+ "tableTo": "objects",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "objects_template_id_templates_id_fk": {
+ "name": "objects_template_id_templates_id_fk",
+ "tableFrom": "objects",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_members": {
+ "name": "workspace_members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workspace_members_workspace_id_user_id_unique": {
+ "name": "workspace_members_workspace_id_user_id_unique",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_members_workspace_id_idx": {
+ "name": "workspace_members_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_members_user_id_idx": {
+ "name": "workspace_members_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_members_workspace_id_workspaces_id_fk": {
+ "name": "workspace_members_workspace_id_workspaces_id_fk",
+ "tableFrom": "workspace_members",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_members_user_id_users_id_fk": {
+ "name": "workspace_members_user_id_users_id_fk",
+ "tableFrom": "workspace_members",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.object_type_defs": {
+ "name": "object_type_defs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "layout": {
+ "name": "layout",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'task'"
+ },
+ "default_properties": {
+ "name": "default_properties",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "object_type_defs_workspace_id_idx": {
+ "name": "object_type_defs_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "object_type_defs_slug_idx": {
+ "name": "object_type_defs_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "object_type_defs_workspace_id_workspaces_id_fk": {
+ "name": "object_type_defs_workspace_id_workspaces_id_fk",
+ "tableFrom": "object_type_defs",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.property_definitions": {
+ "name": "property_definitions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_type": {
+ "name": "field_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "property_definitions_workspace_id_idx": {
+ "name": "property_definitions_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "property_definitions_workspace_id_name_idx": {
+ "name": "property_definitions_workspace_id_name_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "property_definitions_workspace_id_workspaces_id_fk": {
+ "name": "property_definitions_workspace_id_workspaces_id_fk",
+ "tableFrom": "property_definitions",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.property_values": {
+ "name": "property_values",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "object_id": {
+ "name": "object_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "property_def_id": {
+ "name": "property_def_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "property_values_object_id_property_def_id_unique": {
+ "name": "property_values_object_id_property_def_id_unique",
+ "columns": [
+ {
+ "expression": "object_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "property_def_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "property_values_object_id_idx": {
+ "name": "property_values_object_id_idx",
+ "columns": [
+ {
+ "expression": "object_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "property_values_property_def_id_idx": {
+ "name": "property_values_property_def_id_idx",
+ "columns": [
+ {
+ "expression": "property_def_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "property_values_object_id_objects_id_fk": {
+ "name": "property_values_object_id_objects_id_fk",
+ "tableFrom": "property_values",
+ "tableTo": "objects",
+ "columnsFrom": [
+ "object_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "property_values_property_def_id_property_definitions_id_fk": {
+ "name": "property_values_property_def_id_property_definitions_id_fk",
+ "tableFrom": "property_values",
+ "tableTo": "property_definitions",
+ "columnsFrom": [
+ "property_def_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.views": {
+ "name": "views",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "object_id": {
+ "name": "object_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "view_type": {
+ "name": "view_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "views_object_id_idx": {
+ "name": "views_object_id_idx",
+ "columns": [
+ {
+ "expression": "object_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "views_object_id_objects_id_fk": {
+ "name": "views_object_id_objects_id_fk",
+ "tableFrom": "views",
+ "tableTo": "objects",
+ "columnsFrom": [
+ "object_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "accounts_provider_provider_account_id_unique": {
+ "name": "accounts_provider_provider_account_id_unique",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "accounts_user_id_idx": {
+ "name": "accounts_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_session_token_unique": {
+ "name": "sessions_session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "session_token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "users_email_idx": {
+ "name": "users_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification_tokens": {
+ "name": "verification_tokens",
+ "schema": "",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verification_tokens_identifier_token_pk": {
+ "name": "verification_tokens_identifier_token_pk",
+ "columns": [
+ "identifier",
+ "token"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.object_relations": {
+ "name": "object_relations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "relation_type": {
+ "name": "relation_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "object_relations_source_id_idx": {
+ "name": "object_relations_source_id_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "object_relations_target_id_idx": {
+ "name": "object_relations_target_id_idx",
+ "columns": [
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "object_relations_relation_type_idx": {
+ "name": "object_relations_relation_type_idx",
+ "columns": [
+ {
+ "expression": "relation_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "object_relations_source_target_type_unique": {
+ "name": "object_relations_source_target_type_unique",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "relation_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "object_relations_source_id_objects_id_fk": {
+ "name": "object_relations_source_id_objects_id_fk",
+ "tableFrom": "object_relations",
+ "tableTo": "objects",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "object_relations_target_id_objects_id_fk": {
+ "name": "object_relations_target_id_objects_id_fk",
+ "tableFrom": "object_relations",
+ "tableTo": "objects",
+ "columnsFrom": [
+ "target_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.templates": {
+ "name": "templates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "schema": {
+ "name": "schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "templates_workspace_id_idx": {
+ "name": "templates_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "templates_workspace_id_workspaces_id_fk": {
+ "name": "templates_workspace_id_workspaces_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_responses": {
+ "name": "form_responses",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "respondent_id": {
+ "name": "respondent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_object_id": {
+ "name": "created_object_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "submitted_at": {
+ "name": "submitted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_responses_form_id_idx": {
+ "name": "form_responses_form_id_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_responses_respondent_id_idx": {
+ "name": "form_responses_respondent_id_idx",
+ "columns": [
+ {
+ "expression": "respondent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_responses_form_id_forms_id_fk": {
+ "name": "form_responses_form_id_forms_id_fk",
+ "tableFrom": "form_responses",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_responses_respondent_id_users_id_fk": {
+ "name": "form_responses_respondent_id_users_id_fk",
+ "tableFrom": "form_responses",
+ "tableTo": "users",
+ "columnsFrom": [
+ "respondent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "form_responses_created_object_id_objects_id_fk": {
+ "name": "form_responses_created_object_id_objects_id_fk",
+ "tableFrom": "form_responses",
+ "tableTo": "objects",
+ "columnsFrom": [
+ "created_object_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forms": {
+ "name": "forms",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_image": {
+ "name": "cover_image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "object_id": {
+ "name": "object_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'task'"
+ },
+ "fields": {
+ "name": "fields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'"
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "forms_workspace_id_idx": {
+ "name": "forms_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "forms_object_id_idx": {
+ "name": "forms_object_id_idx",
+ "columns": [
+ {
+ "expression": "object_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "forms_workspace_id_workspaces_id_fk": {
+ "name": "forms_workspace_id_workspaces_id_fk",
+ "tableFrom": "forms",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "forms_object_id_objects_id_fk": {
+ "name": "forms_object_id_objects_id_fk",
+ "tableFrom": "forms",
+ "tableTo": "objects",
+ "columnsFrom": [
+ "object_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "forms_created_by_users_id_fk": {
+ "name": "forms_created_by_users_id_fk",
+ "tableFrom": "forms",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_favorites": {
+ "name": "user_favorites",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "object_id": {
+ "name": "object_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "user_favorites_user_object_idx": {
+ "name": "user_favorites_user_object_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "object_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_favorites_user_id_users_id_fk": {
+ "name": "user_favorites_user_id_users_id_fk",
+ "tableFrom": "user_favorites",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_favorites_object_id_objects_id_fk": {
+ "name": "user_favorites_object_id_objects_id_fk",
+ "tableFrom": "user_favorites",
+ "tableTo": "objects",
+ "columnsFrom": [
+ "object_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.markdown_backlog_items": {
+ "name": "markdown_backlog_items",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plan_slug": {
+ "name": "plan_slug",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "epic_slug": {
+ "name": "epic_slug",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "repo_path": {
+ "name": "repo_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner": {
+ "name": "owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "frontmatter": {
+ "name": "frontmatter",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body_markdown": {
+ "name": "body_markdown",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "markdown_backlog_workspace_repo_path_unique": {
+ "name": "markdown_backlog_workspace_repo_path_unique",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "repo_path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "markdown_backlog_workspace_plan_idx": {
+ "name": "markdown_backlog_workspace_plan_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "plan_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "markdown_backlog_parent_id_idx": {
+ "name": "markdown_backlog_parent_id_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "markdown_backlog_workspace_kind_idx": {
+ "name": "markdown_backlog_workspace_kind_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "markdown_backlog_items_workspace_id_workspaces_id_fk": {
+ "name": "markdown_backlog_items_workspace_id_workspaces_id_fk",
+ "tableFrom": "markdown_backlog_items",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk": {
+ "name": "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk",
+ "tableFrom": "markdown_backlog_items",
+ "tableTo": "markdown_backlog_items",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cursor_sync_mappings": {
+ "name": "cursor_sync_mappings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "backlog_item_id": {
+ "name": "backlog_item_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cursor_plan_id": {
+ "name": "cursor_plan_id",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cursor_item_id": {
+ "name": "cursor_item_id",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_pulled_at": {
+ "name": "last_pulled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_pushed_at": {
+ "name": "last_pushed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_content_hash": {
+ "name": "sync_content_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "cursor_sync_mappings_backlog_item_id_unique": {
+ "name": "cursor_sync_mappings_backlog_item_id_unique",
+ "columns": [
+ {
+ "expression": "backlog_item_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cursor_sync_mappings_workspace_id_idx": {
+ "name": "cursor_sync_mappings_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "cursor_sync_mappings_workspace_id_workspaces_id_fk": {
+ "name": "cursor_sync_mappings_workspace_id_workspaces_id_fk",
+ "tableFrom": "cursor_sync_mappings",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk": {
+ "name": "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk",
+ "tableFrom": "cursor_sync_mappings",
+ "tableTo": "markdown_backlog_items",
+ "columnsFrom": [
+ "backlog_item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json
index 9b71eea..7f41d0b 100644
--- a/packages/database/migrations/meta/_journal.json
+++ b/packages/database/migrations/meta/_journal.json
@@ -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
}
]
}
\ No newline at end of file
diff --git a/packages/database/src/schema/cursor_sync.ts b/packages/database/src/schema/cursor_sync.ts
index ddf89bc..c79af15 100644
--- a/packages/database/src/schema/cursor_sync.ts
+++ b/packages/database/src/schema/cursor_sync.ts
@@ -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" }),
diff --git a/packages/database/src/schema/forms.ts b/packages/database/src/schema/forms.ts
index cf6c305..6c65994 100644
--- a/packages/database/src/schema/forms.ts
+++ b/packages/database/src/schema/forms.ts
@@ -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"),
diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts
index 86f2d3c..a631eac 100644
--- a/packages/database/src/schema/index.ts
+++ b/packages/database/src/schema/index.ts
@@ -1,3 +1,4 @@
+export * from "./workspaces";
export * from "./objects";
export * from "./types";
export * from "./properties";
diff --git a/packages/database/src/schema/markdown_backlog.ts b/packages/database/src/schema/markdown_backlog.ts
index 7109940..eaa3a5e 100644
--- a/packages/database/src/schema/markdown_backlog.ts
+++ b/packages/database/src/schema/markdown_backlog.ts
@@ -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(),
diff --git a/packages/database/src/schema/objects.ts b/packages/database/src/schema/objects.ts
index 0936dd4..f3f4a4e 100644
--- a/packages/database/src/schema/objects.ts
+++ b/packages/database/src/schema/objects.ts
@@ -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" }),
diff --git a/packages/database/src/schema/properties.ts b/packages/database/src/schema/properties.ts
index 0fdee31..b49cf2b 100644
--- a/packages/database/src/schema/properties.ts
+++ b/packages/database/src/schema/properties.ts
@@ -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"),
diff --git a/packages/database/src/schema/relations.ts b/packages/database/src/schema/relations.ts
index c2652cd..3683c75 100644
--- a/packages/database/src/schema/relations.ts
+++ b/packages/database/src/schema/relations.ts
@@ -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],
diff --git a/packages/database/src/schema/templates.ts b/packages/database/src/schema/templates.ts
index 3141df9..fe5f08a 100644
--- a/packages/database/src/schema/templates.ts
+++ b/packages/database/src/schema/templates.ts
@@ -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"),
diff --git a/packages/database/src/schema/types.ts b/packages/database/src/schema/types.ts
index f2cb6cd..2cef184 100644
--- a/packages/database/src/schema/types.ts
+++ b/packages/database/src/schema/types.ts
@@ -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"),
diff --git a/packages/database/src/schema/workspaces.ts b/packages/database/src/schema/workspaces.ts
new file mode 100644
index 0000000..5d45f5a
--- /dev/null
+++ b/packages/database/src/schema/workspaces.ts
@@ -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),
+ }),
+);