HEX
Server: Apache/2.4.65 (Debian)
System: Linux kubikelcreative 5.10.0-35-amd64 #1 SMP Debian 5.10.237-1 (2025-05-19) x86_64
User: www-data (33)
PHP: 8.4.13
Disabled: NONE
Upload Files
File: /var/www/indoadvisory_new/web2/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;