Phase 6e: ClientTeam + Pod CRUD — settings pages and APIs

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) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-20 19:25:29 -04:00
parent 69f293682a
commit 4361d4cd2a
15 changed files with 1346 additions and 8 deletions

View file

@ -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<any[]>;
},
});
const createTeam = useCreateClientTeam();
const deleteTeam = useDeleteClientTeam();
const addMember = useAddClientTeamMember();
const removeMember = useRemoveClientTeamMember();
const [newTeamName, setNewTeamName] = useState("");
const [memberSelectByTeam, setMemberSelectByTeam] = useState<Record<string, string>>({});
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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Layers className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">Client Teams</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Groups that drive per-team project visibility. Users only see projects
belonging to the teams they're members of.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Plus className="h-4 w-4" />
<span className="label-upper">New team</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Team name (e.g. Brand, Events, B2B)..."
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="h-8 text-sm"
/>
<Button
size="sm"
className="h-8 shrink-0"
onClick={handleCreate}
disabled={!newTeamName.trim() || createTeam.isPending}
>
Create
</Button>
</div>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{isLoading ? (
<>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full" />
))}
</>
) : teams?.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No client teams yet create one above.
</CardContent>
</Card>
) : (
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 (
<Card key={team.id}>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-sm font-semibold">
<div className="flex items-center gap-2">
<Users className="h-4 w-4" />
<span>{team.name}</span>
<Badge variant="outline" className="h-4 px-1 text-[9px]">
{team.slug}
</Badge>
</div>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-[var(--muted-foreground)] hover:text-red-500"
onClick={() => handleDelete(team.id, team.name)}
>
<Trash2 className="h-3 w-3" />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-3 text-[11px] text-[var(--muted-foreground)]">
<span>
{team._count?.userMemberships ?? 0} member
{(team._count?.userMemberships ?? 0) !== 1 ? "s" : ""}
</span>
<span>·</span>
<span>
{team._count?.projects ?? 0} project
{(team._count?.projects ?? 0) !== 1 ? "s" : ""}
</span>
</div>
<div className="flex gap-2">
<Select
value={selected}
onValueChange={(v) =>
setMemberSelectByTeam((s) => ({ ...s, [team.id]: v }))
}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="Add a user…" />
</SelectTrigger>
<SelectContent>
{candidates.length === 0 ? (
<SelectItem value="_none" disabled>
All users already added
</SelectItem>
) : (
candidates.map((u: any) => (
<SelectItem key={u.id} value={u.id}>
{u.name || u.email}
{u.isExternal ? " (client)" : ""}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
size="sm"
className="h-8 shrink-0"
disabled={!selected || addMember.isPending}
onClick={() => handleAddMember(team.id)}
>
<UserPlus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
<div className="space-y-1">
{(team.userMemberships ?? []).length === 0 ? (
<p className="py-2 text-[11px] text-[var(--muted-foreground)]">
No members. Users without a team membership won't see any
projects on this team.
</p>
) : (
(team.userMemberships ?? []).map((m: any) => (
<div
key={m.user.id}
className="flex items-center justify-between rounded border px-2 py-1 text-xs"
>
<div className="flex items-center gap-2">
<span>{m.user.name || m.user.email}</span>
{m.user.isExternal && (
<Badge
variant="outline"
className="h-4 px-1 text-[9px] text-amber-600"
>
client
</Badge>
)}
<span className="text-[10px] text-[var(--muted-foreground)]">
{m.user.email}
</span>
</div>
<Button
size="icon"
variant="ghost"
className="h-5 w-5 text-[var(--muted-foreground)] hover:text-red-500"
onClick={() => handleRemoveMember(team.id, m.user.id)}
>
<UserMinus className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
);
})
)}
</div>
</div>
);
}

View file

@ -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",

View file

@ -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<any[]>;
},
});
const createPod = useCreatePod();
const updatePod = useUpdatePod();
const deletePod = useDeletePod();
const setHomePod = useSetHomePod();
const unsetHomePod = useUnsetHomePod();
const [newPodName, setNewPodName] = useState("");
const [memberSelect, setMemberSelect] = useState<Record<string, string>>({});
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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Users2 className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">Pods</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Production pods for capacity planning. A user has one home pod but can
work on projects across any client team.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Plus className="h-4 w-4" />
<span className="label-upper">New pod</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Pod name (e.g. Sergio's Pod)..."
value={newPodName}
onChange={(e) => setNewPodName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="h-8 text-sm"
/>
<Button
size="sm"
className="h-8 shrink-0"
onClick={handleCreate}
disabled={!newPodName.trim() || createPod.isPending}
>
Create
</Button>
</div>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{isLoading ? (
<>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full" />
))}
</>
) : pods?.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No pods yet create one above.
</CardContent>
</Card>
) : (
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 (
<Card key={pod.id}>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-sm font-semibold">
<div className="flex items-center gap-2">
<Users2 className="h-4 w-4" />
<span>{pod.name}</span>
<Badge variant="outline" className="h-4 px-1 text-[9px]">
{pod.slug}
</Badge>
</div>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-[var(--muted-foreground)] hover:text-red-500"
onClick={() => handleDelete(pod.id, pod.name)}
>
<Trash2 className="h-3 w-3" />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Lead selector */}
<div className="space-y-1">
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Pod lead
</label>
<Select
value={pod.leadUserId ?? "_none"}
onValueChange={(v) => handleSetLead(pod.id, v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="No lead" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"> no lead </SelectItem>
{internalUsers.map((u: any) => (
<SelectItem key={u.id} value={u.id}>
{u.name || u.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Add member */}
<div className="flex gap-2">
<Select
value={selected}
onValueChange={(v) =>
setMemberSelect((s) => ({ ...s, [pod.id]: v }))
}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="Add a member…" />
</SelectTrigger>
<SelectContent>
{candidates.length === 0 ? (
<SelectItem value="_none" disabled>
All internal users have a pod
</SelectItem>
) : (
candidates.map((u: any) => (
<SelectItem key={u.id} value={u.id}>
{u.name || u.email}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
size="sm"
className="h-8 shrink-0"
disabled={!selected || setHomePod.isPending}
onClick={() => handleAddMember(pod.id)}
>
<UserPlus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
{/* Member list */}
<div className="space-y-1">
{(pod.members ?? []).length === 0 ? (
<p className="py-2 text-[11px] text-[var(--muted-foreground)]">
No members yet.
</p>
) : (
(pod.members ?? []).map((u: any) => (
<div
key={u.id}
className="flex items-center justify-between rounded border px-2 py-1 text-xs"
>
<div className="flex items-center gap-2">
{u.id === pod.leadUserId && (
<Crown className="h-3 w-3 text-amber-500" />
)}
<span>{u.name || u.email}</span>
{u.department && (
<span className="text-[10px] text-[var(--muted-foreground)]">
· {u.department}
</span>
)}
</div>
<Button
size="icon"
variant="ghost"
className="h-5 w-5 text-[var(--muted-foreground)] hover:text-red-500"
onClick={() => handleRemoveMember(pod.id, u.id)}
>
<UserMinus className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
);
})
)}
</div>
</div>
);
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

41
src/app/api/pods/route.ts Normal file
View file

@ -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);
}
}

View file

@ -0,0 +1,88 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiUrl } from "@/lib/api-client";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
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<any[]>("/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"] }),
});
}

98
src/hooks/use-pods.ts Normal file
View file

@ -0,0 +1,98 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiUrl } from "@/lib/api-client";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
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<any[]>("/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"] });
},
});
}

View file

@ -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, "");
}

View file

@ -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, "");
}

View file

@ -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<typeof createClientTeamSchema>;
export type UpdateClientTeamInput = z.infer<typeof updateClientTeamSchema>;
export type ClientTeamMembershipInput = z.infer<typeof clientTeamMembershipSchema>;

25
src/lib/validators/pod.ts Normal file
View file

@ -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<typeof createPodSchema>;
export type UpdatePodInput = z.infer<typeof updatePodSchema>;