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>
118 lines
5.1 KiB
TypeScript
118 lines
5.1 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const productsRoot = join(root, 'data', 'content', 'products')
|
|
const workflowRoot = join(root, 'data', 'content', 'workflow')
|
|
const outRoot = join(root, 'content_population_exports')
|
|
const generatedAt = new Date().toISOString()
|
|
|
|
function readJson(path: string, fallback: any = null) {
|
|
if (!existsSync(path)) return fallback
|
|
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
}
|
|
|
|
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 decideBlocker(product: any) {
|
|
const approval = product.approval || {}
|
|
const blockers = []
|
|
if (approval.content !== 'approved') blockers.push('content_approval')
|
|
if (approval.compliance !== 'approved') blockers.push('compliance_approval')
|
|
if (approval.images !== 'ready' && approval.images !== 'approved') blockers.push('image_approval')
|
|
if (approval.export !== 'ready' && approval.export !== 'approved') blockers.push('export_approval')
|
|
return blockers
|
|
}
|
|
|
|
const rows = readdirSync(productsRoot).filter(file => file.endsWith('.json')).sort().map(file => {
|
|
const product = readJson(join(productsRoot, file), {})
|
|
const sku = product.sku || file.replace(/\.json$/, '')
|
|
const workflow = readJson(join(workflowRoot, `${sku}.json`), {})
|
|
const blockers = decideBlocker(product)
|
|
const imageRefs = [
|
|
product.metafields?.pdpResults,
|
|
product.metafields?.pdpResultsMobile,
|
|
product.metafields?.pdpHowWeCompare
|
|
].filter(Boolean)
|
|
return {
|
|
SKU: sku,
|
|
ProductName: product.name || '',
|
|
Handle: product.handle || '',
|
|
ContentApproval: product.approval?.content || '',
|
|
ComplianceApproval: product.approval?.compliance || '',
|
|
ImageApproval: product.approval?.images || '',
|
|
ExportApproval: product.approval?.export || '',
|
|
ClinicalRef: product.metafields?.pdpClinicallyShownTo || '',
|
|
ImageRefs: imageRefs,
|
|
Blockers: blockers,
|
|
ReadyForExportApproval: blockers.length === 0 ? 'yes' : 'no',
|
|
WorkflowStage: workflow.stage || '',
|
|
LastUpdated: product.updatedAt || workflow.updatedAt || '',
|
|
ReviewerAction: blockers.length === 0
|
|
? 'Confirm final spot-check and keep approval evidence.'
|
|
: `Review and approve/resolve: ${blockers.join(', ')}.`,
|
|
ProductFile: `data/content/products/${sku}.json`,
|
|
WorkflowFile: `data/content/workflow/${sku}.json`
|
|
}
|
|
})
|
|
|
|
const blockerCounts = rows.reduce((acc: Record<string, number>, row: any) => {
|
|
for (const blocker of row.Blockers) acc[blocker] = (acc[blocker] || 0) + 1
|
|
return acc
|
|
}, {})
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: rows.every((row: any) => row.ReadyForExportApproval === 'yes'),
|
|
purpose: 'Reviewer-facing approval queue packet for the 190 product records. This does not self-approve content.',
|
|
counts: {
|
|
products: rows.length,
|
|
readyForExportApproval: rows.filter((row: any) => row.ReadyForExportApproval === 'yes').length,
|
|
blockedProducts: rows.filter((row: any) => row.ReadyForExportApproval !== 'yes').length,
|
|
blockerCounts
|
|
},
|
|
rows
|
|
}
|
|
|
|
const headers = ['SKU', 'ProductName', 'Handle', 'ContentApproval', 'ComplianceApproval', 'ImageApproval', 'ExportApproval', 'ClinicalRef', 'ImageRefs', 'Blockers', 'ReadyForExportApproval', 'WorkflowStage', 'LastUpdated', 'ReviewerAction', 'ProductFile', 'WorkflowFile']
|
|
writeFileSync(join(outRoot, 'product_approval_review_packet.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'product_approval_review_packet.csv'), csv(rows, headers), 'utf8')
|
|
|
|
const md = [
|
|
'# Product approval review packet',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
'This packet turns the product-level approval blocker into a reviewer queue. It does not mark any product approved.',
|
|
'',
|
|
`Products: ${manifest.counts.products}`,
|
|
`Blocked products: ${manifest.counts.blockedProducts}`,
|
|
`Ready for export approval: ${manifest.counts.readyForExportApproval}`,
|
|
'',
|
|
'## Blocker counts',
|
|
...Object.entries(blockerCounts).map(([key, count]) => `- ${key}: ${count}`),
|
|
'',
|
|
'## First 50 reviewer rows',
|
|
...rows.slice(0, 50).map((row: any) => `- **${row.SKU}** ${row.ProductName}: ${row.Blockers.join(', ') || 'no blockers'}; clinical ref=${row.ClinicalRef || 'blank'}`),
|
|
'',
|
|
rows.length > 50 ? '_Full queue is in `product_approval_review_packet.csv`._' : '',
|
|
'',
|
|
'## Regenerate after review',
|
|
'After human approvals are recorded, run `bun run approval:review-packet && bun run export:content && bun run audit:content`.',
|
|
''
|
|
].join('\n')
|
|
writeFileSync(join(outRoot, 'product_approval_review_packet.md'), md, 'utf8')
|
|
|
|
console.log(`Product approval review packet ready: ${manifest.ready}`)
|
|
console.log(`Blocked products: ${manifest.counts.blockedProducts}`)
|
|
if (!manifest.ready) process.exitCode = 1
|