const path = require('path') const fs = require('fs') const crypto = require('crypto') const express = require('express') const cors = require('cors') const { upload } = require('./storage') const { query, closePool } = require('./db') const { runMigrations } = require('./migrations') const PORT = Number(process.env.API_PORT) || 4005 const MAX_SECTIONS = Number(process.env.BLOG_MAX_SECTIONS || 5) const app = express() const allowedOrigins = (process.env.CORS_ORIGINS || 'http://localhost:3000') .split(',') .map(origin => origin.trim()) .filter(Boolean) app.use(cors({ origin: allowedOrigins, credentials: true })) app.use(express.json({ limit: '2mb' })) app.use(express.urlencoded({ extended: true })) const uploadsPath = path.join(__dirname, '..', 'public', 'uploads') if (!fs.existsSync(uploadsPath)) { fs.mkdirSync(uploadsPath, { recursive: true }) } app.use('/uploads', express.static(uploadsPath)) app.get('/health', (_req, res) => { res.json({ status: 'ok' }) }) function slugify(value) { return value .toString() .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)+/g, '') || crypto.randomUUID() } function normalizeCategoryForSearch(category) { if (!category) return null // Convert slug format to display format for backward compatibility const categoryMap = { 'books-magazine': 'Books & Magazine', 'clothing-shoes': 'Clothing & Shoes', 'collectibles-art': 'Collectibles & Art', 'toys-hobbies': 'Toys & Hobbies' } return categoryMap[category.toLowerCase()] || category } async function generateUniqueSlug(title, excludeId) { const base = slugify(title) let slug = base let suffix = 1 // eslint-disable-next-line no-constant-condition while (true) { const values = [slug] let condition = '' const excludeNumeric = Number(excludeId) if (Number.isInteger(excludeNumeric) && excludeNumeric > 0) { condition = 'AND id <> $2' values.push(excludeNumeric) } const existing = await query( `SELECT id FROM blog_posts WHERE slug = $1 ${condition} LIMIT 1`, values ) if (existing.rows.length === 0) { return slug } suffix += 1 slug = `${base}-${suffix}` } } function parsePayload(body) { if (body.payload) { try { return JSON.parse(body.payload) } catch (error) { throw new Error('Invalid payload JSON') } } return body } function buildMainImage(payload, files) { const uploadForMain = files.mainImage && files.mainImage[0] if (payload.removeMainImage === true || payload.removeMainImage === 'true') { return null } if (uploadForMain) { return `/uploads/${uploadForMain.filename}` } if (payload.existingMainImage) { return payload.existingMainImage } return null } function buildSections(payload, files) { const sections = [] const inputSections = Array.isArray(payload.sections) ? payload.sections : [] for (let index = 0; index < MAX_SECTIONS; index += 1) { const sectionInput = inputSections[index] || {} const fileKey = `section${index}Image` const uploadForSection = files[fileKey] && files[fileKey][0] const rawText = typeof sectionInput.text === 'string' ? sectionInput.text : '' const text = rawText.trim() const image = uploadForSection ? `/uploads/${uploadForSection.filename}` : sectionInput.existingImage || null if (text || image) { sections.push({ id: sectionInput.id || crypto.randomUUID(), text: text || null, image }) } } return sections } function mapPostRow(row) { return { id: row.id, title: row.title, slug: row.slug, previewImage: row.preview_image, linkUrl: row.link_url, sections: row.sections || [], footer: row.footer, isEditorsPick: row.is_editors_pick, isSold: row.is_sold || false, category: row.category, createdAt: row.created_at, updatedAt: row.updated_at } } function createExcerpt(sections) { const firstText = sections .map(section => section.text || '') .find(text => text && text.trim().length > 0) if (!firstText) { return '' } const trimmed = firstText.trim() if (trimmed.length <= 220) { return trimmed } return `${trimmed.slice(0, 217)}...` } async function ensureEditorsPickLimit(targetId, makePick) { if (!makePick) { return } let condition = '' const params = [] if (Number.isInteger(targetId) && targetId > 0) { condition = 'AND id <> $1' params.push(targetId) } const result = await query( `SELECT id FROM blog_posts WHERE is_editors_pick = true ${condition}`, params ) if (result.rows.length >= 3) { const ids = result.rows.map(r => r.id) throw new Error(`Only three editor's picks allowed. Currently set: ${ids.join(', ')}`) } } function getUploadFields() { const fields = [{ name: 'mainImage', maxCount: 1 }] for (let index = 0; index < MAX_SECTIONS; index += 1) { fields.push({ name: `section${index}Image`, maxCount: 1 }) } return fields } app.get('/posts', async (req, res) => { try { const { category } = req.query let queryText = 'SELECT * FROM blog_posts' const queryParams = [] if (category) { const normalizedCategory = normalizeCategoryForSearch(category) queryText += ' WHERE category = $1' queryParams.push(normalizedCategory) } queryText += ' ORDER BY created_at DESC' const result = await query(queryText, queryParams) const posts = result.rows.map(mapPostRow).map(post => ({ ...post, excerpt: createExcerpt(post.sections) })) res.json({ data: posts }) } catch (error) { console.error('[GET /posts] error', error) res.status(500).json({ error: 'Failed to fetch posts' }) } }) app.get('/posts/:id', async (req, res) => { const { id } = req.params try { // Try to parse as integer ID first, otherwise treat as slug const numericId = parseInt(id, 10) let result if (!isNaN(numericId) && numericId.toString() === id) { // It's a numeric ID result = await query('SELECT * FROM blog_posts WHERE id = $1', [numericId]) } else { // It's a slug result = await query('SELECT * FROM blog_posts WHERE slug = $1', [id]) } if (result.rows.length === 0) { return res.status(404).json({ error: 'Post not found' }) } const post = mapPostRow(result.rows[0]) post.excerpt = createExcerpt(post.sections) return res.json({ data: post }) } catch (error) { console.error('[GET /posts/:id] error', error) return res.status(500).json({ error: 'Failed to fetch post' }) } }) app.post('/posts', upload.fields(getUploadFields()), async (req, res) => { try { const payload = parsePayload(req.body) if (!payload.title || !payload.title.trim()) { return res.status(400).json({ error: 'Title is required' }) } const mainImage = buildMainImage(payload, req.files) const sections = buildSections(payload, req.files) const slug = await generateUniqueSlug(payload.title.trim()) const isEditorsPick = Boolean(payload.isEditorsPick) await ensureEditorsPickLimit(null, isEditorsPick) const isSold = Boolean(payload.isSold) const result = await query( `INSERT INTO blog_posts (title, slug, preview_image, link_url, sections, footer, is_editors_pick, is_sold, category) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [ payload.title.trim(), slug, mainImage, payload.linkUrl || null, JSON.stringify(sections), payload.footer || null, isEditorsPick, isSold, payload.category || null ] ) if (isEditorsPick) { try { await ensureEditorsPickLimit(result.rows[0].id, true) } catch (limitError) { await query('UPDATE blog_posts SET is_editors_pick = false WHERE id = $1', [result.rows[0].id]) throw limitError } } const post = mapPostRow(result.rows[0]) post.excerpt = createExcerpt(post.sections) res.status(201).json({ data: post }) } catch (error) { console.error('[POST /posts] error', error) const message = error.message || 'Failed to create post' res.status(400).json({ error: message }) } }) app.put('/posts/:id', upload.fields(getUploadFields()), async (req, res) => { const { id } = req.params try { const payload = parsePayload(req.body) if (!payload.title || !payload.title.trim()) { return res.status(400).json({ error: 'Title is required' }) } const existingResult = await query('SELECT * FROM blog_posts WHERE id = $1', [id]) if (existingResult.rows.length === 0) { return res.status(404).json({ error: 'Post not found' }) } const existingPost = mapPostRow(existingResult.rows[0]) const mainImage = buildMainImage(payload, req.files) const sections = buildSections(payload, req.files) const nextSlug = await generateUniqueSlug(payload.title.trim(), Number(id)) const isEditorsPick = Boolean(payload.isEditorsPick) if (isEditorsPick && !existingPost.isEditorsPick) { await ensureEditorsPickLimit(Number(id), true) } const isSold = Boolean(payload.isSold) const result = await query( `UPDATE blog_posts SET title = $1, slug = $2, preview_image = $3, link_url = $4, sections = $5, footer = $6, is_editors_pick = $7, is_sold = $8, category = $9 WHERE id = $10 RETURNING *`, [ payload.title.trim(), nextSlug, mainImage, payload.linkUrl || null, JSON.stringify(sections), payload.footer || null, isEditorsPick, isSold, payload.category || null, id ] ) const post = mapPostRow(result.rows[0]) post.excerpt = createExcerpt(post.sections) res.json({ data: post }) } catch (error) { console.error('[PUT /posts/:id] error', error) const status = error.message && error.message.includes('editor') ? 400 : 500 res.status(status).json({ error: error.message || 'Failed to update post' }) } }) app.delete('/posts/:id', async (req, res) => { const { id } = req.params try { const existing = await query('SELECT * FROM blog_posts WHERE id = $1', [id]) if (existing.rows.length === 0) { return res.status(404).json({ error: 'Post not found' }) } await query('DELETE FROM blog_posts WHERE id = $1', [id]) return res.json({ success: true }) } catch (error) { console.error('[DELETE /posts/:id] error', error) return res.status(500).json({ error: 'Failed to delete post' }) } }) app.patch('/posts/:id/editors-pick', async (req, res) => { const { id } = req.params const makePick = Boolean(req.body?.isEditorsPick) try { const existing = await query('SELECT * FROM blog_posts WHERE id = $1', [id]) if (existing.rows.length === 0) { return res.status(404).json({ error: 'Post not found' }) } if (makePick && !existing.rows[0].is_editors_pick) { await ensureEditorsPickLimit(Number(id), true) } const result = await query( 'UPDATE blog_posts SET is_editors_pick = $1 WHERE id = $2 RETURNING *', [makePick, id] ) const post = mapPostRow(result.rows[0]) post.excerpt = createExcerpt(post.sections) return res.json({ data: post }) } catch (error) { console.error('[PATCH /posts/:id/editors-pick] error', error) const status = error.message && error.message.includes('Only three') ? 400 : 500 return res.status(status).json({ error: error.message || 'Failed to update editor pick' }) } }) async function start() { try { await runMigrations() app.listen(PORT, '0.0.0.0', () => { console.log(`[api] listening on port ${PORT}`) }) } catch (error) { console.error('[api] failed to start', error) await closePool() process.exit(1) } } start()