import { writeFile } from 'node:fs/promises'; import path from 'node:path'; import { and, isNull } from 'drizzle-orm'; import { crmQuotations } from '../src/db/schema'; import { db } from '../src/lib/db'; import { getQuotationDocumentPreviewData } from '../src/features/crm/quotations/document/server/service'; import { resolveTemplateForDocument, resolveTemplateMappings } from '../src/features/foundation/document-template/server/service'; type AuditIssue = | 'NO_MAPPING' | 'NO_TEMPLATE_FIELD' | 'RESOLVED_UNDEFINED' | 'RESOLVED_NULL' | 'TABLE_TYPE_MISMATCH' | 'TOPIC_MAPPING_MISMATCH'; type AuditClassification = | 'static_field' | 'dynamic_template' | 'legacy_content_token' | 'runtime_dynamic_input'; interface AuditResult { placeholderKey: string; templateExists: boolean; mappingExists: boolean; sourcePath: string | null; resolvedValue: unknown; resolvedType: string; classification: AuditClassification; dynamicTemplate: boolean; legacyContentToken: boolean; runtimeGenerated: boolean; issues: AuditIssue[]; notes: string[]; } const STATIC_EXPECTED_KEYS = new Set([ 'customer_name', 'customer_addr', 'customer_tel', 'customer_email', 'customer_att', 'project_name', 'site_location', 'quotation_date', 'quotation_code', 'quotation_price', 'currency', 'exclusion_data', 'app1', 'app1_position', 'app2', 'app2_position', 'app3', 'app3_position' ]); const DYNAMIC_TEMPLATE_NAMES = new Set(['topic', 'data_topic']); const LEGACY_CONTENT_TOKENS = new Set(['item_topic']); function extractSchemaInventory(template: unknown) { const fieldNames = new Set(); const contentTokens = new Set(); if (!template || typeof template !== 'object' || !('schemas' in template)) { return { fieldNames, contentTokens }; } const pages = (template as { schemas?: unknown }).schemas; if (!Array.isArray(pages)) { return { fieldNames, contentTokens }; } for (const page of pages) { if (!Array.isArray(page)) { continue; } for (const field of page) { if (!field || typeof field !== 'object') { continue; } if ('name' in field && typeof field.name === 'string') { fieldNames.add(field.name); } for (const token of extractTokensFromValue((field as { content?: unknown }).content)) { contentTokens.add(token); } const head = (field as { head?: unknown }).head; if (Array.isArray(head)) { for (const value of head) { for (const token of extractTokensFromValue(value)) { contentTokens.add(token); } } } } } return { fieldNames, contentTokens }; } function extractTokensFromValue(value: unknown) { const tokens = new Set(); if (typeof value !== 'string') { return tokens; } for (const match of value.matchAll(/\{([^}]+)\}/g)) { tokens.add(match[1]); } try { const parsed = JSON.parse(value) as unknown; if (Array.isArray(parsed)) { for (const row of parsed) { if (!Array.isArray(row)) { continue; } for (const cell of row) { if (typeof cell !== 'string') { continue; } for (const match of cell.matchAll(/\{([^}]+)\}/g)) { tokens.add(match[1]); } } } } } catch { return tokens; } return tokens; } function classifyKey(key: string): AuditClassification { if (DYNAMIC_TEMPLATE_NAMES.has(key)) { return 'dynamic_template'; } if (LEGACY_CONTENT_TOKENS.has(key)) { return 'legacy_content_token'; } if (key.startsWith('topic_') || key.startsWith('item_topic_')) { return 'runtime_dynamic_input'; } return 'static_field'; } async function findTestQuotation() { const quotations = await db .select() .from(crmQuotations) .where(and(isNull(crmQuotations.deletedAt))) .limit(5); if (quotations.length === 0) { throw new Error('No quotations found in database'); } return quotations[0]; } async function auditPdfmeRuntime() { console.log('=== PDFME Runtime Audit ===\n'); console.log('Step 1: Finding test quotation...'); const quotation = await findTestQuotation(); console.log(` Found quotation: ${quotation.code} (${quotation.id})`); console.log('\nStep 2: Getting base template and mappings...'); const template = await resolveTemplateForDocument(quotation.organizationId, { documentType: 'quotation', productType: 'default', fileType: 'pdfme' }); const mappings = await resolveTemplateMappings(template.version.id, quotation.organizationId); console.log(` Template: ${template.template.templateName} (${template.version.id})`); console.log(` Mappings found: ${mappings.length}`); console.log('\nStep 3: Building preview runtime data...'); const preview = await getQuotationDocumentPreviewData( quotation.id, quotation.organizationId ); const templateInput = preview.templateInput; console.log(' Preview runtime data built successfully'); console.log('\nStep 4: Extracting schema inventory from base template...'); const inventory = extractSchemaInventory(template.version.schemaJson); const auditedKeys = new Set([ ...STATIC_EXPECTED_KEYS, ...inventory.fieldNames, ...inventory.contentTokens, ...Object.keys(templateInput).filter( (key) => key.startsWith('topic_') || key.startsWith('item_topic_') ) ]); console.log(` Audited keys found: ${auditedKeys.size}`); console.log('\nStep 5: Performing audit...\n'); const mappingMap = new Map(); mappings.forEach((mapping) => mappingMap.set(mapping.placeholderKey, mapping)); const auditResults: AuditResult[] = []; for (const key of auditedKeys) { const classification = classifyKey(key); const mappingExists = mappingMap.has(key); const templateExists = inventory.fieldNames.has(key) || inventory.contentTokens.has(key); const result: AuditResult = { placeholderKey: key, templateExists, mappingExists, sourcePath: mappingMap.get(key)?.sourcePath ?? null, resolvedValue: templateInput[key], resolvedType: typeof templateInput[key], classification, dynamicTemplate: classification === 'dynamic_template', legacyContentToken: classification === 'legacy_content_token', runtimeGenerated: classification === 'runtime_dynamic_input', issues: [], notes: [] }; if (classification === 'static_field' && templateExists && !mappingExists) { result.issues.push('NO_MAPPING'); result.notes.push('Static schema field exists in template but has no DB mapping'); } if (classification === 'static_field' && mappingExists && !templateExists) { result.issues.push('NO_TEMPLATE_FIELD'); result.notes.push('DB mapping exists but no corresponding schema field or content token was found'); } if (classification === 'static_field' && result.resolvedValue === undefined) { result.issues.push('RESOLVED_UNDEFINED'); result.notes.push('Resolved runtime value is undefined'); } if (classification === 'static_field' && result.resolvedValue === null) { result.issues.push('RESOLVED_NULL'); result.notes.push('Resolved runtime value is null'); } const mapping = mappingMap.get(key); if (mapping?.dataType === 'table') { const value = result.resolvedValue; if (value && !Array.isArray(value)) { result.issues.push('TABLE_TYPE_MISMATCH'); result.notes.push(`Expected string[][] for table mapping, got ${typeof value}`); } else if ( Array.isArray(value) && value.length > 0 && !Array.isArray(value[0]) ) { result.issues.push('TABLE_TYPE_MISMATCH'); result.notes.push('Expected string[][] for table mapping, got a flat array'); } } if (classification === 'dynamic_template') { if (mappingExists) { result.issues.push('TOPIC_MAPPING_MISMATCH'); result.notes.push('Dynamic schema template must not have a static DB mapping'); } else { result.notes.push('Handled by Dynamic Topic Engine schema cloning'); } } if (classification === 'legacy_content_token') { if (mappingExists) { result.issues.push('TOPIC_MAPPING_MISMATCH'); result.notes.push('Legacy item_topic token must not persist as a DB mapping'); } else { result.notes.push('Injected only through dynamic topic runtime inputs'); } } if (classification === 'runtime_dynamic_input') { if (mappingExists) { result.issues.push('TOPIC_MAPPING_MISMATCH'); result.notes.push('Runtime dynamic topic keys must not be stored in DB mappings'); } else if (result.resolvedValue !== undefined) { result.notes.push('Generated at runtime by Dynamic Topic Engine'); } } auditResults.push(result); } console.log('## Summary'); console.log( `- Static template placeholders: ${[...inventory.fieldNames].filter((key) => STATIC_EXPECTED_KEYS.has(key)).length}` ); console.log(`- Template mappings: ${mappings.length}`); console.log( `- Dynamic topic runtime keys: ${auditResults.filter((item) => item.runtimeGenerated).length}` ); console.log(`- Issues found: ${auditResults.filter((item) => item.issues.length > 0).length}`); console.log(''); auditResults.sort((left, right) => { if (left.issues.length > 0 && right.issues.length === 0) { return -1; } if (left.issues.length === 0 && right.issues.length > 0) { return 1; } return left.placeholderKey.localeCompare(right.placeholderKey); }); console.log('## Detailed Results\n'); for (const result of auditResults) { console.log(`### ${result.placeholderKey}`); console.log(`- Template: ${result.templateExists ? 'yes' : 'no'}`); console.log(`- Mapping: ${result.mappingExists ? 'yes' : 'no'}`); console.log(`- Classification: ${result.classification}`); console.log(`- Source: ${result.sourcePath ?? '-'}`); console.log( `- Value: ${JSON.stringify(result.resolvedValue).slice(0, 120)}${JSON.stringify(result.resolvedValue).length > 120 ? '...' : ''}` ); console.log(`- Type: ${result.resolvedType}`); if (result.issues.length > 0) { console.log(`- Issues: ${result.issues.join(', ')}`); } if (result.notes.length > 0) { console.log(`- Notes: ${result.notes.join('; ')}`); } console.log(''); } const outputPath = path.join( process.cwd(), 'docs', 'implementation', 'pdfme-runtime-audit-data.json' ); await writeFile( outputPath, JSON.stringify( { quotation: { id: quotation.id, code: quotation.code, organizationId: quotation.organizationId }, template: { id: template.template.id, templateName: template.template.templateName, versionId: template.version.id, version: template.version.version }, schemaInventory: { fieldNames: [...inventory.fieldNames].sort(), contentTokens: [...inventory.contentTokens].sort() }, runtimeDynamicKeys: Object.keys(templateInput) .filter((key) => key.startsWith('topic_') || key.startsWith('item_topic_')) .sort(), results: auditResults, documentData: preview.documentData, templateInput }, null, 2 ) ); console.log(`Full audit data saved to: ${outputPath}`); } auditPdfmeRuntime().catch((error) => { console.error('Audit failed:', error); process.exit(1); });