Files
justvitamin/scripts/audit-content-readiness.ts
Omair Saleh 056c47581f feat: editorial review dashboard + elite-grade pilot batch (5 SKUs)
Ships the second dashboard surface — a Pattern Library + Preview Theatre — that
presents the 4-section PDP pilot batch back to Umar, compliance, and the board
in an editorial format. Adds the full data layer that drives it: 5 source-backed
per-SKU drafts at QA 100/100, 15 competitor PDP semantic extracts, PubMed
evidence packs, EFSA claims library extension, JV brand voice guide, hand-curated
product FAQs, and the Matrixify-ready CSV exports for Lewis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 18:50:09 +08:00

123 lines
7.7 KiB
TypeScript

#!/usr/bin/env bun
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
const root = process.cwd()
const outRoot = join(root, 'content_population_exports')
const contentRoot = join(root, 'data', 'content')
const productRoot = join(contentRoot, 'products')
const workflowRoot = join(contentRoot, 'workflow')
const intelRoot = join(root, 'data', 'intelligence')
function readJson(path: string, fallback: any = null) {
if (!existsSync(path)) return fallback
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
}
function writeJson(path: string, data: any) { writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8') }
function csv(rows: any[], headers: string[]) {
const esc = (value: any) => {
if (Array.isArray(value)) value = value.join(' | ')
if (value === undefined || value === null) value = ''
value = String(value)
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
}
return [headers.join(','), ...rows.map(row => headers.map(header => esc(row[header])).join(','))].join('\n') + '\n'
}
function files(dir: string, suffix = '.json') {
if (!existsSync(dir)) return []
return readdirSync(dir).filter(file => file.endsWith(suffix))
}
function gate(name: string, ready: boolean, evidence: any, blocker: string) {
return { name, status: ready ? 'pass' : 'blocked', ready, evidence, blocker: ready ? '' : blocker }
}
const products = files(productRoot).map(file => readJson(join(productRoot, file))).filter(Boolean)
const workflowFiles = files(workflowRoot).filter(file => file !== 'index.json')
const asset = readJson(join(outRoot, 'asset_manifest_validation.json'), {})
const handoff = readJson(join(outRoot, 'handoff_manifest.json'), {})
const template = readJson(join(outRoot, 'template_schema_validation.json'), {})
const sourceDrops = readJson(join(outRoot, 'source_drop_validation.json'), {})
const claims = readJson(join(outRoot, 'claims_library_validation.json'), {})
const sourceRequest = readJson(join(outRoot, 'source_request_manifest.json'), {})
const plannedImages = readJson(join(outRoot, 'planned_image_filenames.json'), {})
const meta = readJson(join(contentRoot, 'metaobjects.json'), { definitions: {} })
const collections = readJson(join(contentRoot, 'smart-collections.json'), { collections: [] })
const requiredIntel = ['review-aspects', 'strengths-weaknesses', 'conversion-blockers', 'improvements', 'image-audit', 'photo-brief', 'competitor-comparison', 'amazon-reviews', 'reddit-pulse', 'flavor-intelligence', 'audience-profile', 'claims-evidence']
const intelCoverage = requiredIntel.map(view => {
const count = products.filter(product => existsSync(join(intelRoot, product.sku, `${view}.json`))).length
return { view, count, missing: products.length - count }
})
const approvalCounts = products.reduce((acc: any, product: any) => {
for (const lane of ['content', 'compliance', 'images', 'export']) {
const value = product.approval?.[lane] || 'missing'
acc[lane] = acc[lane] || {}
acc[lane][value] = (acc[lane][value] || 0) + 1
}
return acc
}, {})
const promoCards = meta.definitions?.promo_card || []
const mappedCollections = (collections.collections || []).filter((row: any) => row.promoCard).length
const gates = [
gate('catalogue_content_seeded', products.length === 190, { products: products.length }, 'Expected 190 product content records.'),
gate('workflow_seeded', workflowFiles.length === products.length, { workflowFiles: workflowFiles.length, products: products.length }, 'Every product needs a workflow record.'),
gate('template_schema', Boolean(template.ready), { checks: template.checks?.length || 0 }, 'Dashboard/migration CSV headers must match Lewis/Euan XLSX template.'),
gate('handoff_ready', Boolean(handoff.readyForLewis), { blockers: handoff.counts?.blockers, blockedSkus: handoff.counts?.blockedSkus }, 'Handoff manifest still has blockers.'),
gate('asset_filenames_assigned', (asset.missingReferenceCount || 0) === 0 && plannedImages.totalRows === products.length * 3, { missingReferenceCount: asset.missingReferenceCount, plannedImageRows: plannedImages.totalRows }, 'All product image filenames must be assigned.'),
gate('asset_uploads_ready', Boolean(asset.ready), { requiredCount: asset.requiredCount, missingUploadCount: asset.missingUploadCount }, 'Referenced product/metaobject assets must exist in the asset root / Shopify Files.'),
gate('source_drops_ready', Boolean(sourceDrops.ready), { issues: sourceDrops.issues?.length || 0 }, 'Amazon/Trustpilot/Reddit source drops are missing or invalid.'),
gate('claims_library_ready', Boolean(claims.ready), { claimCount: claims.claimCount, issues: claims.issueCount }, 'Clinical claims library is missing or invalid.'),
gate('source_request_pack_ready', Boolean(sourceRequest.counts?.sourceRequestItems), sourceRequest.counts || {}, 'Source request pack must exist for external blockers.'),
gate('promo_cards_seeded', promoCards.length > 0 && mappedCollections === (collections.collections || []).length, { promoCards: promoCards.length, mappedCollections, collections: (collections.collections || []).length }, 'Collection promo-card candidates/mappings missing.'),
gate('full_intelligence_coverage', intelCoverage.every(row => row.count === products.length), { coverage: intelCoverage }, 'All intelligence files must exist for every catalogue SKU.'),
gate('human_approval_complete', products.every(product => product.approval?.content === 'approved' && product.approval?.compliance === 'approved' && product.approval?.images === 'ready' && product.approval?.export === 'ready'), approvalCounts, 'Approval lanes are not complete.'),
]
const ready = gates.every(item => item.ready)
const report = {
generatedAt: new Date().toISOString(),
ready,
objective: 'jv-dashboard/CONTENT_POPULATION_PLAN.md',
summary: {
totalGates: gates.length,
passing: gates.filter(item => item.ready).length,
blocked: gates.filter(item => !item.ready).length,
productCount: products.length,
workflowCount: workflowFiles.length,
exportBlockers: handoff.counts?.blockers ?? null,
missingAssetUploads: asset.missingUploadCount ?? null,
sourceDropIssues: sourceDrops.issues?.length ?? null,
claimsIssues: claims.issueCount ?? null,
},
gates,
nextActions: gates.filter(item => !item.ready).map(item => item.blocker)
}
writeJson(join(outRoot, 'content_population_readiness.json'), report)
writeFileSync(join(outRoot, 'content_population_readiness.csv'), csv(gates.map(item => ({ Gate: item.name, Status: item.status, Blocker: item.blocker, Evidence: JSON.stringify(item.evidence) })), ['Gate', 'Status', 'Blocker', 'Evidence']), 'utf8')
const md = [
'# JV content population readiness audit',
'',
`Generated: ${report.generatedAt}`,
'',
`Overall ready: **${ready ? 'YES' : 'NO'}**`,
'',
'## Summary',
`- Gates passing: ${report.summary.passing}/${report.summary.totalGates}`,
`- Product records: ${report.summary.productCount}`,
`- Workflow records: ${report.summary.workflowCount}`,
`- Export blockers: ${report.summary.exportBlockers}`,
`- Missing asset uploads/files: ${report.summary.missingAssetUploads}`,
`- Source drop issues: ${report.summary.sourceDropIssues}`,
`- Claims validation issues: ${report.summary.claimsIssues}`,
'',
'## Gates',
...gates.map(item => `- ${item.ready ? '[PASS]' : '[BLOCKED]'} ${item.name}${item.blocker ? `${item.blocker}` : ''}`),
'',
'## Next actions',
...report.nextActions.map(action => `- ${action}`),
''
].join('\n')
writeFileSync(join(outRoot, 'content_population_readiness.md'), md, 'utf8')
console.log(`Content population ready: ${ready}`)
console.log(`Gates passing: ${report.summary.passing}/${report.summary.totalGates}`)
console.log(`Blocked gates: ${report.summary.blocked}`)
if (!ready) process.exitCode = 1