Files
justvitamin/api/server.ts
T
2026-04-28 17:34:59 +08:00

107 lines
4.0 KiB
TypeScript

import { join } from 'path'
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 DIST_ROOT = join(import.meta.dir, '..', 'dist')
const IS_PROD = process.env.NODE_ENV === 'production'
const cors = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, 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 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>
return catalog.map((p: any) => {
const img = images?.[p.sku] || {}
const intelPath = join(INTEL_ROOT, p.sku, 'review-aspects.json')
return {
sku: p.sku,
name: img.name || p.sku,
totalReviews: p.total_reviews || 0,
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)
}
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()) return new Response(f)
// SPA fallback — serve index.html for all non-file routes
const index = Bun.file(join(DIST_ROOT, 'index.html'))
if (await index.exists()) return new Response(index, { headers: { 'Content-Type': 'text/html' } })
return null
}
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/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/intelligence/:sku/:view
const intelMatch = path.match(/^\/api\/intelligence\/([^/]+)\/([^/]+)$/)
if (intelMatch) {
const [, sku, view] = intelMatch
if (view === 'all') {
const views = ['review-aspects', 'strengths-weaknesses', 'conversion-blockers', 'improvements', 'image-audit', 'photo-brief']
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 = await readJson(join(INTEL_ROOT, sku, `${view}.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 })
}
// 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'})`)