From f40ab1f1eb3ab924d1bf11893fd731d985cdc284 Mon Sep 17 00:00:00 2001 From: Pol Guixe Date: Sun, 7 Jun 2026 07:40:42 +0200 Subject: [PATCH] feat(teams): org-scope projects + active-org context (Phase 5) - migration 0003: backfill org_id/team_id on existing projects/workflows/ reviews/chats/subfolders to each owner's personal org + default team (so nothing disappears once listing is org-scoped). Applied to staging. - backend lib/tenancy: resolveActiveOrg (X-Org-Id header or personal org) + accessibleOrgIds helper - backend projects: POST stamps org_id/team_id; GET lists all projects in the active org (team-wide visibility) plus legacy shared_with (transitional) - frontend: X-Org-Id header from active org (mikeApi), OrgContext provider wired into the (pages) layout, org page drives the shared active-org state so switching org re-scopes the whole app shared_with retained during transition (retire after parity). Other resource types (workflows/reviews/chats standalone listing) still use the prior model and will be scoped next. Backend tsc + frontend tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0003_backfill_resource_orgs.sql | 40 +++++++++ backend/src/lib/tenancy.ts | 41 +++++++++ backend/src/routes/projects.ts | 34 ++++++-- frontend/src/app/(pages)/layout.tsx | 3 + .../src/app/(pages)/organization/page.tsx | 68 ++++++--------- frontend/src/app/contexts/OrgContext.tsx | 83 +++++++++++++++++++ frontend/src/app/lib/mikeApi.ts | 21 +++++ 7 files changed, 241 insertions(+), 49 deletions(-) create mode 100644 backend/migrations/0003_backfill_resource_orgs.sql create mode 100644 frontend/src/app/contexts/OrgContext.tsx diff --git a/backend/migrations/0003_backfill_resource_orgs.sql b/backend/migrations/0003_backfill_resource_orgs.sql new file mode 100644 index 000000000..ee314d33b --- /dev/null +++ b/backend/migrations/0003_backfill_resource_orgs.sql @@ -0,0 +1,40 @@ +-- Migration 0003: Backfill org_id/team_id on existing resources +-- +-- Assigns every existing project/workflow/tabular_review/chat/subfolder to its +-- owner's PERSONAL organization + default team. This must run before listing +-- is scoped by org (migration is paired with the resource-scoping backend +-- change) so existing data doesn't disappear from users' views. +-- +-- Owner is identified by user_id (text) == auth.users.id::text, which is how +-- the app stores ownership today. +-- +-- Idempotent: only fills rows where org_id is still null. + +do $$ +declare + r record; +begin + for r in + select o.id as org_id, t.id as team_id, o.created_by as uid + from public.organizations o + join public.teams t on t.org_id = o.id and t.is_default + where o.is_personal + loop + update public.projects + set org_id = r.org_id, team_id = coalesce(team_id, r.team_id) + where user_id = r.uid::text and org_id is null; + update public.workflows + set org_id = r.org_id, team_id = coalesce(team_id, r.team_id) + where user_id = r.uid::text and org_id is null; + update public.tabular_reviews + set org_id = r.org_id, team_id = coalesce(team_id, r.team_id) + where user_id = r.uid::text and org_id is null; + update public.chats + set org_id = r.org_id, team_id = coalesce(team_id, r.team_id) + where user_id = r.uid::text and org_id is null; + update public.project_subfolders + set org_id = r.org_id + where user_id = r.uid::text and org_id is null; + end loop; +end; +$$; diff --git a/backend/src/lib/tenancy.ts b/backend/src/lib/tenancy.ts index cfcf53a06..1c84b039a 100644 --- a/backend/src/lib/tenancy.ts +++ b/backend/src/lib/tenancy.ts @@ -108,6 +108,47 @@ export async function getPermissions( ); } +/** + * Resolve the "active organization" for a resource operation: + * - the X-Org-Id header if present AND the user is an active member, else + * - the user's personal org (auto-provisioned if missing). + * Also returns the org's default team id. Used by resource routes to stamp + * org_id/team_id on creates and to scope listings. + */ +export async function resolveActiveOrg( + req: Request, + userId: string, + email: string | null | undefined, + db: Db, +): Promise<{ orgId: string; teamId: string | null } | null> { + const headerOrg = req.header("x-org-id") ?? undefined; + let orgId: string | null = null; + if (headerOrg) { + const m = await getMembership(headerOrg, userId, db); + if (m) orgId = headerOrg; + } + if (!orgId) { + orgId = await ensurePersonalOrg(userId, email, db); + } + if (!orgId) return null; + const { data: team } = await db + .from("teams") + .select("id") + .eq("org_id", orgId) + .eq("is_default", true) + .maybeSingle(); + return { orgId, teamId: (team as { id: string } | null)?.id ?? null }; +} + +/** Org ids the user can access (active membership). Convenience wrapper. */ +export async function accessibleOrgIds( + userId: string, + db: Db, +): Promise { + const m = await listMemberships(userId, db); + return m.map((x) => x.org_id); +} + /** Write an audit row (best-effort; never throws into the request path). */ export async function writeAudit( orgId: string | null, diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 38e38b2d1..cdb0808cc 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -9,6 +9,7 @@ import { import { downloadFile, uploadFile, storageKey } from "../lib/storage"; import { docxToPdf, convertedPdfKey } from "../lib/convert"; import { checkProjectAccess } from "../lib/access"; +import { resolveActiveOrg } from "../lib/tenancy"; import { singleFileUpload } from "../lib/upload"; export const projectsRouter = Router(); @@ -29,13 +30,20 @@ projectsRouter.get("/", requireAuth, async (req, res) => { const userEmail = res.locals.userEmail as string; const db = createServerSupabase(); - const { data: ownProjects, error: ownError } = await db - .from("projects") - .select("*") - .eq("user_id", userId) - .order("created_at", { ascending: false }); - if (ownError) return void res.status(500).json({ detail: ownError.message }); + // Active-org view: every project in the org the caller is currently in + // (team-wide visibility). Falls back to the personal org when no X-Org-Id. + const active = await resolveActiveOrg(req, userId, userEmail, db); + const { data: orgProjects, error: orgError } = active + ? await db + .from("projects") + .select("*") + .eq("org_id", active.orgId) + .order("created_at", { ascending: false }) + : { data: [], error: null }; + if (orgError) return void res.status(500).json({ detail: orgError.message }); + // Transitional: also include legacy shares (shared_with my email) until the + // shared_with model is fully retired. const { data: sharedProjects, error: sharedError } = userEmail ? await db .from("projects") @@ -47,9 +55,15 @@ projectsRouter.get("/", requireAuth, async (req, res) => { if (sharedError) return void res.status(500).json({ detail: sharedError.message }); - const projects = [...(ownProjects ?? []), ...(sharedProjects ?? [])].sort( + // De-dupe (a shared project may also be in the active org). + const byId = new Map>(); + for (const p of [...(orgProjects ?? []), ...(sharedProjects ?? [])]) { + byId.set((p as { id: string }).id, p as Record); + } + const projects = [...byId.values()].sort( (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + new Date(b.created_at as string).getTime() - + new Date(a.created_at as string).getTime(), ); const result = await Promise.all( @@ -110,6 +124,8 @@ projectsRouter.post("/", requireAuth, async (req, res) => { } const db = createServerSupabase(); + // Stamp the active organization (X-Org-Id header, else personal org). + const active = await resolveActiveOrg(req, userId, userEmail, db); const { data, error } = await db .from("projects") .insert({ @@ -117,6 +133,8 @@ projectsRouter.post("/", requireAuth, async (req, res) => { name: name.trim(), cm_number: cm_number ?? null, shared_with: cleanedSharedWith, + org_id: active?.orgId ?? null, + team_id: active?.teamId ?? null, }) .select("*") .single(); diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index d21c747f9..f4c9ae7b3 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -6,6 +6,7 @@ import { PanelLeft } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext"; import { SidebarContext } from "@/app/contexts/SidebarContext"; +import { OrgProvider } from "@/app/contexts/OrgContext"; import { AppSidebar } from "@/app/components/shared/AppSidebar"; export default function MikeLayout({ @@ -75,6 +76,7 @@ export default function MikeLayout({ if (!isAuthenticated) return null; return ( + + ); } diff --git a/frontend/src/app/(pages)/organization/page.tsx b/frontend/src/app/(pages)/organization/page.tsx index c4a293782..d702c90ee 100644 --- a/frontend/src/app/(pages)/organization/page.tsx +++ b/frontend/src/app/(pages)/organization/page.tsx @@ -14,8 +14,8 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/contexts/AuthContext"; +import { useOrg } from "@/app/contexts/OrgContext"; import { - listOrganizations, createOrganization, listMembers, changeMemberRole, @@ -37,13 +37,11 @@ const ROLES: OrgRole[] = ["owner", "admin", "member"]; export default function OrganizationPage() { const { user } = useAuth(); - const [orgs, setOrgs] = useState([]); - const [current, setCurrent] = useState(null); + const { orgs, activeOrg: current, loading, setActiveOrg, refresh } = useOrg(); const [switcherOpen, setSwitcherOpen] = useState(false); const [members, setMembers] = useState([]); const [teams, setTeams] = useState([]); const [invites, setInvites] = useState([]); - const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -55,12 +53,12 @@ export default function OrganizationPage() { const canManage = current?.my_role === "owner" || current?.my_role === "admin"; - const loadOrgDetail = useCallback(async (org: Organization) => { + const loadDetail = useCallback(async (orgId: string) => { try { const [m, t, inv] = await Promise.all([ - listMembers(org.id).catch(() => []), - listTeams(org.id).catch(() => []), - listInvitations(org.id).catch(() => []), + listMembers(orgId).catch(() => []), + listTeams(orgId).catch(() => []), + listInvitations(orgId).catch(() => []), ]); setMembers(m); setTeams(t); @@ -70,35 +68,15 @@ export default function OrganizationPage() { } }, []); - const loadOrgs = useCallback(async () => { - setLoading(true); - try { - const list = await listOrganizations(); - setOrgs(list); - const cur = current - ? list.find((o) => o.id === current.id) ?? list[0] - : list[0]; - setCurrent(cur ?? null); - if (cur) await loadOrgDetail(cur); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load organizations"); - } finally { - setLoading(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadOrgDetail]); - useEffect(() => { - loadOrgs(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (current?.id) loadDetail(current.id); + }, [current?.id, loadDetail]); - async function switchTo(org: Organization) { - setCurrent(org); + function switchTo(org: Organization) { + setActiveOrg(org.id); setSwitcherOpen(false); setLastInviteUrl(null); setError(null); - await loadOrgDetail(org); } async function handleCreateOrg() { @@ -107,8 +85,9 @@ export default function OrganizationPage() { try { const org = await createOrganization(newOrg.trim()); setNewOrg(""); - await loadOrgs(); - await switchTo({ ...org, my_role: "owner" }); + setSwitcherOpen(false); + await refresh(); + setActiveOrg(org.id); } catch (e) { setError(e instanceof Error ? e.message : "Create failed"); } finally { @@ -124,7 +103,7 @@ export default function OrganizationPage() { const inv = await createInvitation(current.id, inviteEmail.trim(), inviteRole); setInviteEmail(""); if (inv.accept_url) setLastInviteUrl(inv.accept_url); - await loadOrgDetail(current); + await loadDetail(current.id); } catch (e) { setError(e instanceof Error ? e.message : "Invite failed"); } finally { @@ -136,7 +115,7 @@ export default function OrganizationPage() { if (!current) return; try { await changeMemberRole(current.id, m.user_id, role); - await loadOrgDetail(current); + await loadDetail(current.id); } catch (e) { setError(e instanceof Error ? e.message : "Role change failed"); } @@ -146,7 +125,7 @@ export default function OrganizationPage() { if (!current) return; try { await removeMember(current.id, m.user_id); - await loadOrgDetail(current); + await loadDetail(current.id); } catch (e) { setError(e instanceof Error ? e.message : "Remove failed"); } @@ -158,7 +137,7 @@ export default function OrganizationPage() { try { await createTeam(current.id, newTeam.trim()); setNewTeam(""); - await loadOrgDetail(current); + await loadDetail(current.id); } catch (e) { setError(e instanceof Error ? e.message : "Create team failed"); } finally { @@ -223,7 +202,8 @@ export default function OrganizationPage() { )} {current && (

- Your role: {current.my_role} + Your role: {current.my_role} · switching here + changes which org's projects you see across the app.

)} @@ -344,7 +324,10 @@ export default function OrganizationPage() { {inv.email} · {inv.role}