Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions app/api/cases/[id]/publish/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
requireAuthSession,
serviceErrorToAppError,
} from "@/lib/api-response";
import { validationError } from "@/lib/errors";
import { publishCaseBodySchema } from "@/lib/schemas/publish";
import { requireCaseInformationComplete } from "@/lib/services/case-information-service";
import {
getPublishStatus,
publishAssuranceCase,
Expand Down Expand Up @@ -48,7 +51,13 @@ export async function GET(
/**
* POST /api/cases/[id]/publish
* Publishes an assurance case.
* Body: { description?: string }
* Body: { description?: string } (optional — no body at all is valid)
*
* Gated on case-information completeness (ADR 0003 §4 — "the admission
* ticket to Discover"): the guided publish flow in the case editor already
* runs this same check before it ever shows a confirm step, so a 400 here
* means either a direct API call bypassing that flow, or the record
* changed after the client checked it.
*/
export async function POST(
request: NextRequest,
Expand All @@ -58,13 +67,28 @@ export async function POST(
const session = await requireAuthSession();
const { id: caseId } = await params;

// Parse request body
let description: string | undefined;
try {
const body = await request.json();
description = body.description;
} catch {
// Body is optional, ignore parse errors
// Body is optional — an empty/absent body parses to {} and validates
// fine, since `description` itself is optional.
const parsed = publishCaseBodySchema.safeParse(
await request.json().catch(() => ({}))
);
if (!parsed.success) {
return apiError(
validationError(parsed.error.issues[0]?.message ?? "Invalid input")
);
}
const { description } = parsed.data;

const completeness = await requireCaseInformationComplete(
session.userId,
caseId
);
if ("error" in completeness) {
return apiError(
completeness.fieldErrors
? validationError(completeness.error, completeness.fieldErrors)
: serviceErrorToAppError(completeness.error)
);
}

const result = await publishAssuranceCase(
Expand Down
23 changes: 23 additions & 0 deletions app/api/cases/[id]/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@/lib/api-response";
import { validationError } from "@/lib/errors";
import { updateCaseStatusSchema } from "@/lib/schemas/status";
import { requireCaseInformationComplete } from "@/lib/services/case-information-service";
import {
getFullPublishStatus,
transitionStatus,
Expand Down Expand Up @@ -60,6 +61,17 @@ export async function GET(
* - DRAFT -> PUBLISHED (publish)
* - PUBLISHED -> DRAFT (unpublish)
* - PUBLISHED -> PUBLISHED (republish: fresh snapshot, same slug)
*
* DRAFT -> PUBLISHED and republish (PUBLISHED -> PUBLISHED) are both gated
* on case-information completeness (ADR 0003 §4), via the same
* `requireCaseInformationComplete` helper `POST /api/cases/[id]/publish`
* uses for its own first-publish path. The case editor's guided publish flow
* always goes through the dedicated publish route, which already gated this
* — but this route is a raw API surface too (QA finding, 2026-08-11: a
* direct PATCH here bypassed the gate for first publish, since the check
* used to run `if (isRepublish)` only). Republish is gated for the same
* reason it always was: without it, a published record could regress to
* incomplete via an edit that clears a required field then a republish.
*/
export async function PATCH(
request: Request,
Expand All @@ -80,6 +92,17 @@ export async function PATCH(

const { targetStatus, description } = parsed.data;

if (targetStatus === "PUBLISHED") {
const completeness = await requireCaseInformationComplete(userId, id);
if ("error" in completeness) {
return apiError(
completeness.fieldErrors
? validationError(completeness.error, completeness.fieldErrors)
: serviceErrorToAppError(completeness.error)
);
}
}

const result = await transitionStatus(
userId,
id,
Expand Down
121 changes: 121 additions & 0 deletions components/cases/__tests__/header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useStatusModal } from "@/hooks/use-status-modal";
import useStore from "@/store/store";
import Header from "../header";

// Minimal local replacement for the two reactflow hooks Header calls
// directly (`useReactFlow` for `setCenter`, `useUpdateNodeInternals`) —
// unrelated to the click behaviour under test, same pattern as
// node-options-menu.test.tsx.
vi.mock("reactflow", () => ({
useReactFlow: () => ({ setCenter: vi.fn() }),
useUpdateNodeInternals: () => vi.fn(),
}));

// This header's own `useChangeDetection` call is unrelated to the click
// behaviour under test — stub it so no network activity is needed to prove
// the click handler doesn't gate on one.
vi.mock("@/hooks/use-change-detection", () => ({
useChangeDetection: () => ({
hasChanges: false,
publishedAt: null,
publishedId: null,
changeSummary: null,
isLoading: false,
error: null,
refresh: vi.fn(),
}),
}));

function resetStore(published: boolean): void {
useStore.setState({
assuranceCase: {
id: "case-1",
name: "Test Case",
type: "assurance-case",
permissions: "manage",
createdDate: new Date().toISOString(),
comments: [],
published,
publishStatus: published ? "PUBLISHED" : "DRAFT",
publishedAt: published ? "2026-08-01T00:00:00.000Z" : null,
linkedCaseStudyCount: 0,
},
});
useStatusModal.getState().onClose();
}

function renderHeader() {
return render(<Header setOpen={vi.fn()} />);
}

describe("Header — status button opens the dialog immediately (no synchronous GET /status)", () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
resetStore(false);
fetchSpy = vi.spyOn(global, "fetch");
});

afterEach(() => {
fetchSpy.mockRestore();
});

it("opens the status modal on click for a draft case without ever calling GET /status", async () => {
const user = userEvent.setup();
renderHeader();

await user.click(screen.getByRole("button", { name: "Draft" }));

expect(useStatusModal.getState().isOpen).toBe(true);
expect(useStatusModal.getState().caseId).toBe("case-1");
expect(useStatusModal.getState().status).toBe("DRAFT");

const statusCalls = fetchSpy.mock.calls.filter((call: unknown[]) =>
String(call[0]).includes("/status")
);
expect(statusCalls).toHaveLength(0);
});

it("opens the status modal on click for a published case, precomputed from already-known state, without calling GET /status", async () => {
resetStore(true);
const user = userEvent.setup();
renderHeader();

await user.click(screen.getByRole("button", { name: "Published" }));

expect(useStatusModal.getState().isOpen).toBe(true);
expect(useStatusModal.getState().status).toBe("PUBLISHED");
expect(useStatusModal.getState().publishedAt).toBe(
"2026-08-01T00:00:00.000Z"
);

const statusCalls = fetchSpy.mock.calls.filter((call: unknown[]) =>
String(call[0]).includes("/status")
);
expect(statusCalls).toHaveLength(0);
});

it("does nothing for a user without edit permission", async () => {
useStore.setState({
assuranceCase: {
id: "case-1",
name: "Test Case",
type: "assurance-case",
permissions: "view",
createdDate: new Date().toISOString(),
comments: [],
published: false,
publishStatus: "DRAFT",
},
});
const user = userEvent.setup();
renderHeader();

await user.click(screen.getByRole("button", { name: "Draft" }));

expect(useStatusModal.getState().isOpen).toBe(false);
});
});
30 changes: 29 additions & 1 deletion components/cases/case-information-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
CaseInformationInput,
} from "@/lib/schemas/case-information";
import { upsertCaseInformationSchema } from "@/lib/schemas/case-information";
import useStore from "@/store/store";
import { Button } from "../ui/button";

interface CaseInformationSectionProps {
Expand Down Expand Up @@ -63,7 +64,7 @@ export function CaseInformationSection({
featureImageUrl: "",
},
});
const { reset } = form;
const { reset, setFocus, getValues } = form;

// Sync fetched values into the form once they arrive (the form mounts
// before the fetch resolves).
Expand All @@ -76,6 +77,33 @@ export function CaseInformationSection({
});
}, [information, reset]);

// The publish flow (ADR 0003 §2) sends the user here with a specific
// missing field named — focus it once the fetched values have synced in,
// then clear the request so a later manual open of this sheet doesn't
// re-focus anything.
const caseInformationFocusField = useStore(
(state) => state.caseInformationFocusField
);
const setCaseInformationFocusField = useStore(
(state) => state.setCaseInformationFocusField
);
useEffect(() => {
if (loading || !(canEdit && caseInformationFocusField)) {
return;
}
if (caseInformationFocusField in getValues()) {
setFocus(caseInformationFocusField as keyof CaseInformationInput);
}
setCaseInformationFocusField(null);
}, [
loading,
canEdit,
caseInformationFocusField,
getValues,
setFocus,
setCaseInformationFocusField,
]);

const onSubmit = async (values: CaseInformationData) => {
await save(values);
};
Expand Down
40 changes: 16 additions & 24 deletions components/cases/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ const Header = ({ setOpen }: HeaderProps) => {
const [newCaseName, setNewCaseName] = useState<string>(
assuranceCase?.name || ""
);
const [statusLoading, setStatusLoading] = useState(false);
const _inputRef = useRef<HTMLInputElement>(null);

const { setCenter } = useReactFlow();
Expand Down Expand Up @@ -142,32 +141,26 @@ const Header = ({ setOpen }: HeaderProps) => {
assuranceCase?.permissions === "manage" ||
assuranceCase?.permissions === "edit";

const handleStatusButtonClick = async () => {
// Opens the status/publish dialog immediately using state already known
// (from `assuranceCase` and this header's own `useChangeDetection` call
// above) rather than gating the open on a fetch. `GET /api/cases/[id]/
// status` runs a full export + change-detection synchronously when a
// published snapshot exists (`getFullPublishStatus`), which can take
// several seconds — awaiting it before opening was the bug this issue's
// hard requirement fixes. `StatusModalWrapper` loads any state that's
// still asynchronous (divergence) after the dialog is already visible.
const handleStatusButtonClick = () => {
if (!(assuranceCase?.id && canEditCase)) {
return;
}

setStatusLoading(true);

try {
// Fetch the full status info from the API
const response = await fetch(`/api/cases/${assuranceCase.id}/status`);
const data = await response.json();

if (response.ok) {
statusModal.onOpen({
caseId: assuranceCase.id,
status: data.publishStatus ?? currentStatus,
hasChanges: data.hasChanges ?? hasChanges,
publishedAt: data.publishedAt ?? assuranceCase.publishedAt,
linkedCaseStudyCount: data.linkedCaseStudyCount ?? 0,
});
}
} catch {
// Silently fail - user can try clicking again
} finally {
setStatusLoading(false);
}
statusModal.onOpen({
caseId: assuranceCase.id,
status: currentStatus,
hasChanges,
publishedAt: assuranceCase.publishedAt,
linkedCaseStudyCount: assuranceCase.linkedCaseStudyCount ?? 0,
});
};

const publishedAt = assuranceCase?.publishedAt;
Expand Down Expand Up @@ -206,7 +199,6 @@ const Header = ({ setOpen }: HeaderProps) => {
<StatusButton
disabled={!canEditCase}
hasChanges={hasChanges}
loading={statusLoading}
onClick={handleStatusButtonClick}
publishedAt={publishedAt}
status={currentStatus}
Expand Down
Loading
Loading