feat: implement final approval gate workflow and checklist

This commit is contained in:
2026-05-22 00:31:48 +03:00
parent 6dcbe0e36c
commit d3990b4019
15 changed files with 1851 additions and 14 deletions
@@ -0,0 +1,54 @@
import type {
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
} from "@pipeline/shared";
export type FinalApprovalChecklistItemViewModel = {
key: string;
title: string;
completed: boolean;
blocker: string;
};
export type FinalApprovalViewModel = {
canApprove: boolean;
completedItems: string[];
blockers: string[];
draftVersion: number | null;
requestedDraftVersion: number | null;
items: FinalApprovalChecklistItemViewModel[];
};
export function buildFinalApprovalViewModel(
checklist: FinalApprovalChecklistResponse | null,
): FinalApprovalViewModel {
if (!checklist) {
return {
canApprove: false,
completedItems: [],
blockers: [],
draftVersion: null,
requestedDraftVersion: null,
items: [],
};
}
return {
canApprove: checklist.can_approve,
completedItems: checklist.completed_items ?? [],
blockers: checklist.blockers ?? [],
draftVersion: checklist.draft_version ?? null,
requestedDraftVersion: checklist.requested_draft_version ?? null,
items: (checklist.items ?? []).map(mapChecklistItem),
};
}
function mapChecklistItem(
item: FinalApprovalChecklistItemSummary,
): FinalApprovalChecklistItemViewModel {
return {
key: item.key,
title: item.title,
completed: item.completed,
blocker: item.blocker ?? "",
};
}
@@ -0,0 +1,205 @@
"use client";
import { useMemo, useState } from "react";
import type { FinalApprovalChecklistResponse } from "@pipeline/shared";
import {
ApiError,
fetchFinalApprovalChecklist,
submitFinalApproval,
submitFinalRevisionRequest,
} from "@/shared/pipeline-api";
import { buildFinalApprovalViewModel } from "./model";
type FinalApprovalPanelProps = {
articleId: string;
};
const DEFAULT_FRONTMATTER = '{\n "category": "Guides"\n}';
export function FinalApprovalPanel({ articleId }: FinalApprovalPanelProps) {
const [checklist, setChecklist] = useState<FinalApprovalChecklistResponse | null>(null);
const [draftVersionInput, setDraftVersionInput] = useState("");
const [contentPath, setContentPath] = useState("/guides/final-approval-gate");
const [author, setAuthor] = useState("Editorial Team");
const [publishingMode, setPublishingMode] = useState("MANUAL");
const [frontmatterText, setFrontmatterText] = useState(DEFAULT_FRONTMATTER);
const [revisionReason, setRevisionReason] = useState("Need final refinements before approval.");
const [busyKey, setBusyKey] = useState<string | null>(null);
const [message, setMessage] = useState("");
const viewModel = useMemo(
() => buildFinalApprovalViewModel(checklist),
[checklist],
);
async function refreshChecklist() {
setBusyKey("refresh");
setMessage("");
try {
const response = await fetchFinalApprovalChecklist(articleId);
setChecklist(response);
if (!draftVersionInput && response.draft_version) {
setDraftVersionInput(String(response.draft_version));
}
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to fetch final checklist.");
} finally {
setBusyKey(null);
}
}
async function approve() {
let frontmatter: Record<string, unknown> | null = null;
try {
const parsed = JSON.parse(frontmatterText);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
frontmatter = parsed as Record<string, unknown>;
}
} catch {
setMessage("Frontmatter must be a valid JSON object.");
return;
}
const parsedDraftVersion = Number.parseInt(draftVersionInput, 10);
setBusyKey("approve");
setMessage("");
try {
const response = await submitFinalApproval(articleId, {
draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion,
publishing_settings: {
content_path: contentPath,
author,
publishing_mode: publishingMode,
frontmatter,
},
});
setChecklist(response.checklist);
setMessage(`Final approval completed. Article status: ${response.article.status}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Final approval failed.");
await refreshChecklist();
} finally {
setBusyKey(null);
}
}
async function requestRevision() {
const parsedDraftVersion = Number.parseInt(draftVersionInput, 10);
setBusyKey("revision");
setMessage("");
try {
const response = await submitFinalRevisionRequest(articleId, {
reason: revisionReason,
draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion,
});
setChecklist(response.checklist);
setMessage(`Revision requested. Article status: ${response.article.status}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to request revision.");
} finally {
setBusyKey(null);
}
}
return (
<section className="reviewPanel">
<div className="sectionHeader">
<div>
<h2>Final approval gate</h2>
<p>Checklist blockers: {viewModel.blockers.length}</p>
</div>
<div className="formActions">
<button type="button" onClick={() => void refreshChecklist()} disabled={busyKey !== null}>
{busyKey === "refresh" ? "Refreshing..." : "Refresh checklist"}
</button>
</div>
</div>
{message ? <p className={message.includes("failed") ? "formError" : "formSuccess"}>{message}</p> : null}
{viewModel.items.length > 0 ? (
<table className="table">
<thead>
<tr>
<th>Checklist item</th>
<th>Status</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{viewModel.items.map((item) => (
<tr key={item.key}>
<td>{item.title}</td>
<td>{item.completed ? "Completed" : "Blocked"}</td>
<td>{item.completed ? "—" : item.blocker || "—"}</td>
</tr>
))}
</tbody>
</table>
) : (
<p>No checklist yet. Refresh to load current gate status.</p>
)}
{viewModel.completedItems.length > 0 ? (
<ul className="timeline">
{viewModel.completedItems.map((item) => (
<li key={item}>Completed: {item}</li>
))}
</ul>
) : null}
{viewModel.blockers.length > 0 ? (
<ul className="timeline">
{viewModel.blockers.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : null}
<div className="reviewActions">
<input
value={draftVersionInput}
onChange={(event) => setDraftVersionInput(event.target.value)}
placeholder={viewModel.draftVersion ? String(viewModel.draftVersion) : "Draft version"}
inputMode="numeric"
/>
<input
value={contentPath}
onChange={(event) => setContentPath(event.target.value)}
placeholder="/guides/final-approval-gate"
/>
<input
value={author}
onChange={(event) => setAuthor(event.target.value)}
placeholder="Author"
/>
<input
value={publishingMode}
onChange={(event) => setPublishingMode(event.target.value)}
placeholder="MANUAL"
/>
<textarea
value={frontmatterText}
onChange={(event) => setFrontmatterText(event.target.value)}
rows={4}
/>
<button type="button" onClick={() => void approve()} disabled={busyKey !== null}>
{busyKey === "approve" ? "Approving..." : "Approve final"}
</button>
</div>
<div className="reviewActions">
<input
value={revisionReason}
onChange={(event) => setRevisionReason(event.target.value)}
placeholder="Revision reason"
/>
<button type="button" onClick={() => void requestRevision()} disabled={busyKey !== null}>
{busyKey === "revision" ? "Submitting..." : "Request revision"}
</button>
</div>
</section>
);
}
@@ -8,6 +8,7 @@ import { ArticleDetailShell } from "@/features/article-detail/ui";
import { DraftEditorPanel } from "@/features/article-draft/ui";
import { ArticleReviewPanel } from "@/features/article-review/ui";
import { MediaLibraryPanel } from "@/features/article-assets/ui";
import { FinalApprovalPanel } from "@/features/final-approval/ui";
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
import { ApiError } from "@/shared/pipeline-api";
@@ -44,6 +45,7 @@ export default async function ArticleDetailPage({
assets={detail.assets ?? []}
/>
<ArticleReviewPanel articleId={articleId} />
<FinalApprovalPanel articleId={articleId} />
</section>
</main>
);
+33
View File
@@ -24,6 +24,11 @@ import type {
EvidenceCreateRequest,
EvidenceUpdateRequest,
FinalReviewIssuesResponse,
FinalApprovalChecklistResponse,
FinalApprovalRequest,
FinalApprovalResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
LanguageReviewReportResponse,
LanguageReviewRunResponse,
PlanListResponse,
@@ -622,3 +627,31 @@ export function fetchFinalReviewIssues(
): Promise<FinalReviewIssuesResponse> {
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`);
}
export function fetchFinalApprovalChecklist(
articleId: string,
): Promise<FinalApprovalChecklistResponse> {
return apiGet<FinalApprovalChecklistResponse>(
`/api/articles/${articleId}/final-approval/checklist`,
);
}
export function submitFinalApproval(
articleId: string,
request: FinalApprovalRequest,
): Promise<FinalApprovalResponse> {
return apiPost<FinalApprovalRequest, FinalApprovalResponse>(
`/api/articles/${articleId}/final-approval`,
request,
);
}
export function submitFinalRevisionRequest(
articleId: string,
request: FinalRevisionRequest,
): Promise<FinalRevisionRequestResponse> {
return apiPost<FinalRevisionRequest, FinalRevisionRequestResponse>(
`/api/articles/${articleId}/final-revision-request`,
request,
);
}
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(testDir, "../src/features/final-approval/model.ts");
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { buildFinalApprovalViewModel } = moduleExports;
const viewModel = buildFinalApprovalViewModel({
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
draft_version: 3,
requested_draft_version: 2,
can_approve: false,
completed_items: ["Plan followed", "Evidence reviewed"],
blockers: [
"Final approval requires latest draft version 3.",
"Required publishing settings are missing: content path.",
],
items: [
{
key: "exact_draft_version",
title: "Exact draft version selected",
completed: false,
blocker: "Final approval requires latest draft version 3.",
},
{
key: "plan_followed",
title: "Plan followed",
completed: true,
blocker: null,
},
],
});
assert.equal(viewModel.canApprove, false);
assert.equal(viewModel.draftVersion, 3);
assert.equal(viewModel.requestedDraftVersion, 2);
assert.equal(viewModel.completedItems.length, 2);
assert.equal(viewModel.blockers.length, 2);
assert.equal(viewModel.items.length, 2);
assert.equal(viewModel.items[0].completed, false);
assert.equal(viewModel.items[1].completed, true);