434 lines
12 KiB
JavaScript
434 lines
12 KiB
JavaScript
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,
|
|
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 result = await query(
|
|
`INSERT INTO blog_posts (title, slug, preview_image, link_url, sections, footer, is_editors_pick, category)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *`,
|
|
[
|
|
payload.title.trim(),
|
|
slug,
|
|
mainImage,
|
|
payload.linkUrl || null,
|
|
JSON.stringify(sections),
|
|
payload.footer || null,
|
|
isEditorsPick,
|
|
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 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,
|
|
category = $8
|
|
WHERE id = $9
|
|
RETURNING *`,
|
|
[
|
|
payload.title.trim(),
|
|
nextSlug,
|
|
mainImage,
|
|
payload.linkUrl || null,
|
|
JSON.stringify(sections),
|
|
payload.footer || null,
|
|
isEditorsPick,
|
|
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()
|
|
|
|
|
|
|
|
|