494 lines
No EOL
14 KiB
JavaScript
494 lines
No EOL
14 KiB
JavaScript
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;
|
|
}; |