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:
parent
69f293682a
commit
4361d4cd2a
15 changed files with 1346 additions and 8 deletions
268
src/app/(app)/settings/client-teams/page.tsx
Normal file
268
src/app/(app)/settings/client-teams/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
290
src/app/(app)/settings/pods/page.tsx
Normal file
290
src/app/(app)/settings/pods/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/app/api/client-teams/[teamId]/members/route.ts
Normal file
47
src/app/api/client-teams/[teamId]/members/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
52
src/app/api/client-teams/[teamId]/route.ts
Normal file
52
src/app/api/client-teams/[teamId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
42
src/app/api/client-teams/route.ts
Normal file
42
src/app/api/client-teams/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/app/api/pods/[podId]/members/route.ts
Normal file
46
src/app/api/pods/[podId]/members/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
45
src/app/api/pods/[podId]/route.ts
Normal file
45
src/app/api/pods/[podId]/route.ts
Normal 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
41
src/app/api/pods/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
88
src/hooks/use-client-teams.ts
Normal file
88
src/hooks/use-client-teams.ts
Normal 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
98
src/hooks/use-pods.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
132
src/lib/services/client-team-service.ts
Normal file
132
src/lib/services/client-team-service.ts
Normal 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, "");
|
||||
}
|
||||
127
src/lib/services/pod-service.ts
Normal file
127
src/lib/services/pod-service.ts
Normal 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, "");
|
||||
}
|
||||
25
src/lib/validators/client-team.ts
Normal file
25
src/lib/validators/client-team.ts
Normal 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
25
src/lib/validators/pod.ts
Normal 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>;
|
||||
Loading…
Add table
Reference in a new issue