File: /var/www/indoadvisory_new/web/webapp/routes/articles.js
const express = require('express');
const { body } = require('express-validator');
const slugify = require('slugify');
const { marked } = require('marked');
const { db } = require('../config/database');
const { logAuditEvent } = require('../middleware/auth');
const {
handleAPIValidation,
upload,
handleUploadError,
uploadRateLimit,
validateRichText
} = require('../middleware/security');
const router = express.Router();
// Get all articles (API endpoint)
router.get('/', async (req, res) => {
try {
const {
published,
featured,
category,
author_id,
limit,
offset,
search
} = req.query;
let whereConditions = [];
let params = [];
let paramCount = 0;
if (published !== undefined) {
paramCount++;
whereConditions.push(`is_published = $${paramCount}`);
params.push(published === 'true');
}
if (featured !== undefined) {
paramCount++;
whereConditions.push(`is_featured = $${paramCount}`);
params.push(featured === 'true');
}
if (category) {
paramCount++;
whereConditions.push(`(category_en ILIKE $${paramCount} OR category_id ILIKE $${paramCount})`);
params.push(`%${category}%`);
}
if (author_id) {
paramCount++;
whereConditions.push(`author_id = $${paramCount}`);
params.push(author_id);
}
if (search) {
paramCount++;
whereConditions.push(`
(title_en ILIKE $${paramCount} OR title_id ILIKE $${paramCount} OR
content_en ILIKE $${paramCount} OR content_id ILIKE $${paramCount})
`);
params.push(`%${search}%`);
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(' AND ')}`
: '';
let limitClause = '';
if (limit) {
paramCount++;
limitClause += ` LIMIT $${paramCount}`;
params.push(parseInt(limit));
if (offset) {
paramCount++;
limitClause += ` OFFSET $${paramCount}`;
params.push(parseInt(offset));
}
}
const articles = await db.query(`
SELECT
a.id, a.title_en, a.title_id, a.slug, a.summary_en, a.summary_id,
a.content_en, a.content_id, a.featured_image_url, a.category_en, a.category_id,
a.tags, a.author_id, a.published_at, a.is_published, a.is_featured,
a.view_count, a.seo_title_en, a.seo_title_id, a.seo_description_en, a.seo_description_id,
a.created_at, a.updated_at,
u.name as author_name, u.email as author_email
FROM articles a
LEFT JOIN users u ON a.author_id = u.id
${whereClause}
ORDER BY
CASE WHEN a.is_featured THEN 0 ELSE 1 END,
COALESCE(a.published_at, a.created_at) DESC
${limitClause}
`, params);
res.json({
success: true,
articles: articles.rows
});
} catch (error) {
console.error('Get articles error:', error);
res.status(500).json({
success: false,
message: 'Error retrieving articles'
});
}
});
// Get single article
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query(`
SELECT
a.*,
u.name as author_name, u.email as author_email
FROM articles a
LEFT JOIN users u ON a.author_id = u.id
WHERE a.id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Article not found'
});
}
res.json({
success: true,
article: result.rows[0]
});
} catch (error) {
console.error('Get article error:', error);
res.status(500).json({
success: false,
message: 'Error retrieving article'
});
}
});
// Get article by slug (public endpoint)
router.get('/slug/:slug', async (req, res) => {
try {
const { slug } = req.params;
const result = await db.query(`
SELECT
a.*,
u.name as author_name
FROM articles a
LEFT JOIN users u ON a.author_id = u.id
WHERE a.slug = $1 AND a.is_published = true
`, [slug]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Article not found'
});
}
// Increment view count
await db.query(
'UPDATE articles SET view_count = view_count + 1 WHERE id = $1',
[result.rows[0].id]
);
res.json({
success: true,
article: result.rows[0]
});
} catch (error) {
console.error('Get article by slug error:', error);
res.status(500).json({
success: false,
message: 'Error retrieving article'
});
}
});
// Create new article
router.post('/',
uploadRateLimit,
upload.single('featured_image'),
handleUploadError,
[
body('title_en')
.trim()
.isLength({ min: 5, max: 500 })
.withMessage('Title (EN) must be between 5 and 500 characters'),
body('title_id')
.trim()
.isLength({ min: 5, max: 500 })
.withMessage('Title (ID) must be between 5 and 500 characters'),
body('content_en')
.trim()
.isLength({ min: 10 })
.withMessage('Content (EN) must be at least 10 characters'),
body('content_id')
.trim()
.isLength({ min: 10 })
.withMessage('Content (ID) must be at least 10 characters'),
body('category_en')
.optional()
.trim()
.isLength({ max: 100 })
.withMessage('Category (EN) must be less than 100 characters'),
body('category_id')
.optional()
.trim()
.isLength({ max: 100 })
.withMessage('Category (ID) must be less than 100 characters'),
body('tags')
.optional()
.custom((value) => {
if (typeof value === 'string') {
return true; // Will be processed as comma-separated
}
if (Array.isArray(value)) {
return true;
}
throw new Error('Tags must be a string or array');
}),
body('seo_title_en')
.optional()
.trim()
.isLength({ max: 255 })
.withMessage('SEO Title (EN) must be less than 255 characters'),
body('seo_description_en')
.optional()
.trim()
.isLength({ max: 500 })
.withMessage('SEO Description (EN) must be less than 500 characters')
],
handleAPIValidation,
async (req, res) => {
try {
const {
title_en, title_id, summary_en, summary_id, content_en, content_id,
category_en, category_id, tags, seo_title_en, seo_title_id,
seo_description_en, seo_description_id, is_published = false,
is_featured = false
} = req.body;
// Generate slug from English title
const slug = slugify(title_en, { lower: true, strict: true });
// Check if slug already exists
const existingSlug = await db.query('SELECT id FROM articles WHERE slug = $1', [slug]);
let finalSlug = slug;
if (existingSlug.rows.length > 0) {
finalSlug = `${slug}-${Date.now()}`;
}
// Process tags
let processedTags = [];
if (tags) {
if (typeof tags === 'string') {
processedTags = tags.split(',').map(tag => tag.trim()).filter(tag => tag);
} else if (Array.isArray(tags)) {
processedTags = tags.filter(tag => tag && tag.trim());
}
}
const featured_image_url = req.file ? `/uploads/${req.file.filename}` : null;
const result = await db.query(`
INSERT INTO articles (
title_en, title_id, slug, summary_en, summary_id, content_en, content_id,
featured_image_url, category_en, category_id, tags, author_id,
published_at, is_published, is_featured, seo_title_en, seo_title_id,
seo_description_en, seo_description_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19
) RETURNING id
`, [
title_en, title_id, finalSlug, summary_en, summary_id, content_en, content_id,
featured_image_url, category_en, category_id, processedTags, req.user.id,
(is_published === 'true' || is_published === true) ? new Date() : null,
is_published === 'true' || is_published === true,
is_featured === 'true' || is_featured === true,
seo_title_en, seo_title_id, seo_description_en, seo_description_id
]);
const articleId = result.rows[0].id;
await logAuditEvent(req.user.id, 'article_created', 'article', articleId, req, {
title: title_en,
slug: finalSlug
});
res.json({
success: true,
message: 'Article created successfully',
article_id: articleId,
slug: finalSlug
});
} catch (error) {
console.error('Create article error:', error);
res.status(500).json({
success: false,
message: 'Error creating article'
});
}
}
);
// Update article
router.put('/:id',
uploadRateLimit,
upload.single('featured_image'),
handleUploadError,
[
body('title_en')
.optional()
.trim()
.isLength({ min: 5, max: 500 })
.withMessage('Title (EN) must be between 5 and 500 characters'),
body('content_en')
.optional()
.trim()
.isLength({ min: 10 })
.withMessage('Content (EN) must be at least 10 characters'),
body('tags')
.optional()
.custom((value) => {
if (typeof value === 'string') {
return true;
}
if (Array.isArray(value)) {
return true;
}
throw new Error('Tags must be a string or array');
})
],
handleAPIValidation,
async (req, res) => {
try {
const { id } = req.params;
// Check if article exists
const existingArticle = await db.query('SELECT * FROM articles WHERE id = $1', [id]);
if (existingArticle.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Article not found'
});
}
const updateFields = [];
const updateValues = [];
let paramCount = 0;
// Build dynamic update query
for (const [key, value] of Object.entries(req.body)) {
if (value !== undefined && value !== '') {
paramCount++;
if (key === 'tags') {
// Process tags
let processedTags = [];
if (typeof value === 'string') {
processedTags = value.split(',').map(tag => tag.trim()).filter(tag => tag);
} else if (Array.isArray(value)) {
processedTags = value.filter(tag => tag && tag.trim());
}
updateFields.push(`tags = $${paramCount}`);
updateValues.push(processedTags);
} else if (key === 'is_published') {
const isPublished = value === 'true' || value === true;
updateFields.push(`is_published = $${paramCount}`);
updateValues.push(isPublished);
// Set published_at if publishing for the first time
if (isPublished && !existingArticle.rows[0].published_at) {
paramCount++;
updateFields.push(`published_at = $${paramCount}`);
updateValues.push(new Date());
}
} else if (key === 'is_featured') {
updateFields.push(`${key} = $${paramCount}`);
updateValues.push(value === 'true' || value === true);
} else {
updateFields.push(`${key} = $${paramCount}`);
updateValues.push(value);
}
}
}
// Handle file upload
if (req.file) {
paramCount++;
updateFields.push(`featured_image_url = $${paramCount}`);
updateValues.push(`/uploads/${req.file.filename}`);
}
// Add updated_at timestamp
paramCount++;
updateFields.push(`updated_at = $${paramCount}`);
updateValues.push(new Date());
// Add WHERE clause parameter
paramCount++;
updateValues.push(id);
if (updateFields.length === 1) { // Only updated_at was added
return res.status(400).json({
success: false,
message: 'No valid fields to update'
});
}
await db.query(`
UPDATE articles
SET ${updateFields.join(', ')}
WHERE id = $${paramCount}
`, updateValues);
await logAuditEvent(req.user.id, 'article_updated', 'article', id, req, req.body);
res.json({
success: true,
message: 'Article updated successfully'
});
} catch (error) {
console.error('Update article error:', error);
res.status(500).json({
success: false,
message: 'Error updating article'
});
}
}
);
// Delete article
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
// Check if article exists
const existingArticle = await db.query(
'SELECT title_en, slug FROM articles WHERE id = $1',
[id]
);
if (existingArticle.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Article not found'
});
}
await db.query('DELETE FROM articles WHERE id = $1', [id]);
await logAuditEvent(req.user.id, 'article_deleted', 'article', id, req, {
title: existingArticle.rows[0].title_en,
slug: existingArticle.rows[0].slug
});
res.json({
success: true,
message: 'Article deleted successfully'
});
} catch (error) {
console.error('Delete article error:', error);
res.status(500).json({
success: false,
message: 'Error deleting article'
});
}
});
// Publish/unpublish article
router.post('/:id/publish', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query(`
UPDATE articles
SET
is_published = NOT is_published,
published_at = CASE
WHEN NOT is_published THEN CURRENT_TIMESTAMP
ELSE published_at
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING is_published, title_en
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Article not found'
});
}
const { is_published, title_en } = result.rows[0];
await logAuditEvent(req.user.id, `article_${is_published ? 'published' : 'unpublished'}`, 'article', id, req, {
title: title_en
});
res.json({
success: true,
message: `Article ${is_published ? 'published' : 'unpublished'} successfully`,
is_published
});
} catch (error) {
console.error('Publish article error:', error);
res.status(500).json({
success: false,
message: 'Error updating article'
});
}
});
// Toggle featured status
router.post('/:id/featured', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query(`
UPDATE articles
SET is_featured = NOT is_featured, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING is_featured, title_en
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Article not found'
});
}
const { is_featured, title_en } = result.rows[0];
await logAuditEvent(req.user.id, 'article_featured_toggled', 'article', id, req, {
title: title_en,
is_featured
});
res.json({
success: true,
message: `Article ${is_featured ? 'featured' : 'unfeatured'} successfully`,
is_featured
});
} catch (error) {
console.error('Toggle featured error:', error);
res.status(500).json({
success: false,
message: 'Error updating article'
});
}
});
module.exports = router;