ReviewData Engine + PP-Pendirian Vertical Slice — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the normalized ReviewData read-model end-to-end — compiler-forced review registry + one GET /api/submissions/:id/review endpoint + the frontend ReviewEngine — proven by migrating PENDIRIAN_PP as the first tenant behind a parity oracle.
Architecture: Backend: a REVIEW_REGISTRY: Record<SubmissionType, ReviewProjection | null> (separate from flow-engine's FLOW_REGISTRY — review migration is orthogonal to processing migration; a flow can migrate its review while staying a v1-fork processor) feeds one generic route that validates responses against contract/review.ts zod schemas outside production. Frontend: createReviewHook parses with the same schemas; <ReviewEngine> walks sections[] and dispatches by kind to renderers. Legacy endpoints/pages stay until parity + Efran's smoke test (strangler).
Tech Stack: Bun + Hono + Prisma (backend), zod v4 (contract/review.ts — already built + tested), React 19 + TanStack Query + vitest (frontend).
Global Constraints
- Spec:
docs/specs/2026-07-03-reviewdata-read-model-design.md(APPROVED). VM shapes there are law; the contract schemas incontract/review.tsalready encode them — do not modifycontract/review.tsin this plan. - Do NOT touch the un-smoke-tested new PT flows: no edits under
backend/src/flow-engine/,services/akuisisi-processor.ts,routes/peleburan.ts,routes/laporan-rups.ts, or their frontend pages. - Do NOT modify legacy behavior:
GET /api/pp/pendirian/:id/review-dataandPpPendirianReviewPagekeep working unchanged; the V2 page mounts at a NEW route (/pp/pendirian/:submissionId/review-v2) until Efran smoke-tests it. - TDD every task (RED → verify RED → GREEN → verify). Gate every commit with
cd backend && bun run test(uses--timeout 20000; kill straybunprocesses first:for pid in $(pgrep -x bun); do ps -o comm= -p $pid | grep -qx bun && kill $pid; done). - Architecture guards must stay green (
bun test src/__tests__/architecture/): new backend files ≤ 800 lines, no console.* (usecreateLogger), no hand-declared contract-enum unions, request bodies viavalidateBody. - Backend runs from
backend/, frontend fromfrontend/; worktree root is/home/efran/remote-development/poc-ahu-ai/ahu-ocr-tidyup. Frontend tests:npx vitest run <file>. - All optional payload values are
T | null(never undefined) — the contract schemas enforce this;.optional()exists only where the spec says so (confirmMode,columns,docTypes,format). - Commits end with the session trailer line used across this branch (
Claude-Session: https://claude.ai/code/session_01VJPKQCWek7jes4Lx4AoGiF).
Task 1: Compiler-forced REVIEW_REGISTRY skeleton
Files:
- Create: backend/src/review/review-registry.ts
- Test: backend/src/review/__tests__/review-registry.test.ts
Interfaces:
- Consumes: SubmissionType (generated enum), ReviewData/SectionSchema types from @contract/review.
- Produces (later tasks rely on these exact names):
- interface ReviewProjection { sections: SectionSchema[]; projectReview(submissionId: string): Promise<ReviewData | null> } (null = submission not found)
- const REVIEW_REGISTRY: Record<SubmissionType, ReviewProjection | null> (null entry = review not migrated; legacy endpoint still owns it)
- function reviewProjectionFor(type: SubmissionType): ReviewProjection | null
- [ ] Step 1: Write the failing test
// backend/src/review/__tests__/review-registry.test.ts
import { describe, test, expect } from "bun:test";
import { SubmissionType } from "../../generated/prisma/enums";
import { REVIEW_REGISTRY, reviewProjectionFor } from "../review-registry";
describe("REVIEW_REGISTRY", () => {
test("is total over SubmissionType (compiler-forced, runtime-checked)", () => {
for (const t of Object.values(SubmissionType)) {
expect(Object.hasOwn(REVIEW_REGISTRY, t)).toBe(true);
}
});
test("a registered projection exposes sections + projectReview", () => {
for (const t of Object.values(SubmissionType)) {
const p = reviewProjectionFor(t);
if (p !== null) {
expect(Array.isArray(p.sections)).toBe(true);
expect(typeof p.projectReview).toBe("function");
}
}
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bun test src/review/__tests__/review-registry.test.ts
Expected: FAIL — Cannot find module '../review-registry'
- [ ] Step 3: Write the registry
// backend/src/review/review-registry.ts
/**
* Compiler-forced review-projection registry (approved ReviewData spec §9).
* SEPARATE from flow-engine's FLOW_REGISTRY on purpose: review migration is
* orthogonal to processing migration — PENDIRIAN_PP migrates its review here
* while staying a v1-fork processor. When a flow is fully v2 end-to-end the
* two registries can merge; until then this map answers exactly one question:
* "does GET /api/submissions/:id/review know how to project this type?"
*
* Adding a SubmissionType to the Prisma enum makes this a missing-key COMPILE
* error. `null` = not migrated yet (the legacy per-flow endpoint still owns
* the review page); the route returns REVIEW_NOT_MIGRATED for it.
*/
import type { SubmissionType } from "../generated/prisma/enums";
import type { ReviewData, SectionSchema } from "@contract/review";
export interface ReviewProjection {
/** The serializable per-flow structure declaration (spec §5.2). */
sections: SectionSchema[];
/** Project the submission into the normalized ReviewData. null = not found. */
projectReview(submissionId: string): Promise<ReviewData | null>;
}
export const REVIEW_REGISTRY: Record<SubmissionType, ReviewProjection | null> = {
PENDIRIAN_PT: null,
PENDIRIAN_PP: null, // becomes the first tenant in Task 5
PERUBAHAN_PT: null,
PERBAIKAN_DATA_PT: null,
PERUBAHAN_PP: null,
PEMBUBARAN_PP: null,
PERBAIKAN_DATA_PP: null,
PERALIHAN_PP_KE_PT: null,
APOSTILLE: null,
AKUISISI_PT: null,
LAPORAN_RUPS_TAHUNAN: null,
PELEBURAN_PT: null,
PEMBUBARAN_PT: null,
};
export function reviewProjectionFor(type: SubmissionType): ReviewProjection | null {
return REVIEW_REGISTRY[type];
}
- [ ] Step 4: Run test to verify it passes
Run: cd backend && bun test src/review/__tests__/review-registry.test.ts
Expected: 2 pass
- [ ] Step 5: Typecheck + commit
Run: cd backend && bunx tsc --noEmit
git add backend/src/review
git commit -m "feat(review): compiler-forced REVIEW_REGISTRY skeleton (all null)"
Task 2: Generic GET /api/submissions/:id/review route
Files:
- Create: backend/src/routes/review-data.ts
- Modify: backend/src/index.ts (one mount line, next to the existing app.route("/api/submissions", submissions))
- Test: backend/src/routes/__tests__/review-data-route.test.ts
Interfaces:
- Consumes: reviewProjectionFor (Task 1), ReviewDataSchema from @contract/review, db from ../lib/db.
- Produces: GET /api/submissions/:id/review → 200 ReviewData | 404 {error, code: "NOT_FOUND"} | 404 {error, code: "REVIEW_NOT_MIGRATED", type}. Contract-validated outside production (CONTRACT_VALIDATION !== "off" && NODE_ENV !== "production").
- Note: /api/submissions is already AUTHED in ROUTE_POLICIES — longest-prefix matching covers this router; no policy change needed (the mount-exhaustiveness test will confirm).
- [ ] Step 1: Write the failing test
// backend/src/routes/__tests__/review-data-route.test.ts
import { describe, test, expect, afterAll } from "bun:test";
import { Hono } from "hono";
import { db } from "../../lib/db";
import reviewData from "../review-data";
const app = new Hono().route("/api/submissions", reviewData);
const ids: string[] = [];
afterAll(async () => {
await db.submission.deleteMany({ where: { id: { in: ids } } });
});
describe("GET /api/submissions/:id/review", () => {
test("404 NOT_FOUND for an unknown submission", async () => {
const res = await app.request("/api/submissions/nope-xyz/review");
expect(res.status).toBe(404);
expect((await res.json()).code).toBe("NOT_FOUND");
});
test("404 REVIEW_NOT_MIGRATED for a type with a null registry entry", async () => {
const id = `review-route-test-${Date.now()}`;
ids.push(id);
// PERUBAHAN_PT stays null in this plan — a stable not-migrated probe.
await db.submission.create({ data: { id, type: "PERUBAHAN_PT", status: "READY" } });
const res = await app.request(`/api/submissions/${id}/review`);
expect(res.status).toBe(404);
const body = await res.json();
expect(body.code).toBe("REVIEW_NOT_MIGRATED");
expect(body.type).toBe("PERUBAHAN_PT");
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bun test src/routes/__tests__/review-data-route.test.ts
Expected: FAIL — Cannot find module '../review-data'
- [ ] Step 3: Write the route
// backend/src/routes/review-data.ts
/**
* THE generic review read endpoint (approved ReviewData spec §9): one URL for
* every flow, dispatched through REVIEW_REGISTRY. Legacy per-flow review-data
* endpoints keep serving their pages until each flow migrates (strangler);
* a null registry entry answers REVIEW_NOT_MIGRATED so nothing guesses.
* Responses are contract-validated outside production — a projector emitting
* off-spec data fails loudly in dev/test/CI, never silently drifts.
*/
import { Hono } from "hono";
import { db } from "../lib/db";
import { reviewProjectionFor } from "../review/review-registry";
import { ReviewDataSchema } from "@contract/review";
import { createLogger } from "../lib/logger";
const log = createLogger({ scope: "review-data" });
const validateResponses =
process.env.CONTRACT_VALIDATION !== "off" && process.env.NODE_ENV !== "production";
const reviewData = new Hono();
reviewData.get("/:id/review", async (c) => {
const id = c.req.param("id");
const sub = await db.submission.findUnique({ where: { id }, select: { type: true } });
if (!sub) return c.json({ error: "Submission not found", code: "NOT_FOUND" }, 404);
const projection = reviewProjectionFor(sub.type);
if (!projection) {
return c.json(
{ error: `Review projection not yet migrated for ${sub.type}`, code: "REVIEW_NOT_MIGRATED", type: sub.type },
404,
);
}
const data = await projection.projectReview(id);
if (!data) return c.json({ error: "Submission not found", code: "NOT_FOUND" }, 404);
if (validateResponses) {
const parsed = ReviewDataSchema.safeParse(data);
if (!parsed.success) {
log.error("projector emitted off-contract ReviewData", {
submissionId: id,
type: sub.type,
issues: parsed.error.issues.slice(0, 5),
});
return c.json({ error: "Review projection violated the contract", code: "CONTRACT_VIOLATION" }, 500);
}
}
return c.json(data);
});
export default reviewData;
- [ ] Step 4: Mount it in index.ts
In backend/src/index.ts, add the import next to the other route imports and the mount line directly ABOVE app.route("/api/submissions", submissions);:
import reviewData from "./routes/review-data";
// …
app.route("/api/submissions", reviewData); // generic /:id/review (ReviewData spec §9)
app.route("/api/submissions", submissions);
- [ ] Step 5: Run tests to verify green
Run: cd backend && bun test src/routes/__tests__/review-data-route.test.ts src/security/
Expected: route tests 2 pass; security suite (incl. mount-exhaustiveness) all pass
- [ ] Step 6: Commit
git add backend/src/routes/review-data.ts backend/src/routes/__tests__/review-data-route.test.ts backend/src/index.ts
git commit -m "feat(review): generic GET /api/submissions/:id/review with contract validation"
Task 3: Extract the shared PP shape module (no behavior change)
Files:
- Create: backend/src/review/pp-shape.ts
- Modify: backend/src/routes/pp-pendirian.ts (delete the local PpSection/PP_SECTIONS/PP_PENDIRIAN_DECLARATIONS definitions; import from the new module; KEEP re-exporting PP_PENDIRIAN_DECLARATIONS because routes/pp-perubahan.ts:25 imports it from ./pp-pendirian)
- Test: existing suites are the safety net (src/__tests__ PP tests must stay green)
Interfaces:
- Produces: export type PpSection = { key: string; title: string; docType: string; keys: string[]; extra?: { docType: string; keys: string[] } }, export const PP_SECTIONS: PpSection[], export const PP_PENDIRIAN_DECLARATIONS: string[] — all byte-identical to today's values in routes/pp-pendirian.ts:67-111.
- [ ] Step 1: Create
backend/src/review/pp-shape.ts— move (verbatim) thePpSectiontype,PP_SECTIONSarray, andPP_PENDIRIAN_DECLARATIONSarray fromroutes/pp-pendirian.ts:67-111, including their comments. Header comment:
/**
* PENDIRIAN_PP review structure — the single source both the legacy
* /review-data route and the ReviewData projector read. Moved verbatim from
* routes/pp-pendirian.ts (2026-07-03) so review/ never imports from routes/.
*/
- [ ] Step 2: Update
routes/pp-pendirian.ts— replace the moved definitions with:
import { PP_SECTIONS, PP_PENDIRIAN_DECLARATIONS, type PpSection } from "../review/pp-shape";
export { PP_PENDIRIAN_DECLARATIONS }; // pp-perubahan.ts imports it from here
- [ ] Step 3: Verify no behavior change
Run: cd backend && bunx tsc --noEmit && bun test src/__tests__/pp src/routes/__tests__ 2>/dev/null | tail -3
(If PP tests live elsewhere, run the full suite: bun run test.) Expected: green, same counts as before the change.
- [ ] Step 4: Commit
git add backend/src/review/pp-shape.ts backend/src/routes/pp-pendirian.ts
git commit -m "refactor(review): extract PP_SECTIONS/declarations into review/pp-shape (verbatim move)"
Task 4: PENDIRIAN_PP projector
Files:
- Create: backend/src/review/projectors/pp-pendirian-review.ts
- Test: backend/src/review/__tests__/pp-pendirian-review.test.ts
Interfaces:
- Consumes: PP_SECTIONS, PP_PENDIRIAN_DECLARATIONS (Task 3), db, fieldProvenance from ../../lib/field-provenance, contract types.
- Produces: export const ppPendirianReviewProjection: ReviewProjection — sections declared as SectionSchema[]; projectReview(id) returns spec-shaped ReviewData.
- Field ref convention (spec §6.1): `${documentId}:${fieldKey}`. Roster keys: "kbli", "pemilik_manfaat". Section keys: the four PP_SECTIONS keys + "kbli" + "pemilik_manfaat" (+ approvable "pernyataan" carried by declarations, matching the legacy page).
- Provenance mapping: fieldProvenance(f) returns the legacy strings; map AUTO_OCR→AUTO_OCR, NOTARIS_EDITED→USER_EDITED, NOTARIS_CONFIRMED→USER_CONFIRMED (check lib/field-provenance.ts for the exact return values first and adjust the map to cover them all — the contract only accepts the three PROVENANCE_VALUES).
- [ ] Step 1: Write the failing test (seeds a real submission; asserts contract-parse + content)
// backend/src/review/__tests__/pp-pendirian-review.test.ts
import { describe, test, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { ReviewDataSchema } from "@contract/review";
import { ppPendirianReviewProjection } from "../projectors/pp-pendirian-review";
const ids: string[] = [];
const docIds: string[] = [];
afterAll(async () => {
await db.submission.deleteMany({ where: { id: { in: ids } } });
await db.document.deleteMany({ where: { id: { in: docIds } } });
});
async function seed(): Promise<string> {
const id = `pp-review-proj-${Date.now()}`;
ids.push(id);
const doc = await db.document.create({
data: {
filename: "sp.pdf", originalName: "sp.pdf", fileSize: 9, filePath: "/tmp/x.pdf",
documentType: "SURAT_PERNYATAAN_PP", pageCount: 2,
fields: { create: [
{ fieldKey: "namaPerseroan", value: "PP MAJU", confidence: 96, status: "LOCKED" },
{ fieldKey: "modal", value: "50000000", confidence: 80, status: "EDITABLE" },
]},
boundingBoxes: { create: [{ fieldKey: "namaPerseroan", page: 0, x1: 1, y1: 2, x2: 3, y2: 4 }] },
kbliEntries: { create: [
{ orderIndex: 0, kbliCode: "47911", description: "Perdagangan eceran", confidence: 90, status: "EDITABLE", notarisConfirmed: true },
]},
},
});
docIds.push(doc.id);
await db.submission.create({
data: {
id, type: "PENDIRIAN_PP", status: "READY",
documents: { create: [{ documentId: doc.id, classifiedType: "SURAT_PERNYATAAN_PP", confidence: 0.99, processingOrder: 0, extractionStatus: "DONE" }] },
sectionApprovals: { create: [{ sectionKey: "data_perseroan", approved: true }] },
validationResults: { create: [{ ruleCode: "PP_MODAL_MAX", ruleLabel: "Modal maksimal", status: "PASS", message: "ok" }] },
pemilikManfaatEntries: { create: [{
orderIndex: 0, isFounder: true, nama: "Budi", kewarganegaraan: "WNI",
jenisIdentitas: "KTP", nomorIdentitas: "3171000000000001",
kriteriaIds: [1, 2], confidence: 75, status: "EDITABLE", notarisConfirmed: false,
}]},
},
});
return id;
}
describe("ppPendirianReviewProjection", () => {
test("projects a seeded submission into contract-valid ReviewData", async () => {
const id = await seed();
const data = await ppPendirianReviewProjection.projectReview(id);
expect(data).not.toBeNull();
const parsed = ReviewDataSchema.parse(data!); // throws on any spec violation
// fields store keyed by ref, bbox attached
const doc = (await db.submission.findUnique({ where: { id }, include: { documents: true } }))!.documents[0];
const ref = `${doc.documentId}:namaPerseroan`;
expect(parsed.fields[ref].value).toBe("PP MAJU");
expect(parsed.fields[ref].bbox).toEqual({ page: 0, x1: 1, y1: 2, x2: 3, y2: 4 });
expect(parsed.fields[ref].confirmed).toBe(false);
// sections reference the store; only keys with data are required
const dataPerseroan = parsed.sections.find((s) => s.key === "data_perseroan");
expect(dataPerseroan?.kind).toBe("field-list");
expect(parsed.requiredSections).toContain("data_perseroan");
expect(parsed.sectionApprovals["data_perseroan"]).toBe(true);
// rosters
expect(parsed.rosters["kbli"].rows[0].cells["kbliCode"]).toBe("47911");
expect(parsed.rosters["kbli"].rows[0].confirmed).toBe(true);
expect(parsed.rosters["pemilik_manfaat"].rows[0].cells["nama"]).toBe("Budi");
expect(parsed.rosters["pemilik_manfaat"].rows[0].detail).toMatchObject({ kriteriaIds: [1, 2] });
// declarations + docs
expect(parsed.declarations.length).toBeGreaterThan(3);
expect(parsed.documents[0].classifiedType).toBe("SURAT_PERNYATAAN_PP");
});
test("returns null for an unknown submission", async () => {
expect(await ppPendirianReviewProjection.projectReview("missing-id")).toBeNull();
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bun test src/review/__tests__/pp-pendirian-review.test.ts
Expected: FAIL — module not found
- [ ] Step 3: Implement the projector — mirror the legacy route's reads (
routes/pp-pendirian.ts:172-280) exactly; only the OUTPUT shape changes:
// backend/src/review/projectors/pp-pendirian-review.ts
import { db } from "../../lib/db";
import { fieldProvenance } from "../../lib/field-provenance";
import { PP_BO_KRITERIA } from "../../services/pp-bo-kriteria";
import { PP_SECTIONS, PP_PENDIRIAN_DECLARATIONS } from "../pp-shape";
import type { ReviewProjection } from "../review-registry";
import type {
ReviewData, ReviewFieldVM, RosterVM, SectionVM, SectionSchema,
} from "@contract/review";
const PROVENANCE_MAP: Record<string, ReviewFieldVM["provenance"]> = {
AUTO_OCR: "AUTO_OCR",
NOTARIS_EDITED: "USER_EDITED",
NOTARIS_CONFIRMED: "USER_CONFIRMED",
};
const KBLI_COLUMNS = [
{ key: "kbliCode", label: "Kode KBLI" },
{ key: "description", label: "Uraian" },
];
const BO_COLUMNS = [
{ key: "nama", label: "Nama" },
{ key: "jenisIdentitas", label: "Jenis ID" },
{ key: "nomorIdentitas", label: "Nomor Identitas" },
{ key: "kewarganegaraan", label: "Kewarganegaraan" },
];
const SECTION_SCHEMAS: SectionSchema[] = [
...PP_SECTIONS.map((s, i) => ({
key: s.key, label: s.title, kind: "field-list" as const,
approvable: true, required: true, order: i,
docTypes: [s.docType, ...(s.extra ? [s.extra.docType] : [])],
})),
{ key: "kbli", label: "KBLI", kind: "roster", approvable: true, required: true, order: 10, columns: KBLI_COLUMNS },
{ key: "pemilik_manfaat", label: "Pemilik Manfaat", kind: "roster", approvable: true, required: true, order: 11, columns: BO_COLUMNS },
];
export const ppPendirianReviewProjection: ReviewProjection = {
sections: SECTION_SCHEMAS,
async projectReview(submissionId: string): Promise<ReviewData | null> {
const sub = await db.submission.findUnique({
where: { id: submissionId },
include: {
documents: { include: { document: { include: {
fields: true,
kbliEntries: { orderBy: { orderIndex: "asc" } },
boundingBoxes: true,
} } } },
validationResults: { orderBy: { createdAt: "asc" } },
sectionApprovals: true,
pemilikManfaatEntries: { orderBy: { orderIndex: "asc" } },
},
});
if (!sub || sub.type !== "PENDIRIAN_PP") return null;
const docByType = (t: string) => sub.documents.find((d) => d.document.documentType === t)?.document;
// ── fields store + field-list sections ──────────────────────────────
const fields: Record<string, ReviewFieldVM> = {};
const addField = (doc: NonNullable<ReturnType<typeof docByType>>, fieldKey: string): string | null => {
const f = doc.fields.find((x) => x.fieldKey === fieldKey);
if (!f) return null;
const ref = `${doc.id}:${fieldKey}`;
const bb = doc.boundingBoxes.find((b) => b.fieldKey === fieldKey) ?? null;
fields[ref] = {
ref, fieldKey, documentId: doc.id, label: fieldKey,
value: f.value, displayValue: null,
confidence: f.confidence, status: f.status as ReviewFieldVM["status"],
confirmed: f.notarisConfirmed,
confirmedAt: f.notarisConfirmedAt ? f.notarisConfirmedAt.toISOString() : null,
provenance: PROVENANCE_MAP[fieldProvenance(f)] ?? "AUTO_OCR",
editable: f.status !== "LOCKED",
editReason: f.editReason ?? null,
bbox: bb ? { page: bb.page, x1: bb.x1, y1: bb.y1, x2: bb.x2, y2: bb.y2 } : null,
};
return ref;
};
const sections: SectionVM[] = [];
for (const s of PP_SECTIONS) {
const doc = docByType(s.docType);
const refs: string[] = [];
if (doc) for (const k of s.keys) { const r = addField(doc, k); if (r) refs.push(r); }
if (s.extra) {
const xdoc = docByType(s.extra.docType);
if (xdoc) for (const k of s.extra.keys) { const r = addField(xdoc, k); if (r) refs.push(r); }
}
sections.push({
kind: "field-list", key: s.key, label: s.title,
approvable: true, required: true, documentId: doc?.id ?? null, fieldRefs: refs,
});
}
// ── rosters ─────────────────────────────────────────────────────────
const suratDoc = docByType("SURAT_PERNYATAAN_PP");
const rosters: Record<string, RosterVM> = {
kbli: {
key: "kbli", columns: KBLI_COLUMNS,
rows: (suratDoc?.kbliEntries ?? []).map((k) => ({
id: k.id, cells: { kbliCode: k.kbliCode, description: k.description },
cellFieldRefs: {}, confirmed: k.notarisConfirmed,
status: k.status as ReviewFieldVM["status"],
provenance: "AUTO_OCR", personRef: null, detail: null,
})),
},
pemilik_manfaat: {
key: "pemilik_manfaat", columns: BO_COLUMNS,
rows: sub.pemilikManfaatEntries.map((b) => ({
id: b.id,
cells: { nama: b.nama, jenisIdentitas: b.jenisIdentitas, nomorIdentitas: b.nomorIdentitas, kewarganegaraan: b.kewarganegaraan },
cellFieldRefs: {}, confirmed: b.notarisConfirmed,
status: b.status as ReviewFieldVM["status"],
provenance: "AUTO_OCR", personRef: null,
detail: {
isFounder: b.isFounder, npwp: b.npwp, tempatLahir: b.tempatLahir, tanggalLahir: b.tanggalLahir,
alamat: b.alamat, negaraAsal: b.negaraAsal, hubungan: b.hubungan,
kriteriaIds: b.kriteriaIds, npwpVerified: b.matchedNpwpDocumentId != null,
kriteriaCatalog: PP_BO_KRITERIA.map((k) => ({ id: k.id, group: k.group, label: k.label })),
},
})),
},
};
sections.push({ kind: "roster", key: "kbli", label: "KBLI", approvable: true, required: rosters.kbli.rows.length > 0, documentId: suratDoc?.id ?? null, rosterKey: "kbli" });
sections.push({ kind: "roster", key: "pemilik_manfaat", label: "Pemilik Manfaat", approvable: true, required: rosters.pemilik_manfaat.rows.length > 0, documentId: null, rosterKey: "pemilik_manfaat" });
// ── approvals / required (mirrors legacy ppRequiredSections logic) ──
const sectionApprovals: Record<string, boolean> = {};
for (const sa of sub.sectionApprovals) sectionApprovals[sa.sectionKey] = sa.approved;
const requiredSections = sections
.filter((s) => s.required && (s.kind !== "field-list" || s.fieldRefs.length > 0))
.map((s) => s.key);
return {
submissionId: sub.id, type: sub.type, status: sub.status,
sections, fields, rosters, diff: null, registryEntities: {},
documents: sub.documents.map((d) => ({
documentId: d.documentId, classifiedType: d.classifiedType, userOverride: d.userOverride,
filename: d.document.filename, pageCount: Math.max(1, d.document.pageCount ?? 1),
extractionStatus: d.extractionStatus, meta: null,
})),
pdfSources: sub.documents.map((d) => ({
documentId: d.documentId, label: d.classifiedType,
originalName: d.document.originalName, pageCount: Math.max(1, d.document.pageCount ?? 1),
})),
validationResults: sub.validationResults.map((v) => ({
id: v.id, ruleCode: v.ruleCode, ruleLabel: v.ruleLabel,
status: v.status, message: v.message, details: (v.details ?? null) as unknown,
sectionKey: null, overridden: v.overridden ?? false,
overrideReason: v.overrideReason ?? null, overridable: true,
})),
sectionApprovals, requiredSections,
declarations: PP_PENDIRIAN_DECLARATIONS.map((label, i) => ({
key: `D${i + 1}`, label, checked: sectionApprovals["pernyataan"] ?? false,
})),
meta: { expiresAt: null, aiSummary: null, evidenceCheckAvailable: false, revisionFeedback: null },
};
},
};
NB: the exact
ValidationResultcolumn names (overridden,overrideReason) andfieldProvenancereturn values must be checked againstprisma/schema.prisma/lib/field-provenance.tswhile implementing — adjust the two mappings, keep the VM shape fixed.
- [ ] Step 4: Run test to verify it passes
Run: cd backend && bun test src/review/__tests__/pp-pendirian-review.test.ts
Expected: 2 pass (the contract parse inside the test is the spec compliance check)
- [ ] Step 5: Typecheck + commit
cd backend && bunx tsc --noEmit
git add backend/src/review
git commit -m "feat(review): PENDIRIAN_PP projector — first ReviewData tenant"
Task 5: Register the tenant + parity oracle
Files:
- Modify: backend/src/review/review-registry.ts (PENDIRIAN_PP: null → the projection)
- Test: backend/src/review/__tests__/pp-pendirian-parity.test.ts
Interfaces:
- Consumes: the seeded-submission pattern from Task 4's test, the legacy route GET /api/pp/pendirian/:id/review-data (mounted via routes/pp-pendirian.ts default export), the new route (Task 2).
- Produces: the parity guarantee later flows copy — every value the legacy payload renders exists in the new payload.
- [ ] Step 1: Register
// review-registry.ts
import { ppPendirianReviewProjection } from "./projectors/pp-pendirian-review";
// …
PENDIRIAN_PP: ppPendirianReviewProjection,
- [ ] Step 2: Write the parity oracle test (this is the RED→GREEN of the registration — before registering it would 404)
// backend/src/review/__tests__/pp-pendirian-parity.test.ts
import { describe, test, expect, afterAll } from "bun:test";
import { Hono } from "hono";
import { db } from "../../lib/db";
import ppPendirian from "../../routes/pp-pendirian";
import reviewData from "../../routes/review-data";
// Seed EXACTLY as in pp-pendirian-review.test.ts (copy the seed() helper verbatim,
// including the afterAll cleanup — do not import across test files).
const app = new Hono()
.route("/api/pp/pendirian", ppPendirian)
.route("/api/submissions", reviewData);
describe("parity: legacy review-data ⊆ new ReviewData", () => {
test("every field value + roster row + approval the legacy payload renders exists in the new payload", async () => {
const id = await seed();
const legacy = await (await app.request(`/api/pp/pendirian/${id}/review-data`)).json();
const next = await (await app.request(`/api/submissions/${id}/review`)).json();
// 1) every legacy section field appears in the new fields store with same value
for (const s of legacy.sections) {
for (const f of s.fields) {
const ref = `${f.documentId}:${f.fieldKey}`;
expect(next.fields[ref], `missing field ${ref}`).toBeDefined();
expect(next.fields[ref].value).toBe(f.value);
expect(next.fields[ref].confirmed).toBe(f.notarisConfirmed);
}
// section exists under the same key
expect(next.sections.some((x: { key: string }) => x.key === s.key)).toBe(true);
}
// 2) kbli + BO rosters row-for-row
expect(next.rosters.kbli.rows.map((r: { cells: Record<string, unknown> }) => r.cells.kbliCode))
.toEqual(legacy.kbli.map((k: { kbliCode: string }) => k.kbliCode));
expect(next.rosters.pemilik_manfaat.rows.length).toBe(legacy.pemilikManfaat.length);
// 3) approvals, required, declarations, documents
expect(next.sectionApprovals).toEqual(legacy.sectionApprovals);
for (const k of legacy.requiredSections) expect(next.requiredSections).toContain(k);
expect(next.declarations.map((d: { label: string }) => d.label)).toEqual(legacy.declarations);
expect(next.documents.length).toBe(legacy.documents.length);
});
});
- [ ] Step 3: Run — RED first (with registration reverted), then GREEN
Revert the registry line to null, run: cd backend && bun test src/review/__tests__/pp-pendirian-parity.test.ts → expect FAIL (404 REVIEW_NOT_MIGRATED). Re-apply the registration, run again → expect PASS.
- [ ] Step 4: Full backend gate + commit
cd backend && bun run test # full suite, kill stray bun first
git add backend/src/review
git commit -m "feat(review): register PENDIRIAN_PP + parity oracle vs legacy endpoint"
Task 6: Frontend useReviewData hook (contract-parsed)
Files:
- Create: frontend/src/hooks/use-review-engine.ts
- Test: frontend/src/hooks/use-review-engine.test.ts
Interfaces:
- Consumes: apiFetch from @/lib/api-client, ReviewDataSchema/ReviewData from @contract/review (zod resolves through the root node_modules — see vite.config fs.allow already covering ../contract).
- Produces: export function useReviewEngineData(submissionId: string): UseQueryResult<ReviewData> — queryKey ["review-engine", submissionId], parses the response with ReviewDataSchema.parse (client-side contract check, always on per spec §9).
- [ ] Step 1: Write the failing test
// frontend/src/hooks/use-review-engine.test.ts
import { describe, it, expect, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useReviewEngineData } from "./use-review-engine";
import * as api from "@/lib/api-client";
const MINIMAL = {
submissionId: "s1", type: "PENDIRIAN_PP", status: "READY",
sections: [], fields: {}, rosters: {}, diff: null, registryEntities: {},
documents: [], pdfSources: [], validationResults: [], sectionApprovals: {},
requiredSections: [], declarations: [],
meta: { expiresAt: null, aiSummary: null, evidenceCheckAvailable: false, revisionFeedback: null },
};
function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("useReviewEngineData", () => {
it("fetches and contract-parses ReviewData", async () => {
vi.spyOn(api, "apiFetch").mockResolvedValue(MINIMAL);
const { result } = renderHook(() => useReviewEngineData("s1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.submissionId).toBe("s1");
expect(api.apiFetch).toHaveBeenCalledWith("/api/submissions/s1/review");
});
it("surfaces a contract violation as a query error", async () => {
vi.spyOn(api, "apiFetch").mockResolvedValue({ ...MINIMAL, type: "NOT_A_TYPE" });
const { result } = renderHook(() => useReviewEngineData("s1"), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
(File must be .tsx if JSX in wrapper complains — name it use-review-engine.test.tsx in that case and adjust imports accordingly.)
-
[ ] Step 2: Run to verify RED —
cd frontend && npx vitest run src/hooks/use-review-engine.test.tsx→ FAIL (module not found) -
[ ] Step 3: Implement
// frontend/src/hooks/use-review-engine.ts
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api-client";
import { ReviewDataSchema, type ReviewData } from "@contract/review";
/** The one review hook (ReviewData spec §9): fetches the generic endpoint and
* parses with the SAME zod schema the backend validates against — drift
* between the two sides fails here, loudly, not in a renderer. */
export function useReviewEngineData(submissionId: string): UseQueryResult<ReviewData> {
return useQuery({
queryKey: ["review-engine", submissionId],
queryFn: async () => ReviewDataSchema.parse(await apiFetch(`/api/submissions/${submissionId}/review`)),
enabled: !!submissionId,
});
}
- [ ] Step 4: GREEN + typecheck + commit
cd frontend && npx vitest run src/hooks/use-review-engine.test.tsx && bunx tsc --noEmit
git add frontend/src/hooks/use-review-engine*
git commit -m "feat(review-engine): contract-parsed useReviewEngineData hook"
Task 7: ReviewEngine shell + renderer registry
Files:
- Create: frontend/src/components/review-engine/ReviewEngine.tsx, frontend/src/components/review-engine/types.ts
- Test: frontend/src/components/review-engine/__tests__/ReviewEngine.test.tsx
Interfaces:
- Produces (all later renderers + the page rely on these):
// types.ts
import type { ReviewData, SectionVM } from "@contract/review";
export interface ReviewActions {
editField(ref: string, value: string, reason?: string): void;
confirmField(ref: string, confirmed: boolean): void;
confirmRosterRow(rosterKey: string, rowId: string, confirmed: boolean): void;
approveSection(sectionKey: string, approved: boolean): void;
focusField(ref: string): void; // PDF viewer focus
}
export interface SectionRendererProps { data: ReviewData; section: SectionVM; actions: ReviewActions; }
export type SectionRenderer = (props: SectionRendererProps) => React.ReactNode;
-
<ReviewEngine data={ReviewData} actions={ReviewActions} renderers?={Partial<Record<SectionKind, SectionRenderer>>} />— walksdata.sectionsin order, dispatches onsection.kindthrough a default renderer registry merged with therenderersoverride prop; unknown/unimplemented kind renders a visible fallback card (never crashes, never silently skips). -
[ ] Step 1: RED — test renders a two-section fixture (one
field-list, one kind with no renderer) and asserts: both section labels visible; the unimplemented one shows textRenderer belum tersedia; renderer override prop wins.
// __tests__/ReviewEngine.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ReviewEngine } from "../ReviewEngine";
import type { ReviewData } from "@contract/review";
const noop = () => {};
const actions = { editField: noop, confirmField: noop, confirmRosterRow: noop, approveSection: noop, focusField: noop };
const data = {
submissionId: "s1", type: "PENDIRIAN_PP", status: "READY",
sections: [
{ kind: "field-list", key: "a", label: "Bagian A", approvable: true, required: true, documentId: null, fieldRefs: [] },
{ kind: "registry-entity", key: "b", label: "Bagian B", approvable: false, required: false, documentId: null, entityKey: "x" },
],
fields: {}, rosters: {}, diff: null,
registryEntities: { x: { source: "PTP", matchedBy: null, label: "PP", groups: [], candidates: null } },
documents: [], pdfSources: [], validationResults: [], sectionApprovals: {},
requiredSections: ["a"], declarations: [],
meta: { expiresAt: null, aiSummary: null, evidenceCheckAvailable: false, revisionFeedback: null },
} as unknown as ReviewData;
describe("ReviewEngine", () => {
it("renders every section by kind and falls back visibly for missing renderers", () => {
render(<ReviewEngine data={data} actions={actions} />);
expect(screen.getByText("Bagian A")).toBeInTheDocument();
expect(screen.getByText("Bagian B")).toBeInTheDocument();
expect(screen.getByText(/renderer belum tersedia/i)).toBeInTheDocument();
});
});
-
[ ] Step 2: verify RED —
npx vitest run src/components/review-engine/__tests__/ReviewEngine.test.tsx -
[ ] Step 3: implement
ReviewEngine.tsx— default registry starts withfield-list(Task 8 fills it; until then it may render an empty card with the label), fallback card for anything unregistered:
// ReviewEngine.tsx
import type { ReviewData, SectionVM } from "@contract/review";
import type { ReviewActions, SectionRenderer } from "./types";
import { FieldListSection } from "./FieldListSection"; // Task 8 (stub first: label-only card)
const DEFAULT_RENDERERS: Partial<Record<SectionVM["kind"], SectionRenderer>> = {
"field-list": FieldListSection,
};
export function ReviewEngine({ data, actions, renderers }: {
data: ReviewData; actions: ReviewActions;
renderers?: Partial<Record<SectionVM["kind"], SectionRenderer>>;
}) {
const registry = { ...DEFAULT_RENDERERS, ...renderers };
return (
<div className="space-y-4">
{data.sections.map((section) => {
const Renderer = registry[section.kind];
return (
<section key={section.key} className="rounded-xl border bg-card p-4">
<h2 className="mb-3 text-base font-semibold">{section.label}</h2>
{Renderer ? (
<Renderer data={data} section={section} actions={actions} />
) : (
<p className="text-sm text-muted-foreground">
Renderer belum tersedia untuk bagian ini ({section.kind}).
</p>
)}
</section>
);
})}
</div>
);
}
For this task, FieldListSection is the minimal stub: export function FieldListSection() { return null; } in its own file (Task 8 replaces it TDD).
- [ ] Step 4: GREEN + commit —
git add frontend/src/components/review-engine && git commit -m "feat(review-engine): engine shell + renderer registry with visible fallback"
Task 8: FieldListSection renderer
Files:
- Modify: frontend/src/components/review-engine/FieldListSection.tsx
- Test: frontend/src/components/review-engine/__tests__/FieldListSection.test.tsx
Interfaces: consumes SectionRendererProps; resolves each fieldRefs entry via data.fields[ref]; renders per field: label, value (displayValue ?? value ?? "—"), confidence badge, LOCKED lock icon, confirm toggle button (actions.confirmField(ref, !confirmed), disabled when status === "LOCKED" or !editable), edit button (actions.editField(ref, …) opens caller-provided dialog later — for now call with current value), row click → actions.focusField(ref).
- [ ] Step 1: RED — fixture with 2 fields (one LOCKED conf 96 confirmed, one EDITABLE conf 75 unconfirmed); assert values render; confirm button present only for the editable one; clicking it calls
confirmField("d1:modal", true); clicking the row callsfocusField. - [ ] Step 2: verify RED
- [ ] Step 3: implement — plain list rows; use existing UI atoms (
Button,cn); confidence badge = colored span (>=95green />=70amber / red) with the number; no new deps. - [ ] Step 4: GREEN +
bunx tsc --noEmit+ commitfeat(review-engine): field-list renderer
Task 9: RosterSection renderer
Files:
- Create: frontend/src/components/review-engine/RosterSection.tsx (+ register in DEFAULT_RENDERERS)
- Test: frontend/src/components/review-engine/__tests__/RosterSection.test.tsx
Interfaces: consumes data.rosters[section.rosterKey]; renders columns as a table header, one row per RosterRowVM (cells[col.key] ?? "—"), per-row confirm checkbox → actions.confirmRosterRow(rosterKey, row.id, checked); a row with detail !== null gets a "Detail" button opening an inline expandable block listing detail entries (skip kriteriaCatalog; render kriteriaIds as a count chip).
- [ ] Step 1: RED — kbli-shaped fixture (2 rows, one confirmed); assert header labels, cell text, checkbox states; toggling calls
confirmRosterRow("kbli", "r2", true); BO-shaped fixture with detail expands on click. - [ ] Step 2: verify RED → Step 3: implement → Step 4: GREEN + commit
feat(review-engine): roster renderer
Task 10: Validation banner + approval bar + submit gate
Files:
- Create: frontend/src/components/review-engine/ValidationBanner.tsx, frontend/src/components/review-engine/SectionApprovalBar.tsx
- Modify: ReviewEngine.tsx (render ValidationBanner above sections; render SectionApprovalBar under each approvable section)
- Test: frontend/src/components/review-engine/__tests__/approval-and-validation.test.tsx
Interfaces:
- ValidationBanner({ results }) — groups by status; FAIL red list, WARNING amber list, hides PASS/SKIPPED; shows overridden as struck-through with reason.
- SectionApprovalBar({ section, approved, onToggle }) — "Setujui Bagian Ini" / "Batalkan Persetujuan" → actions.approveSection(key, !approved).
- export function submitGate(data: ReviewData): { ready: boolean; missingSections: string[]; failingRules: string[] } (pure, in frontend/src/components/review-engine/submit-gate.ts) — ready when every requiredSections key is approved AND no un-overridden FAIL validation. The page (Task 11) uses it to enable finalize.
- [ ] Step 1: RED — pure
submitGatetests first (4 cases: ready; missing section; un-overridden FAIL blocks; overridden FAIL doesn't block) + render tests for banner/bar. - [ ] Step 2–4: verify RED → implement → GREEN + commit
feat(review-engine): validation banner, approval bar, pure submit gate
Task 11: PP Pendirian V2 page (new route, legacy untouched)
Files:
- Create: frontend/src/pages/pp/PpPendirianReviewPageV2.tsx
- Modify: frontend/src/routes.tsx (add { path: "pp/pendirian/:submissionId/review-v2", element: <PpPendirianReviewPageV2 /> } in the SAME RoleGuard group as the legacy PP pendirian review route)
- Test: frontend/src/pages/pp/__tests__/PpPendirianReviewPageV2.test.tsx
Interfaces:
- Consumes: useReviewEngineData (Task 6), ReviewEngine + submitGate (Tasks 7–10), ReviewPdfViewer from @/components/pendirian/review-pdf-viewer (existing), and the EXISTING PP mutation hooks from @/hooks/use-pp-pendirian (verify exact names in that file: usePpEditField, usePpConfirmField, usePpSectionApproval, useSetKbliConfirmed, usePpSetBoConfirmed, useFinalizePpPendirian — wire each into a ReviewActions impl; roster confirm dispatches by rosterKey: "kbli" → KBLI hook, "pemilik_manfaat" → BO hook).
- Produces: /pp/pendirian/:submissionId/review-v2 — the smoke-test surface. On success every mutation invalidates ["review-engine", submissionId].
- Layout: engine left (65%), ReviewPdfViewer right (35%) fed by data.pdfSources; focusField(ref) sets active document + field key from data.fields[ref] (documentId, fieldKey).
- [ ] Step 1: RED — page test mocks
useReviewEngineData(fixture with one field-list + kbli roster) and the PP mutation hooks (vi.mock, jest-fn mutate); asserts sections render, finalize button disabled when gate not ready, confirm click reaches the right mocked hook. - [ ] Step 2: verify RED → Step 3: implement page → Step 4: GREEN +
bunx tsc --noEmit - [ ] Step 5: Commit
feat(review-engine): PP Pendirian V2 page at /review-v2 (legacy untouched)
Task 12: Full gates + trackers + handoff note
- [ ] Step 1:
cd backend && bun run test→ expect ≥1950 pass / 0 fail (all guards green; new files under FF7 cap) - [ ] Step 2:
cd frontend && npx vitest run && bunx tsc --noEmit→ all pass - [ ] Step 3: Update
tidyup/progress.md(new entry: engine + first tenant live at/review-v2; parity oracle green; Efran smoke-test = flip the main route + delete legacy endpoint next) andtidyup/tasks.md(Phase B checklist: pembubaran-PP → PT pendirian → peralihan → PP perubahan/perbaikan + FF4 fork collapse — each = "copy Tasks 4–5+11 pattern"). - [ ] Step 4: Commit
docs(tidyup): ReviewData engine + PP-pendirian slice complete — awaiting smoke test
Self-Review Notes
- Spec coverage: §4 envelope (Task 4 return), §5 sections/schemas (Tasks 1, 4), §6 VMs (Task 4 mappings; identity/diff/document-set kinds deliberately NOT implemented — not needed by PENDIRIAN_PP; engine fallback card covers them until their flows migrate), §8 mutation refs (Task 11 wiring), §9 endpoint/validation/oracle (Tasks 2, 5), §10 order (this plan = first tenant; follow-ups per flow).
- Deviation from spec recorded:
registryEntity?→registryEntities: Record<string, …>was already amended incontract/review.ts; the separateREVIEW_REGISTRY(vs. extending flow-engine's FlowConfig) is documented in Task 1's header — revisit when the new PT flows are smoke-tested and the registries can merge. - Type consistency:
ReviewProjection/reviewProjectionFor(T1) used in T2/T5;ReviewActions(T7) used in T8–T11; ref format${documentId}:${fieldKey}consistent T4/T5/T8/T11; queryKey["review-engine", id]T6/T11.