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>
2061 lines
94 KiB
TypeScript
2061 lines
94 KiB
TypeScript
import { join, dirname } from 'path'
|
|
import { readdir, writeFile, mkdir } from 'fs/promises'
|
|
|
|
const PORT = 3456
|
|
const PIPELINE_ROOT = process.env.PIPELINE_ROOT ||
|
|
join(import.meta.dir, '..', 'data', 'pipeline')
|
|
const INTEL_ROOT = join(import.meta.dir, '..', 'data', 'intelligence')
|
|
const CONTENT_ROOT = join(import.meta.dir, '..', 'data', 'content')
|
|
const CONTENT_PRODUCTS_ROOT = join(CONTENT_ROOT, 'products')
|
|
const CONTENT_WORKFLOW_ROOT = join(CONTENT_ROOT, 'workflow')
|
|
const RESEARCH_PROFILE_ROOT = join(import.meta.dir, '..', 'data', 'research-profiles')
|
|
const SOURCE_ROOT = join(import.meta.dir, '..', 'data', 'sources')
|
|
const GENERATED_ASSET_ROOT = join(import.meta.dir, '..', 'data', 'generated-assets')
|
|
const DASHBOARD_EXPORT_ROOT = join(import.meta.dir, '..', 'content_population_exports')
|
|
const MIGRATION_EXPORT_ROOT = join(import.meta.dir, '..', '..', 'JV Migration to shopify', 'content_population_exports')
|
|
const MIGRATION_PRODUCTS_CSV = join(import.meta.dir, '..', '..', 'JV Migration to shopify', 'products_import_lewis_v4.csv')
|
|
const ASSET_ROOT = process.env.ASSET_ROOT || join(import.meta.dir, '..', '..', '..', 'jv-assets')
|
|
const DIST_ROOT = join(import.meta.dir, '..', 'dist')
|
|
// Editorial dashboard (separate Vite build mounted under /editorial/*)
|
|
const EDITORIAL_DIST_ROOT = join(import.meta.dir, '..', 'dist-editorial')
|
|
const PILOT_EXTENDED_ROOT = join(import.meta.dir, '..', 'data', 'content', 'pilot-extended')
|
|
const COMPETITOR_EXTRACTS_ROOT = join(import.meta.dir, '..', 'data', 'sources', 'competitor-section-extracts')
|
|
const PRODUCT_TRUTH_ROOT = join(import.meta.dir, '..', 'data', 'sources', 'jv-product-truth')
|
|
const IS_PROD = process.env.NODE_ENV === 'production'
|
|
|
|
const cors = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, PUT, POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
|
|
async function readJson(path: string) {
|
|
const f = Bun.file(path)
|
|
if (!(await f.exists())) return null
|
|
return f.json()
|
|
}
|
|
|
|
async function readText(path: string) {
|
|
const f = Bun.file(path)
|
|
if (!(await f.exists())) return null
|
|
return f.text()
|
|
}
|
|
|
|
function redactRaw(value: any) {
|
|
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
|
return String(text || '')
|
|
.replace(/apify_api_[A-Za-z0-9]+/g, '[REDACTED_APIFY_TOKEN]')
|
|
.replace(/apify_proxy_[A-Za-z0-9]+/g, '[REDACTED_APIFY_PROXY]')
|
|
.replace(/("?(?:password|token|api[_-]?key|authorization)"?\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
|
|
.replace(/(Bearer\s+)[A-Za-z0-9._-]+/gi, '$1[REDACTED]')
|
|
}
|
|
|
|
function clipText(text: any, limit = 12000) {
|
|
const value = redactRaw(text)
|
|
return {
|
|
text: value.length > limit ? `${value.slice(0, limit)}\n\n[... clipped ${value.length - limit} characters in dashboard preview ...]` : value,
|
|
chars: value.length,
|
|
clipped: value.length > limit,
|
|
limit
|
|
}
|
|
}
|
|
|
|
const RAW_EVIDENCE_ITEM_LIMIT = Math.min(1000, Math.max(100, Number(Bun.env.RAW_EVIDENCE_ITEM_LIMIT || 1000)))
|
|
const RAW_EVIDENCE_BLOCK_LIMIT = Math.min(1000, Math.max(100, Number(Bun.env.RAW_EVIDENCE_BLOCK_LIMIT || 250)))
|
|
const RAW_EVIDENCE_PREVIEW_CHARS = Math.min(20000, Math.max(2500, Number(Bun.env.RAW_EVIDENCE_PREVIEW_CHARS || 8000)))
|
|
|
|
async function rawFileBlock(filePath: string, label: string, limit = 12000) {
|
|
const file = Bun.file(filePath)
|
|
if (!(await file.exists())) return null
|
|
return {
|
|
label,
|
|
path: filePath,
|
|
bytes: file.size,
|
|
preview: {
|
|
text: '',
|
|
chars: file.size,
|
|
clipped: file.size > limit,
|
|
limit: 0
|
|
}
|
|
}
|
|
}
|
|
|
|
function normaliseApifyItems(items: any) {
|
|
if (Array.isArray(items)) return items
|
|
if (Array.isArray(items?.value)) return items.value
|
|
return []
|
|
}
|
|
|
|
function compactComprehensiveItem(item: any) {
|
|
return {
|
|
sku: item?.sku,
|
|
sourceType: item?.sourceType,
|
|
label: item?.label,
|
|
competitor: item?.competitor || null,
|
|
requestedUrl: item?.requestedUrl,
|
|
loadedUrl: item?.loadedUrl,
|
|
statusCode: item?.statusCode,
|
|
title: item?.title,
|
|
h1: item?.h1,
|
|
headings: item?.headings || [],
|
|
prices: item?.prices || [],
|
|
reviewLike: item?.reviewLike || [],
|
|
textPreview: clipText(item?.text || '', 1400).text,
|
|
textChars: item?.textChars || String(item?.text || '').length,
|
|
htmlChars: item?.htmlChars || 0,
|
|
capturedAt: item?.capturedAt
|
|
}
|
|
}
|
|
|
|
function compactSpecialistItem(job: string, item: any) {
|
|
if (String(job || '').includes('amazon-reviews')) {
|
|
return {
|
|
sku: item?.sku,
|
|
matchedAsin: item?.matchedAsin,
|
|
productAsin: item?.productAsin,
|
|
matchContext: item?.matchContext,
|
|
matchNote: item?.matchNote,
|
|
reviewTitle: item?.reviewTitle,
|
|
reviewDescription: item?.reviewDescription,
|
|
ratingScore: item?.ratingScore,
|
|
reviewId: item?.reviewId,
|
|
date: item?.date,
|
|
reviewedIn: item?.reviewedIn,
|
|
reviewUrl: item?.reviewUrl,
|
|
isVerified: item?.isVerified,
|
|
variant: item?.variant,
|
|
totalCategoryRatings: item?.totalCategoryRatings,
|
|
totalCategoryReviews: item?.totalCategoryReviews
|
|
}
|
|
}
|
|
if (job === 'trustpilot') {
|
|
return {
|
|
title: item?.title,
|
|
text: item?.text || item?.reviewText || item?.content,
|
|
rating: item?.rating,
|
|
date: item?.date || item?.publishedDate,
|
|
url: item?.url || item?.reviewUrl,
|
|
consumerName: item?.consumerName || item?.author
|
|
}
|
|
}
|
|
if (job === 'reddit') {
|
|
return {
|
|
sku: item?.sku,
|
|
title: item?.title,
|
|
text: item?.text || item?.body || item?.selftext,
|
|
url: item?.url || item?.permalink,
|
|
subreddit: item?.subreddit,
|
|
score: item?.score,
|
|
createdAt: item?.createdAt || item?.created_utc
|
|
}
|
|
}
|
|
if (String(job || '').includes('serp')) {
|
|
return {
|
|
sku: item?.sku,
|
|
sourceType: item?.sourceType,
|
|
label: item?.label,
|
|
matchContext: item?.matchContext,
|
|
searchQuery: item?.searchQuery,
|
|
url: item?.url,
|
|
conclusion: item?.conclusion,
|
|
organicResults: (item?.organicResults || []).slice(0, 10).map((row: any) => ({
|
|
title: row?.title,
|
|
websiteTitle: row?.websiteTitle,
|
|
url: row?.url,
|
|
displayedUrl: row?.displayedUrl,
|
|
description: row?.description,
|
|
position: row?.position
|
|
})),
|
|
note: item?.note
|
|
}
|
|
}
|
|
if (job === 'healthspan-retailer') {
|
|
return {
|
|
sku: item?.sku,
|
|
sourceType: item?.sourceType,
|
|
label: item?.label,
|
|
competitor: item?.competitor,
|
|
retailer: item?.retailer,
|
|
matchContext: item?.matchContext,
|
|
requestedUrl: item?.requestedUrl,
|
|
loadedUrl: item?.loadedUrl,
|
|
title: item?.title,
|
|
h1: item?.h1,
|
|
headings: item?.headings || [],
|
|
prices: item?.prices || [],
|
|
textPreview: clipText(item?.text || '', 1800).text,
|
|
textChars: item?.textChars,
|
|
htmlChars: item?.htmlChars,
|
|
note: item?.note
|
|
}
|
|
}
|
|
return {
|
|
preview: clipText(item, 5000).text,
|
|
chars: clipText(item, 5000).chars,
|
|
clipped: clipText(item, 5000).clipped
|
|
}
|
|
}
|
|
|
|
function compactAmazonItem(item: any) {
|
|
const rating = typeof item?.rating === 'object' ? item.rating : null
|
|
return {
|
|
type: item?.type || null,
|
|
rankGroup: item?.rank_group ?? null,
|
|
rankAbsolute: item?.rank_absolute ?? null,
|
|
title: item?.title || null,
|
|
url: item?.url || null,
|
|
asin: item?.data_asin || item?.asin || null,
|
|
priceFrom: item?.price_from ?? item?.price ?? null,
|
|
currency: item?.currency || null,
|
|
rating: rating?.value ?? item?.rating ?? null,
|
|
votesCount: rating?.votes_count ?? item?.votes_count ?? null,
|
|
boughtPastMonth: item?.bought_past_month ?? null,
|
|
isSponsored: item?.is_sponsored ?? item?.type === 'amazon_paid'
|
|
}
|
|
}
|
|
|
|
function compactRawEvidencePayload(sections: any[]) {
|
|
return sections.map(section => ({
|
|
...section,
|
|
displayLimits: {
|
|
itemLimit: RAW_EVIDENCE_ITEM_LIMIT,
|
|
rawBlockLimit: RAW_EVIDENCE_BLOCK_LIMIT,
|
|
previewChars: RAW_EVIDENCE_PREVIEW_CHARS
|
|
},
|
|
itemCount: (section.items || []).length,
|
|
rawBlockCount: (section.rawBlocks || []).length,
|
|
files: (section.files || []).map((file: any) => ({
|
|
label: file.label,
|
|
path: file.path,
|
|
bytes: file.bytes,
|
|
chars: file.preview?.chars ?? file.chars,
|
|
clipped: file.preview?.clipped ?? file.clipped,
|
|
previewLimit: file.preview?.limit
|
|
})),
|
|
items: (section.items || []).slice(0, RAW_EVIDENCE_ITEM_LIMIT),
|
|
rawBlocks: (section.rawBlocks || []).slice(0, RAW_EVIDENCE_BLOCK_LIMIT).map((block: any) => {
|
|
const preview = clipText(block.body || '', RAW_EVIDENCE_PREVIEW_CHARS)
|
|
return {
|
|
title: block.title,
|
|
url: block.url,
|
|
body: preview.text,
|
|
chars: block.chars ?? preview.chars,
|
|
clipped: Boolean(block.clipped || preview.clipped),
|
|
previewLimit: preview.limit
|
|
}
|
|
})
|
|
}))
|
|
}
|
|
|
|
function collectAmazonRows(node: any, rows: any[] = []) {
|
|
if (!node || rows.length >= RAW_EVIDENCE_ITEM_LIMIT) return rows
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) collectAmazonRows(child, rows)
|
|
return rows
|
|
}
|
|
if (typeof node === 'object') {
|
|
if ((node.title && node.url && (node.data_asin || node.rank_group || node.type)) || node.data_asin) {
|
|
rows.push(compactAmazonItem(node))
|
|
}
|
|
for (const value of Object.values(node)) collectAmazonRows(value, rows)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
function filterRawRowsForSku(sku: string, rows: any[]) {
|
|
const rules: Record<string, RegExp> = {
|
|
'JV-TURMERIC500': /turmeric|curcumin|bioperine|black pepper/i,
|
|
'JV-D1000': /vitamin d|vitamin d3|1000\s?iu|effervescent|fizz|colecalciferol/i,
|
|
'JV-DEFF1000': /vitamin d|vitamin d3|1000\s?iu|effervescent|fizz|colecalciferol/i,
|
|
'JV-VISISOFT': /visisoft|lutein|zeaxanthin|bilberry|vision|eye/i
|
|
}
|
|
const rule = rules[sku]
|
|
if (!rule) return rows
|
|
const filtered = rows.filter(row => rule.test(JSON.stringify(row)))
|
|
return filtered.length ? filtered : rows
|
|
}
|
|
|
|
function filterStrictRawRowsForSku(sku: string, rows: any[]) {
|
|
const rules: Record<string, RegExp> = {
|
|
'JV-TURMERIC500': /turmeric|curcumin|bioperine|black pepper/i,
|
|
'JV-D1000': /vitamin d|vitamin d3|1000\s?iu|effervescent|fizz|colecalciferol|deficiency|iu/i,
|
|
'JV-DEFF1000': /vitamin d|vitamin d3|1000\s?iu|effervescent|fizz|colecalciferol|deficiency|iu/i,
|
|
'JV-VISISOFT': /visisoft|lutein|zeaxanthin|bilberry|vision|eye/i
|
|
}
|
|
const rule = rules[sku]
|
|
return rule ? rows.filter(row => rule.test(JSON.stringify(row))) : rows
|
|
}
|
|
|
|
function sourceSkuAliases(sku: string) {
|
|
if (sku === 'JV-DEFF1000') return ['JV-DEFF1000', 'JV-D1000']
|
|
if (sku === 'COLLAGEN-NPD') return ['COLLAGEN-NPD', 'JV-COLLAGEN400']
|
|
return [sku]
|
|
}
|
|
|
|
const imageCarouselTypes = [
|
|
{ key: '01-hero-outcome', label: 'Hero Outcome Image' },
|
|
{ key: '02-offer-value-prop', label: 'Offer / Value Proposition Image' },
|
|
{ key: '03-social-proof-trust', label: 'Social Proof / Trust Image' },
|
|
{ key: '04-product-clarity-feature', label: 'Product Clarity / Feature Image' },
|
|
{ key: '05-problem-solution-transformation', label: 'Problem / Solution Transformation Image' },
|
|
{ key: '06-lifestyle-identity', label: 'Lifestyle / Identity Image' }
|
|
]
|
|
|
|
async function getProductImageGenerationSet(sku: string) {
|
|
const imageSourceSku = sku === 'JV-DEFF1000' ? 'JV-D1000' : sku
|
|
const truth = await getJvDatabaseTruth(imageSourceSku)
|
|
const directRoot = join(GENERATED_ASSET_ROOT, 'top3-direct-imagegen-final', sku)
|
|
const promptSummaryPath = join(GENERATED_ASSET_ROOT, 'top3-direct-imagegen-final', 'top3-direct-imagegen-summary.json')
|
|
const promptLockPath = join(GENERATED_ASSET_ROOT, 'top3-direct-imagegen-final', 'locked-article-prompt-format.md')
|
|
const generated = []
|
|
for (const type of imageCarouselTypes) {
|
|
const filename = `${type.key}.png`
|
|
const filePath = join(directRoot, filename)
|
|
const file = Bun.file(filePath)
|
|
if (await file.exists()) {
|
|
generated.push({
|
|
...type,
|
|
filename,
|
|
url: `/api/generated-product-image/${sku}/${filename}`,
|
|
bytes: file.size
|
|
})
|
|
} else {
|
|
generated.push({
|
|
...type,
|
|
filename,
|
|
url: null,
|
|
missing: true
|
|
})
|
|
}
|
|
}
|
|
const promptSummary = await readJson(promptSummaryPath)
|
|
return {
|
|
sku,
|
|
generatedAt: new Date().toISOString(),
|
|
source: {
|
|
database: MIGRATION_PRODUCTS_CSV,
|
|
requestedSku: sku,
|
|
imageSourceSku,
|
|
correctionNote: sku === 'JV-DEFF1000'
|
|
? 'DEFF1000 migration image is a non-JV effervescent tube; dashboard image source is corrected to the current Just Vitamins pouch reference from JV-D1000.'
|
|
: null,
|
|
title: truth?.product?.title || null,
|
|
handle: truth?.product?.handle || null,
|
|
liveImageUrl: truth?.product?.imageSrc || null,
|
|
localOriginalUrl: `/api/original-product-image/${sku}`
|
|
},
|
|
lockedPrompt: {
|
|
path: promptLockPath,
|
|
summaryPath: promptSummaryPath,
|
|
mode: promptSummary?.mode || 'direct_imagegen_in_image_product',
|
|
note: 'Article prompt format is locked. These are direct in-image generations: product was requested inside the ImageGen output, not post-composited into the final assets.'
|
|
},
|
|
generated,
|
|
ready: generated.every(row => !row.missing)
|
|
}
|
|
}
|
|
|
|
function parseCsv(text: string) {
|
|
const rows: string[][] = []
|
|
let row: string[] = []
|
|
let cell = ''
|
|
let quoted = false
|
|
for (let i = 0; i < text.length; i++) {
|
|
const char = text[i]
|
|
const next = text[i + 1]
|
|
if (char === '"') {
|
|
if (quoted && next === '"') {
|
|
cell += '"'
|
|
i++
|
|
} else {
|
|
quoted = !quoted
|
|
}
|
|
} else if (char === ',' && !quoted) {
|
|
row.push(cell)
|
|
cell = ''
|
|
} else if ((char === '\n' || char === '\r') && !quoted) {
|
|
if (char === '\r' && next === '\n') i++
|
|
row.push(cell)
|
|
if (row.some(value => value !== '')) rows.push(row)
|
|
row = []
|
|
cell = ''
|
|
} else {
|
|
cell += char
|
|
}
|
|
}
|
|
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, values[index] || ''])))
|
|
}
|
|
|
|
async function getJvDatabaseTruth(sku: string) {
|
|
const text = await readText(MIGRATION_PRODUCTS_CSV)
|
|
if (!text) return null
|
|
const rows = parseCsv(text)
|
|
const variants = rows.filter((row: any) => row['Variant SKU'] === sku || String(row['Variant SKU'] || '').startsWith(`${sku}-`))
|
|
if (!variants.length) return null
|
|
const master = variants.find((row: any) => row.Title || row['Body HTML']) || variants[0]
|
|
return {
|
|
sourceOfTruth: 'JV migration database export / products_import_lewis_v4.csv',
|
|
sourcePath: MIGRATION_PRODUCTS_CSV,
|
|
sku,
|
|
product: {
|
|
handle: master.Handle,
|
|
title: master.Title,
|
|
bodyHtml: master['Body HTML'],
|
|
tags: master.Tags,
|
|
imageSrc: master['Image Src'],
|
|
status: master.Status,
|
|
titleTag: master['Metafield: title_tag [string]'],
|
|
descriptionTag: master['Metafield: description_tag [string]'],
|
|
ingredients: master['Metafield: pdp.ingredients [rich_text_field]'],
|
|
recommendedIntake: master['Metafield: pdp.recommended_intake [single_line_text_field]'],
|
|
strength: master['Metafield: custom.strength [single_line_text_field]'] || master['Metafield: filter.strength [single_line_text_field]'],
|
|
strapline: master['Metafield: pdp.strapline [single_line_text_field]'],
|
|
format: master['Metafield: filter.format [single_line_text_field]'],
|
|
legalCategory: master['Metafield: custom.legal_category [single_line_text_field]'],
|
|
dimensions: master['Metafield: custom.dimensions [single_line_text_field]'],
|
|
quickCode: master['Metafield: code.quickcode [single_line_text_field]'],
|
|
uspList: master['Metafield: usp.list [list.single_line_text_field]'],
|
|
warning: master['Metafield: pdp.warning [multi_line_text_field]'],
|
|
allergens: master['Metafield: pdp.allergens [rich_text_field]'],
|
|
guid: master['Metafield: code.guid [single_line_text_field]'],
|
|
oldUrl: master['Metafield: temp.url [single_line_text_field]']
|
|
},
|
|
variants: variants.map((row: any) => ({
|
|
sku: row['Variant SKU'],
|
|
option: row['Option1 Value'],
|
|
price: row['Variant Price'],
|
|
compareAtPrice: row['Variant Compare At Price'],
|
|
barcode: row['Variant Barcode'],
|
|
inventoryQty: row['Variant Inventory Qty'],
|
|
weight: row['Variant Weight'],
|
|
weightUnit: row['Variant Weight Unit'],
|
|
binLocation: row['Variant Metafield: shipping.bin_location [single_line_text_field]'],
|
|
largeLetterFriendly: row['Variant Metafield: shipping.large_letter_friendly [boolean]'],
|
|
packMultiplier: row['Variant Metafield: shipping.pack_multiplier [single_line_text_field]']
|
|
}))
|
|
}
|
|
}
|
|
|
|
async function getRawEvidence(sku: string) {
|
|
const sections: any[] = []
|
|
const files: any[] = []
|
|
const pushFile = (block: any) => {
|
|
if (block) files.push({ label: block.label, path: block.path, bytes: block.bytes, chars: block.preview.chars, clipped: block.preview.clipped })
|
|
return block
|
|
}
|
|
const aliases = sourceSkuAliases(sku)
|
|
const internetSourceAliases = sku === 'JV-D1000' ? [] : aliases
|
|
|
|
const jvTruth = await getJvDatabaseTruth(sku)
|
|
sections.push({
|
|
key: 'jv_truth',
|
|
label: 'JV database product truth',
|
|
status: jvTruth ? 'source_of_truth_available' : 'missing',
|
|
files: jvTruth ? [{ label: 'JV migration product export', path: MIGRATION_PRODUCTS_CSV, bytes: Bun.file(MIGRATION_PRODUCTS_CSV).size }] : [],
|
|
items: jvTruth ? [jvTruth.product, ...jvTruth.variants] : [],
|
|
rawBlocks: jvTruth ? [{ title: 'JV database product truth row + variants', body: clipText(jvTruth, 18000).text, chars: clipText(jvTruth, 18000).chars, clipped: clipText(jvTruth, 18000).clipped }] : []
|
|
})
|
|
|
|
const officialPath = sku === 'JV-D1000' ? '' : join(SOURCE_ROOT, 'top3-public-evidence', `${sku}.json`)
|
|
const officialBlock = officialPath ? pushFile(await rawFileBlock(officialPath, 'Top-3 public product evidence', 16000)) : null
|
|
sections.push({
|
|
key: 'official',
|
|
label: 'Official / public product evidence',
|
|
status: sku === 'JV-D1000' ? 'skipped_stale_effervescent_evidence_moved_to_jv_deff1000' : (officialBlock ? 'available' : 'missing'),
|
|
files: officialBlock ? [officialBlock] : [],
|
|
rawBlocks: officialBlock ? [{ title: officialBlock.label, body: officialBlock.preview.text, chars: officialBlock.preview.chars, clipped: officialBlock.preview.clipped }] : []
|
|
})
|
|
|
|
const apifyMainPath = join(SOURCE_ROOT, 'apify', 'raw', 'top3-market-competitor-capped-apify.json')
|
|
const apifyMain = await readJson(apifyMainPath)
|
|
const apifyItems = normaliseApifyItems(apifyMain?.items)
|
|
const apifyItem = apifyItems.find((item: any) => internetSourceAliases.includes(item?.sku))
|
|
const apifyBlocks: any[] = []
|
|
if (apifyItem) {
|
|
apifyBlocks.push({
|
|
title: `${apifyItem.competitor || 'Competitor'} raw scraped page text`,
|
|
url: apifyItem.loadedUrl || apifyItem.url,
|
|
body: clipText({
|
|
sku: apifyItem.sku,
|
|
competitor: apifyItem.competitor,
|
|
url: apifyItem.url,
|
|
loadedUrl: apifyItem.loadedUrl,
|
|
title: apifyItem.title,
|
|
h1: apifyItem.h1,
|
|
headings: apifyItem.headings,
|
|
prices: apifyItem.prices,
|
|
bullets: apifyItem.bullets,
|
|
text: apifyItem.text
|
|
}, 18000).text,
|
|
chars: clipText(apifyItem.text || '', 18000).chars,
|
|
clipped: clipText(apifyItem.text || '', 18000).clipped
|
|
})
|
|
}
|
|
const apifyFiles = []
|
|
const apifyMainBlock = pushFile(await rawFileBlock(apifyMainPath, 'Apify capped competitor crawl raw run', 8000))
|
|
if (apifyMainBlock) apifyFiles.push(apifyMainBlock)
|
|
if (sku === 'JV-VISISOFT') {
|
|
const visisoftPath = join(SOURCE_ROOT, 'apify', 'raw', 'top3-visisoft-competitor-healthspan-capped-apify.json')
|
|
const visisoftBlock = pushFile(await rawFileBlock(visisoftPath, 'Apify Healthspan capped one-request attempt', 8000))
|
|
if (visisoftBlock) apifyFiles.push(visisoftBlock)
|
|
}
|
|
sections.push({
|
|
key: 'apify',
|
|
label: 'Apify competitor raw scrape',
|
|
status: apifyItem ? 'scraped' : (apifyMain ? 'run_available_no_item_for_sku' : 'missing'),
|
|
run: apifyMain ? {
|
|
actor: apifyMain.actor,
|
|
runId: apifyMain.runId,
|
|
datasetId: apifyMain.datasetId,
|
|
status: apifyMain.status,
|
|
statusMessage: apifyMain.statusMessage,
|
|
requestCap: apifyMain.requestCap,
|
|
itemCount: apifyMain.itemCount,
|
|
computeUnits: apifyMain.computeUnits
|
|
} : null,
|
|
items: apifyItem ? [apifyItem] : [],
|
|
files: apifyFiles,
|
|
rawBlocks: apifyBlocks
|
|
})
|
|
|
|
const comprehensiveApifyFiles = [
|
|
join(SOURCE_ROOT, 'apify', 'raw', 'top3-comprehensive-source-scrape-latest.json'),
|
|
join(SOURCE_ROOT, 'apify', 'raw', 'top3-social-source-scrape-latest.json')
|
|
]
|
|
const comprehensiveItems: any[] = []
|
|
const comprehensiveRawBlocks: any[] = []
|
|
const comprehensiveFiles = []
|
|
for (const filePath of comprehensiveApifyFiles) {
|
|
const raw = await readJson(filePath)
|
|
const block = pushFile(await rawFileBlock(filePath, `Apify comprehensive/source run: ${filePath.split(/[\\/]/).pop()}`, 8000))
|
|
if (block) comprehensiveFiles.push(block)
|
|
const rows = Array.isArray(raw?.items) ? raw.items : []
|
|
const matching = rows.filter((item: any) => item.sku === sku || item.sku === 'ALL_TOP3' || (sku === 'JV-DEFF1000' && item.sku === 'JV-D1000'))
|
|
comprehensiveItems.push(...matching.map(compactComprehensiveItem))
|
|
for (const item of matching) {
|
|
comprehensiveRawBlocks.push({
|
|
title: `${item.sourceType}: ${item.label}`,
|
|
url: item.loadedUrl || item.requestedUrl,
|
|
body: clipText({
|
|
sourceType: item.sourceType,
|
|
label: item.label,
|
|
title: item.title,
|
|
h1: item.h1,
|
|
metaDescription: item.metaDescription,
|
|
headings: item.headings,
|
|
prices: item.prices,
|
|
reviewLike: item.reviewLike,
|
|
text: item.text
|
|
}, 16000).text,
|
|
chars: clipText(item.text || '', 16000).chars,
|
|
clipped: clipText(item.text || '', 16000).clipped
|
|
})
|
|
}
|
|
}
|
|
sections.push({
|
|
key: 'apify_comprehensive',
|
|
label: 'Apify comprehensive raw source scrape',
|
|
status: comprehensiveItems.length ? 'captured' : (comprehensiveFiles.length ? 'run_available_no_item_for_sku' : 'missing'),
|
|
files: comprehensiveFiles,
|
|
items: comprehensiveItems,
|
|
rawBlocks: comprehensiveRawBlocks
|
|
})
|
|
|
|
const specialistRawDir = join(SOURCE_ROOT, 'apify', 'raw')
|
|
const specialistApifyFiles = (await readdir(specialistRawDir).catch(() => []))
|
|
.filter((file: string) => /^top3-specialist-.*-latest\.json$/.test(file))
|
|
.sort()
|
|
.map((file: string) => join(specialistRawDir, file))
|
|
const specialistItems: any[] = []
|
|
const specialistBlocks: any[] = []
|
|
const specialistFiles = []
|
|
const matchesSpecialist = (item: any, job: string) => {
|
|
if (sku === 'COLLAGEN-NPD') return item?.sku === 'COLLAGEN-NPD'
|
|
if (item?.sku === sku || item?.sku === 'ALL_TOP3') return true
|
|
if (item?.sku) return false
|
|
if (String(job || '').includes('amazon-reviews')) return false
|
|
if (job === 'trustpilot') return true
|
|
return filterStrictRawRowsForSku(sku, [item]).length > 0
|
|
}
|
|
for (const filePath of specialistApifyFiles) {
|
|
const raw = await readJson(filePath)
|
|
if (!raw) continue
|
|
const block = pushFile(await rawFileBlock(filePath, `Apify specialist run: ${raw.job || filePath.split(/[\\/]/).pop()}`, 8000))
|
|
if (block) specialistFiles.push(block)
|
|
const rows = Array.isArray(raw.items) ? raw.items : []
|
|
const matching = rows.filter((item: any) => matchesSpecialist(item, raw.job))
|
|
for (const item of matching) {
|
|
const wrapped = { job: raw.job, actor: raw.actor, runId: raw.run?.id, item: compactSpecialistItem(raw.job, item) }
|
|
specialistItems.push(wrapped)
|
|
specialistBlocks.push({
|
|
title: `${raw.job}: ${item.title || item.url || item.id || 'raw item'}`,
|
|
url: item.url || item.link || item.businessUrl || null,
|
|
body: clipText(wrapped, 12000).text,
|
|
chars: clipText(wrapped, 12000).chars,
|
|
clipped: clipText(wrapped, 12000).clipped
|
|
})
|
|
}
|
|
}
|
|
sections.push({
|
|
key: 'apify_specialists',
|
|
label: 'Apify specialist raw scrapes',
|
|
status: specialistItems.length ? 'captured' : (specialistFiles.length ? 'run_available_no_item_for_sku' : 'missing'),
|
|
files: specialistFiles,
|
|
items: specialistItems,
|
|
rawBlocks: specialistBlocks
|
|
})
|
|
|
|
if (sku === 'COLLAGEN-NPD') {
|
|
const collagenCompetitorDir = join(SOURCE_ROOT, 'competitor-pdps')
|
|
const competitorFiles = (await readdir(collagenCompetitorDir).catch(() => []))
|
|
.filter((file: string) => file.endsWith('.json'))
|
|
.sort()
|
|
.map((file: string) => join(collagenCompetitorDir, file))
|
|
const competitorBlocks = []
|
|
const competitorItems = []
|
|
for (const filePath of competitorFiles) {
|
|
const raw = await readJson(filePath)
|
|
const brand = raw?.brand || filePath.split(/[\\/]/).pop()?.replace(/\.json$/, '')
|
|
const pages = Array.isArray(raw?.pages) ? raw.pages : []
|
|
competitorItems.push({
|
|
brand,
|
|
domain: raw?.domain,
|
|
pages: pages.length,
|
|
captured: pages.filter((page: any) => page.ok).length,
|
|
sourceFile: filePath.split(/[\\/]/).pop()
|
|
})
|
|
const block = pushFile(await rawFileBlock(filePath, `Collagen competitor PDP raw: ${brand}`, 12000))
|
|
if (block) competitorBlocks.push(block)
|
|
}
|
|
sections.push({
|
|
key: 'collagen_competitors',
|
|
label: 'Collagen competitor PDP/raw positioning captures',
|
|
status: competitorItems.length ? 'captured' : 'missing',
|
|
files: competitorBlocks,
|
|
items: competitorItems,
|
|
rawBlocks: competitorBlocks.map(block => ({ title: block.label, body: block.preview.text, chars: block.preview.chars, clipped: block.preview.clipped }))
|
|
})
|
|
}
|
|
|
|
const dataforseoDir = join(SOURCE_ROOT, 'dataforseo', 'products_raw')
|
|
const dataforseoFiles = (await readdir(dataforseoDir).catch(() => []))
|
|
.filter((file: string) => file.endsWith('.json') && internetSourceAliases.some(alias => file.includes(alias)))
|
|
.sort()
|
|
const dataforseoBlocks = []
|
|
const amazonRows = []
|
|
for (const file of dataforseoFiles) {
|
|
const filePath = join(dataforseoDir, file)
|
|
const raw = await readJson(filePath)
|
|
amazonRows.push(...collectAmazonRows(raw).map(row => ({ ...row, rawFile: file })))
|
|
const block = pushFile(await rawFileBlock(filePath, `DataForSEO Amazon products raw: ${file}`, 7000))
|
|
if (block) dataforseoBlocks.push(block)
|
|
}
|
|
const amazonSnippetPath = join(SOURCE_ROOT, 'amazon', 'amazon-uk-vitamin-d3-k2-public-snippet-2026-05-19.json')
|
|
const amazonSnippetBlock = internetSourceAliases.includes('JV-D1000') ? pushFile(await rawFileBlock(amazonSnippetPath, 'Amazon UK public Vitamin D category snippet', 10000)) : null
|
|
if (amazonSnippetBlock) dataforseoBlocks.push(amazonSnippetBlock)
|
|
sections.push({
|
|
key: 'amazon',
|
|
label: 'Amazon / DataForSEO raw marketplace data',
|
|
status: dataforseoBlocks.length ? 'available' : 'missing',
|
|
files: dataforseoBlocks,
|
|
items: filterRawRowsForSku(sku, amazonRows).slice(0, RAW_EVIDENCE_ITEM_LIMIT),
|
|
rawBlocks: dataforseoBlocks.map(block => ({ title: block.label, body: block.preview.text, chars: block.preview.chars, clipped: block.preview.clipped }))
|
|
})
|
|
|
|
const redditPath = join(SOURCE_ROOT, 'reddit', 'supplements-vitamin-d-turmeric-public-snippets-2026-05-19.json')
|
|
const redditRaw = await readJson(redditPath)
|
|
const redditBlock = pushFile(await rawFileBlock(redditPath, 'Reddit public snippets raw capture', 12000))
|
|
const redditRows = filterStrictRawRowsForSku(sku, Array.isArray(redditRaw?.items) ? redditRaw.items : Array.isArray(redditRaw?.snippets) ? redditRaw.snippets : Array.isArray(redditRaw) ? redditRaw : [])
|
|
const redditPulse = await readJson(join(INTEL_ROOT, sku, 'reddit-pulse.json'))
|
|
sections.push({
|
|
key: 'reddit',
|
|
label: 'Reddit raw snippets and public source links',
|
|
status: redditRows.length ? 'raw_snippets_available' : (redditPulse ? 'generated_public_links_available' : 'missing'),
|
|
files: redditBlock ? [redditBlock] : [],
|
|
items: redditRows,
|
|
generatedPulse: redditRows.length ? null : redditPulse,
|
|
rawBlocks: redditRows.length
|
|
? [{ title: 'Filtered Reddit raw snippet rows for this SKU', body: clipText(redditRows, 14000).text, chars: clipText(redditRows, 14000).chars, clipped: clipText(redditRows, 14000).clipped }]
|
|
: (redditPulse ? [{ title: 'Generated Reddit pulse with public source links', body: clipText(redditPulse, 14000).text, chars: clipText(redditPulse, 14000).chars, clipped: clipText(redditPulse, 14000).clipped }] : [])
|
|
})
|
|
|
|
return {
|
|
sku,
|
|
generatedAt: new Date().toISOString(),
|
|
note: `Raw source material is shown for inspection. Dashboard item caps are raised for top-3 research: up to ${RAW_EVIDENCE_ITEM_LIMIT} rows and ${RAW_EVIDENCE_BLOCK_LIMIT} raw blocks per section, with redacted/clipped previews for browser stability. File paths are listed for full local inspection.`,
|
|
sections: compactRawEvidencePayload(sections),
|
|
files
|
|
}
|
|
}
|
|
|
|
async function readLatestDailyStatus() {
|
|
try {
|
|
const files = (await readdir(DASHBOARD_EXPORT_ROOT))
|
|
.filter((file: string) => /^daily_status_\d{4}-\d{2}-\d{2}\.json$/.test(file))
|
|
.sort()
|
|
const latest = files.at(-1)
|
|
return latest ? readJson(join(DASHBOARD_EXPORT_ROOT, latest)) : null
|
|
} catch (_) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function safeSku(value: string) {
|
|
if (!/^[A-Z0-9_-]+$/i.test(value)) return null
|
|
return value
|
|
}
|
|
|
|
function normaliseListField(value: any) {
|
|
if (Array.isArray(value)) return value.map((item: any) => String(item).trim()).filter(Boolean)
|
|
if (value === undefined || value === null || value === '') return []
|
|
if (typeof value === 'string') return value.split(',').map(item => item.trim()).filter(Boolean)
|
|
return [String(value)]
|
|
}
|
|
|
|
function normaliseProductContent(data: any) {
|
|
const metafields = data?.metafields || {}
|
|
for (const field of ['filterDietaryTag', 'filterHealthGoals', 'pdpProductBenefits']) {
|
|
if (field in metafields) metafields[field] = normaliseListField(metafields[field])
|
|
}
|
|
return { ...data, metafields }
|
|
}
|
|
|
|
async function writeJson(path: string, data: any) {
|
|
await mkdir(dirname(path), { recursive: true })
|
|
await writeFile(path, JSON.stringify(data, null, 2) + '\n', 'utf8')
|
|
}
|
|
|
|
async function listProductContent() {
|
|
try {
|
|
const files = (await readdir(CONTENT_PRODUCTS_ROOT)).filter((file: string) => file.endsWith('.json'))
|
|
const rows = []
|
|
for (const file of files) {
|
|
const data = await readJson(join(CONTENT_PRODUCTS_ROOT, file))
|
|
if (data) rows.push(data)
|
|
}
|
|
return rows.sort((a: any, b: any) => String(a.sku).localeCompare(String(b.sku)))
|
|
} catch (_) {
|
|
return []
|
|
}
|
|
}
|
|
|
|
async function getExportStatus() {
|
|
const products = await listProductContent()
|
|
const metaobjects = await readJson(join(CONTENT_ROOT, 'metaobjects.json'))
|
|
const collections = await readJson(join(CONTENT_ROOT, 'smart-collections.json'))
|
|
const handoffManifest = await readJson(join(DASHBOARD_EXPORT_ROOT, 'handoff_manifest.json'))
|
|
const readinessAudit = await readJson(join(DASHBOARD_EXPORT_ROOT, 'content_population_readiness.json'))
|
|
const intelligenceWorkqueue = await readJson(join(DASHBOARD_EXPORT_ROOT, 'intelligence_generation_workqueue.json'))
|
|
const handoffActionPlan = await readJson(join(DASHBOARD_EXPORT_ROOT, 'handoff_action_plan.json'))
|
|
const handoffPacketRefresh = await readJson(join(DASHBOARD_EXPORT_ROOT, 'handoff_packet_refresh.json'))
|
|
const handoffArtifactFreshness = await readJson(join(DASHBOARD_EXPORT_ROOT, 'handoff_artifact_freshness.json'))
|
|
const unmatchedClaimsReview = await readJson(join(DASHBOARD_EXPORT_ROOT, 'unmatched_claims_review_packet.json'))
|
|
const productApprovalReview = await readJson(join(DASHBOARD_EXPORT_ROOT, 'product_approval_review_packet.json'))
|
|
const productApprovalDecisionGate = await readJson(join(DASHBOARD_EXPORT_ROOT, 'product_approval_decision_gate.json'))
|
|
const executiveDecisionBrief = await readJson(join(DASHBOARD_EXPORT_ROOT, 'executive_decision_brief.json'))
|
|
const blockerBurndown = await readJson(join(DASHBOARD_EXPORT_ROOT, 'blocker_burndown.json'))
|
|
const handoffEvidenceDossier = await readJson(join(DASHBOARD_EXPORT_ROOT, 'handoff_evidence_dossier.json'))
|
|
const reviewerDecisionWorkbook = await readJson(join(DASHBOARD_EXPORT_ROOT, 'reviewer_decision_workbook.json'))
|
|
const reviewerInputPreflight = await readJson(join(DASHBOARD_EXPORT_ROOT, 'reviewer_input_preflight.json'))
|
|
const ownerHandoffMessages = await readJson(join(DASHBOARD_EXPORT_ROOT, 'owner_handoff_messages.json'))
|
|
const ownerResponseIntake = await readJson(join(DASHBOARD_EXPORT_ROOT, 'owner_response_intake.json'))
|
|
const handoffGateDependencyMap = await readJson(join(DASHBOARD_EXPORT_ROOT, 'handoff_gate_dependency_map.json'))
|
|
const postOwnerResponseRerunPlan = await readJson(join(DASHBOARD_EXPORT_ROOT, 'post_owner_response_rerun_plan.json'))
|
|
const ownerEvidenceAcceptanceGuide = await readJson(join(DASHBOARD_EXPORT_ROOT, 'owner_evidence_acceptance_guide.json'))
|
|
const dailyStatus = await readLatestDailyStatus()
|
|
const stagingHandoffPacket = await readJson(join(DASHBOARD_EXPORT_ROOT, 'staging_handoff_packet.json'))
|
|
const stagingChecklist = await readJson(join(DASHBOARD_EXPORT_ROOT, 'staging_import_checklist.json'))
|
|
const stagingEvidence = await readJson(join(DASHBOARD_EXPORT_ROOT, 'staging_evidence_validation.json'))
|
|
const stagingEvidenceIntake = await readJson(join(DASHBOARD_EXPORT_ROOT, 'staging_evidence_intake.json'))
|
|
const handoffVerification = await readJson(join(DASHBOARD_EXPORT_ROOT, 'handoff_verification_run.json'))
|
|
const deliveryIndex = await readJson(join(DASHBOARD_EXPORT_ROOT, 'delivery_index.json'))
|
|
const sourceAcquisitionPlan = await readJson(join(DASHBOARD_EXPORT_ROOT, 'source_acquisition_plan.json'))
|
|
const dataforseoPilot = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_pilot_manifest.json'))
|
|
const dataforseoLiveConnection = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_live_connection.json'))
|
|
const dataforseoProductsLiveRun = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_products_live_run.json'))
|
|
const dataforseoReviewsLivePilot = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_reviews_live_pilot.json'))
|
|
const dataforseoReviewsDocsStatus = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_reviews_docs_status.json'))
|
|
const dataforseoSupportEscalation = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_support_escalation.json'))
|
|
const dataforseoSupportResponseGate = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_support_response_gate.json'))
|
|
const dataforseoApprovalRequest = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_stage1_approval_request.json'))
|
|
const manualAmazonReviewCapture = await readJson(join(DASHBOARD_EXPORT_ROOT, 'manual_amazon_review_capture.json'))
|
|
const manualAmazonReviewCaptureValidation = await readJson(join(DASHBOARD_EXPORT_ROOT, 'manual_amazon_review_capture_validation.json'))
|
|
const dataforseoIngest = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_ingest_validation.json'))
|
|
const dataforseoAsinLookup = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_asin_lookup_queue.json'))
|
|
const dataforseoAsinCandidates = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_asin_candidate_report.json'))
|
|
const dataforseoAsinWebEvidence = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_asin_web_evidence.json'))
|
|
const dataforseoUnresolvedAsinEvidence = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_unresolved_asin_evidence.json'))
|
|
const dataforseoAsinConfirmations = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_asin_confirmation_report.json'))
|
|
const dataforseoAsinPromotion = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_asin_promotion_report.json'))
|
|
const dataforseoTaskPreflight = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_task_preflight.json'))
|
|
const apifyGapfill = await readJson(join(DASHBOARD_EXPORT_ROOT, 'apify_gapfill_manifest.json'))
|
|
const apifyTaskPreflight = await readJson(join(DASHBOARD_EXPORT_ROOT, 'apify_task_preflight.json'))
|
|
const sourcePricingReferences = await readJson(join(DASHBOARD_EXPORT_ROOT, 'source_acquisition_pricing_references.json'))
|
|
const sourceCostScenarios = await readJson(join(DASHBOARD_EXPORT_ROOT, 'source_acquisition_cost_scenarios.json'))
|
|
const sourceBudgetGate = await readJson(join(DASHBOARD_EXPORT_ROOT, 'source_acquisition_budget_gate.json'))
|
|
const sourceCostPlan = await readJson(join(DASHBOARD_EXPORT_ROOT, 'source_acquisition_cost_plan.json'))
|
|
const sourceApprovalGate = await readJson(join(DASHBOARD_EXPORT_ROOT, 'source_acquisition_approval_gate.json'))
|
|
const sourceSpendGate = await readJson(join(DASHBOARD_EXPORT_ROOT, 'source_acquisition_spend_gate.json'))
|
|
const dataforseoPilotReview = await readJson(join(DASHBOARD_EXPORT_ROOT, 'dataforseo_pilot_review.json'))
|
|
const sourceAcquisitionControlRun = await readJson(join(DASHBOARD_EXPORT_ROOT, 'source_acquisition_control_run.json'))
|
|
const sourceSubscriptionDecision = await readJson(join(DASHBOARD_EXPORT_ROOT, 'source_subscription_decision.json'))
|
|
const sourceAcquisitionNextActions = await readJson(join(DASHBOARD_EXPORT_ROOT, 'source_acquisition_next_actions.json'))
|
|
const clinicalBlankDecisionTemplate = await readJson(join(DASHBOARD_EXPORT_ROOT, 'clinical_blank_decision_template.json'))
|
|
const clinicalBlankDecisionFile = await readJson(join(CONTENT_ROOT, 'compliance', 'clinical-blank-decisions.json'))
|
|
const clinicalBlankDecisionBySku = new Map((clinicalBlankDecisionFile?.decisions || []).map((row: any) => [row.sku, row]))
|
|
const isApprovedBlankClinicalDecision = (decision: any) => decision?.decision === 'approved_blank' &&
|
|
Boolean(decision.approvedBy) &&
|
|
Boolean(decision.approvedAt) &&
|
|
Boolean(decision.rationale) &&
|
|
decision.constraintsAcknowledged === true
|
|
const assetStatus = await getAssetStatus()
|
|
const blockers: any[] = []
|
|
for (const product of products) {
|
|
const m = product.metafields || {}
|
|
if (!m.pdpClinicallyShownTo && !isApprovedBlankClinicalDecision(clinicalBlankDecisionBySku.get(product.sku))) blockers.push({ sku: product.sku, type: 'compliance', message: 'No clinically_shown_to reference or approved blank compliance decision yet' })
|
|
for (const field of ['pdpResults', 'pdpResultsMobile', 'pdpHowWeCompare']) {
|
|
if (!m[field]) blockers.push({ sku: product.sku, type: 'asset', message: `${field} file reference missing` })
|
|
}
|
|
if (!product.approval || product.approval.content !== 'approved') blockers.push({ sku: product.sku, type: 'approval', message: 'Content not approved' })
|
|
}
|
|
for (const item of assetStatus.missingReferences || []) {
|
|
blockers.push({ sku: item.owner, type: 'asset_reference', message: `${item.field} filename missing` })
|
|
}
|
|
for (const item of assetStatus.missingUploads || []) {
|
|
blockers.push({ sku: item.owner, type: 'asset_upload', message: `${item.field} file not found/uploaded: ${item.filename}` })
|
|
}
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
productCount: products.length,
|
|
metaobjectDefinitions: Object.keys(metaobjects?.definitions || {}).length,
|
|
collectionMappings: collections?.collections?.length || 0,
|
|
handoffManifest,
|
|
readinessAudit: readinessAudit ? {
|
|
ready: readinessAudit.ready,
|
|
summary: readinessAudit.summary,
|
|
gates: readinessAudit.gates,
|
|
nextActions: readinessAudit.nextActions
|
|
} : null,
|
|
intelligenceWorkqueue: intelligenceWorkqueue ? {
|
|
ready: intelligenceWorkqueue.ready,
|
|
totalProducts: intelligenceWorkqueue.totalProducts,
|
|
missingTasks: intelligenceWorkqueue.missingTasks,
|
|
affectedSkus: intelligenceWorkqueue.affectedSkus,
|
|
gateSummary: intelligenceWorkqueue.gateSummary,
|
|
topSkus: intelligenceWorkqueue.topSkus
|
|
} : null,
|
|
handoffActionPlan: handoffActionPlan ? {
|
|
ready: handoffActionPlan.ready,
|
|
counts: handoffActionPlan.counts,
|
|
actions: handoffActionPlan.actions
|
|
} : null,
|
|
handoffPacketRefresh: handoffPacketRefresh ? {
|
|
refreshReady: handoffPacketRefresh.refreshReady,
|
|
handoffReady: handoffPacketRefresh.handoffReady,
|
|
objective: handoffPacketRefresh.objective,
|
|
counts: handoffPacketRefresh.counts,
|
|
steps: handoffPacketRefresh.steps
|
|
} : null,
|
|
handoffArtifactFreshness: handoffArtifactFreshness ? {
|
|
ready: handoffArtifactFreshness.ready,
|
|
note: handoffArtifactFreshness.note,
|
|
refreshStartedAt: handoffArtifactFreshness.refreshStartedAt,
|
|
counts: handoffArtifactFreshness.counts,
|
|
staleRows: handoffArtifactFreshness.staleRows,
|
|
rows: handoffArtifactFreshness.rows
|
|
} : null,
|
|
unmatchedClaimsReview: unmatchedClaimsReview ? {
|
|
ready: unmatchedClaimsReview.ready,
|
|
purpose: unmatchedClaimsReview.purpose,
|
|
rule: unmatchedClaimsReview.rule,
|
|
counts: unmatchedClaimsReview.counts,
|
|
rows: unmatchedClaimsReview.rows
|
|
} : null,
|
|
productApprovalReview: productApprovalReview ? {
|
|
ready: productApprovalReview.ready,
|
|
purpose: productApprovalReview.purpose,
|
|
counts: productApprovalReview.counts,
|
|
rows: productApprovalReview.rows
|
|
} : null,
|
|
productApprovalDecisionGate: productApprovalDecisionGate ? {
|
|
ready: productApprovalDecisionGate.ready,
|
|
decisionFile: productApprovalDecisionGate.decisionFile,
|
|
counts: productApprovalDecisionGate.counts,
|
|
guardrail: productApprovalDecisionGate.guardrail,
|
|
rows: productApprovalDecisionGate.rows
|
|
} : null,
|
|
executiveDecisionBrief: executiveDecisionBrief ? {
|
|
ready: executiveDecisionBrief.ready,
|
|
purpose: executiveDecisionBrief.purpose,
|
|
statusLine: executiveDecisionBrief.statusLine,
|
|
counts: executiveDecisionBrief.counts,
|
|
decisions: executiveDecisionBrief.decisions,
|
|
readinessRows: executiveDecisionBrief.readinessRows
|
|
} : null,
|
|
blockerBurndown: blockerBurndown ? {
|
|
ready: blockerBurndown.ready,
|
|
headline: blockerBurndown.headline,
|
|
counts: blockerBurndown.counts,
|
|
lanes: blockerBurndown.lanes,
|
|
nextActions: blockerBurndown.nextActions
|
|
} : null,
|
|
handoffEvidenceDossier: handoffEvidenceDossier ? {
|
|
ready: handoffEvidenceDossier.ready,
|
|
headline: handoffEvidenceDossier.headline,
|
|
counts: handoffEvidenceDossier.counts,
|
|
requirements: handoffEvidenceDossier.requirements,
|
|
blockerSources: handoffEvidenceDossier.blockerSources
|
|
} : null,
|
|
reviewerDecisionWorkbook: reviewerDecisionWorkbook ? {
|
|
ready: reviewerDecisionWorkbook.ready,
|
|
purpose: reviewerDecisionWorkbook.purpose,
|
|
counts: reviewerDecisionWorkbook.counts,
|
|
files: reviewerDecisionWorkbook.files,
|
|
ownerRows: reviewerDecisionWorkbook.ownerRows,
|
|
completionRows: reviewerDecisionWorkbook.completionRows,
|
|
currentStatus: reviewerDecisionWorkbook.currentStatus
|
|
} : null,
|
|
reviewerInputPreflight: reviewerInputPreflight ? {
|
|
ready: reviewerInputPreflight.ready,
|
|
purpose: reviewerInputPreflight.purpose,
|
|
counts: reviewerInputPreflight.counts,
|
|
files: reviewerInputPreflight.files,
|
|
rows: reviewerInputPreflight.rows,
|
|
blockingRows: reviewerInputPreflight.blockingRows
|
|
} : null,
|
|
ownerHandoffMessages: ownerHandoffMessages ? {
|
|
ready: ownerHandoffMessages.ready,
|
|
purpose: ownerHandoffMessages.purpose,
|
|
status: ownerHandoffMessages.status,
|
|
counts: ownerHandoffMessages.counts,
|
|
guardrails: ownerHandoffMessages.guardrails,
|
|
messages: ownerHandoffMessages.messages,
|
|
files: ownerHandoffMessages.files
|
|
} : null,
|
|
ownerResponseIntake: ownerResponseIntake ? {
|
|
ready: ownerResponseIntake.ready,
|
|
purpose: ownerResponseIntake.purpose,
|
|
intakeFile: ownerResponseIntake.intakeFile,
|
|
counts: ownerResponseIntake.counts,
|
|
rows: ownerResponseIntake.rows,
|
|
files: ownerResponseIntake.files
|
|
} : null,
|
|
handoffGateDependencyMap: handoffGateDependencyMap ? {
|
|
ready: handoffGateDependencyMap.ready,
|
|
purpose: handoffGateDependencyMap.purpose,
|
|
counts: handoffGateDependencyMap.counts,
|
|
blockedRows: handoffGateDependencyMap.blockedRows,
|
|
ownerGroups: handoffGateDependencyMap.ownerGroups,
|
|
files: handoffGateDependencyMap.files
|
|
} : null,
|
|
postOwnerResponseRerunPlan: postOwnerResponseRerunPlan ? {
|
|
ready: postOwnerResponseRerunPlan.ready,
|
|
purpose: postOwnerResponseRerunPlan.purpose,
|
|
counts: postOwnerResponseRerunPlan.counts,
|
|
rows: postOwnerResponseRerunPlan.rows,
|
|
files: postOwnerResponseRerunPlan.files
|
|
} : null,
|
|
ownerEvidenceAcceptanceGuide: ownerEvidenceAcceptanceGuide ? {
|
|
ready: ownerEvidenceAcceptanceGuide.ready,
|
|
purpose: ownerEvidenceAcceptanceGuide.purpose,
|
|
counts: ownerEvidenceAcceptanceGuide.counts,
|
|
rows: ownerEvidenceAcceptanceGuide.rows,
|
|
files: ownerEvidenceAcceptanceGuide.files
|
|
} : null,
|
|
dailyStatus: dailyStatus ? {
|
|
ready: dailyStatus.ready,
|
|
date: dailyStatus.date,
|
|
headline: dailyStatus.headline,
|
|
guardrails: dailyStatus.guardrails,
|
|
rows: dailyStatus.rows
|
|
} : null,
|
|
stagingHandoffPacket: stagingHandoffPacket ? {
|
|
ready: stagingHandoffPacket.ready,
|
|
purpose: stagingHandoffPacket.purpose,
|
|
counts: stagingHandoffPacket.counts,
|
|
importFiles: stagingHandoffPacket.importFiles,
|
|
sampleRows: stagingHandoffPacket.sampleRows,
|
|
actionRows: stagingHandoffPacket.actionRows
|
|
} : null,
|
|
clinicalBlankDecisionTemplate: clinicalBlankDecisionTemplate ? {
|
|
ready: clinicalBlankDecisionTemplate.ready,
|
|
decisionFile: clinicalBlankDecisionTemplate.decisionFile,
|
|
counts: clinicalBlankDecisionTemplate.counts,
|
|
guardrail: clinicalBlankDecisionTemplate.guardrail,
|
|
rows: clinicalBlankDecisionTemplate.rows
|
|
} : null,
|
|
stagingChecklist: stagingChecklist ? {
|
|
ready: stagingChecklist.ready,
|
|
counts: stagingChecklist.counts,
|
|
checks: stagingChecklist.checks
|
|
} : null,
|
|
stagingEvidence: stagingEvidence ? {
|
|
ready: stagingEvidence.ready,
|
|
counts: stagingEvidence.counts,
|
|
gates: stagingEvidence.gates,
|
|
templates: stagingEvidence.templates
|
|
} : null,
|
|
stagingEvidenceIntake: stagingEvidenceIntake ? {
|
|
ready: stagingEvidenceIntake.ready,
|
|
purpose: stagingEvidenceIntake.purpose,
|
|
cutoverCreated: stagingEvidenceIntake.cutoverCreated,
|
|
counts: stagingEvidenceIntake.counts,
|
|
rows: stagingEvidenceIntake.rows,
|
|
files: stagingEvidenceIntake.files
|
|
} : null,
|
|
handoffVerification: handoffVerification ? {
|
|
ready: handoffVerification.ready,
|
|
counts: handoffVerification.counts,
|
|
checks: handoffVerification.checks
|
|
} : null,
|
|
deliveryIndex: deliveryIndex ? {
|
|
ready: deliveryIndex.ready,
|
|
counts: deliveryIndex.counts,
|
|
groups: deliveryIndex.groups,
|
|
files: deliveryIndex.files
|
|
} : null,
|
|
sourceAcquisitionPlan: sourceAcquisitionPlan ? {
|
|
ready: sourceAcquisitionPlan.ready,
|
|
recommendation: sourceAcquisitionPlan.recommendation,
|
|
counts: sourceAcquisitionPlan.counts,
|
|
stages: sourceAcquisitionPlan.stages,
|
|
targets: sourceAcquisitionPlan.targets
|
|
} : null,
|
|
dataforseoPilot: dataforseoPilot ? {
|
|
ready: dataforseoPilot.ready,
|
|
endpoint: dataforseoPilot.endpoint,
|
|
guardrails: dataforseoPilot.guardrails,
|
|
counts: dataforseoPilot.counts,
|
|
files: dataforseoPilot.files,
|
|
targets: dataforseoPilot.targets
|
|
} : null,
|
|
dataforseoLiveConnection: dataforseoLiveConnection ? {
|
|
ready: dataforseoLiveConnection.ready,
|
|
credentialPrinted: dataforseoLiveConnection.credentialPrinted,
|
|
accountConnection: dataforseoLiveConnection.accountConnection,
|
|
counts: dataforseoLiveConnection.counts,
|
|
files: dataforseoLiveConnection.files,
|
|
reviewEndpointStatus: dataforseoLiveConnection.reviewEndpointStatus,
|
|
blocker: dataforseoLiveConnection.blocker,
|
|
confirmedRows: dataforseoLiveConnection.confirmedRows
|
|
} : null,
|
|
dataforseoProductsLiveRun: dataforseoProductsLiveRun ? {
|
|
ready: dataforseoProductsLiveRun.ready,
|
|
blocked: dataforseoProductsLiveRun.blocked,
|
|
blocker: dataforseoProductsLiveRun.blocker,
|
|
endpoint: dataforseoProductsLiveRun.endpoint,
|
|
allowToken: dataforseoProductsLiveRun.allowToken,
|
|
counts: dataforseoProductsLiveRun.counts,
|
|
files: dataforseoProductsLiveRun.files,
|
|
accountConnection: dataforseoProductsLiveRun.accountConnection,
|
|
post: dataforseoProductsLiveRun.post,
|
|
taskSummary: dataforseoProductsLiveRun.taskSummary
|
|
} : null,
|
|
dataforseoReviewsLivePilot: dataforseoReviewsLivePilot ? {
|
|
ready: dataforseoReviewsLivePilot.ready,
|
|
blocked: dataforseoReviewsLivePilot.blocked,
|
|
blocker: dataforseoReviewsLivePilot.blocker,
|
|
endpoint: dataforseoReviewsLivePilot.endpoint,
|
|
allowToken: dataforseoReviewsLivePilot.allowToken,
|
|
counts: dataforseoReviewsLivePilot.counts,
|
|
files: dataforseoReviewsLivePilot.files,
|
|
accountConnection: dataforseoReviewsLivePilot.accountConnection,
|
|
post: dataforseoReviewsLivePilot.post,
|
|
latestArchivedReviewPost: dataforseoReviewsLivePilot.latestArchivedReviewPost,
|
|
taskSummary: dataforseoReviewsLivePilot.taskSummary,
|
|
preflightBlockers: dataforseoReviewsLivePilot.preflightBlockers
|
|
} : null,
|
|
dataforseoReviewsDocsStatus: dataforseoReviewsDocsStatus ? {
|
|
ready: dataforseoReviewsDocsStatus.ready,
|
|
provider: dataforseoReviewsDocsStatus.provider,
|
|
endpoint: dataforseoReviewsDocsStatus.endpoint,
|
|
docsUrl: dataforseoReviewsDocsStatus.docsUrl,
|
|
httpStatus: dataforseoReviewsDocsStatus.httpStatus,
|
|
credentialPrinted: dataforseoReviewsDocsStatus.credentialPrinted,
|
|
endpointTemporarilyUnavailable: dataforseoReviewsDocsStatus.endpointTemporarilyUnavailable,
|
|
recommendation: dataforseoReviewsDocsStatus.recommendation,
|
|
counts: dataforseoReviewsDocsStatus.counts,
|
|
checks: dataforseoReviewsDocsStatus.checks
|
|
} : null,
|
|
dataforseoSupportEscalation: dataforseoSupportEscalation ? {
|
|
ready: dataforseoSupportEscalation.ready,
|
|
provider: dataforseoSupportEscalation.provider,
|
|
endpoint: dataforseoSupportEscalation.endpoint,
|
|
issue: dataforseoSupportEscalation.issue,
|
|
credentialPrinted: dataforseoSupportEscalation.credentialPrinted,
|
|
counts: dataforseoSupportEscalation.counts,
|
|
files: dataforseoSupportEscalation.files,
|
|
issueRows: dataforseoSupportEscalation.issueRows
|
|
} : null,
|
|
dataforseoSupportResponseGate: dataforseoSupportResponseGate ? {
|
|
ready: dataforseoSupportResponseGate.ready,
|
|
recommendation: dataforseoSupportResponseGate.recommendation,
|
|
responseFile: dataforseoSupportResponseGate.responseFile,
|
|
supportPacket: dataforseoSupportResponseGate.supportPacket,
|
|
counts: dataforseoSupportResponseGate.counts,
|
|
checks: dataforseoSupportResponseGate.checks,
|
|
nextActions: dataforseoSupportResponseGate.nextActions
|
|
} : null,
|
|
dataforseoApprovalRequest: dataforseoApprovalRequest ? {
|
|
ready: dataforseoApprovalRequest.ready,
|
|
approvalAlreadyValid: dataforseoApprovalRequest.approvalAlreadyValid,
|
|
recommendation: dataforseoApprovalRequest.recommendation,
|
|
approvalFile: dataforseoApprovalRequest.approvalFile,
|
|
counts: dataforseoApprovalRequest.counts,
|
|
checks: dataforseoApprovalRequest.checks,
|
|
files: dataforseoApprovalRequest.files,
|
|
proposedApproval: dataforseoApprovalRequest.proposedApproval
|
|
} : null,
|
|
manualAmazonReviewCapture: manualAmazonReviewCapture ? {
|
|
ready: manualAmazonReviewCapture.ready,
|
|
recommendation: manualAmazonReviewCapture.recommendation,
|
|
counts: manualAmazonReviewCapture.counts,
|
|
files: manualAmazonReviewCapture.files,
|
|
queue: manualAmazonReviewCapture.queue
|
|
} : null,
|
|
manualAmazonReviewCaptureValidation: manualAmazonReviewCaptureValidation ? {
|
|
ready: manualAmazonReviewCaptureValidation.ready,
|
|
recommendation: manualAmazonReviewCaptureValidation.recommendation,
|
|
counts: manualAmazonReviewCaptureValidation.counts,
|
|
rows: manualAmazonReviewCaptureValidation.rows,
|
|
blockers: manualAmazonReviewCaptureValidation.blockers
|
|
} : null,
|
|
dataforseoIngest: dataforseoIngest ? {
|
|
ready: dataforseoIngest.ready,
|
|
rawInputDir: dataforseoIngest.rawInputDir,
|
|
amazonOutputDir: dataforseoIngest.amazonOutputDir,
|
|
counts: dataforseoIngest.counts,
|
|
issues: dataforseoIngest.issues,
|
|
outputs: dataforseoIngest.outputs
|
|
} : null,
|
|
dataforseoAsinLookup: dataforseoAsinLookup ? {
|
|
ready: dataforseoAsinLookup.ready,
|
|
counts: dataforseoAsinLookup.counts,
|
|
files: dataforseoAsinLookup.files,
|
|
guardrails: dataforseoAsinLookup.guardrails,
|
|
queue: dataforseoAsinLookup.queue
|
|
} : null,
|
|
dataforseoAsinCandidates: dataforseoAsinCandidates ? {
|
|
ready: dataforseoAsinCandidates.ready,
|
|
counts: dataforseoAsinCandidates.counts,
|
|
files: dataforseoAsinCandidates.files,
|
|
guardrails: dataforseoAsinCandidates.guardrails,
|
|
candidates: dataforseoAsinCandidates.candidates
|
|
} : null,
|
|
dataforseoAsinWebEvidence: dataforseoAsinWebEvidence ? {
|
|
ready: dataforseoAsinWebEvidence.ready,
|
|
counts: dataforseoAsinWebEvidence.counts,
|
|
files: dataforseoAsinWebEvidence.files,
|
|
guardrails: dataforseoAsinWebEvidence.guardrails,
|
|
evidence: dataforseoAsinWebEvidence.evidence
|
|
} : null,
|
|
dataforseoUnresolvedAsinEvidence: dataforseoUnresolvedAsinEvidence ? {
|
|
ready: dataforseoUnresolvedAsinEvidence.ready,
|
|
recommendation: dataforseoUnresolvedAsinEvidence.recommendation,
|
|
counts: dataforseoUnresolvedAsinEvidence.counts,
|
|
files: dataforseoUnresolvedAsinEvidence.files,
|
|
unresolvedTargets: dataforseoUnresolvedAsinEvidence.unresolvedTargets
|
|
} : null,
|
|
dataforseoAsinConfirmations: dataforseoAsinConfirmations ? {
|
|
ready: dataforseoAsinConfirmations.ready,
|
|
counts: dataforseoAsinConfirmations.counts,
|
|
files: dataforseoAsinConfirmations.files,
|
|
guardrails: dataforseoAsinConfirmations.guardrails,
|
|
confirmations: dataforseoAsinConfirmations.confirmations
|
|
} : null,
|
|
dataforseoAsinPromotion: dataforseoAsinPromotion ? {
|
|
ready: dataforseoAsinPromotion.ready,
|
|
applied: dataforseoAsinPromotion.applied,
|
|
counts: dataforseoAsinPromotion.counts,
|
|
files: dataforseoAsinPromotion.files,
|
|
guardrails: dataforseoAsinPromotion.guardrails,
|
|
promotions: dataforseoAsinPromotion.promotions
|
|
} : null,
|
|
dataforseoTaskPreflight: dataforseoTaskPreflight ? {
|
|
ready: dataforseoTaskPreflight.ready,
|
|
endpoint: dataforseoTaskPreflight.endpoint,
|
|
command: dataforseoTaskPreflight.command,
|
|
paidRunCommand: dataforseoTaskPreflight.paidRunCommand,
|
|
counts: dataforseoTaskPreflight.counts,
|
|
files: dataforseoTaskPreflight.files,
|
|
checks: dataforseoTaskPreflight.checks,
|
|
blockers: dataforseoTaskPreflight.blockers,
|
|
payloadPreview: dataforseoTaskPreflight.payloadPreview
|
|
} : null,
|
|
apifyGapfill: apifyGapfill ? {
|
|
ready: apifyGapfill.ready,
|
|
recommendation: apifyGapfill.recommendation,
|
|
counts: apifyGapfill.counts,
|
|
guardrails: apifyGapfill.guardrails,
|
|
files: apifyGapfill.files,
|
|
targets: apifyGapfill.targets
|
|
} : null,
|
|
apifyTaskPreflight: apifyTaskPreflight ? {
|
|
ready: apifyTaskPreflight.ready,
|
|
command: apifyTaskPreflight.command,
|
|
paidRunCommand: apifyTaskPreflight.paidRunCommand,
|
|
counts: apifyTaskPreflight.counts,
|
|
files: apifyTaskPreflight.files,
|
|
checks: apifyTaskPreflight.checks,
|
|
blockers: apifyTaskPreflight.blockers,
|
|
targetPreview: apifyTaskPreflight.targetPreview
|
|
} : null,
|
|
sourcePricingReferences: sourcePricingReferences ? {
|
|
ready: sourcePricingReferences.ready,
|
|
note: sourcePricingReferences.note,
|
|
counts: sourcePricingReferences.counts,
|
|
guardrails: sourcePricingReferences.guardrails,
|
|
references: sourcePricingReferences.references
|
|
} : null,
|
|
sourceCostScenarios: sourceCostScenarios ? {
|
|
ready: sourceCostScenarios.ready,
|
|
note: sourceCostScenarios.note,
|
|
counts: sourceCostScenarios.counts,
|
|
recommendation: sourceCostScenarios.recommendation,
|
|
guardrails: sourceCostScenarios.guardrails,
|
|
scenarios: sourceCostScenarios.scenarios,
|
|
budgetState: sourceCostScenarios.budgetState
|
|
} : null,
|
|
sourceBudgetGate: sourceBudgetGate ? {
|
|
ready: sourceBudgetGate.ready,
|
|
budgetAllowed: sourceBudgetGate.budgetAllowed,
|
|
budgetFile: sourceBudgetGate.budgetFile,
|
|
currency: sourceBudgetGate.currency,
|
|
counts: sourceBudgetGate.counts,
|
|
checks: sourceBudgetGate.checks,
|
|
nextActions: sourceBudgetGate.nextActions
|
|
} : null,
|
|
sourceCostPlan: sourceCostPlan ? {
|
|
ready: sourceCostPlan.ready,
|
|
costReady: sourceCostPlan.costReady,
|
|
costFile: sourceCostPlan.costFile,
|
|
currency: sourceCostPlan.currency,
|
|
counts: sourceCostPlan.counts,
|
|
estimates: sourceCostPlan.estimates,
|
|
guardrails: sourceCostPlan.guardrails,
|
|
checks: sourceCostPlan.checks,
|
|
nextActions: sourceCostPlan.nextActions
|
|
} : null,
|
|
sourceApprovalGate: sourceApprovalGate ? {
|
|
ready: sourceApprovalGate.ready,
|
|
counts: sourceApprovalGate.counts,
|
|
files: sourceApprovalGate.files,
|
|
guardrails: sourceApprovalGate.guardrails,
|
|
approvals: sourceApprovalGate.approvals,
|
|
checks: sourceApprovalGate.checks,
|
|
nextActions: sourceApprovalGate.nextActions
|
|
} : null,
|
|
sourceSpendGate: sourceSpendGate ? {
|
|
ready: sourceSpendGate.ready,
|
|
spendAllowed: sourceSpendGate.spendAllowed,
|
|
counts: sourceSpendGate.counts,
|
|
approvalFiles: sourceSpendGate.approvalFiles,
|
|
checks: sourceSpendGate.checks,
|
|
nextActions: sourceSpendGate.nextActions
|
|
} : null,
|
|
dataforseoPilotReview: dataforseoPilotReview ? {
|
|
ready: dataforseoPilotReview.ready,
|
|
decision: dataforseoPilotReview.decision,
|
|
reviewFile: dataforseoPilotReview.reviewFile,
|
|
counts: dataforseoPilotReview.counts,
|
|
checks: dataforseoPilotReview.checks,
|
|
recommendations: dataforseoPilotReview.recommendations
|
|
} : null,
|
|
sourceAcquisitionControlRun: sourceAcquisitionControlRun ? {
|
|
ready: sourceAcquisitionControlRun.ready,
|
|
note: sourceAcquisitionControlRun.note,
|
|
counts: sourceAcquisitionControlRun.counts,
|
|
steps: sourceAcquisitionControlRun.steps,
|
|
blockedSteps: sourceAcquisitionControlRun.blockedSteps
|
|
} : null,
|
|
sourceSubscriptionDecision: sourceSubscriptionDecision ? {
|
|
ready: sourceSubscriptionDecision.ready,
|
|
headline: sourceSubscriptionDecision.headline,
|
|
currentDecision: sourceSubscriptionDecision.currentDecision,
|
|
counts: sourceSubscriptionDecision.counts,
|
|
files: sourceSubscriptionDecision.files,
|
|
rows: sourceSubscriptionDecision.rows
|
|
} : null,
|
|
sourceAcquisitionNextActions: sourceAcquisitionNextActions ? {
|
|
ready: sourceAcquisitionNextActions.ready,
|
|
recommendation: sourceAcquisitionNextActions.recommendation,
|
|
currentInstruction: sourceAcquisitionNextActions.currentInstruction,
|
|
counts: sourceAcquisitionNextActions.counts,
|
|
asinExamples: sourceAcquisitionNextActions.asinExamples,
|
|
actions: sourceAcquisitionNextActions.actions,
|
|
immediateActions: sourceAcquisitionNextActions.immediateActions,
|
|
paidSpendBlockedActions: sourceAcquisitionNextActions.paidSpendBlockedActions
|
|
} : null,
|
|
assetStatus: {
|
|
ready: assetStatus.ready,
|
|
requiredCount: assetStatus.requiredCount,
|
|
missingUploadCount: assetStatus.missingUploadCount,
|
|
missingReferenceCount: assetStatus.missingReferenceCount
|
|
},
|
|
blockerSummary: blockers.reduce((acc: any, blocker: any) => {
|
|
acc[blocker.type] = (acc[blocker.type] || 0) + 1
|
|
return acc
|
|
}, {}),
|
|
outputFiles: {
|
|
dashboard: await listOutputFiles(DASHBOARD_EXPORT_ROOT),
|
|
migration: await listOutputFiles(MIGRATION_EXPORT_ROOT)
|
|
},
|
|
blockers,
|
|
ready: blockers.length === 0 && readinessAudit?.ready !== false && intelligenceWorkqueue?.ready !== false
|
|
}
|
|
}
|
|
|
|
async function listAssetFiles(root: string): Promise<any[]> {
|
|
try {
|
|
const entries = await readdir(root, { withFileTypes: true })
|
|
const files = []
|
|
for (const entry of entries) {
|
|
const path = join(root, entry.name)
|
|
if (entry.isDirectory()) files.push(...await listAssetFiles(path))
|
|
else if (entry.isFile()) files.push({ name: entry.name, path })
|
|
}
|
|
return files
|
|
} catch (_) {
|
|
return []
|
|
}
|
|
}
|
|
|
|
async function listJsonFiles(root: string): Promise<any[]> {
|
|
try {
|
|
const entries = await readdir(root, { withFileTypes: true })
|
|
const files = []
|
|
for (const entry of entries) {
|
|
const path = join(root, entry.name)
|
|
if (entry.isDirectory()) files.push(...await listJsonFiles(path))
|
|
else if (entry.isFile() && entry.name.endsWith('.json')) files.push({ name: entry.name, path })
|
|
}
|
|
return files
|
|
} catch (_) {
|
|
return []
|
|
}
|
|
}
|
|
|
|
async function getSourceStatus() {
|
|
const products = await listProductContent()
|
|
const competitorConfig = await readJson(join(import.meta.dir, '..', 'data', 'sources', 'competitor-sources.json'))
|
|
const competitorFiles = await listJsonFiles(join(import.meta.dir, '..', 'data', 'sources', 'competitor-pdps'))
|
|
const feefoFiles = (await listJsonFiles(INTEL_ROOT)).filter((file: any) => file.name === '_raw_reviews.json')
|
|
const sourceKinds = [
|
|
{
|
|
key: 'feefo',
|
|
label: 'JV Feefo reviews',
|
|
requiredFor: 'first-party VoC, benefit copy, blockers, FAQs',
|
|
files: feefoFiles,
|
|
expected: products.filter((p: any) => p.status !== 'pending_intelligence').length,
|
|
note: 'Canonical first-party evidence. Existing pilot intelligence was built from Feefo exports.'
|
|
},
|
|
{
|
|
key: 'competitor-pdps',
|
|
label: 'Competitor PDP crawl',
|
|
requiredFor: 'competitor comparison, image/claim positioning, collagen NPD whitespace',
|
|
files: competitorFiles,
|
|
expected: competitorConfig?.competitors?.length || 7,
|
|
note: 'Umar competitor list PDP probes. Does not replace Amazon/Trustpilot review evidence.'
|
|
},
|
|
{
|
|
key: 'amazon',
|
|
label: 'Amazon JV + competitor reviews',
|
|
requiredFor: 'market-wide shopper objections and competitor review themes',
|
|
files: await listJsonFiles(join(import.meta.dir, '..', 'data', 'sources', 'amazon')),
|
|
expected: products.length,
|
|
note: 'Raw Amazon source drops are still required; current per-SKU files are extraction queues only.'
|
|
},
|
|
{
|
|
key: 'trustpilot',
|
|
label: 'Trustpilot reviews',
|
|
requiredFor: 'brand-level trust objections and competitor validation',
|
|
files: await listJsonFiles(join(import.meta.dir, '..', 'data', 'sources', 'trustpilot')),
|
|
expected: 1,
|
|
note: 'Raw Trustpilot source drops are still required.'
|
|
},
|
|
{
|
|
key: 'reddit',
|
|
label: 'Reddit/social listening',
|
|
requiredFor: 'unprompted audience language, collagen flavour concerns, problem framing',
|
|
files: await listJsonFiles(join(import.meta.dir, '..', 'data', 'sources', 'reddit')),
|
|
expected: 1,
|
|
note: 'Raw Reddit exports/search results are still required.'
|
|
},
|
|
{
|
|
key: 'claims',
|
|
label: 'Clinical claims library',
|
|
requiredFor: 'pdp.clinically_shown_to metaobjects and compliance approval',
|
|
files: await listJsonFiles(join(import.meta.dir, '..', 'data', 'sources', 'claims')),
|
|
expected: 1,
|
|
note: 'Non-negotiable compliance input. Feefo themes cannot be used as clinical claims.'
|
|
}
|
|
].map((source: any) => ({
|
|
...source,
|
|
fileCount: source.files.length,
|
|
status: source.files.length >= source.expected && source.expected > 0 ? 'ready' : source.files.length ? 'partial' : 'missing'
|
|
}))
|
|
|
|
const intelligenceFiles = [
|
|
'review-aspects',
|
|
'strengths-weaknesses',
|
|
'conversion-blockers',
|
|
'improvements',
|
|
'image-audit',
|
|
'photo-brief',
|
|
'competitor-comparison',
|
|
'amazon-reviews',
|
|
'reddit-pulse',
|
|
'flavor-intelligence',
|
|
'audience-profile',
|
|
'claims-evidence'
|
|
]
|
|
const coverage = []
|
|
for (const view of intelligenceFiles) {
|
|
let count = 0
|
|
const skus = []
|
|
for (const product of products) {
|
|
const file = Bun.file(join(INTEL_ROOT, product.sku, `${view}.json`))
|
|
if (await file.exists()) {
|
|
count += 1
|
|
skus.push(product.sku)
|
|
}
|
|
}
|
|
coverage.push({
|
|
view,
|
|
count,
|
|
total: products.length,
|
|
percent: products.length ? Math.round((count / products.length) * 100) : 0,
|
|
missing: products.length - count,
|
|
skus
|
|
})
|
|
}
|
|
|
|
const configuredCompetitors = competitorConfig?.competitors || []
|
|
const competitorCoverage = configuredCompetitors.map((competitor: any) => {
|
|
const slug = String(competitor.brand || '').toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
const file = competitorFiles.find((item: any) => item.name === `${slug}.json`)
|
|
return {
|
|
brand: competitor.brand,
|
|
domain: competitor.domain,
|
|
pdpProbe: Boolean(file),
|
|
file: file?.path || null
|
|
}
|
|
})
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
productCount: products.length,
|
|
sourceKinds,
|
|
coverage,
|
|
competitorCoverage,
|
|
blockers: sourceKinds
|
|
.filter((source: any) => source.status !== 'ready')
|
|
.map((source: any) => ({
|
|
key: source.key,
|
|
label: source.label,
|
|
status: source.status,
|
|
message: `${source.fileCount}/${source.expected} required source files available`
|
|
})),
|
|
ready: sourceKinds.every((source: any) => source.status === 'ready') && coverage.every((row: any) => row.count === products.length)
|
|
}
|
|
}
|
|
|
|
async function getAssetStatus() {
|
|
const products = await listProductContent()
|
|
const metaobjects = await readJson(join(CONTENT_ROOT, 'metaobjects.json'))
|
|
const collections = await readJson(join(CONTENT_ROOT, 'smart-collections.json'))
|
|
const assetFiles = await listAssetFiles(ASSET_ROOT)
|
|
const available = new Map(assetFiles.map((file: any) => [file.name, file.path]))
|
|
const required: any[] = []
|
|
const missingReferences: any[] = []
|
|
|
|
for (const product of products) {
|
|
const m = product.metafields || {}
|
|
for (const field of ['pdpResults', 'pdpResultsMobile', 'pdpHowWeCompare']) {
|
|
const filename = m[field]
|
|
if (filename) required.push({ kind: 'product', owner: product.sku, field, filename, exists: available.has(filename), path: available.get(filename) || null })
|
|
else missingReferences.push({ kind: 'product', owner: product.sku, field, reason: 'No filename assigned' })
|
|
}
|
|
}
|
|
for (const [definition, rows] of Object.entries(metaobjects?.definitions || {}) as any) {
|
|
for (const row of rows) {
|
|
for (const field of ['icon', 'image', 'background_image']) {
|
|
const filename = row[field]
|
|
if (filename) required.push({ kind: 'metaobject', owner: `${definition}.${row.handle || row.name || ''}`, field, filename, exists: available.has(filename), path: available.get(filename) || null })
|
|
}
|
|
}
|
|
}
|
|
for (const row of collections?.collections || []) {
|
|
const filename = row.backgroundImage || row.background_image
|
|
if (filename) required.push({ kind: 'collection', owner: row.handle, field: 'backgroundImage', filename, exists: available.has(filename), path: available.get(filename) || null })
|
|
}
|
|
const missingUploads = required.filter(item => !item.exists)
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
assetRoot: ASSET_ROOT,
|
|
availableCount: assetFiles.length,
|
|
requiredCount: required.length,
|
|
uploadedCount: required.length - missingUploads.length,
|
|
missingUploadCount: missingUploads.length,
|
|
missingReferenceCount: missingReferences.length,
|
|
ready: missingUploads.length === 0 && missingReferences.length === 0,
|
|
required,
|
|
missingUploads,
|
|
missingReferences
|
|
}
|
|
}
|
|
|
|
async function getComplianceStatus() {
|
|
const products = await listProductContent()
|
|
const rows = []
|
|
|
|
for (const product of products) {
|
|
const sku = product.sku
|
|
const claims = await readJson(join(INTEL_ROOT, sku, 'claims-evidence.json'))
|
|
const workflow = await readJson(join(CONTENT_WORKFLOW_ROOT, `${sku}.json`))
|
|
const claimRef = product.metafields?.pdpClinicallyShownTo || ''
|
|
const sourceBackedClaims = claims?.claims || []
|
|
const candidateThemes = claims?.candidateNonClinicalThemes || []
|
|
const field = workflow?.fields?.pdpClinicallyShownTo || null
|
|
const approval = product.approval?.compliance || 'blocked'
|
|
const blockers = []
|
|
|
|
if (!claims) blockers.push('claims-evidence.json missing')
|
|
if (claims && sourceBackedClaims.length === 0) blockers.push('no source-backed clinical claims')
|
|
if (!claimRef) blockers.push('pdp.clinically_shown_to metafield empty')
|
|
if (!['approved', 'reviewed', 'ready'].includes(approval)) blockers.push(`compliance approval is ${approval}`)
|
|
|
|
let status = 'blocked_source'
|
|
if (product.status === 'pending_intelligence') status = 'pending_intelligence'
|
|
else if (sourceBackedClaims.length > 0 && !claimRef) status = 'blocked_mapping'
|
|
else if (sourceBackedClaims.length > 0 && claimRef && !['approved', 'reviewed', 'ready'].includes(approval)) status = 'needs_review'
|
|
else if (sourceBackedClaims.length > 0 && claimRef) status = 'ready'
|
|
|
|
rows.push({
|
|
sku,
|
|
name: product.name,
|
|
productStatus: product.status,
|
|
status,
|
|
approval,
|
|
claimRef,
|
|
complianceGate: claims?.complianceGate || (claims ? 'unknown' : 'missing_claims_evidence'),
|
|
sourceBackedClaimCount: sourceBackedClaims.length,
|
|
candidateThemeCount: candidateThemes.length,
|
|
sourceBackedClaims,
|
|
candidateThemes: candidateThemes.slice(0, 5),
|
|
fieldStatus: field?.status || null,
|
|
citations: field?.citations || [],
|
|
blockers
|
|
})
|
|
}
|
|
|
|
const summary = rows.reduce((acc: any, row: any) => {
|
|
acc.total += 1
|
|
acc[row.status] = (acc[row.status] || 0) + 1
|
|
acc.sourceBackedClaims += row.sourceBackedClaimCount
|
|
acc.candidateThemes += row.candidateThemeCount
|
|
return acc
|
|
}, { total: 0, sourceBackedClaims: 0, candidateThemes: 0 })
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
summary,
|
|
rows,
|
|
ready: rows.every((row: any) => row.status === 'ready')
|
|
}
|
|
}
|
|
|
|
async function listOutputFiles(root: string) {
|
|
try {
|
|
const files = (await readdir(root)).filter((file: string) => /\.(csv|json|md)$/i.test(file))
|
|
return files.map((file: string) => ({ name: file, path: join(root, file) })).sort((a: any, b: any) => a.name.localeCompare(b.name))
|
|
} catch (_) {
|
|
return []
|
|
}
|
|
}
|
|
|
|
async function getProducts() {
|
|
const catalog = await readJson(join(PIPELINE_ROOT, 'catalog.json')) as any[]
|
|
const images = await readJson(join(PIPELINE_ROOT, 'product-images.json')) as Record<string, any>
|
|
const contentRows = await listProductContent()
|
|
const contentBySku = new Map(contentRows.map((row: any) => [row.sku, row]))
|
|
|
|
const rows = catalog.map((p: any) => {
|
|
const img = images?.[p.sku] || {}
|
|
const content: any = contentBySku.get(p.sku) || null
|
|
const intelPath = join(INTEL_ROOT, p.sku, 'review-aspects.json')
|
|
return {
|
|
sku: p.sku,
|
|
name: content?.name || img.name || p.sku,
|
|
status: content?.status || null,
|
|
approval: content?.approval || null,
|
|
totalReviews: p.total_reviews || 0,
|
|
variants: content?.variants || p.variants || [],
|
|
imageUrl: img.image_url || null,
|
|
productUrl: img.url ? `https://www.justvitamins.co.uk${img.url}` : null,
|
|
hasIntelligence: Bun.file(intelPath).exists()
|
|
}
|
|
}).sort((a: any, b: any) => b.totalReviews - a.totalReviews)
|
|
|
|
const collagenFlavorIntel = join(INTEL_ROOT, 'COLLAGEN-NPD', 'flavor-intelligence.json')
|
|
const collagenProfile = join(RESEARCH_PROFILE_ROOT, 'COLLAGEN-NPD.json')
|
|
rows.unshift({
|
|
sku: 'COLLAGEN-NPD',
|
|
name: 'Collagen NPD market intelligence',
|
|
status: 'priority_npd_workstream',
|
|
approval: null,
|
|
totalReviews: 0,
|
|
variants: ['unflavoured', 'vanilla', 'chocolate', 'fresh-berry'],
|
|
imageUrl: null,
|
|
productUrl: null,
|
|
hasIntelligence: Bun.file(collagenFlavorIntel).exists() || Bun.file(collagenProfile).exists(),
|
|
isNpdWorkstream: true
|
|
})
|
|
|
|
return rows
|
|
}
|
|
|
|
// Map view name -> { items array key in main file, items array key in supporting file }
|
|
const SUPPORTING_VIEWS: Record<string, { itemsKey: string; supportingFile: string; supportingKey: string }> = {
|
|
'review-aspects': { itemsKey: 'aspects', supportingFile: 'review-aspects-supporting.json', supportingKey: 'supportingReviews' },
|
|
'strengths-weaknesses': { itemsKey: 'items', supportingFile: 'strengths-weaknesses-supporting.json', supportingKey: 'items' },
|
|
'conversion-blockers': { itemsKey: 'blockers', supportingFile: 'conversion-blockers-supporting.json', supportingKey: 'blockers' },
|
|
'improvements': { itemsKey: 'improvements', supportingFile: 'improvements-supporting.json', supportingKey: 'improvements' },
|
|
'competitor-comparison': { itemsKey: 'jvSignals', supportingFile: 'competitor-comparison-supporting.json', supportingKey: 'jvSignals' },
|
|
'amazon-reviews': { itemsKey: 'themes', supportingFile: 'amazon-reviews-supporting.json', supportingKey: 'themes' },
|
|
'reddit-pulse': { itemsKey: 'pulses', supportingFile: 'reddit-pulse-supporting.json', supportingKey: 'pulses' },
|
|
'audience-profile': { itemsKey: 'profiles', supportingFile: 'audience-profile-supporting.json', supportingKey: 'profiles' }
|
|
}
|
|
|
|
async function enrichWithSupporting(data: any, view: string, sku: string) {
|
|
const cfg = SUPPORTING_VIEWS[view]
|
|
if (!cfg) return
|
|
const items = data[cfg.itemsKey]
|
|
if (!Array.isArray(items)) return
|
|
const supporting: any = await readJson(join(INTEL_ROOT, sku, cfg.supportingFile))
|
|
const supportingItems = supporting?.[cfg.supportingKey]
|
|
if (!Array.isArray(supportingItems)) return
|
|
const byRank = new Map<number, number[]>()
|
|
for (const s of supportingItems) byRank.set(s.rank, s.reviewIndices || [])
|
|
data[cfg.itemsKey] = items.map((it: any) => ({
|
|
...it,
|
|
supportingReviewIndices: byRank.get(it.rank) || []
|
|
}))
|
|
}
|
|
|
|
async function serveStatic(pathname: string): Promise<Response | null> {
|
|
if (!IS_PROD) return null
|
|
// Strip query string, decode
|
|
const clean = decodeURIComponent(pathname.split('?')[0])
|
|
const filePath = join(DIST_ROOT, clean === '/' ? 'index.html' : clean)
|
|
const f = Bun.file(filePath)
|
|
if (await f.exists()) {
|
|
// Hashed assets under /assets/* are immutable - cache aggressively
|
|
// HTML and other non-asset files MUST NOT be cached so a deploy flushes immediately
|
|
const isAsset = clean.startsWith('/assets/')
|
|
const headers: Record<string, string> = isAsset
|
|
? { 'Cache-Control': 'public, max-age=31536000, immutable' }
|
|
: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }
|
|
return new Response(f, { headers })
|
|
}
|
|
// Never fall back to index.html for missing static asset paths - return a real 404
|
|
// so missing/hash-mismatched files surface clearly instead of being served as HTML.
|
|
if (clean.startsWith('/assets/') || /\.[a-z0-9]+$/i.test(clean)) {
|
|
return new Response('Not found', { status: 404 })
|
|
}
|
|
// SPA fallback - serve index.html for all non-file app routes
|
|
const index = Bun.file(join(DIST_ROOT, 'index.html'))
|
|
if (await index.exists()) {
|
|
return new Response(index, {
|
|
headers: {
|
|
'Content-Type': 'text/html',
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
}
|
|
})
|
|
}
|
|
return null
|
|
}
|
|
|
|
// In-memory cache for scraped image URLs per SKU
|
|
const imageCache: Record<string, string[]> = {}
|
|
|
|
async function scrapeProductImages(sku: string): Promise<string[]> {
|
|
if (imageCache[sku]) return imageCache[sku]
|
|
const images = await readJson(join(PIPELINE_ROOT, 'product-images.json')) as Record<string, any>
|
|
const productPath = images?.[sku]?.url
|
|
if (!productPath) return []
|
|
try {
|
|
const res = await fetch(`https://www.justvitamins.co.uk${productPath}`, {
|
|
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }
|
|
})
|
|
const html = await res.text()
|
|
// Extract filenames like {uuid}-{n}.jpg from anywhere in the page
|
|
const matches = [...html.matchAll(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-\d+)\.jpg/g)]
|
|
const filenames = [...new Set(matches.map(m => m[1]))]
|
|
const urls = filenames.map(f => `https://images.justvitamins.co.uk/product-images/Normal/${f}.jpg`)
|
|
imageCache[sku] = urls
|
|
return urls
|
|
} catch { return [] }
|
|
}
|
|
|
|
const server = Bun.serve({
|
|
port: PORT,
|
|
async fetch(req) {
|
|
const url = new URL(req.url)
|
|
const path = url.pathname
|
|
|
|
if (req.method === 'OPTIONS') return new Response(null, { headers: cors })
|
|
|
|
// API routes
|
|
if (path.startsWith('/api/')) {
|
|
// GET /api/content/products — all seeded product-content rows
|
|
if (path === '/api/content/products') {
|
|
return new Response(JSON.stringify(await listProductContent()), { headers: cors })
|
|
}
|
|
|
|
// GET /api/content/workflow — review workflow index
|
|
if (path === '/api/content/workflow') {
|
|
const data = await readJson(join(CONTENT_WORKFLOW_ROOT, 'index.json'))
|
|
return new Response(JSON.stringify(data || { products: [] }), { headers: cors })
|
|
}
|
|
|
|
// GET/PUT/POST /api/content/workflow/:sku — per-SKU status, field citations, review log
|
|
const workflowMatch = path.match(/^\/api\/content\/workflow\/([^/]+)$/)
|
|
if (workflowMatch) {
|
|
const sku = safeSku(workflowMatch[1])
|
|
if (!sku) return new Response(JSON.stringify({ error: 'Invalid SKU' }), { status: 400, headers: cors })
|
|
const file = join(CONTENT_WORKFLOW_ROOT, `${sku}.json`)
|
|
const existing = await readJson(file)
|
|
if (!existing && req.method !== 'PUT') {
|
|
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
if (req.method === 'PUT') {
|
|
const body = normaliseProductContent(await req.json())
|
|
if (body.sku && body.sku !== sku) return new Response(JSON.stringify({ error: 'SKU mismatch' }), { status: 400, headers: cors })
|
|
await writeJson(file, { ...body, sku, updatedAt: new Date().toISOString() })
|
|
return new Response(JSON.stringify({ ok: true }), { headers: cors })
|
|
}
|
|
if (req.method === 'POST') {
|
|
const body = await req.json()
|
|
const event = {
|
|
at: new Date().toISOString(),
|
|
type: body.type || 'review_note',
|
|
actor: body.actor || 'dashboard',
|
|
field: body.field || null,
|
|
from: body.from ?? null,
|
|
to: body.to ?? null,
|
|
summary: body.summary || ''
|
|
}
|
|
existing.events = Array.isArray(existing.events) ? existing.events : []
|
|
existing.events.unshift(event)
|
|
if (body.field && existing.fields?.[body.field] && body.status) {
|
|
existing.fields[body.field].status = body.status
|
|
existing.fields[body.field].lastReviewedAt = event.at
|
|
existing.fields[body.field].reviewer = event.actor
|
|
existing.fields[body.field].decision = body.status
|
|
}
|
|
await writeJson(file, { ...existing, updatedAt: new Date().toISOString() })
|
|
return new Response(JSON.stringify({ ok: true, event }), { headers: cors })
|
|
}
|
|
return new Response(JSON.stringify(existing), { headers: cors })
|
|
}
|
|
|
|
// GET/PUT /api/content/products/:sku — product content editor persistence
|
|
const approvalMatch = path.match(/^\/api\/content\/products\/([^/]+)\/approval$/)
|
|
if (approvalMatch) {
|
|
const sku = safeSku(approvalMatch[1])
|
|
if (!sku) return new Response(JSON.stringify({ error: 'Invalid SKU' }), { status: 400, headers: cors })
|
|
if (req.method !== 'POST') return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, headers: cors })
|
|
|
|
const file = join(CONTENT_PRODUCTS_ROOT, `${sku}.json`)
|
|
const product = await readJson(file)
|
|
if (!product) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
|
|
const body = await req.json()
|
|
const area = body.area
|
|
const allowedAreas = ['content', 'compliance', 'images', 'export']
|
|
if (!allowedAreas.includes(area)) return new Response(JSON.stringify({ error: 'Invalid approval area' }), { status: 400, headers: cors })
|
|
const status = String(body.status || '').trim()
|
|
if (!status) return new Response(JSON.stringify({ error: 'Missing approval status' }), { status: 400, headers: cors })
|
|
|
|
const at = new Date().toISOString()
|
|
product.approval = { ...(product.approval || {}), [area]: status }
|
|
await writeJson(file, normaliseProductContent({ ...product, sku, updatedAt: at }))
|
|
|
|
const workflowFile = join(CONTENT_WORKFLOW_ROOT, `${sku}.json`)
|
|
const workflow = await readJson(workflowFile)
|
|
if (workflow) {
|
|
workflow.approval = { ...(workflow.approval || {}), [area]: status }
|
|
workflow.events = Array.isArray(workflow.events) ? workflow.events : []
|
|
workflow.events.unshift({
|
|
at,
|
|
type: 'approval_updated',
|
|
actor: body.actor || 'dashboard',
|
|
field: area,
|
|
from: body.from ?? null,
|
|
to: status,
|
|
summary: body.summary || `${area} approval set to ${status}`
|
|
})
|
|
await writeJson(workflowFile, { ...workflow, updatedAt: at })
|
|
}
|
|
|
|
const indexFile = join(CONTENT_WORKFLOW_ROOT, 'index.json')
|
|
const index = await readJson(indexFile)
|
|
if (index?.products) {
|
|
index.products = index.products.map((row: any) => row.sku === sku
|
|
? { ...row, approval: { ...(row.approval || {}), [area]: status } }
|
|
: row)
|
|
await writeJson(indexFile, { ...index, updatedAt: at })
|
|
}
|
|
|
|
return new Response(JSON.stringify({ ok: true, sku, area, status }), { headers: cors })
|
|
}
|
|
|
|
const contentProductMatch = path.match(/^\/api\/content\/products\/([^/]+)$/)
|
|
if (contentProductMatch) {
|
|
const sku = safeSku(contentProductMatch[1])
|
|
if (!sku) return new Response(JSON.stringify({ error: 'Invalid SKU' }), { status: 400, headers: cors })
|
|
const file = join(CONTENT_PRODUCTS_ROOT, `${sku}.json`)
|
|
if (req.method === 'PUT') {
|
|
const body = normaliseProductContent(await req.json())
|
|
if (body.sku && body.sku !== sku) return new Response(JSON.stringify({ error: 'SKU mismatch' }), { status: 400, headers: cors })
|
|
await writeJson(file, { ...body, sku, updatedAt: new Date().toISOString() })
|
|
return new Response(JSON.stringify({ ok: true }), { headers: cors })
|
|
}
|
|
const data = await readJson(file)
|
|
return data
|
|
? new Response(JSON.stringify(data), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// GET /api/content/metaobjects - metaobject production library
|
|
if (path === '/api/content/metaobjects') {
|
|
const data = await readJson(join(CONTENT_ROOT, 'metaobjects.json'))
|
|
return data
|
|
? new Response(JSON.stringify(data), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// GET /api/content/collections - smart collection promo card mappings
|
|
if (path === '/api/content/collections') {
|
|
const data = await readJson(join(CONTENT_ROOT, 'smart-collections.json'))
|
|
return data
|
|
? new Response(JSON.stringify(data), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// GET /api/export/status - readiness summary for Lewis/Euan handoff
|
|
if (path === '/api/export/status') {
|
|
return new Response(JSON.stringify(await getExportStatus()), { headers: cors })
|
|
}
|
|
|
|
// GET /api/assets/status - Shopify Files/local asset manifest readiness
|
|
if (path === '/api/assets/status') {
|
|
return new Response(JSON.stringify(await getAssetStatus()), { headers: cors })
|
|
}
|
|
|
|
// GET /api/product-image-generation/:sku - original/live product image plus direct ImageGen carousel set
|
|
const productImageGenerationMatch = path.match(/^\/api\/product-image-generation\/([^/]+)$/)
|
|
if (productImageGenerationMatch) {
|
|
const sku = safeSku(productImageGenerationMatch[1])
|
|
if (!sku) return new Response(JSON.stringify({ error: 'Invalid SKU' }), { status: 400, headers: cors })
|
|
return new Response(JSON.stringify(await getProductImageGenerationSet(sku)), { headers: cors })
|
|
}
|
|
|
|
// GET /api/generated-product-image/:sku/:filename - generated direct ImageGen PNG
|
|
const generatedProductImageMatch = path.match(/^\/api\/generated-product-image\/([^/]+)\/([^/]+)$/)
|
|
if (generatedProductImageMatch) {
|
|
const sku = safeSku(generatedProductImageMatch[1])
|
|
const filename = generatedProductImageMatch[2]
|
|
if (!sku || !/^[a-z0-9-]+\.png$/i.test(filename)) return new Response('Invalid image path', { status: 400 })
|
|
const filePath = join(GENERATED_ASSET_ROOT, 'top3-direct-imagegen-final', sku, filename)
|
|
const file = Bun.file(filePath)
|
|
if (!(await file.exists())) return new Response('Not found', { status: 404 })
|
|
return new Response(file, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' } })
|
|
}
|
|
|
|
// GET /api/original-product-image/:sku - original live image downloaded from database URL
|
|
const originalProductImageMatch = path.match(/^\/api\/original-product-image\/([^/]+)$/)
|
|
if (originalProductImageMatch) {
|
|
const sku = safeSku(originalProductImageMatch[1])
|
|
if (!sku) return new Response('Invalid SKU', { status: 400 })
|
|
const filePath = join(GENERATED_ASSET_ROOT, 'top3-originals', `${sku}-original.jpg`)
|
|
const file = Bun.file(filePath)
|
|
if (!(await file.exists())) return new Response('Not found', { status: 404 })
|
|
return new Response(file, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'no-cache' } })
|
|
}
|
|
|
|
// GET /api/sources/status - raw source availability and intelligence coverage
|
|
if (path === '/api/sources/status') {
|
|
return new Response(JSON.stringify(await getSourceStatus()), { headers: cors })
|
|
}
|
|
|
|
// GET /api/compliance/status - clinical claims evidence and approval gate
|
|
if (path === '/api/compliance/status') {
|
|
return new Response(JSON.stringify(await getComplianceStatus()), { headers: cors })
|
|
}
|
|
|
|
// GET /api/products
|
|
if (path === '/api/products') {
|
|
const products = await getProducts()
|
|
const resolved = await Promise.all(
|
|
products.map(async (p: any) => ({ ...p, hasIntelligence: await p.hasIntelligence }))
|
|
)
|
|
return new Response(JSON.stringify(resolved), { headers: cors })
|
|
}
|
|
|
|
// GET /api/product-images/:sku
|
|
const imgMatch = path.match(/^\/api\/product-images\/([^/]+)$/)
|
|
if (imgMatch) {
|
|
const urls = await scrapeProductImages(imgMatch[1])
|
|
return new Response(JSON.stringify({ urls }), { headers: cors })
|
|
}
|
|
|
|
// GET /api/research-profile/:sku - complete PDP research profile for content generation
|
|
const researchProfileMatch = path.match(/^\/api\/research-profile\/([^/]+)$/)
|
|
if (researchProfileMatch) {
|
|
const sku = safeSku(researchProfileMatch[1])
|
|
if (!sku) return new Response(JSON.stringify({ error: 'Invalid SKU' }), { status: 400, headers: cors })
|
|
const data = await readJson(join(RESEARCH_PROFILE_ROOT, `${sku}.json`))
|
|
return data
|
|
? new Response(JSON.stringify(data), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// GET /api/raw-evidence/:sku - raw scraped/source content behind synthesis
|
|
const rawEvidenceMatch = path.match(/^\/api\/raw-evidence\/([^/]+)$/)
|
|
if (rawEvidenceMatch) {
|
|
const sku = safeSku(rawEvidenceMatch[1])
|
|
if (!sku) return new Response(JSON.stringify({ error: 'Invalid SKU' }), { status: 400, headers: cors })
|
|
return new Response(JSON.stringify(await getRawEvidence(sku)), { headers: cors })
|
|
}
|
|
|
|
// GET /api/reviews/:sku - raw reviews (for resolving supportingReviewIndices)
|
|
const reviewsMatch = path.match(/^\/api\/reviews\/([^/]+)$/)
|
|
if (reviewsMatch) {
|
|
const [, sku] = reviewsMatch
|
|
const reviews = await readJson(join(INTEL_ROOT, sku, '_raw_reviews.json'))
|
|
return reviews
|
|
? new Response(JSON.stringify(reviews), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// GET /api/intelligence/:sku/:view
|
|
const intelMatch = path.match(/^\/api\/intelligence\/([^/]+)\/([^/]+)$/)
|
|
if (intelMatch) {
|
|
const [, sku, view] = intelMatch
|
|
if (view === 'all') {
|
|
const views = [
|
|
'review-aspects', 'conversion-driver', 'strengths-weaknesses', 'conversion-blockers',
|
|
'improvements', 'image-audit', 'photo-brief', 'competitor-analysis',
|
|
'competitor-comparison', 'amazon-reviews', 'reddit-pulse',
|
|
'flavor-intelligence', 'audience-profile', 'claims-evidence'
|
|
]
|
|
const result: Record<string, any> = {}
|
|
for (const v of views) {
|
|
const data = await readJson(join(INTEL_ROOT, sku, `${v}.json`))
|
|
if (data) result[v.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase())] = data
|
|
}
|
|
return Object.keys(result).length
|
|
? new Response(JSON.stringify(result), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'No intelligence data' }), { status: 404, headers: cors })
|
|
}
|
|
const data: any = await readJson(join(INTEL_ROOT, sku, `${view}.json`))
|
|
if (!data) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
await enrichWithSupporting(data, view, sku)
|
|
return new Response(JSON.stringify(data), { headers: cors })
|
|
}
|
|
|
|
// ==========================================================
|
|
// EDITORIAL DASHBOARD — pilot batch + competitor extracts
|
|
// ==========================================================
|
|
|
|
// GET /api/editorial/pilot — batch summary across 5 SKUs
|
|
if (path === '/api/editorial/pilot') {
|
|
const summary = await readJson(join(PILOT_EXTENDED_ROOT, '_summary.json'))
|
|
return summary
|
|
? new Response(JSON.stringify(summary), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Pilot summary not generated yet. Run: npm run pilot:all' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// GET /api/editorial/pilot/:sku — full per-SKU draft with QA + sources
|
|
const pilotMatch = path.match(/^\/api\/editorial\/pilot\/([^/]+)$/)
|
|
if (pilotMatch) {
|
|
const sku = safeSku(pilotMatch[1])
|
|
if (!sku) return new Response(JSON.stringify({ error: 'Invalid SKU' }), { status: 400, headers: cors })
|
|
const data = await readJson(join(PILOT_EXTENDED_ROOT, `${sku}.json`))
|
|
return data
|
|
? new Response(JSON.stringify(data), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// GET /api/editorial/competitors — competitor section-extract index
|
|
if (path === '/api/editorial/competitors') {
|
|
const idx = await readJson(join(COMPETITOR_EXTRACTS_ROOT, '_index.json'))
|
|
return idx
|
|
? new Response(JSON.stringify(idx), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Competitor index not generated yet' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// GET /api/editorial/competitor/:handle — single brand section extract
|
|
const competitorMatch = path.match(/^\/api\/editorial\/competitor\/([^/]+)$/)
|
|
if (competitorMatch) {
|
|
const handle = competitorMatch[1].replace(/[^a-z0-9-]/gi, '')
|
|
const data = await readJson(join(COMPETITOR_EXTRACTS_ROOT, `${handle}.json`))
|
|
return data
|
|
? new Response(JSON.stringify(data), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// GET /api/editorial/product-truth/:sku — DB-truth metadata for ATF/render
|
|
const truthMatch = path.match(/^\/api\/editorial\/product-truth\/([^/]+)$/)
|
|
if (truthMatch) {
|
|
const sku = safeSku(truthMatch[1])
|
|
if (!sku) return new Response(JSON.stringify({ error: 'Invalid SKU' }), { status: 400, headers: cors })
|
|
const data = await readJson(join(PRODUCT_TRUTH_ROOT, `${sku}.json`))
|
|
return data
|
|
? new Response(JSON.stringify(data), { headers: cors })
|
|
: new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
|
|
}
|
|
|
|
// Editorial dashboard static serving (production) — mounted at /editorial/*
|
|
if (IS_PROD && path.startsWith('/editorial')) {
|
|
const clean = decodeURIComponent(path.split('?')[0])
|
|
const rel = clean === '/editorial' || clean === '/editorial/' ? 'index.html' : clean.replace(/^\/editorial\/?/, '')
|
|
const filePath = join(EDITORIAL_DIST_ROOT, rel)
|
|
const f = Bun.file(filePath)
|
|
if (await f.exists()) {
|
|
const headers: Record<string, string> = rel.startsWith('assets/')
|
|
? { 'Cache-Control': 'public, max-age=31536000, immutable' }
|
|
: {}
|
|
return new Response(f, { headers })
|
|
}
|
|
// SPA fallback for editorial app routes
|
|
if (!/\.[a-z0-9]+$/i.test(rel)) {
|
|
const index = Bun.file(join(EDITORIAL_DIST_ROOT, 'index.html'))
|
|
if (await index.exists()) {
|
|
return new Response(index, {
|
|
headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache, must-revalidate' }
|
|
})
|
|
}
|
|
}
|
|
return new Response('Not found', { status: 404 })
|
|
}
|
|
|
|
// Static file serving in production
|
|
const static_ = await serveStatic(path)
|
|
if (static_) return static_
|
|
|
|
return new Response('Not found', { status: 404 })
|
|
}
|
|
})
|
|
|
|
console.log(`JV Dashboard running on http://localhost:${PORT} (${IS_PROD ? 'production' : 'dev'})`)
|