"use client"; /** * use-members — workspace member operations (role change, remove, invite). * * Why not the core `workspaceApi`? * - `workspaceApi.invite` POSTs to `/workspaces/:id/invites`, but the controller * only exposes `POST /workspaces/:id/members/invites` (the core path 404s — * flagged to the core agent). There is also no `GET /workspaces/:id/invites` * endpoint, so the "pending invites" list can't be backed by an API call. * - The core client has no member role-change or member-remove method. * * The API is the source of truth for the wire: role values are the UPPERCASE * Prisma enum (OWNER / ADMIN / MEMBER / GUEST) with no normalizing serializer, * while the core `WorkspaceRole` type is lowercase. Per the architecture doc * ("record the conflict rather than guessing") this hook speaks UPPERCASE on the * wire (matches the real API + zod `WORKSPACE_ROLES`) and offers `roleLabel` for * display. Role *writes* here are therefore UPPERCASE — note this is the opposite * case from the core type; when the core client is fixed to the real endpoints it * should align to this wire casing (or a serializer should normalize both). */ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; /** Roles that can be assigned to a member via PATCH …/members/:userId. * OWNER is never assignable here — ownership changes only via workspace transfer. */ export const ASSIGNABLE_ROLES = ["ADMIN", "MEMBER", "GUEST"] as const; export type AssignableRole = (typeof ASSIGNABLE_ROLES)[number]; export const ROLE_LABELS: Record = { OWNER: "Owner", ADMIN: "Admin", MEMBER: "Member", GUEST: "Guest" }; /** Human label for any role casing (UPPERCASE from the wire, lowercase from core types). */ export function roleLabel(role: string): string { if (!role) return role; const key = role.toUpperCase(); return ROLE_LABELS[key] ?? key.charAt(0).toUpperCase() + key.slice(1).toLowerCase(); } export interface UseMembersArgs { workspaceId: string; } /** Change a member's role. Owner-only change is not allowed (transfer only). */ export function useUpdateMemberRole({ workspaceId }: UseMembersArgs) { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: { userId: string; role: AssignableRole }): Promise => { await apiFetch(`/workspaces/${workspaceId}/members/${input.userId}`, { method: "PATCH", body: { role: input.role } }); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "members"] }); } }); } /** Remove a member from the workspace. */ export function useRemoveMember({ workspaceId }: UseMembersArgs) { const qc = useQueryClient(); return useMutation({ mutationFn: async (userId: string): Promise => { await apiFetch(`/workspaces/${workspaceId}/members/${userId}`, { method: "DELETE" }); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "members"] }); } }); } /** Invite a member by email. Role must be an assignable role (not OWNER). */ export function useInviteMember({ workspaceId }: UseMembersArgs) { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: { email: string; role: AssignableRole }): Promise => { await apiFetch(`/workspaces/${workspaceId}/members/invites`, { method: "POST", body: { email: input.email, role: input.role } }); }, onSuccess: () => { // The invited user isn't a member until they accept; the members list // won't change, but the invites state (client-side) is reset by the caller. qc.invalidateQueries({ queryKey: ["workspaces", workspaceId] }); } }); }