Files
justvitamin/scripts/sync-product-approval-decisions.ts
T
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

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