ubiquitous-invention/apps/web/lib/hooks/use-view-data.ts
Randall Stillwell c582d621ce multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:

- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
  property_definitions, templates, forms, markdown_backlog_items,
  cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
  with a hand-augmented data-copy migration that preserves IDs and slug-collision-
  proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
  that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
  tenant-scoped routers (objects, types, properties, templates, forms, search,
  ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
  of the old `workspaceId`, including a workspace-sync layer that rewrites
  /<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
  workspace://{handle}/tree resource to accept either a slug or UUID so existing
  agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
  rename with redirect, owner-only archive).

Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:02:55 -05:00

123 lines
3.7 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { api } from "@/lib/trpc";
export type SortDirection = "asc" | "desc";
export interface ViewFilter {
field: string;
operator: "eq" | "neq" | "contains" | "gt" | "lt" | "in" | "isEmpty" | "isNotEmpty";
value: unknown;
}
export interface ViewSort {
field: string;
direction: SortDirection;
}
export interface ViewConfig {
filters: ViewFilter[];
sorts: ViewSort[];
groupBy: string | null;
}
export interface ViewObject {
id: string;
type: string;
title: string;
status: string | null;
icon: string | null;
sortOrder: number;
parentId: string | null;
createdAt: string;
updatedAt: string;
assignees?: { user: { id: string; name: string | null; avatarUrl: string | null } }[];
propertyValues?: { propertyDefinition: { id: string; name: string; fieldType: string }; value: unknown }[];
}
function applyFilters(objects: ViewObject[], filters: ViewFilter[]): ViewObject[] {
return objects.filter((obj) =>
filters.every((f) => {
const val = f.field === "status" ? obj.status : f.field === "title" ? obj.title : null;
if (f.operator === "eq") return val === f.value;
if (f.operator === "neq") return val !== f.value;
if (f.operator === "contains" && typeof val === "string" && typeof f.value === "string")
return val.toLowerCase().includes(f.value.toLowerCase());
if (f.operator === "isEmpty") return !val;
if (f.operator === "isNotEmpty") return !!val;
return true;
}),
);
}
function getField(obj: ViewObject, field: string): unknown {
return (obj as unknown as Record<string, unknown>)[field];
}
function applySorts(objects: ViewObject[], sorts: ViewSort[]): ViewObject[] {
if (sorts.length === 0) return objects;
return [...objects].sort((a, b) => {
for (const s of sorts) {
const aVal = getField(a, s.field) ?? "";
const bVal = getField(b, s.field) ?? "";
const cmp = String(aVal).localeCompare(String(bVal));
if (cmp !== 0) return s.direction === "asc" ? cmp : -cmp;
}
return 0;
});
}
function applyGroupBy(objects: ViewObject[], groupBy: string | null): Record<string, ViewObject[]> {
if (!groupBy) return { "All Items": objects };
const groups: Record<string, ViewObject[]> = {};
for (const obj of objects) {
const key = String(getField(obj, groupBy) ?? "No Value");
if (!groups[key]) groups[key] = [];
groups[key].push(obj);
}
return groups;
}
export function useViewData(
config: ViewConfig,
workspaceHandle?: string,
parentId?: string | null,
) {
const { data, isLoading: queryLoading } = api.objects.list.useQuery(
{ workspace: workspaceHandle!, parentId: parentId ?? undefined, limit: 200 },
{ enabled: Boolean(workspaceHandle) },
);
const objects: ViewObject[] = useMemo(() => {
if (!data?.objects) return [];
return data.objects.map((row) => ({
id: row.id,
type: row.type,
title: row.title,
status: row.status,
icon: row.icon,
sortOrder: row.sortOrder,
parentId: row.parentId,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
updatedAt:
row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
}));
}, [data?.objects]);
const processed = useMemo(() => {
let result = applyFilters(objects, config.filters);
result = applySorts(result, config.sorts);
const grouped = applyGroupBy(result, config.groupBy);
return { items: result, grouped };
}, [objects, config]);
return {
items: processed.items,
grouped: processed.grouped,
isLoading: queryLoading,
total: processed.items.length,
};
}