feat: implement final approval gate workflow and checklist
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user