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>
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const contentRoot = join(root, 'data', 'content')
|
|
const productsRoot = join(contentRoot, 'products')
|
|
const workflowRoot = join(contentRoot, 'workflow')
|
|
const approvalRoot = join(contentRoot, 'approvals')
|
|
const decisionPath = join(approvalRoot, 'product-approval-decisions.json')
|
|
const decisionCsvPath = join(approvalRoot, 'product-approval-decisions.csv')
|
|
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 writeJson(path: string, data: any) {
|
|
mkdirSync(join(path, '..'), { recursive: true })
|
|
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 parseCsv(text: string) {
|
|
const rows: string[][] = []
|
|
let row: string[] = []
|
|
let cell = ''
|
|
let quoted = false
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i]
|
|
const next = text[i + 1]
|
|
if (quoted && ch === '"' && next === '"') {
|
|
cell += '"'
|
|
i += 1
|
|
} else if (ch === '"') {
|
|
quoted = !quoted
|
|
} else if (!quoted && ch === ',') {
|
|
row.push(cell)
|
|
cell = ''
|
|
} else if (!quoted && (ch === '\n' || ch === '\r')) {
|
|
if (ch === '\r' && next === '\n') i += 1
|
|
row.push(cell)
|
|
if (row.some(value => value !== '')) rows.push(row)
|
|
row = []
|
|
cell = ''
|
|
} else {
|
|
cell += ch
|
|
}
|
|
}
|
|
if (cell || row.length) {
|
|
row.push(cell)
|
|
if (row.some(value => value !== '')) rows.push(row)
|
|
}
|
|
const headers = rows.shift() || []
|
|
return rows.map(values => Object.fromEntries(headers.map((header, index) => [header.replace(/^\uFEFF/, ''), values[index] ?? ''])))
|
|
}
|
|
function bool(value: any) {
|
|
return value === true || String(value || '').toLowerCase() === 'true' || String(value || '').toLowerCase() === 'yes'
|
|
}
|
|
function validDecision(row: any) {
|
|
return row?.decision === 'approve_all' &&
|
|
Boolean(row.approvedBy) &&
|
|
Boolean(row.approvedAt) &&
|
|
Boolean(row.reviewEvidence) &&
|
|
row.contentReviewed === true &&
|
|
row.complianceReviewed === true &&
|
|
row.imagesReviewed === true &&
|
|
row.exportReviewed === true &&
|
|
row.noFakeEvidenceAcknowledged === true
|
|
}
|
|
function currentBlocking(product: any) {
|
|
const approval = product.approval || {}
|
|
const blockers = []
|
|
if (approval.content !== 'approved') blockers.push('content')
|
|
if (approval.compliance !== 'approved') blockers.push('compliance')
|
|
if (!['ready', 'approved'].includes(approval.images)) blockers.push('images')
|
|
if (!['ready', 'approved'].includes(approval.export)) blockers.push('export')
|
|
return blockers
|
|
}
|
|
|
|
mkdirSync(approvalRoot, { recursive: true })
|
|
mkdirSync(outRoot, { recursive: true })
|
|
|
|
const products = readdirSync(productsRoot).filter(file => file.endsWith('.json')).sort().map(file => readJson(join(productsRoot, file), {}))
|
|
const existing = readJson(decisionPath, { decisions: [] })
|
|
const csvExisting = existsSync(decisionCsvPath)
|
|
? parseCsv(readFileSync(decisionCsvPath, 'utf8')).map((row: any) => ({
|
|
sku: row.sku || row.SKU,
|
|
decision: row.decision || row.Decision,
|
|
approvedBy: row.approvedBy || row.ApprovedBy,
|
|
approvedAt: row.approvedAt || row.ApprovedAt,
|
|
reviewEvidence: row.reviewEvidence || row.ReviewEvidence,
|
|
contentReviewed: bool(row.contentReviewed || row.ContentReviewed),
|
|
complianceReviewed: bool(row.complianceReviewed || row.ComplianceReviewed),
|
|
imagesReviewed: bool(row.imagesReviewed || row.ImagesReviewed),
|
|
exportReviewed: bool(row.exportReviewed || row.ExportReviewed),
|
|
noFakeEvidenceAcknowledged: bool(row.noFakeEvidenceAcknowledged || row.NoFakeEvidenceAcknowledged),
|
|
note: row.note || row.Note
|
|
}))
|
|
: []
|
|
const existingBySku = new Map([...(existing.decisions || []), ...csvExisting].map((row: any) => [row.sku, row]))
|
|
const decisions = products.map(product => {
|
|
const previous: any = existingBySku.get(product.sku) || {}
|
|
return {
|
|
sku: product.sku,
|
|
productName: product.name || '',
|
|
handle: product.handle || '',
|
|
decision: previous.decision || 'pending',
|
|
approvedBy: previous.approvedBy || '',
|
|
approvedAt: previous.approvedAt || '',
|
|
reviewEvidence: previous.reviewEvidence || '',
|
|
contentReviewed: Boolean(previous.contentReviewed),
|
|
complianceReviewed: Boolean(previous.complianceReviewed),
|
|
imagesReviewed: Boolean(previous.imagesReviewed),
|
|
exportReviewed: Boolean(previous.exportReviewed),
|
|
noFakeEvidenceAcknowledged: Boolean(previous.noFakeEvidenceAcknowledged),
|
|
currentApproval: product.approval || {},
|
|
currentBlockers: currentBlocking(product),
|
|
note: previous.note || 'Set decision=approve_all only after human review of content, compliance, images, export, and supporting evidence. This script will not infer approvals.'
|
|
}
|
|
})
|
|
writeJson(decisionPath, {
|
|
generatedAt,
|
|
purpose: 'Human product approval decisions for the JV content handoff.',
|
|
allowedDecisions: ['pending', 'approve_all', 'reject_or_needs_changes'],
|
|
guardrail: 'A row applies only when decision=approve_all and approver/timestamp/evidence plus all review acknowledgements are present. No approvals are inferred by scripts.',
|
|
decisions
|
|
})
|
|
writeFileSync(decisionCsvPath, csv(decisions.map(row => ({
|
|
sku: row.sku,
|
|
productName: row.productName,
|
|
decision: row.decision,
|
|
approvedBy: row.approvedBy,
|
|
approvedAt: row.approvedAt,
|
|
reviewEvidence: row.reviewEvidence,
|
|
contentReviewed: row.contentReviewed ? 'yes' : 'no',
|
|
complianceReviewed: row.complianceReviewed ? 'yes' : 'no',
|
|
imagesReviewed: row.imagesReviewed ? 'yes' : 'no',
|
|
exportReviewed: row.exportReviewed ? 'yes' : 'no',
|
|
noFakeEvidenceAcknowledged: row.noFakeEvidenceAcknowledged ? 'yes' : 'no',
|
|
note: row.note
|
|
})), ['sku', 'productName', 'decision', 'approvedBy', 'approvedAt', 'reviewEvidence', 'contentReviewed', 'complianceReviewed', 'imagesReviewed', 'exportReviewed', 'noFakeEvidenceAcknowledged', 'note']), 'utf8')
|
|
|
|
let applied = 0
|
|
const rows: any[] = []
|
|
for (const row of decisions) {
|
|
const productPath = join(productsRoot, `${row.sku}.json`)
|
|
const product = readJson(productPath, null)
|
|
const isValid = validDecision(row)
|
|
if (product && isValid) {
|
|
const previousApproval = product.approval || {}
|
|
product.approval = {
|
|
...previousApproval,
|
|
content: 'approved',
|
|
compliance: 'approved',
|
|
images: 'ready',
|
|
export: 'ready'
|
|
}
|
|
product.approvalEvidence = {
|
|
...(product.approvalEvidence || {}),
|
|
approvedBy: row.approvedBy,
|
|
approvedAt: row.approvedAt,
|
|
reviewEvidence: row.reviewEvidence,
|
|
source: 'data/content/approvals/product-approval-decisions.json'
|
|
}
|
|
product.notes = Array.isArray(product.notes) ? product.notes : []
|
|
const note = `Human approval decision applied from product-approval-decisions.json by ${row.approvedBy} at ${row.approvedAt}.`
|
|
if (!product.notes.includes(note)) product.notes.push(note)
|
|
product.updatedAt = generatedAt
|
|
writeJson(productPath, product)
|
|
|
|
const workflowPath = join(workflowRoot, `${row.sku}.json`)
|
|
const workflow = readJson(workflowPath, null)
|
|
if (workflow) {
|
|
workflow.approval = { ...(workflow.approval || {}), content: 'approved', compliance: 'approved', images: 'ready', export: 'ready' }
|
|
workflow.events = Array.isArray(workflow.events) ? workflow.events : []
|
|
workflow.events.unshift({
|
|
at: generatedAt,
|
|
type: 'approval_decision_applied',
|
|
actor: row.approvedBy,
|
|
field: 'all',
|
|
from: previousApproval,
|
|
to: workflow.approval,
|
|
summary: `Applied valid approve_all decision. Evidence: ${row.reviewEvidence}`
|
|
})
|
|
workflow.updatedAt = generatedAt
|
|
writeJson(workflowPath, workflow)
|
|
}
|
|
applied += 1
|
|
}
|
|
rows.push({
|
|
SKU: row.sku,
|
|
ProductName: row.productName,
|
|
Decision: row.decision,
|
|
ValidApproval: isValid ? 'yes' : 'no',
|
|
Applied: isValid ? 'yes' : 'no',
|
|
ApprovedBy: row.approvedBy,
|
|
ApprovedAt: row.approvedAt,
|
|
ReviewEvidence: row.reviewEvidence,
|
|
CurrentBlockers: row.currentBlockers,
|
|
Note: row.note
|
|
})
|
|
}
|
|
|
|
const indexPath = join(workflowRoot, 'index.json')
|
|
const workflowIndex = readJson(indexPath, null)
|
|
if (workflowIndex?.products) {
|
|
workflowIndex.products = workflowIndex.products.map((entry: any) => {
|
|
const product = readJson(join(productsRoot, `${entry.sku}.json`), null)
|
|
return product ? { ...entry, approval: product.approval || entry.approval } : entry
|
|
})
|
|
workflowIndex.updatedAt = generatedAt
|
|
writeJson(indexPath, workflowIndex)
|
|
}
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: decisions.length > 0 && decisions.every(validDecision),
|
|
decisionFile: 'data/content/approvals/product-approval-decisions.json',
|
|
counts: {
|
|
products: decisions.length,
|
|
validApprovals: decisions.filter(validDecision).length,
|
|
applied,
|
|
pending: decisions.filter(row => row.decision === 'pending').length,
|
|
rejectedOrNeedsChanges: decisions.filter(row => row.decision === 'reject_or_needs_changes').length
|
|
},
|
|
guardrail: 'No product is approved unless the editable decision file contains a valid approve_all row with all acknowledgements.',
|
|
rows
|
|
}
|
|
writeJson(join(outRoot, 'product_approval_decision_gate.json'), manifest)
|
|
writeFileSync(join(outRoot, 'product_approval_decision_gate.csv'), csv(rows, ['SKU', 'ProductName', 'Decision', 'ValidApproval', 'Applied', 'ApprovedBy', 'ApprovedAt', 'ReviewEvidence', 'CurrentBlockers', 'Note']), 'utf8')
|
|
writeFileSync(join(outRoot, 'product_approval_decision_gate.md'), [
|
|
'# Product approval decision gate',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
`Decision file: \`${manifest.decisionFile}\``,
|
|
'Editable CSV: `data/content/approvals/product-approval-decisions.csv`',
|
|
'',
|
|
'This gate provides an editable approval file and applies only explicit human approvals. It does not infer approval from generated content or passing asset checks.',
|
|
'',
|
|
'## Counts',
|
|
`- Products: ${manifest.counts.products}`,
|
|
`- Valid approvals: ${manifest.counts.validApprovals}`,
|
|
`- Applied this run: ${manifest.counts.applied}`,
|
|
`- Pending: ${manifest.counts.pending}`,
|
|
`- Rejected / needs changes: ${manifest.counts.rejectedOrNeedsChanges}`,
|
|
'',
|
|
'## Required fields for apply',
|
|
'- `decision`: `approve_all`',
|
|
'- `approvedBy`',
|
|
'- `approvedAt`',
|
|
'- `reviewEvidence`',
|
|
'- `contentReviewed`, `complianceReviewed`, `imagesReviewed`, `exportReviewed`, `noFakeEvidenceAcknowledged`: all `true`',
|
|
'',
|
|
'After valid approvals are entered, run `bun run approval:decisions && bun run approval:review-packet && bun run export:content && bun run audit:content`.',
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`Product approval decisions: ${decisions.length}`)
|
|
console.log(`Valid approvals: ${manifest.counts.validApprovals}`)
|
|
console.log(`Applied approvals: ${applied}`)
|
|
if (!manifest.ready) process.exitCode = 1
|