Files
kandev/apps/web/lib/api/domains/office-status-gate.ts
T

101 lines
4.5 KiB
TypeScript

// Shared status mutation for office tasks, plus the translation of the one
// gate it can trip.
//
// The backend refuses an `in_review -> done` transition with 409 and a
// `pending_approvers` body whenever a reviewer has not signed off. A caller
// that does not translate that shows a bare "Update failed" and the user has
// no idea who they are waiting on. This lived inside status-picker.tsx while
// the picker was the only way to change a status; the task board's drag
// handler is the second, so it moved here rather than being copied.
import { ApiError } from "../client";
import { updateTask } from "./office-extended-api";
import { t } from "@/lib/i18n";
import type { OfficeTaskStatus } from "@/lib/state/slices/office/types";
import type { TaskStatus } from "@/app/office/tasks/[id]/types";
export type PendingApprover = { agent_profile_id?: string; name?: string };
type ApprovalGateReason = "approvals" | "workflow_step";
// Builds the message the user sees when the approver gate rejects the move.
// Names render in the order the backend echoed them.
export function formatPendingApproversMessage(pending: PendingApprover[]): string {
const names = pending.map((p) => p.name?.trim() || p.agent_profile_id || "").filter(Boolean);
if (names.length === 0) return t("task:cannotMarkDoneAwaitingApprovals");
return t("task:cannotMarkDoneAwaitingApprovalFrom", { names: names.join(", ") });
}
export function extractPendingApprovers(err: unknown): PendingApprover[] | null {
if (!(err instanceof ApiError)) return null;
if (err.status !== 409) return null;
const body = err.body;
if (!body || typeof body !== "object") return null;
const pending = (body as { pending_approvers?: unknown }).pending_approvers;
if (!Array.isArray(pending)) return null;
return pending.filter((p): p is PendingApprover => !!p && typeof p === "object");
}
function extractRedirectedStatus(err: unknown): (OfficeTaskStatus | TaskStatus) | null {
if (!(err instanceof ApiError)) return null;
const body = err.body;
if (!body || typeof body !== "object") return null;
const status = (body as { status?: unknown }).status;
return typeof status === "string" && status.length > 0
? (status as OfficeTaskStatus | TaskStatus)
: null;
}
function extractGateReason(err: unknown): ApprovalGateReason | null {
if (!(err instanceof ApiError)) return null;
const body = err.body;
if (!body || typeof body !== "object") return null;
const reason = (body as { reason?: unknown }).reason;
return reason === "approvals" || reason === "workflow_step" ? reason : null;
}
// Thrown instead of a plain Error when the approver gate fires. The backend
// does not reject a gated "done" move: it redirects the task to in_review,
// persists and broadcasts that, and only then returns 409 (see
// respondStatusUpdateError in the backend handler). A caller that rolls back
// to its pre-mutation snapshot on any thrown error would show a status the
// server no longer holds, so this carries the server's actual redirected
// status through instead of discarding it.
export class ApprovalGateError extends Error {
readonly redirectedStatus: OfficeTaskStatus | TaskStatus;
constructor(message: string, redirectedStatus: OfficeTaskStatus | TaskStatus) {
super(message);
this.name = "ApprovalGateError";
this.redirectedStatus = redirectedStatus;
}
}
// Sets a task's status, re-throwing the approver gate as an ApprovalGateError
// so every caller surfaces the same sentence and can settle on the status the
// server actually redirected to instead of blindly rolling back.
export async function updateTaskStatusOrTranslateGate(
taskId: string,
status: OfficeTaskStatus | TaskStatus,
): Promise<void> {
try {
await updateTask(taskId, { status });
} catch (err) {
if (extractGateReason(err) === "workflow_step") {
const redirectedStatus = extractRedirectedStatus(err) ?? "in_review";
throw new ApprovalGateError(
t("task:cannotMarkDoneBeforeFinalWorkflowStep"),
redirectedStatus,
);
}
const pending = extractPendingApprovers(err);
if (pending) {
// The gate always redirects to in_review today (see applyApprovalGate
// in the backend); reading it from the response body rather than
// hardcoding it here keeps this in sync if the backend ever changes.
const redirectedStatus = extractRedirectedStatus(err) ?? "in_review";
throw new ApprovalGateError(formatPendingApproversMessage(pending), redirectedStatus);
}
throw err;
}
}