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
507 changes: 507 additions & 0 deletions apps/api/src/server.integration.test.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/api/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("@runroot/api", () => {
expect(response.json()).toEqual({
status: "ok",
project: "Runroot",
phase: 26,
phase: 27,
});
});

Expand Down
102 changes: 102 additions & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,12 @@ export function buildServer(options: BuildServerOptions = {}) {
})),
);

app.get("/audit/catalog/acknowledged", async (_request, reply) =>
handleOperatorResponse(reply, async () => ({
acknowledged: await operator.listAcknowledgedCatalogEntries(),
})),
);

app.post("/audit/saved-views", async (request, reply) =>
handleOperatorResponse(reply, async () => {
const body = request.body as {
Expand Down Expand Up @@ -512,6 +518,22 @@ export function buildServer(options: BuildServerOptions = {}) {
}),
);

app.get(
"/audit/catalog/:catalogEntryId/acknowledgment",
async (request, reply) =>
handleOperatorResponse(reply, async () => {
const params = request.params as {
readonly catalogEntryId: string;
};

return {
acknowledgment: await operator.getCatalogChecklistItemAcknowledgment(
params.catalogEntryId,
),
};
}),
);

app.get("/audit/catalog/:catalogEntryId", async (request, reply) =>
handleOperatorResponse(reply, async () => {
const params = request.params as {
Expand Down Expand Up @@ -812,6 +834,42 @@ export function buildServer(options: BuildServerOptions = {}) {
}),
);

app.post(
"/audit/catalog/:catalogEntryId/acknowledgment",
async (request, reply) =>
handleOperatorResponse(reply, async () => {
const params = request.params as {
readonly catalogEntryId: string;
};
const body = request.body as {
readonly acknowledgmentNote?: string;
readonly items?: unknown;
};
const items = readChecklistItemAcknowledgmentItems(
body?.items,
"items",
);

if (items.length === 0) {
throw new OperatorInputError(
"items must include at least one checklist item acknowledgment entry.",
);
}

return {
acknowledgment: await operator.acknowledgeCatalogEntry(
params.catalogEntryId,
{
...(body?.acknowledgmentNote !== undefined
? { acknowledgmentNote: body.acknowledgmentNote }
: {}),
items,
},
),
};
}),
);

app.post(
"/audit/catalog/:catalogEntryId/review/clear",
async (request, reply) =>
Expand Down Expand Up @@ -956,6 +1014,23 @@ export function buildServer(options: BuildServerOptions = {}) {
}),
);

app.post(
"/audit/catalog/:catalogEntryId/acknowledgment/clear",
async (request, reply) =>
handleOperatorResponse(reply, async () => {
const params = request.params as {
readonly catalogEntryId: string;
};

return {
acknowledgment:
await operator.clearCatalogChecklistItemAcknowledgment(
params.catalogEntryId,
),
};
}),
);

app.get("/audit/saved-views/:savedViewId/apply", async (request, reply) =>
handleOperatorResponse(reply, async () => {
const params = request.params as {
Expand Down Expand Up @@ -1496,3 +1571,30 @@ function readChecklistItemAttestationItems(

return value;
}

function readChecklistItemAcknowledgmentItems(
value: unknown,
fieldName: string,
): readonly {
readonly item: string;
readonly state: "acknowledged" | "unacknowledged";
}[] {
if (
!Array.isArray(value) ||
!value.every(
(entry) =>
typeof entry === "object" &&
entry !== null &&
"item" in entry &&
typeof entry.item === "string" &&
"state" in entry &&
(entry.state === "acknowledged" || entry.state === "unacknowledged"),
)
) {
throw new OperatorInputError(
`${fieldName} must be an array of { item, state } objects with state acknowledged|unacknowledged.`,
);
}

return value;
}
120 changes: 120 additions & 0 deletions apps/web/src/app/runs/catalog/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,53 @@ export async function POST(request: Request) {
return Response.redirect(redirectUrl, 303);
}

if (intent === "acknowledge") {
const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId");
const acknowledgmentItems = readChecklistItemAcknowledgmentItems(
formData.get("acknowledgmentItems"),
);
const acknowledgmentNoteValue = formData.get("acknowledgmentNote");

if (!catalogEntryId) {
appendFlashMessage(
redirectUrl,
"error",
"A catalog entry id is required to update checklist item acknowledgment metadata.",
);

return Response.redirect(redirectUrl, 303);
}

if (acknowledgmentItems.length === 0) {
appendFlashMessage(
redirectUrl,
"error",
"At least one checklist item acknowledgment entry is required.",
);

return Response.redirect(redirectUrl, 303);
}

const acknowledgment =
await createRunrootApiClient().setAuditCatalogChecklistItemAcknowledgment(
catalogEntryId,
{
...(typeof acknowledgmentNoteValue === "string"
? { acknowledgmentNote: acknowledgmentNoteValue }
: {}),
items: acknowledgmentItems,
},
);

appendFlashMessage(
redirectUrl,
"notice",
`Checklist item acknowledgments for ${acknowledgment.attestation.evidence.verification.resolution.blocker.progress.checklist.assignment.review.visibility.catalogEntry.entry.name} updated.`,
);

return Response.redirect(redirectUrl, 303);
}

if (intent === "clear-review") {
const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId");

Expand Down Expand Up @@ -693,6 +740,33 @@ export async function POST(request: Request) {
return Response.redirect(redirectUrl, 303);
}

if (intent === "clear-acknowledgment") {
const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId");

if (!catalogEntryId) {
appendFlashMessage(
redirectUrl,
"error",
"A catalog entry id is required to clear checklist item acknowledgment metadata.",
);

return Response.redirect(redirectUrl, 303);
}

const acknowledgment =
await createRunrootApiClient().clearAuditCatalogChecklistItemAcknowledgment(
catalogEntryId,
);

appendFlashMessage(
redirectUrl,
"notice",
`Checklist item acknowledgments for ${acknowledgment.attestation.evidence.verification.resolution.blocker.progress.checklist.assignment.review.visibility.catalogEntry.entry.name} cleared.`,
);

return Response.redirect(redirectUrl, 303);
}

if (intent === "clear-progress") {
const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId");

Expand Down Expand Up @@ -1096,3 +1170,49 @@ function readChecklistItemAttestationItems(
};
});
}

function readChecklistItemAcknowledgmentItems(
value: FormDataEntryValue | null,
): readonly {
readonly item: string;
readonly state: "acknowledged" | "unacknowledged";
}[] {
if (typeof value !== "string") {
return [];
}

return value
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
const separatorIndex = line.indexOf(":");

if (separatorIndex < 0) {
return {
item: line,
state: "unacknowledged" as const,
};
}

const rawState = line.slice(0, separatorIndex).trim();
const item = line.slice(separatorIndex + 1).trim();

if (item.length === 0) {
throw new Error(
"Checklist item acknowledgment lines require a non-empty item.",
);
}

if (rawState !== "acknowledged" && rawState !== "unacknowledged") {
throw new Error(
`Checklist item acknowledgment state must be acknowledged or unacknowledged for "${item}".`,
);
}

return {
item,
state: rawState,
};
});
}
25 changes: 25 additions & 0 deletions apps/web/src/app/runs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
AuditViewCatalogsView,
CatalogReviewAssignmentsView,
CatalogReviewSignalsView,
ChecklistItemAcknowledgmentsView,
ChecklistItemAttestationsView,
ChecklistItemBlockersView,
ChecklistItemEvidencesView,
Expand All @@ -23,6 +24,7 @@ import {
} from "../../lib/navigation";
import {
type ApiAuditCatalogAssignmentChecklistView,
type ApiAuditCatalogChecklistItemAcknowledgmentView,
type ApiAuditCatalogChecklistItemAttestationView,
type ApiAuditCatalogChecklistItemBlockerView,
type ApiAuditCatalogChecklistItemEvidenceView,
Expand Down Expand Up @@ -62,6 +64,7 @@ export default async function RunsPage({
const drilldownFilters = readAuditDrilldownFilters(resolvedSearchParams);
const [
runs,
acknowledgedEntries,
blockedEntries,
evidencedEntries,
attestedEntries,
Expand All @@ -78,6 +81,7 @@ export default async function RunsPage({
catalogChecklistItemBlocker,
catalogChecklistItemResolution,
catalogChecklistItemEvidence,
catalogChecklistItemAcknowledgment,
catalogChecklistItemAttestation,
catalogChecklistItemVerification,
catalogChecklistItemProgress,
Expand All @@ -86,6 +90,7 @@ export default async function RunsPage({
catalogReviewAssignment,
] = await Promise.all([
api.listRuns(),
api.listAcknowledgedAuditCatalogEntries(),
api.listBlockedAuditCatalogEntries(),
api.listEvidencedAuditCatalogEntries(),
api.listAttestedAuditCatalogEntries(),
Expand Down Expand Up @@ -123,6 +128,11 @@ export default async function RunsPage({
.getAuditCatalogChecklistItemEvidence(catalogEntryId)
.catch(() => undefined)
: Promise.resolve(undefined),
catalogEntryId
? api
.getAuditCatalogChecklistItemAcknowledgment(catalogEntryId)
.catch(() => undefined)
: Promise.resolve(undefined),
catalogEntryId
? api
.getAuditCatalogChecklistItemAttestation(catalogEntryId)
Expand Down Expand Up @@ -166,6 +176,9 @@ export default async function RunsPage({
let activeCatalogChecklistItemEvidence:
| ApiAuditCatalogChecklistItemEvidenceView
| undefined;
let activeCatalogChecklistItemAcknowledgment:
| ApiAuditCatalogChecklistItemAcknowledgmentView
| undefined;
let activeCatalogChecklistItemAttestation:
| ApiAuditCatalogChecklistItemAttestationView
| undefined;
Expand All @@ -187,6 +200,8 @@ export default async function RunsPage({
activeCatalogChecklistItemBlocker = catalogChecklistItemBlocker;
activeCatalogChecklistItemResolution = catalogChecklistItemResolution;
activeCatalogChecklistItemEvidence = catalogChecklistItemEvidence;
activeCatalogChecklistItemAcknowledgment =
catalogChecklistItemAcknowledgment;
activeCatalogChecklistItemAttestation = catalogChecklistItemAttestation;
activeCatalogChecklistItemVerification = catalogChecklistItemVerification;
activeCatalogChecklistItemProgress = catalogChecklistItemProgress;
Expand Down Expand Up @@ -226,6 +241,12 @@ export default async function RunsPage({
? { activeCatalogChecklistItemEvidence }
: {})}
/>
<ChecklistItemAcknowledgmentsView
acknowledgedEntries={acknowledgedEntries}
{...(activeCatalogChecklistItemAcknowledgment
? { activeCatalogChecklistItemAcknowledgment }
: {})}
/>
<ChecklistItemAttestationsView
attestedEntries={attestedEntries}
{...(activeCatalogChecklistItemAttestation
Expand Down Expand Up @@ -266,6 +287,7 @@ export default async function RunsPage({
catalogEntries={catalogEntries}
checklistedEntries={checklistedEntries}
evidencedEntries={evidencedEntries}
acknowledgedEntries={acknowledgedEntries}
attestedEntries={attestedEntries}
progressedEntries={progressedEntries}
resolvedEntries={resolvedEntries}
Expand All @@ -281,6 +303,9 @@ export default async function RunsPage({
{...(activeCatalogChecklistItemEvidence
? { activeCatalogChecklistItemEvidence }
: {})}
{...(activeCatalogChecklistItemAcknowledgment
? { activeCatalogChecklistItemAcknowledgment }
: {})}
{...(activeCatalogChecklistItemAttestation
? { activeCatalogChecklistItemAttestation }
: {})}
Expand Down
Loading
Loading