From 4361d4cd2a2d3f4ec94b9dc7eebaa2fbb2752442 Mon Sep 17 00:00:00 2001 From: DJP Date: Mon, 20 Apr 2026 19:25:29 -0400 Subject: [PATCH] =?UTF-8?q?Phase=206e:=20ClientTeam=20+=20Pod=20CRUD=20?= =?UTF-8?q?=E2=80=94=20settings=20pages=20and=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the only way to set up Dow's six client teams + production pods was via the seed script. Now admins manage them from the Settings UI. Validators (Zod): - src/lib/validators/client-team.ts: create/update/membership schemas; slug regex enforced (lowercase + dashes only — keeps it stable for the XLSX/webhook ingest path which resolves teams by slug). - src/lib/validators/pod.ts: create/update + setHomePod schemas. Services: - src/lib/services/client-team-service.ts: list/create/update/delete + addMember/removeMember. delete blocks if the team still has projects (forces an explicit move first). Auto-derives slug from name when not provided. - src/lib/services/pod-service.ts: list/create/update/delete + setUserHomePod. delete is non-cascading on members — sets User.homePodId=null instead of deleting people. Lead-user assignment is org-scope-validated. API routes (gated by new permissions CLIENT_TEAM_MANAGE / POD_MANAGE seeded for ADMIN in Phase 3): - GET/POST /api/client-teams - PATCH/DELETE /api/client-teams/[teamId] - POST/DELETE /api/client-teams/[teamId]/members - GET/POST /api/pods - PATCH/DELETE /api/pods/[podId] - POST/DELETE /api/pods/[podId]/members GET endpoints are open to any signed-in user — they need the lists for filter dropdowns and to know their own team. Project-row visibility is still enforced via Phase 2's visibility helpers, untouched. Hooks: - src/hooks/use-client-teams.ts and src/hooks/use-pods.ts — TanStack Query wrappers with cache invalidation on mutations. Settings pages: - src/app/(app)/settings/client-teams/page.tsx — create teams, manage memberships, see project counts. Hides external (CLIENT_VIEWER) users with a "client" badge so admins know who's who. - src/app/(app)/settings/pods/page.tsx — create pods, set lead, add/remove members. Filters out external users from the pod-eligible list. - src/app/(app)/settings/page.tsx — added Client Teams + Pods cards to the index, reordered to surface user-management first. Verified: tsc --noEmit ✓ zero errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(app)/settings/client-teams/page.tsx | 268 ++++++++++++++++ src/app/(app)/settings/page.tsx | 28 +- src/app/(app)/settings/pods/page.tsx | 290 ++++++++++++++++++ .../client-teams/[teamId]/members/route.ts | 47 +++ src/app/api/client-teams/[teamId]/route.ts | 52 ++++ src/app/api/client-teams/route.ts | 42 +++ src/app/api/pods/[podId]/members/route.ts | 46 +++ src/app/api/pods/[podId]/route.ts | 45 +++ src/app/api/pods/route.ts | 41 +++ src/hooks/use-client-teams.ts | 88 ++++++ src/hooks/use-pods.ts | 98 ++++++ src/lib/services/client-team-service.ts | 132 ++++++++ src/lib/services/pod-service.ts | 127 ++++++++ src/lib/validators/client-team.ts | 25 ++ src/lib/validators/pod.ts | 25 ++ 15 files changed, 1346 insertions(+), 8 deletions(-) create mode 100644 src/app/(app)/settings/client-teams/page.tsx create mode 100644 src/app/(app)/settings/pods/page.tsx create mode 100644 src/app/api/client-teams/[teamId]/members/route.ts create mode 100644 src/app/api/client-teams/[teamId]/route.ts create mode 100644 src/app/api/client-teams/route.ts create mode 100644 src/app/api/pods/[podId]/members/route.ts create mode 100644 src/app/api/pods/[podId]/route.ts create mode 100644 src/app/api/pods/route.ts create mode 100644 src/hooks/use-client-teams.ts create mode 100644 src/hooks/use-pods.ts create mode 100644 src/lib/services/client-team-service.ts create mode 100644 src/lib/services/pod-service.ts create mode 100644 src/lib/validators/client-team.ts create mode 100644 src/lib/validators/pod.ts diff --git a/src/app/(app)/settings/client-teams/page.tsx b/src/app/(app)/settings/client-teams/page.tsx new file mode 100644 index 0000000..ef44880 --- /dev/null +++ b/src/app/(app)/settings/client-teams/page.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useState } from "react"; +import { Layers, Plus, Trash2, Users, UserPlus, UserMinus } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + useClientTeams, + useCreateClientTeam, + useDeleteClientTeam, + useAddClientTeamMember, + useRemoveClientTeamMember, +} from "@/hooks/use-client-teams"; +import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { apiUrl } from "@/lib/api-client"; + +export default function ClientTeamsSettingsPage() { + const { data: teams, isLoading } = useClientTeams(); + const { data: users } = useQuery({ + queryKey: ["users"], + queryFn: async () => { + const res = await fetch(apiUrl("/api/users")); + if (!res.ok) throw new Error("Failed to fetch users"); + return res.json() as Promise; + }, + }); + + const createTeam = useCreateClientTeam(); + const deleteTeam = useDeleteClientTeam(); + const addMember = useAddClientTeamMember(); + const removeMember = useRemoveClientTeamMember(); + + const [newTeamName, setNewTeamName] = useState(""); + const [memberSelectByTeam, setMemberSelectByTeam] = useState>({}); + + const handleCreate = async () => { + const name = newTeamName.trim(); + if (!name) return; + try { + await createTeam.mutateAsync({ name }); + setNewTeamName(""); + toast.success(`Team "${name}" created`); + } catch (e: any) { + toast.error(e.message || "Failed to create team"); + } + }; + + const handleDelete = async (id: string, name: string) => { + if (!confirm(`Delete team "${name}"? This cannot be undone.`)) return; + try { + await deleteTeam.mutateAsync(id); + toast.success("Team deleted"); + } catch (e: any) { + toast.error(e.message || "Failed to delete team"); + } + }; + + const handleAddMember = async (teamId: string) => { + const userId = memberSelectByTeam[teamId]; + if (!userId) return; + try { + await addMember.mutateAsync({ teamId, userId }); + setMemberSelectByTeam((s) => ({ ...s, [teamId]: "" })); + toast.success("Member added"); + } catch (e: any) { + toast.error(e.message || "Failed to add member"); + } + }; + + const handleRemoveMember = async (teamId: string, userId: string) => { + try { + await removeMember.mutateAsync({ teamId, userId }); + toast.success("Member removed"); + } catch (e: any) { + toast.error(e.message || "Failed to remove member"); + } + }; + + return ( +
+
+ +
+

Client Teams

+

+ Groups that drive per-team project visibility. Users only see projects + belonging to the teams they're members of. +

+
+
+ + + + + + New team + + + +
+ setNewTeamName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + className="h-8 text-sm" + /> + +
+
+
+ +
+ {isLoading ? ( + <> + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + ) : teams?.length === 0 ? ( + + + No client teams yet — create one above. + + + ) : ( + teams?.map((team: any) => { + const memberIds = new Set( + (team.userMemberships ?? []).map((m: any) => m.user.id) + ); + const candidates = (users ?? []).filter((u: any) => !memberIds.has(u.id)); + const selected = memberSelectByTeam[team.id] ?? ""; + + return ( + + + +
+ + {team.name} + + {team.slug} + +
+ +
+
+ +
+ + {team._count?.userMemberships ?? 0} member + {(team._count?.userMemberships ?? 0) !== 1 ? "s" : ""} + + · + + {team._count?.projects ?? 0} project + {(team._count?.projects ?? 0) !== 1 ? "s" : ""} + +
+ +
+ + +
+ +
+ {(team.userMemberships ?? []).length === 0 ? ( +

+ No members. Users without a team membership won't see any + projects on this team. +

+ ) : ( + (team.userMemberships ?? []).map((m: any) => ( +
+
+ {m.user.name || m.user.email} + {m.user.isExternal && ( + + client + + )} + + {m.user.email} + +
+ +
+ )) + )} +
+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 9608f07..1e81ccf 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -1,8 +1,26 @@ import Link from "next/link"; -import { Settings, Wrench, Shield, GitBranch, Users, Columns, Bell, Zap } from "lucide-react"; +import { Settings, Wrench, Shield, GitBranch, Users, Users2, Layers, Columns, Bell, Zap } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; const settingsPages = [ + { + href: "/settings/team", + label: "Team", + description: "Add and invite team members. Issues a one-link login for each user (works for both internal staff and external Dow client viewers).", + icon: Users, + }, + { + href: "/settings/client-teams", + label: "Client Teams", + description: "Brand, Events, B2B, Content — the groups that drive per-team project visibility. Each user's memberships gate which projects they see.", + icon: Layers, + }, + { + href: "/settings/pods", + label: "Pods", + description: "Production pods for capacity planning — Sergio's pod, Deborah's pod, etc. Orthogonal to client teams.", + icon: Users2, + }, { href: "/settings/pipelines", label: "Pipeline Templates", @@ -12,15 +30,9 @@ const settingsPages = [ { href: "/settings/permissions", label: "Permissions", - description: "Configure what each role can do — manage access for Admins, Producers, and Artists", + description: "Configure what each role can do — Admins, Producers, Artists, and Client Viewers", icon: Shield, }, - { - href: "/settings/team", - label: "Team", - description: "Manage team members and send invitations to join your organization", - icon: Users, - }, { href: "/settings/fields", label: "Custom Fields", diff --git a/src/app/(app)/settings/pods/page.tsx b/src/app/(app)/settings/pods/page.tsx new file mode 100644 index 0000000..c0cd3b9 --- /dev/null +++ b/src/app/(app)/settings/pods/page.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useState } from "react"; +import { Users2, Plus, Trash2, UserPlus, UserMinus, Crown } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + usePods, + useCreatePod, + useUpdatePod, + useDeletePod, + useSetHomePod, + useUnsetHomePod, +} from "@/hooks/use-pods"; +import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { apiUrl } from "@/lib/api-client"; + +export default function PodsSettingsPage() { + const { data: pods, isLoading } = usePods(); + const { data: users } = useQuery({ + queryKey: ["users"], + queryFn: async () => { + const res = await fetch(apiUrl("/api/users")); + if (!res.ok) throw new Error("Failed to fetch users"); + return res.json() as Promise; + }, + }); + + const createPod = useCreatePod(); + const updatePod = useUpdatePod(); + const deletePod = useDeletePod(); + const setHomePod = useSetHomePod(); + const unsetHomePod = useUnsetHomePod(); + + const [newPodName, setNewPodName] = useState(""); + const [memberSelect, setMemberSelect] = useState>({}); + + const internalUsers = (users ?? []).filter((u: any) => !u.isExternal); + + const handleCreate = async () => { + const name = newPodName.trim(); + if (!name) return; + try { + await createPod.mutateAsync({ name }); + setNewPodName(""); + toast.success(`Pod "${name}" created`); + } catch (e: any) { + toast.error(e.message || "Failed to create pod"); + } + }; + + const handleDelete = async (id: string, name: string) => { + if (!confirm(`Delete pod "${name}"? Members will be unassigned.`)) return; + try { + await deletePod.mutateAsync(id); + toast.success("Pod deleted"); + } catch (e: any) { + toast.error(e.message || "Failed to delete pod"); + } + }; + + const handleSetLead = async (podId: string, leadUserId: string) => { + try { + await updatePod.mutateAsync({ + id: podId, + leadUserId: leadUserId === "_none" ? null : leadUserId, + }); + toast.success("Pod lead updated"); + } catch (e: any) { + toast.error(e.message || "Failed to update lead"); + } + }; + + const handleAddMember = async (podId: string) => { + const userId = memberSelect[podId]; + if (!userId) return; + try { + await setHomePod.mutateAsync({ podId, userId }); + setMemberSelect((s) => ({ ...s, [podId]: "" })); + toast.success("Member added to pod"); + } catch (e: any) { + toast.error(e.message || "Failed to add member"); + } + }; + + const handleRemoveMember = async (podId: string, userId: string) => { + try { + await unsetHomePod.mutateAsync({ podId, userId }); + toast.success("Member removed from pod"); + } catch (e: any) { + toast.error(e.message || "Failed to remove member"); + } + }; + + return ( +
+
+ +
+

Pods

+

+ Production pods for capacity planning. A user has one home pod but can + work on projects across any client team. +

+
+
+ + + + + + New pod + + + +
+ setNewPodName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + className="h-8 text-sm" + /> + +
+
+
+ +
+ {isLoading ? ( + <> + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ) : pods?.length === 0 ? ( + + + No pods yet — create one above. + + + ) : ( + pods?.map((pod: any) => { + const memberIds = new Set((pod.members ?? []).map((m: any) => m.id)); + const candidates = internalUsers.filter((u: any) => !memberIds.has(u.id)); + const selected = memberSelect[pod.id] ?? ""; + + return ( + + + +
+ + {pod.name} + + {pod.slug} + +
+ +
+
+ + {/* Lead selector */} +
+ + +
+ + {/* Add member */} +
+ + +
+ + {/* Member list */} +
+ {(pod.members ?? []).length === 0 ? ( +

+ No members yet. +

+ ) : ( + (pod.members ?? []).map((u: any) => ( +
+
+ {u.id === pod.leadUserId && ( + + )} + {u.name || u.email} + {u.department && ( + + · {u.department} + + )} +
+ +
+ )) + )} +
+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/src/app/api/client-teams/[teamId]/members/route.ts b/src/app/api/client-teams/[teamId]/members/route.ts new file mode 100644 index 0000000..ec0590e --- /dev/null +++ b/src/app/api/client-teams/[teamId]/members/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { addMember, removeMember } from "@/lib/services/client-team-service"; +import { clientTeamMembershipSchema } from "@/lib/validators/client-team"; + +type Params = { params: Promise<{ teamId: string }> }; + +// POST /api/client-teams/:teamId/members — add or update membership +export async function POST(req: NextRequest, { params }: Params) { + const { session, error } = await requireAuth("CLIENT_TEAM_MANAGE"); + if (error) return error; + try { + const { teamId } = await params; + const body = await req.json(); + const parsed = clientTeamMembershipSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const membership = await addMember( + teamId, + session.user.organizationId, + parsed.data.userId, + parsed.data.isPrimary ?? false + ); + return NextResponse.json(membership, { status: 201 }); + } catch (e: any) { + if (e.message?.includes("not found")) return badRequest(e.message); + return serverError(e); + } +} + +// DELETE /api/client-teams/:teamId/members?userId=... — remove membership +export async function DELETE(req: NextRequest, { params }: Params) { + const { session, error } = await requireAuth("CLIENT_TEAM_MANAGE"); + if (error) return error; + try { + const { teamId } = await params; + const userId = new URL(req.url).searchParams.get("userId"); + if (!userId) return badRequest("userId query param is required"); + await removeMember(teamId, session.user.organizationId, userId); + return NextResponse.json({ ok: true }); + } catch (e: any) { + if (e.message?.includes("not found")) return badRequest(e.message); + return serverError(e); + } +} diff --git a/src/app/api/client-teams/[teamId]/route.ts b/src/app/api/client-teams/[teamId]/route.ts new file mode 100644 index 0000000..98b9d7b --- /dev/null +++ b/src/app/api/client-teams/[teamId]/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { + updateClientTeam, + deleteClientTeam, +} from "@/lib/services/client-team-service"; +import { updateClientTeamSchema } from "@/lib/validators/client-team"; + +type Params = { params: Promise<{ teamId: string }> }; + +// PATCH /api/client-teams/:teamId +export async function PATCH(req: NextRequest, { params }: Params) { + const { session, error } = await requireAuth("CLIENT_TEAM_MANAGE"); + if (error) return error; + try { + const { teamId } = await params; + const body = await req.json(); + const parsed = updateClientTeamSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const team = await updateClientTeam( + teamId, + session.user.organizationId, + parsed.data + ); + return NextResponse.json(team); + } catch (e: any) { + if (e.message?.includes("not found")) return badRequest(e.message); + return serverError(e); + } +} + +// DELETE /api/client-teams/:teamId +export async function DELETE(_req: NextRequest, { params }: Params) { + const { session, error } = await requireAuth("CLIENT_TEAM_MANAGE"); + if (error) return error; + try { + const { teamId } = await params; + await deleteClientTeam(teamId, session.user.organizationId); + return NextResponse.json({ ok: true }); + } catch (e: any) { + if ( + e.message?.includes("not found") || + e.message?.includes("still has projects") + ) { + return badRequest(e.message); + } + return serverError(e); + } +} diff --git a/src/app/api/client-teams/route.ts b/src/app/api/client-teams/route.ts new file mode 100644 index 0000000..6c9723b --- /dev/null +++ b/src/app/api/client-teams/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { + listClientTeams, + createClientTeam, +} from "@/lib/services/client-team-service"; +import { createClientTeamSchema } from "@/lib/validators/client-team"; + +// GET /api/client-teams — list teams for the user's org. +// Any signed-in user can read the list (they need it for filter dropdowns +// and to know their own team). Scoping to visible projects happens elsewhere. +export async function GET() { + const { session, error } = await requireAuth(); + if (error) return error; + try { + const teams = await listClientTeams(session.user.organizationId); + return NextResponse.json(teams); + } catch (e) { + return serverError(e); + } +} + +// POST /api/client-teams — create a new team. Admin only. +export async function POST(req: NextRequest) { + const { session, error } = await requireAuth("CLIENT_TEAM_MANAGE"); + if (error) return error; + try { + const body = await req.json(); + const parsed = createClientTeamSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const team = await createClientTeam(session.user.organizationId, parsed.data); + return NextResponse.json(team, { status: 201 }); + } catch (e: any) { + if (e.code === "P2002") { + return badRequest("A team with that slug already exists in this organization"); + } + return serverError(e); + } +} diff --git a/src/app/api/pods/[podId]/members/route.ts b/src/app/api/pods/[podId]/members/route.ts new file mode 100644 index 0000000..e77df88 --- /dev/null +++ b/src/app/api/pods/[podId]/members/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { setUserHomePod } from "@/lib/services/pod-service"; +import { setHomePodSchema } from "@/lib/validators/pod"; + +type Params = { params: Promise<{ podId: string }> }; + +// POST /api/pods/:podId/members — set a user's home pod to this pod +export async function POST(req: NextRequest, { params }: Params) { + const { session, error } = await requireAuth("POD_MANAGE"); + if (error) return error; + try { + const { podId } = await params; + const body = await req.json(); + const parsed = setHomePodSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const result = await setUserHomePod( + parsed.data.userId, + session.user.organizationId, + podId + ); + return NextResponse.json(result); + } catch (e: any) { + if (e.message?.includes("not found")) return badRequest(e.message); + return serverError(e); + } +} + +// DELETE /api/pods/:podId/members?userId=... — unset home pod (User.homePodId → null) +export async function DELETE(req: NextRequest, { params }: Params) { + const { session, error } = await requireAuth("POD_MANAGE"); + if (error) return error; + try { + await params; // consume but unused — podId is implied by the user's current homePodId + const userId = new URL(req.url).searchParams.get("userId"); + if (!userId) return badRequest("userId query param is required"); + const result = await setUserHomePod(userId, session.user.organizationId, null); + return NextResponse.json(result); + } catch (e: any) { + if (e.message?.includes("not found")) return badRequest(e.message); + return serverError(e); + } +} diff --git a/src/app/api/pods/[podId]/route.ts b/src/app/api/pods/[podId]/route.ts new file mode 100644 index 0000000..cbc9b11 --- /dev/null +++ b/src/app/api/pods/[podId]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { updatePod, deletePod } from "@/lib/services/pod-service"; +import { updatePodSchema } from "@/lib/validators/pod"; + +type Params = { params: Promise<{ podId: string }> }; + +// PATCH /api/pods/:podId +export async function PATCH(req: NextRequest, { params }: Params) { + const { session, error } = await requireAuth("POD_MANAGE"); + if (error) return error; + try { + const { podId } = await params; + const body = await req.json(); + const parsed = updatePodSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const pod = await updatePod(podId, session.user.organizationId, parsed.data); + return NextResponse.json(pod); + } catch (e: any) { + if ( + e.message?.includes("not found") || + e.message?.includes("belong to this organization") + ) { + return badRequest(e.message); + } + return serverError(e); + } +} + +// DELETE /api/pods/:podId +export async function DELETE(_req: NextRequest, { params }: Params) { + const { session, error } = await requireAuth("POD_MANAGE"); + if (error) return error; + try { + const { podId } = await params; + await deletePod(podId, session.user.organizationId); + return NextResponse.json({ ok: true }); + } catch (e: any) { + if (e.message?.includes("not found")) return badRequest(e.message); + return serverError(e); + } +} diff --git a/src/app/api/pods/route.ts b/src/app/api/pods/route.ts new file mode 100644 index 0000000..11062f3 --- /dev/null +++ b/src/app/api/pods/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { listPods, createPod } from "@/lib/services/pod-service"; +import { createPodSchema } from "@/lib/validators/pod"; + +// GET /api/pods — any signed-in user can list pods (they need to know +// theirs and other pods' members for the capacity planner). +export async function GET() { + const { session, error } = await requireAuth(); + if (error) return error; + try { + const pods = await listPods(session.user.organizationId); + return NextResponse.json(pods); + } catch (e) { + return serverError(e); + } +} + +// POST /api/pods — admin only +export async function POST(req: NextRequest) { + const { session, error } = await requireAuth("POD_MANAGE"); + if (error) return error; + try { + const body = await req.json(); + const parsed = createPodSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const pod = await createPod(session.user.organizationId, parsed.data); + return NextResponse.json(pod, { status: 201 }); + } catch (e: any) { + if (e.code === "P2002") { + return badRequest("A pod with that slug already exists in this organization"); + } + if (e.message?.includes("belong to this organization")) { + return badRequest(e.message); + } + return serverError(e); + } +} diff --git a/src/hooks/use-client-teams.ts b/src/hooks/use-client-teams.ts new file mode 100644 index 0000000..9b7f52a --- /dev/null +++ b/src/hooks/use-client-teams.ts @@ -0,0 +1,88 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiUrl } from "@/lib/api-client"; + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(apiUrl(url), init); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed: ${res.status}`); + } + return res.json(); +} + +export function useClientTeams() { + return useQuery({ + queryKey: ["client-teams"], + queryFn: () => fetchJson("/api/client-teams"), + }); +} + +export function useCreateClientTeam() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string; slug?: string }) => + fetchJson("/api/client-teams", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["client-teams"] }), + }); +} + +export function useUpdateClientTeam() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string; name?: string }) => + fetchJson(`/api/client-teams/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["client-teams"] }), + }); +} + +export function useDeleteClientTeam() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + fetchJson(`/api/client-teams/${id}`, { method: "DELETE" }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["client-teams"] }), + }); +} + +export function useAddClientTeamMember() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + teamId, + userId, + isPrimary, + }: { + teamId: string; + userId: string; + isPrimary?: boolean; + }) => + fetchJson(`/api/client-teams/${teamId}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, isPrimary }), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["client-teams"] }), + }); +} + +export function useRemoveClientTeamMember() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ teamId, userId }: { teamId: string; userId: string }) => + fetchJson( + `/api/client-teams/${teamId}/members?userId=${encodeURIComponent(userId)}`, + { method: "DELETE" } + ), + onSuccess: () => qc.invalidateQueries({ queryKey: ["client-teams"] }), + }); +} diff --git a/src/hooks/use-pods.ts b/src/hooks/use-pods.ts new file mode 100644 index 0000000..928c772 --- /dev/null +++ b/src/hooks/use-pods.ts @@ -0,0 +1,98 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiUrl } from "@/lib/api-client"; + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(apiUrl(url), init); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed: ${res.status}`); + } + return res.json(); +} + +export function usePods() { + return useQuery({ + queryKey: ["pods"], + queryFn: () => fetchJson("/api/pods"), + }); +} + +export function useCreatePod() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string; slug?: string; leadUserId?: string | null }) => + fetchJson("/api/pods", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["pods"] }), + }); +} + +export function useUpdatePod() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + ...data + }: { + id: string; + name?: string; + leadUserId?: string | null; + }) => + fetchJson(`/api/pods/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pods"] }); + qc.invalidateQueries({ queryKey: ["users"] }); + }, + }); +} + +export function useDeletePod() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => fetchJson(`/api/pods/${id}`, { method: "DELETE" }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pods"] }); + qc.invalidateQueries({ queryKey: ["users"] }); + }, + }); +} + +export function useSetHomePod() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ podId, userId }: { podId: string; userId: string }) => + fetchJson(`/api/pods/${podId}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId }), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pods"] }); + qc.invalidateQueries({ queryKey: ["users"] }); + }, + }); +} + +export function useUnsetHomePod() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ podId, userId }: { podId: string; userId: string }) => + fetchJson( + `/api/pods/${podId}/members?userId=${encodeURIComponent(userId)}`, + { method: "DELETE" } + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pods"] }); + qc.invalidateQueries({ queryKey: ["users"] }); + }, + }); +} diff --git a/src/lib/services/client-team-service.ts b/src/lib/services/client-team-service.ts new file mode 100644 index 0000000..bb8d124 --- /dev/null +++ b/src/lib/services/client-team-service.ts @@ -0,0 +1,132 @@ +import { prisma } from "@/lib/prisma"; +import type { + CreateClientTeamInput, + UpdateClientTeamInput, +} from "@/lib/validators/client-team"; + +/** + * ClientTeam CRUD — drives per-team project visibility. A user's + * ClientTeamMembership rows gate which projects they see (see + * src/lib/rbac/visibility.ts). + * + * Slugs are the canonical identifier used at ingest time: the XLSX + * importer and the OMG webhook both resolve team names → slugs via + * normalizeTeamSlug() and then look up / auto-create the team by slug. + * Renaming a team is safe (just update .name); changing the slug is NOT + * — it will break the import path until the next re-run maps every + * project back. + */ + +export async function listClientTeams(organizationId: string) { + return prisma.clientTeam.findMany({ + where: { organizationId }, + include: { + _count: { select: { projects: true, userMemberships: true } }, + userMemberships: { + include: { + user: { + select: { id: true, name: true, email: true, role: true, isExternal: true }, + }, + }, + }, + }, + orderBy: { name: "asc" }, + }); +} + +export async function createClientTeam( + organizationId: string, + input: CreateClientTeamInput +) { + const slug = (input.slug ?? slugify(input.name)).trim(); + return prisma.clientTeam.create({ + data: { organizationId, name: input.name.trim(), slug }, + include: { + _count: { select: { projects: true, userMemberships: true } }, + }, + }); +} + +export async function updateClientTeam( + id: string, + organizationId: string, + input: UpdateClientTeamInput +) { + // Scope the update to this org so a stray ID from another tenant can't + // be edited. Prisma's update needs a unique where; guard via findFirst. + const existing = await prisma.clientTeam.findFirst({ + where: { id, organizationId }, + select: { id: true }, + }); + if (!existing) throw new Error("Client team not found"); + + return prisma.clientTeam.update({ + where: { id }, + data: { ...(input.name ? { name: input.name.trim() } : {}) }, + }); +} + +export async function deleteClientTeam(id: string, organizationId: string) { + const existing = await prisma.clientTeam.findFirst({ + where: { id, organizationId }, + select: { id: true, _count: { select: { projects: true } } }, + }); + if (!existing) throw new Error("Client team not found"); + if (existing._count.projects > 0) { + throw new Error( + "Cannot delete a team that still has projects. Move or delete the projects first." + ); + } + + // ClientTeamMembership rows cascade via onDelete: Cascade in schema. + await prisma.clientTeam.delete({ where: { id } }); + return { ok: true }; +} + +export async function addMember( + teamId: string, + organizationId: string, + userId: string, + isPrimary = false +) { + // Verify team belongs to this org + const team = await prisma.clientTeam.findFirst({ + where: { id: teamId, organizationId }, + select: { id: true }, + }); + if (!team) throw new Error("Client team not found"); + + return prisma.clientTeamMembership.upsert({ + where: { userId_clientTeamId: { userId, clientTeamId: teamId } }, + update: { isPrimary }, + create: { userId, clientTeamId: teamId, isPrimary }, + }); +} + +export async function removeMember( + teamId: string, + organizationId: string, + userId: string +) { + const team = await prisma.clientTeam.findFirst({ + where: { id: teamId, organizationId }, + select: { id: true }, + }); + if (!team) throw new Error("Client team not found"); + + await prisma.clientTeamMembership + .delete({ + where: { userId_clientTeamId: { userId, clientTeamId: teamId } }, + }) + .catch(() => { + // Idempotent — no-op if the membership never existed + }); + return { ok: true }; +} + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} diff --git a/src/lib/services/pod-service.ts b/src/lib/services/pod-service.ts new file mode 100644 index 0000000..655949a --- /dev/null +++ b/src/lib/services/pod-service.ts @@ -0,0 +1,127 @@ +import { prisma } from "@/lib/prisma"; +import type { CreatePodInput, UpdatePodInput } from "@/lib/validators/pod"; + +/** + * Pod CRUD — production pods for capacity planning. Orthogonal to + * ClientTeam (which gates visibility): pods group internal staff for + * resourcing. A user has one homePodId but may work on any client team's + * projects. + */ + +export async function listPods(organizationId: string) { + return prisma.pod.findMany({ + where: { organizationId }, + include: { + leadUser: { select: { id: true, name: true, email: true } }, + members: { + select: { id: true, name: true, email: true, role: true, department: true }, + orderBy: { name: "asc" }, + }, + }, + orderBy: { name: "asc" }, + }); +} + +export async function createPod(organizationId: string, input: CreatePodInput) { + const slug = (input.slug ?? slugify(input.name)).trim(); + + // If leadUserId is set, verify the user belongs to this org to prevent + // a stray ID from another tenant bypassing scope. + if (input.leadUserId) { + const lead = await prisma.user.findFirst({ + where: { id: input.leadUserId, organizationId }, + select: { id: true }, + }); + if (!lead) throw new Error("Pod lead must belong to this organization"); + } + + return prisma.pod.create({ + data: { + organizationId, + name: input.name.trim(), + slug, + leadUserId: input.leadUserId ?? null, + }, + include: { + leadUser: { select: { id: true, name: true, email: true } }, + }, + }); +} + +export async function updatePod( + id: string, + organizationId: string, + input: UpdatePodInput +) { + const existing = await prisma.pod.findFirst({ + where: { id, organizationId }, + select: { id: true }, + }); + if (!existing) throw new Error("Pod not found"); + + if (input.leadUserId !== undefined && input.leadUserId !== null) { + const lead = await prisma.user.findFirst({ + where: { id: input.leadUserId, organizationId }, + select: { id: true }, + }); + if (!lead) throw new Error("Pod lead must belong to this organization"); + } + + return prisma.pod.update({ + where: { id }, + data: { + ...(input.name ? { name: input.name.trim() } : {}), + ...(input.leadUserId !== undefined ? { leadUserId: input.leadUserId } : {}), + }, + }); +} + +export async function deletePod(id: string, organizationId: string) { + const existing = await prisma.pod.findFirst({ + where: { id, organizationId }, + select: { id: true, _count: { select: { members: true } } }, + }); + if (!existing) throw new Error("Pod not found"); + + // Detach members (User.homePodId) instead of cascading — pods are + // labels for people, people aren't owned by pods. + await prisma.user.updateMany({ + where: { homePodId: id }, + data: { homePodId: null }, + }); + await prisma.pod.delete({ where: { id } }); + return { ok: true }; +} + +export async function setUserHomePod( + userId: string, + organizationId: string, + podId: string | null +) { + const user = await prisma.user.findFirst({ + where: { id: userId, organizationId }, + select: { id: true }, + }); + if (!user) throw new Error("User not found"); + + if (podId) { + const pod = await prisma.pod.findFirst({ + where: { id: podId, organizationId }, + select: { id: true }, + }); + if (!pod) throw new Error("Pod not found"); + } + + return prisma.user.update({ + where: { id: userId }, + data: { homePodId: podId }, + select: { id: true, homePodId: true }, + }); +} + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} diff --git a/src/lib/validators/client-team.ts b/src/lib/validators/client-team.ts new file mode 100644 index 0000000..06dc24d --- /dev/null +++ b/src/lib/validators/client-team.ts @@ -0,0 +1,25 @@ +import { z } from "zod/v4"; + +export const createClientTeamSchema = z.object({ + name: z.string().trim().min(1, "Name is required").max(80), + slug: z + .string() + .trim() + .regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and dashes") + .min(1) + .max(60) + .optional(), +}); + +export const updateClientTeamSchema = z.object({ + name: z.string().trim().min(1).max(80).optional(), +}); + +export const clientTeamMembershipSchema = z.object({ + userId: z.string().min(1), + isPrimary: z.boolean().optional(), +}); + +export type CreateClientTeamInput = z.infer; +export type UpdateClientTeamInput = z.infer; +export type ClientTeamMembershipInput = z.infer; diff --git a/src/lib/validators/pod.ts b/src/lib/validators/pod.ts new file mode 100644 index 0000000..fcbb1e8 --- /dev/null +++ b/src/lib/validators/pod.ts @@ -0,0 +1,25 @@ +import { z } from "zod/v4"; + +export const createPodSchema = z.object({ + name: z.string().trim().min(1, "Name is required").max(80), + slug: z + .string() + .trim() + .regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and dashes") + .min(1) + .max(60) + .optional(), + leadUserId: z.string().min(1).nullable().optional(), +}); + +export const updatePodSchema = z.object({ + name: z.string().trim().min(1).max(80).optional(), + leadUserId: z.string().min(1).nullable().optional(), +}); + +export const setHomePodSchema = z.object({ + userId: z.string().min(1), +}); + +export type CreatePodInput = z.infer; +export type UpdatePodInput = z.infer;