056c47581f
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>
123 lines
7.7 KiB
TypeScript
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
|