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 = { '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 = { '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 { 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 { 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 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 = { '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() 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 { 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 = 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 = {} async function scrapeProductImages(sku: string): Promise { if (imageCache[sku]) return imageCache[sku] const images = await readJson(join(PIPELINE_ROOT, 'product-images.json')) as Record 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 = {} 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 = 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'})`)