/*
RFC-0885: PBP evidence schema migrator — transforms existing Nachweis consent and
evidence-source entities to the new consentScope and display fields. Idempotent: running
twice produces the same result.
- Does not create new entities — only transforms existing ones.
- Does not set websiteUrl or websiteScreenshot — operators add those manually after migration.
- RFC-0885: initial migrator — consentStatus/grantedAt/method → consentScope, add default display to Nachweis evidence kinds.
*/
import path from "node:path";
import {
parseMarkdownFrontmatter,
stringifyMarkdownFrontmatter,
} from "@warpgogol/werkstatt-shared/content";
import type { Migrator, SternsystemData, MigrationContext } from "./types.ts";
import { MigrationError } from "./types.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export const RFC_0885_MIGRATOR_ID = "rfc-0885";
const NACHWEIS_EVIDENCE_KINDS = new Set([
"client-statement",
"project-confirmation",
"certificate",
"operational-evidence",
"technical-assessment",
]);
const DEFAULT_NOT_REQUESTED = {
status: "not_requested",
grantedAt: null,
method: "none",
};
function mapConsentStatus(
oldStatus: string,
oldGrantedAt: string | null,
oldMethod: string,
): { document: unknown; screenshot: unknown; websiteLink: unknown } {
switch (oldStatus) {
case "partially_granted":
case "granted":
return {
document: {
status: "granted",
grantedAt: oldGrantedAt,
method: oldMethod,
},
screenshot: { ...DEFAULT_NOT_REQUESTED },
websiteLink: { ...DEFAULT_NOT_REQUESTED },
};
case "revoked":
return {
document: { status: "denied", grantedAt: null, method: "none" },
screenshot: { ...DEFAULT_NOT_REQUESTED },
websiteLink: { ...DEFAULT_NOT_REQUESTED },
};
case "not_requested":
case "requested":
case "expired":
default:
return {
document: { ...DEFAULT_NOT_REQUESTED },
screenshot: { ...DEFAULT_NOT_REQUESTED },
websiteLink: { ...DEFAULT_NOT_REQUESTED },
};
}
}
async function migrateConsentFiles(
rootPath: string,
ctx: MigrationContext,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const bpBaseDir = path.join(rootPath, "src", "content", "business-profile");
let langDirs: string[];
try {
langDirs = (await io.readdir(bpBaseDir)).map((e) => e.name);
} catch {
ctx.logger.info(
`[rfc-0885] no business-profile directory at ${bpBaseDir} — skipping consent migration`,
);
return;
}
for (const lang of langDirs) {
const consentDir = path.join(bpBaseDir, lang, "consent");
const stat = await io.stat(consentDir).catch(() => null);
if (!stat?.isDirectory) continue;
const files = (await io.readdir(consentDir)).filter((f) => f.name.endsWith(".md"));
for (const file of files) {
const filePath = path.join(consentDir, file.name);
let content: string;
try {
content = await io.readFile(filePath);
} catch {
continue;
}
const { data, content: body } = parseMarkdownFrontmatter(content);
// Skip if already migrated (consentScope present, no consentStatus)
if (data.consentScope != null && data.consentStatus == null) continue;
// Skip if no old fields and no consentScope — nothing to migrate
if (data.consentStatus == null && data.consentScope == null) continue;
const oldStatus = (data.consentStatus as string | undefined) ?? "not_requested";
const oldGrantedAt = (data.grantedAt as string | null | undefined) ?? null;
const oldMethod = (data.method as string | undefined) ?? "none";
const scope = mapConsentStatus(oldStatus, oldGrantedAt, oldMethod);
data.consentScope = scope;
delete data.consentStatus;
delete data.grantedAt;
delete data.method;
const updated = stringifyMarkdownFrontmatter(body, data);
try {
await io.writeFile(filePath, updated);
ctx.logger.info(`[rfc-0885] migrated consent: ${lang}/${file}`);
} catch (err) {
throw new MigrationError(
RFC_0885_MIGRATOR_ID,
filePath,
"consentScope",
`Failed to update consent entity: ${(err as Error).message}`,
);
}
}
}
}
async function migrateEvidenceSourceFiles(
rootPath: string,
ctx: MigrationContext,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const bpBaseDir = path.join(rootPath, "src", "content", "business-profile");
let langDirs: string[];
try {
langDirs = (await io.readdir(bpBaseDir)).map((e) => e.name);
} catch {
return;
}
for (const lang of langDirs) {
const esDir = path.join(bpBaseDir, lang, "evidence-source");
const stat = await io.stat(esDir).catch(() => null);
if (!stat?.isDirectory) continue;
const files = (await io.readdir(esDir)).filter((f) => f.name.endsWith(".md"));
for (const file of files) {
const filePath = path.join(esDir, file.name);
let content: string;
try {
content = await io.readFile(filePath);
} catch {
continue;
}
const { data, content: body } = parseMarkdownFrontmatter(content);
// Only migrate Nachweis evidence kinds
const kind = data.kind as string | undefined;
if (kind == null || !NACHWEIS_EVIDENCE_KINDS.has(kind)) continue;
// Skip if display already present
if (data.display != null) continue;
// Add default display: document visible, others hidden
data.display = {
document: "visible",
screenshot: "hidden",
websiteLink: "hidden",
};
const updated = stringifyMarkdownFrontmatter(body, data);
try {
await io.writeFile(filePath, updated);
ctx.logger.info(`[rfc-0885] migrated evidence-source: ${lang}/${file}`);
} catch (err) {
throw new MigrationError(
RFC_0885_MIGRATOR_ID,
filePath,
"display",
`Failed to update evidence-source entity: ${(err as Error).message}`,
);
}
}
}
}
export const rfc0885Migrator: Migrator = {
id: RFC_0885_MIGRATOR_ID,
fromVersion: "6.22.0",
toVersion: "6.23.0",
description:
"Transform existing Nachweis consent entities (consentStatus/grantedAt/method → consentScope) and add default display to Nachweis evidence-source entities. Advances migratorCursor for RFC-0885 (PBP evidence schema display control, granular consent, website fields).",
transform: async (data: SternsystemData, ctx: MigrationContext) => {
await migrateConsentFiles(data.rootPath, ctx);
await migrateEvidenceSourceFiles(data.rootPath, ctx);
return data;
},
};