import { createHash, randomUUID } from 'node:crypto'; import { buildSupportedMappingsForFieldNames, bumpMinorTemplateVersion, classifyTemplateField, computeFieldDiff, createSqlClient, extractTemplateFields, getEffectiveMappedKeys, loadTemplateSourceByOrganizationName, summarizeIssues, type TemplateMappingSeed, type TemplateSourceVariant, } from './pdf-audit-utils.ts'; type TemplateRow = { id: string; organizationId: string; organizationName: string; templateName: string; createdBy: string; updatedBy: string; }; type TemplateVersionRow = { id: string; templateId: string; version: string; schemaJson: unknown; filePath: string | null; isActive: boolean; createdAt: string; }; type ScriptOptions = { variant: TemplateSourceVariant; activate: boolean; }; function computeSchemaHash(schemaJson: unknown): string { return createHash('sha256').update(JSON.stringify(schemaJson)).digest('hex'); } function normalizeSchemaJson(schemaJson: unknown): unknown { if (typeof schemaJson === 'string') { return JSON.parse(schemaJson) as unknown; } return schemaJson; } function parseArgs(argv: string[]): ScriptOptions { let variant: TemplateSourceVariant = 'legacy'; let activate = false; for (const arg of argv) { if (arg === '--activate') { activate = true; continue; } if (arg.startsWith('--template-variant=')) { const value = arg.slice('--template-variant='.length); if (value === 'legacy' || value === 'product-v1') { variant = value; } } } if (variant === 'legacy') { activate = true; } return { variant, activate }; } function resolveVersionLabel(args: { variant: TemplateSourceVariant; versions: TemplateVersionRow[]; activeVersion: TemplateVersionRow; matchingVersion: TemplateVersionRow | null; }): string { if (args.matchingVersion) { return args.matchingVersion.version; } if (args.variant === 'product-v1') { return '2.0'; } return bumpMinorTemplateVersion(args.versions.at(-1)?.version ?? args.activeVersion.version); } async function upsertMapping( sql: any, organizationId: string, templateVersionId: string, mapping: TemplateMappingSeed, ) { const existing = ( await sql` select id from crm_document_template_mappings where template_version_id = ${templateVersionId} and placeholder_key = ${mapping.placeholderKey} limit 1 ` )[0] as { id: string } | undefined; const mappingId = existing?.id ?? randomUUID(); if (existing) { await sql` update crm_document_template_mappings set organization_id = ${organizationId}, source_path = ${mapping.sourcePath}, data_type = ${mapping.dataType}, sheet_name = ${null}, default_value = ${mapping.defaultValue ?? null}, format_mask = ${mapping.formatMask ?? null}, sort_order = ${mapping.sortOrder}, deleted_at = ${null}, updated_at = now() where id = ${mappingId} `; } else { await sql` insert into crm_document_template_mappings ( id, organization_id, template_version_id, placeholder_key, source_path, data_type, sheet_name, default_value, format_mask, sort_order ) values ( ${mappingId}, ${organizationId}, ${templateVersionId}, ${mapping.placeholderKey}, ${mapping.sourcePath}, ${mapping.dataType}, ${null}, ${mapping.defaultValue ?? null}, ${mapping.formatMask ?? null}, ${mapping.sortOrder} ) `; } const desiredColumnNames = new Set((mapping.columns ?? []).map((column) => column.columnName)); for (const column of mapping.columns ?? []) { const existingColumn = ( await sql` select id from crm_document_template_table_columns where mapping_id = ${mappingId} and column_name = ${column.columnName} and deleted_at is null limit 1 ` )[0] as { id: string } | undefined; const columnId = existingColumn?.id ?? randomUUID(); if (existingColumn) { await sql` update crm_document_template_table_columns set organization_id = ${organizationId}, source_field = ${column.sourceField}, column_letter = ${column.columnLetter ?? null}, sort_order = ${column.sortOrder ?? 0}, format_mask = ${column.formatMask ?? null}, deleted_at = ${null}, updated_at = now() where id = ${columnId} `; } else { await sql` insert into crm_document_template_table_columns ( id, organization_id, mapping_id, column_name, source_field, column_letter, sort_order, format_mask ) values ( ${columnId}, ${organizationId}, ${mappingId}, ${column.columnName}, ${column.sourceField}, ${column.columnLetter ?? null}, ${column.sortOrder ?? 0}, ${column.formatMask ?? null} ) `; } } const existingColumns = await sql` select id, column_name as "columnName" from crm_document_template_table_columns where mapping_id = ${mappingId} and deleted_at is null ` as Array<{ id: string; columnName: string }>; for (const column of existingColumns) { if (desiredColumnNames.has(column.columnName)) { continue; } await sql` update crm_document_template_table_columns set deleted_at = now(), updated_at = now() where id = ${column.id} `; } } async function main() { const options = parseArgs(process.argv.slice(2)); const sql = createSqlClient(); try { const templates = await sql` select t.id, t.organization_id as "organizationId", o.name as "organizationName", t.template_name as "templateName", t.created_by as "createdBy", t.updated_by as "updatedBy" from crm_document_templates t inner join organizations o on o.id = t.organization_id where t.document_type = 'quotation' and t.file_type = 'pdfme' and t.product_type = 'default' and t.deleted_at is null order by o.name asc, t.created_at asc ` as TemplateRow[]; const results: Array> = []; for (const template of templates) { const templateSource = loadTemplateSourceByOrganizationName( template.organizationName, options.variant, ); if (!templateSource) { results.push({ organizationName: template.organizationName, templateName: template.templateName, status: 'WARNING', message: 'No source template file found for organization.', }); continue; } const versions = await sql` select id, template_id as "templateId", version, schema_json as "schemaJson", file_path as "filePath", is_active as "isActive", created_at as "createdAt" from crm_document_template_versions where template_id = ${template.id} and deleted_at is null order by created_at asc ` as TemplateVersionRow[]; const activeVersion = versions.find((version) => version.isActive) ?? versions.at(-1) ?? null; if (!activeVersion) { throw new Error(`Template ${template.templateName} has no version rows.`); } const targetSchema = templateSource.schemaJson; const targetSchemaHash = computeSchemaHash(targetSchema); const matchingVersion = versions.find( (version) => computeSchemaHash(normalizeSchemaJson(version.schemaJson)) === targetSchemaHash, ) ?? null; const previousFields = extractTemplateFields(normalizeSchemaJson(activeVersion.schemaJson)); const targetFields = extractTemplateFields(targetSchema); const targetFieldNames = new Set(targetFields.map((field) => field.name).filter(Boolean)); const targetTokens = new Set(targetFields.flatMap((field) => field.tokens)); const supportedMappings = buildSupportedMappingsForFieldNames( targetFieldNames, targetTokens, ); const mappedKeys = getEffectiveMappedKeys(targetFields, supportedMappings); const fieldDiff = computeFieldDiff(targetFields, previousFields); const createdNewVersion = !matchingVersion; const targetVersionLabel = resolveVersionLabel({ variant: options.variant, versions, activeVersion, matchingVersion, }); const targetVersionId = matchingVersion?.id ?? randomUUID(); await sql.begin(async (tx) => { if (!matchingVersion) { await tx` insert into crm_document_template_versions ( id, organization_id, template_id, version, file_path, schema_json, preview_image_url, is_active, deleted_at, created_by ) values ( ${targetVersionId}, ${template.organizationId}, ${template.id}, ${targetVersionLabel}, ${templateSource.relativePath}, ${JSON.stringify(targetSchema)}, ${null}, ${options.activate}, ${null}, ${template.updatedBy || template.createdBy} ) `; } else { await tx` update crm_document_template_versions set file_path = ${templateSource.relativePath}, is_active = ${options.activate ? true : matchingVersion.isActive}, updated_at = now() where id = ${targetVersionId} `; } if (options.activate) { await tx` update crm_document_template_versions set is_active = case when id = ${targetVersionId} then true else false end, updated_at = now() where template_id = ${template.id} and deleted_at is null `; } for (const mapping of supportedMappings) { await upsertMapping(tx, template.organizationId, targetVersionId, mapping); } const desiredKeys = new Set(supportedMappings.map((mapping) => mapping.placeholderKey)); const existingMappings = await tx` select id, placeholder_key as "placeholderKey" from crm_document_template_mappings where template_version_id = ${targetVersionId} and deleted_at is null ` as Array<{ id: string; placeholderKey: string }>; for (const mapping of existingMappings) { if (desiredKeys.has(mapping.placeholderKey)) { continue; } await tx` update crm_document_template_mappings set deleted_at = now(), updated_at = now() where id = ${mapping.id} `; } }); const classificationCounts = new Map(); for (const field of targetFields) { const classification = classifyTemplateField(field, mappedKeys); classificationCounts.set( classification, (classificationCounts.get(classification) ?? 0) + 1, ); } results.push({ organizationName: template.organizationName, templateName: template.templateName, variant: options.variant, activated: options.activate, sourceFile: templateSource.relativePath, previousActiveVersion: activeVersion.version, targetVersion: targetVersionLabel, createdNewVersion, retainedInactiveVersions: versions.filter((version) => version.id !== targetVersionId).length, deletedFields: fieldDiff.deletedFields, newFields: fieldDiff.newFields, mappingKeys: supportedMappings.map((mapping) => mapping.placeholderKey), classificationCounts: Object.fromEntries(classificationCounts.entries()), status: summarizeIssues([ supportedMappings.some((mapping) => mapping.placeholderKey === 'tel_label') ? 'PASS' : 'FAIL', options.variant === 'product-v1' && supportedMappings.some((mapping) => mapping.placeholderKey === 'items_table') ? 'PASS' : 'PASS', ]), }); } const overallStatus = summarizeIssues( results.map((result) => String(result.status) as 'PASS' | 'WARNING' | 'FAIL'), ); console.log( JSON.stringify( { action: 'reseed-pdf-template-version', overallStatus, variant: options.variant, activate: options.activate, resultCount: results.length, results, }, null, 2, ), ); } finally { await sql.end({ timeout: 5 }); } } main().catch((error) => { console.error(`[reseed-pdf-template-version] ${error.message}`); process.exit(1); });