241 lines
7.7 KiB
TypeScript
241 lines
7.7 KiB
TypeScript
import {
|
|
buildAuditPayload,
|
|
createSqlClient,
|
|
getActiveTemplateVersions,
|
|
loadTemplateSourceByOrganizationName,
|
|
summarizeIssues,
|
|
writeAuditArtifact
|
|
} from './pdf-audit-utils.ts';
|
|
|
|
type IntegrityIssue = {
|
|
kind:
|
|
| 'DUPLICATE_ACTIVE_TEMPLATE'
|
|
| 'DUPLICATE_ACTIVE_VERSION'
|
|
| 'ACTIVE_VERSION_WITHOUT_TEMPLATE'
|
|
| 'MAPPING_WITHOUT_VERSION'
|
|
| 'INVALID_APPROVED_TEMPLATE_VERSION'
|
|
| 'ACTIVE_VERSION_SCHEMA_DRIFT'
|
|
| 'MISSING_RETAINED_INACTIVE_VERSION';
|
|
message: string;
|
|
context: Record<string, unknown>;
|
|
};
|
|
|
|
async function main() {
|
|
const sql = createSqlClient();
|
|
|
|
try {
|
|
const [
|
|
activeTemplateVersions,
|
|
versionRows,
|
|
orphanMappingRows,
|
|
payload,
|
|
templateRows
|
|
] = await Promise.all([
|
|
getActiveTemplateVersions(sql),
|
|
sql`
|
|
select
|
|
v.id,
|
|
v.template_id as "templateId",
|
|
v.organization_id as "organizationId",
|
|
o.name as "organizationName",
|
|
v.version,
|
|
v.is_active as "isActive",
|
|
v.schema_json as "schemaJson",
|
|
t.id as "resolvedTemplateId"
|
|
from crm_document_template_versions v
|
|
left join crm_document_templates t
|
|
on t.id = v.template_id
|
|
left join organizations o
|
|
on o.id = v.organization_id
|
|
where v.deleted_at is null
|
|
`,
|
|
sql`
|
|
select
|
|
m.id,
|
|
m.template_version_id as "templateVersionId",
|
|
m.placeholder_key as "placeholderKey"
|
|
from crm_document_template_mappings m
|
|
left join crm_document_template_versions v
|
|
on v.id = m.template_version_id
|
|
where m.deleted_at is null
|
|
and (v.id is null or v.deleted_at is not null)
|
|
`,
|
|
buildAuditPayload(sql),
|
|
sql`
|
|
select
|
|
t.id,
|
|
t.organization_id as "organizationId",
|
|
o.name as "organizationName",
|
|
t.template_name as "templateName"
|
|
from crm_document_templates t
|
|
inner join organizations o
|
|
on o.id = t.organization_id
|
|
where t.deleted_at is null
|
|
and t.document_type = 'quotation'
|
|
and t.file_type = 'pdfme'
|
|
`
|
|
]);
|
|
const issues: IntegrityIssue[] = [];
|
|
const templateBuckets = new Map<string, typeof activeTemplateVersions>();
|
|
|
|
for (const record of activeTemplateVersions) {
|
|
const bucketKey = [
|
|
record.organizationId,
|
|
record.documentType,
|
|
record.productType,
|
|
record.fileType
|
|
].join(':');
|
|
const items = templateBuckets.get(bucketKey) ?? [];
|
|
items.push(record);
|
|
templateBuckets.set(bucketKey, items);
|
|
}
|
|
|
|
for (const [bucketKey, records] of templateBuckets.entries()) {
|
|
const templateIds = [...new Set(records.map((record) => record.templateId))];
|
|
|
|
if (templateIds.length > 1) {
|
|
issues.push({
|
|
kind: 'DUPLICATE_ACTIVE_TEMPLATE',
|
|
message: `More than one active template exists for ${bucketKey}`,
|
|
context: {
|
|
bucketKey,
|
|
templateIds,
|
|
templates: records.map((record) => ({
|
|
templateId: record.templateId,
|
|
templateName: record.templateName,
|
|
versionId: record.versionId,
|
|
version: record.version
|
|
}))
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const versionBuckets = new Map<string, Array<Record<string, unknown>>>();
|
|
|
|
for (const row of versionRows as Array<Record<string, unknown>>) {
|
|
if (!row.isActive) {
|
|
continue;
|
|
}
|
|
|
|
const items = versionBuckets.get(String(row.templateId)) ?? [];
|
|
items.push(row);
|
|
versionBuckets.set(String(row.templateId), items);
|
|
|
|
if (!row.resolvedTemplateId) {
|
|
issues.push({
|
|
kind: 'ACTIVE_VERSION_WITHOUT_TEMPLATE',
|
|
message: `Active version ${String(row.id)} has no parent template`,
|
|
context: row
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const [templateId, rows] of versionBuckets.entries()) {
|
|
if (rows.length > 1) {
|
|
issues.push({
|
|
kind: 'DUPLICATE_ACTIVE_VERSION',
|
|
message: `Template ${templateId} has more than one active version`,
|
|
context: {
|
|
templateId,
|
|
versions: rows.map((row) => ({
|
|
id: row.id,
|
|
version: row.version
|
|
}))
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const templateRow of templateRows as Array<Record<string, unknown>>) {
|
|
const templateId = String(templateRow.id);
|
|
const rows = (versionRows as Array<Record<string, unknown>>).filter(
|
|
(row) => String(row.templateId) === templateId
|
|
);
|
|
const activeRows = rows.filter((row) => Boolean(row.isActive));
|
|
const inactiveRows = rows.filter((row) => !row.isActive);
|
|
const templateSource = loadTemplateSourceByOrganizationName(String(templateRow.organizationName));
|
|
|
|
if (templateSource && activeRows.length === 1) {
|
|
const activeSchema =
|
|
typeof activeRows[0].schemaJson === 'string'
|
|
? JSON.parse(String(activeRows[0].schemaJson))
|
|
: activeRows[0].schemaJson;
|
|
|
|
if (JSON.stringify(activeSchema) !== JSON.stringify(templateSource.schemaJson)) {
|
|
issues.push({
|
|
kind: 'ACTIVE_VERSION_SCHEMA_DRIFT',
|
|
message: `Active template version does not match source JSON for ${String(templateRow.templateName)}`,
|
|
context: {
|
|
templateId,
|
|
organizationName: templateRow.organizationName,
|
|
activeVersionId: activeRows[0].id,
|
|
activeVersion: activeRows[0].version,
|
|
sourceFile: templateSource.relativePath
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if (rows.length > 1 && inactiveRows.length === 0) {
|
|
issues.push({
|
|
kind: 'MISSING_RETAINED_INACTIVE_VERSION',
|
|
message: `Template ${String(templateRow.templateName)} has no retained inactive historical version`,
|
|
context: {
|
|
templateId,
|
|
organizationName: templateRow.organizationName
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const row of orphanMappingRows as Array<Record<string, unknown>>) {
|
|
issues.push({
|
|
kind: 'MAPPING_WITHOUT_VERSION',
|
|
message: `Mapping ${String(row.id)} points to a missing template version`,
|
|
context: row
|
|
});
|
|
}
|
|
|
|
const approvedTemplateVersionExists = payload.approvedTemplateVersionId
|
|
? (versionRows as Array<Record<string, unknown>>).some(
|
|
(row) => String(row.id) === payload.approvedTemplateVersionId
|
|
)
|
|
: true;
|
|
|
|
if (payload.approvedTemplateVersionId && !approvedTemplateVersionExists) {
|
|
issues.push({
|
|
kind: 'INVALID_APPROVED_TEMPLATE_VERSION',
|
|
message: 'Audit quotation approvedTemplateVersionId does not resolve to a retained template version',
|
|
context: {
|
|
quotationCode: payload.quotationCode,
|
|
approvedTemplateVersionId: payload.approvedTemplateVersionId
|
|
}
|
|
});
|
|
}
|
|
|
|
const status = summarizeIssues(issues.length ? ['FAIL'] : ['PASS']);
|
|
const report = {
|
|
audit: 'template-version-integrity',
|
|
overallStatus: status,
|
|
activeTemplateCount: activeTemplateVersions.length,
|
|
quotationCode: payload.quotationCode,
|
|
approvedTemplateVersionId: payload.approvedTemplateVersionId,
|
|
approvedTemplateVersionIsActive: activeTemplateVersions.some(
|
|
(record) => record.versionId === payload.approvedTemplateVersionId
|
|
),
|
|
issues
|
|
};
|
|
const artifactPath = await writeAuditArtifact('template-version-report.json', report);
|
|
|
|
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
|
|
} finally {
|
|
await sql.end({ timeout: 5 });
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[audit-template-version-integrity] ${error.message}`);
|
|
process.exit(1);
|
|
});
|