const express = require('express'); const { v4: uuidv4 } = require('uuid'); const router = express.Router(); const slugify = require('slugify'); module.exports = (pool, query, authMiddleware) => { // Apply authentication middleware to all routes router.use(authMiddleware); // Get all blog posts (admin) router.get('/', async (req, res, next) => { try { if (!req.user.is_admin) { return res.status(403).json({ error: true, message: 'Admin access required' }); } // Get all posts with basic info const result = await query(` SELECT b.id, b.title, b.slug, b.excerpt, b.status, b.featured_image_path, b.published_at, b.created_at, u.first_name as author_first_name, u.last_name as author_last_name, bc.name as category_name FROM blog_posts b LEFT JOIN users u ON b.author_id = u.id LEFT JOIN blog_categories bc ON b.category_id = bc.id ORDER BY CASE WHEN b.status = 'draft' THEN 1 WHEN b.status = 'published' THEN 2 ELSE 3 END, b.created_at DESC `); res.json(result.rows); } catch (error) { next(error); } }); // Get single blog post for editing (admin) router.get('/:id', async (req, res, next) => { try { const { id } = req.params; if (!req.user.is_admin) { return res.status(403).json({ error: true, message: 'Admin access required' }); } // Get post with all details const postQuery = ` SELECT b.id, b.title, b.slug, b.content, b.excerpt, b.featured_image_path, b.status, b.published_at, b.created_at, b.updated_at, b.author_id, b.category_id, u.first_name as author_first_name, u.last_name as author_last_name, bc.name as category_name, ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags FROM blog_posts b LEFT JOIN users u ON b.author_id = u.id LEFT JOIN blog_categories bc ON b.category_id = bc.id LEFT JOIN blog_post_tags bpt ON b.id = bpt.post_id LEFT JOIN tags t ON bpt.tag_id = t.id WHERE b.id = $1 GROUP BY b.id, u.id, bc.id `; const postResult = await query(postQuery, [id]); if (postResult.rows.length === 0) { return res.status(404).json({ error: true, message: 'Blog post not found' }); } const post = postResult.rows[0]; // Get post images const imagesQuery = ` SELECT id, image_path, caption, display_order FROM blog_post_images WHERE post_id = $1 ORDER BY display_order `; const imagesResult = await query(imagesQuery, [id]); // Return the post with images res.json({ ...post, images: imagesResult.rows }); } catch (error) { next(error); } }); // Create a new blog post router.post('/', async (req, res, next) => { try { const { title, content, excerpt, categoryId, tags, featuredImagePath, status, publishNow } = req.body; if (!req.user.is_admin) { return res.status(403).json({ error: true, message: 'Admin access required' }); } // Validate required fields if (!title || !content) { return res.status(400).json({ error: true, message: 'Title and content are required' }); } // Generate slug from title let slug = slugify(title, { lower: true, // convert to lower case strict: true, // strip special characters remove: /[*+~.()'"!:@]/g // regex to remove characters }); const slugCheck = await query( 'SELECT id FROM blog_posts WHERE slug = $1', [slug] ); // If slug exists, append a random string if (slugCheck.rows.length > 0) { const randomString = Math.random().toString(36).substring(2, 8); slug = `${slug}-${randomString}`; } // Begin transaction const client = await pool.connect(); try { await client.query('BEGIN'); // Set published_at date if publishing now const publishedAt = (status === 'published' && publishNow) ? new Date() : null; // Create the post const postId = uuidv4(); const postResult = await client.query( `INSERT INTO blog_posts (id, title, slug, content, excerpt, author_id, category_id, status, featured_image_path, published_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, [postId, title, slug, content, excerpt || null, req.user.id, categoryId || null, status || 'draft', featuredImagePath || null, publishedAt] ); // Add tags if provided if (tags && Array.isArray(tags) && tags.length > 0) { for (const tagName of tags) { // Get or create tag let tagId; const tagResult = await client.query( 'SELECT id FROM tags WHERE name = $1', [tagName] ); if (tagResult.rows.length > 0) { tagId = tagResult.rows[0].id; } else { const newTagResult = await client.query( 'INSERT INTO tags (id, name) VALUES ($1, $2) RETURNING id', [uuidv4(), tagName] ); tagId = newTagResult.rows[0].id; } // Add tag to post await client.query( 'INSERT INTO blog_post_tags (post_id, tag_id) VALUES ($1, $2)', [postId, tagId] ); } } await client.query('COMMIT'); res.status(201).json({ message: 'Blog post created successfully', post: postResult.rows[0] }); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } catch (error) { next(error); } }); // Update blog post router.put('/:id', async (req, res, next) => { try { const { id } = req.params; const { title, content, excerpt, categoryId, tags, featuredImagePath, status, publishNow } = req.body; if (!req.user.is_admin) { return res.status(403).json({ error: true, message: 'Admin access required' }); } // Validate required fields if (!title || !content) { return res.status(400).json({ error: true, message: 'Title and content are required' }); } // Check if post exists const postCheck = await query( 'SELECT id, slug, status, published_at FROM blog_posts WHERE id = $1', [id] ); if (postCheck.rows.length === 0) { return res.status(404).json({ error: true, message: 'Blog post not found' }); } const existingPost = postCheck.rows[0]; let updatedSlug = existingPost.slug; // Update slug if title changed if (title) { const newSlug = slugify(title, { lower: true, strict: true, remove: /[*+~.()'"!:@]/g }); // Only update slug if it's different if (newSlug !== existingPost.slug) { // Check if new slug already exists const slugCheck = await query( 'SELECT id FROM blog_posts WHERE slug = $1 AND id != $2', [newSlug, id] ); if (slugCheck.rows.length === 0) { updatedSlug = newSlug; } else { // Append random string to slug const randomString = Math.random().toString(36).substring(2, 8); updatedSlug = `${newSlug}-${randomString}`; } } } // Begin transaction const client = await pool.connect(); try { await client.query('BEGIN'); // Determine published_at date let publishedAt = existingPost.published_at; if (status === 'published' && publishNow && !existingPost.published_at) { publishedAt = new Date(); } // Update the post const postResult = await client.query( `UPDATE blog_posts SET title = $1, slug = $2, content = $3, excerpt = $4, category_id = $5, status = $6, featured_image_path = $7, published_at = $8, updated_at = NOW() WHERE id = $9 RETURNING *`, [title, updatedSlug, content, excerpt || null, categoryId || null, status || existingPost.status, featuredImagePath, publishedAt, id] ); // Update tags if provided if (tags !== undefined) { // Remove existing tags await client.query('DELETE FROM blog_post_tags WHERE post_id = $1', [id]); // Add new tags if (Array.isArray(tags) && tags.length > 0) { for (const tagName of tags) { // Get or create tag let tagId; const tagResult = await client.query( 'SELECT id FROM tags WHERE name = $1', [tagName] ); if (tagResult.rows.length > 0) { tagId = tagResult.rows[0].id; } else { const newTagResult = await client.query( 'INSERT INTO tags (id, name) VALUES ($1, $2) RETURNING id', [uuidv4(), tagName] ); tagId = newTagResult.rows[0].id; } // Add tag to post await client.query( 'INSERT INTO blog_post_tags (post_id, tag_id) VALUES ($1, $2)', [id, tagId] ); } } } await client.query('COMMIT'); res.json({ message: 'Blog post updated successfully', post: postResult.rows[0] }); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } catch (error) { next(error); } }); // Delete blog post router.delete('/:id', async (req, res, next) => { try { const { id } = req.params; if (!req.user.is_admin) { return res.status(403).json({ error: true, message: 'Admin access required' }); } // Check if post exists const postCheck = await query( 'SELECT id FROM blog_posts WHERE id = $1', [id] ); if (postCheck.rows.length === 0) { return res.status(404).json({ error: true, message: 'Blog post not found' }); } // Delete post - cascade will handle related records await query( 'DELETE FROM blog_posts WHERE id = $1', [id] ); res.json({ message: 'Blog post deleted successfully' }); } catch (error) { next(error); } }); // Upload blog post image router.post('/:postId/images', async (req, res, next) => { try { const { postId } = req.params; const { imagePath, caption, displayOrder } = req.body; if (!req.user.is_admin) { return res.status(403).json({ error: true, message: 'Admin access required' }); } // Check if post exists const postCheck = await query( 'SELECT id FROM blog_posts WHERE id = $1', [postId] ); if (postCheck.rows.length === 0) { return res.status(404).json({ error: true, message: 'Blog post not found' }); } // Check if image path is provided if (!imagePath) { return res.status(400).json({ error: true, message: 'Image path is required' }); } // Add image to post const result = await query( `INSERT INTO blog_post_images (id, post_id, image_path, caption, display_order) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [uuidv4(), postId, imagePath, caption || null, displayOrder || 0] ); res.status(201).json({ message: 'Image added to blog post', image: result.rows[0] }); } catch (error) { next(error); } }); // Delete blog post image router.delete('/:postId/images/:imageId', async (req, res, next) => { try { const { postId, imageId } = req.params; if (!req.user.is_admin) { return res.status(403).json({ error: true, message: 'Admin access required' }); } // Check if image exists and belongs to post const imageCheck = await query( 'SELECT id FROM blog_post_images WHERE id = $1 AND post_id = $2', [imageId, postId] ); if (imageCheck.rows.length === 0) { return res.status(404).json({ error: true, message: 'Image not found or does not belong to this post' }); } // Delete image await query( 'DELETE FROM blog_post_images WHERE id = $1', [imageId] ); res.json({ message: 'Image deleted successfully' }); } catch (error) { next(error); } }); return router; };