diff --git a/controller/news/NewsController.js b/controller/news/NewsController.js new file mode 100644 index 0000000..b4565d1 --- /dev/null +++ b/controller/news/NewsController.js @@ -0,0 +1,241 @@ +const multer = require('multer') +const { S3Client, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3') +const NewsService = require('../../services/news/NewsService') +const { logger } = require('../../middleware/logger') + +const PREFIX = 'news/' + +function buildImageUrlFromKey(key) { + if (!key) return '' + const endpoint = process.env.EXOSCALE_ENDPOINT || '' + const bucket = process.env.EXOSCALE_BUCKET || '' + if (endpoint.startsWith('http')) { + return `${endpoint.replace(/\/$/, '')}/${bucket}/${key}` + } + return key +} + +exports.list = async (req, res) => { + try { + const rows = await NewsService.list() + const data = (rows || []).map(r => ({ + id: r.id, + title: r.title, + summary: r.summary, + slug: r.slug, + category: r.category, + is_active: !!r.is_active, + published_at: r.published_at, + imageUrl: r.object_storage_id ? buildImageUrlFromKey(r.object_storage_id) : '' + })) + res.json({ success: true, data }) + } catch (e) { + logger.error('[NewsController.list] error', { msg: e.message }) + res.status(500).json({ error: 'Failed to load news' }) + } +} + +exports.listActive = async (req, res) => { + try { + const rows = await NewsService.list() + const data = (rows || []) + .filter(r => r.is_active) + .map(r => ({ + id: r.id, + title: r.title, + summary: r.summary, + slug: r.slug, + category: r.category, + published_at: r.published_at, + imageUrl: r.object_storage_id ? buildImageUrlFromKey(r.object_storage_id) : '' + })) + res.json({ success: true, data }) + } catch (e) { + logger.error('[NewsController.listActive] error', { msg: e.message }) + res.status(500).json({ error: 'Failed to load news' }) + } +} + +exports.create = async (req, res) => { + try { + const { title, summary, content, slug, category, isActive, publishedAt } = req.body + if (!title || !slug) { + return res.status(400).json({ success: false, error: 'Title and slug are required' }) + } + let objectKey = null + let originalFilename = null + + if (req.file) { + const allowedMime = ['image/jpeg','image/png','image/webp','image/svg+xml'] + if (!allowedMime.includes(req.file.mimetype)) { + return res.status(400).json({ success: false, error: 'Invalid image type. Allowed: JPG, PNG, WebP, SVG' }) + } + if (req.file.size > 5 * 1024 * 1024) { + return res.status(400).json({ success: false, error: 'Image exceeds 5MB limit' }) + } + const s3 = new S3Client({ + region: process.env.EXOSCALE_REGION, + endpoint: process.env.EXOSCALE_ENDPOINT, + credentials: { + accessKeyId: process.env.EXOSCALE_ACCESS_KEY, + secretAccessKey: process.env.EXOSCALE_SECRET_KEY, + }, + }) + originalFilename = req.file.originalname + const key = `${PREFIX}${Date.now()}_${originalFilename.replace(/\s+/g, '_')}` + await s3.send(new PutObjectCommand({ + Bucket: process.env.EXOSCALE_BUCKET, + Key: key, + Body: req.file.buffer, + ContentType: req.file.mimetype, + ACL: 'public-read' + })) + objectKey = key + logger.info('[NewsController.create] uploaded image', { key }) + } + + let id + try { + id = await NewsService.create({ + title, + summary, + content, + slug, + category, + object_storage_id: objectKey, + original_filename: originalFilename, + is_active: isActive ? 1 : 0, + published_at: publishedAt || null, + }) + } catch (err) { + const msg = err && err.message ? err.message : String(err) + // Handle duplicate slug gracefully + if (msg.includes('ER_DUP_ENTRY') || msg.toLowerCase().includes('duplicate')) { + return res.status(409).json({ success: false, error: 'Slug already exists' }) + } + logger.error('[NewsController.create] db error', { msg }) + return res.status(500).json({ success: false, error: 'Database error while creating news' }) + } + + res.json({ success: true, data: { id } }) + } catch (e) { + logger.error('[NewsController.create] error', { msg: e.message }) + res.status(500).json({ success: false, error: e.message || 'Failed to create news' }) + } +} + +exports.update = async (req, res) => { + try { + const { id } = req.params + const { title, summary, content, slug, category, isActive, publishedAt, removeImage } = req.body + const existing = await NewsService.get(id) + if (!existing) return res.status(404).json({ error: 'Not found' }) + + let objectKey = existing.object_storage_id + let originalFilename = existing.original_filename + + if (removeImage && objectKey) { + try { + const s3 = new S3Client({ + region: process.env.EXOSCALE_REGION, + endpoint: process.env.EXOSCALE_ENDPOINT, + credentials: { + accessKeyId: process.env.EXOSCALE_ACCESS_KEY, + secretAccessKey: process.env.EXOSCALE_SECRET_KEY, + }, + }) + await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: objectKey })) + } catch (err) { + logger.error('[NewsController.update] delete image error', { msg: err.message }) + } + objectKey = null + originalFilename = null + } + + if (req.file) { + const allowedMime = ['image/jpeg','image/png','image/webp','image/svg+xml'] + if (!allowedMime.includes(req.file.mimetype)) { + return res.status(400).json({ success: false, error: 'Invalid image type. Allowed: JPG, PNG, WebP, SVG' }) + } + if (req.file.size > 5 * 1024 * 1024) { + return res.status(400).json({ success: false, error: 'Image exceeds 5MB limit' }) + } + const s3 = new S3Client({ + region: process.env.EXOSCALE_REGION, + endpoint: process.env.EXOSCALE_ENDPOINT, + credentials: { + accessKeyId: process.env.EXOSCALE_ACCESS_KEY, + secretAccessKey: process.env.EXOSCALE_SECRET_KEY, + }, + }) + const newKey = `${PREFIX}${Date.now()}_${req.file.originalname.replace(/\s+/g, '_')}` + await s3.send(new PutObjectCommand({ + Bucket: process.env.EXOSCALE_BUCKET, + Key: newKey, + Body: req.file.buffer, + ContentType: req.file.mimetype, + ACL: 'public-read' + })) + objectKey = newKey + originalFilename = req.file.originalname + logger.info('[NewsController.update] uploaded new image', { key: newKey }) + } + + await NewsService.update(id, { + title, + summary, + content, + slug, + category, + object_storage_id: objectKey, + original_filename: originalFilename, + is_active: isActive !== undefined ? (isActive ? 1 : 0) : undefined, + published_at: publishedAt !== undefined ? (publishedAt || null) : undefined, + }) + + res.json({ success: true }) + } catch (e) { + logger.error('[NewsController.update] error', { msg: e.message }) + res.status(500).json({ error: 'Failed to update news' }) + } +} + +exports.updateStatus = async (req, res) => { + try { + const { id } = req.params + const { isActive } = req.body + await NewsService.update(id, { is_active: isActive ? 1 : 0 }) + res.json({ success: true }) + } catch (e) { + logger.error('[NewsController.updateStatus] error', { msg: e.message }) + res.status(500).json({ error: 'Failed to update status' }) + } +} + +exports.delete = async (req, res) => { + try { + const { id } = req.params + const existing = await NewsService.get(id) + if (!existing) return res.status(404).json({ error: 'Not found' }) + if (existing.object_storage_id) { + try { + const s3 = new S3Client({ + region: process.env.EXOSCALE_REGION, + endpoint: process.env.EXOSCALE_ENDPOINT, + credentials: { + accessKeyId: process.env.EXOSCALE_ACCESS_KEY, + secretAccessKey: process.env.EXOSCALE_SECRET_KEY, + }, + }) + await s3.send(new DeleteObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: existing.object_storage_id })) + } catch (err) { + logger.error('[NewsController.delete] delete image error', { msg: err.message }) + } + } + await NewsService.delete(id) + res.json({ success: true }) + } catch (e) { + logger.error('[NewsController.delete] error', { msg: e.message }) + res.status(500).json({ error: 'Failed to delete news' }) + } +} diff --git a/database/createDb.js b/database/createDb.js index 28584ad..1a90f86 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -436,6 +436,25 @@ async function createDatabase() { INDEX idx_created_by (created_by_user_id) ); `); + + // News table for News Manager + await connection.query(` + CREATE TABLE IF NOT EXISTS news ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + summary TEXT NULL, + content MEDIUMTEXT NULL, + slug VARCHAR(255) UNIQUE NOT NULL, + category VARCHAR(128) NULL, + object_storage_id VARCHAR(255) NULL, + original_filename VARCHAR(255) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + published_at DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ); + `); + console.log('✅ News table created/verified'); console.log('✅ Referral tokens table created/verified'); // 13. referral_token_usage table: Tracks each use of a referral token diff --git a/models/News.js b/models/News.js new file mode 100644 index 0000000..0cf56d2 --- /dev/null +++ b/models/News.js @@ -0,0 +1,35 @@ +class News { + constructor(row) { + this.id = row.id + this.title = row.title + this.summary = row.summary + this.content = row.content + this.slug = row.slug + this.category = row.category + this.object_storage_id = row.object_storage_id + this.original_filename = row.original_filename + this.is_active = !!row.is_active + this.published_at = row.published_at + this.created_at = row.created_at + this.updated_at = row.updated_at + } + + toJSON() { + return { + id: this.id, + title: this.title, + summary: this.summary, + content: this.content, + slug: this.slug, + category: this.category, + object_storage_id: this.object_storage_id, + original_filename: this.original_filename, + is_active: this.is_active, + published_at: this.published_at, + created_at: this.created_at, + updated_at: this.updated_at, + } + } +} + +module.exports = News diff --git a/routes/deleteRoutes.js b/routes/deleteRoutes.js index 3202ba7..ebba7fd 100644 --- a/routes/deleteRoutes.js +++ b/routes/deleteRoutes.js @@ -7,6 +7,7 @@ const DocumentTemplateController = require('../controller/documentTemplate/Docum const CompanyStampController = require('../controller/companyStamp/CompanyStampController'); const CoffeeController = require('../controller/admin/CoffeeController'); const AffiliateController = require('../controller/affiliate/AffiliateController'); +const NewsController = require('../controller/news/NewsController'); // Helper middlewares for company-stamp function adminOnly(req, res, next) { @@ -35,4 +36,7 @@ router.delete('/admin/coffee/:id', authMiddleware, adminOnly, CoffeeController.r // Admin: delete affiliate router.delete('/admin/affiliates/:id', authMiddleware, adminOnly, AffiliateController.delete); +// Admin: delete news +router.delete('/admin/news/:id', authMiddleware, adminOnly, NewsController.delete); + module.exports = router; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 8737f8d..018f4cd 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -19,6 +19,7 @@ const CoffeeController = require('../controller/admin/CoffeeController'); const PoolController = require('../controller/pool/PoolController'); const TaxController = require('../controller/tax/taxController'); const AffiliateController = require('../controller/affiliate/AffiliateController'); +const NewsController = require('../controller/news/NewsController'); // small helpers copied from original files function adminOnly(req, res, next) { @@ -147,5 +148,9 @@ router.get('/admin/affiliates', authMiddleware, adminOnly, AffiliateController.l // Public Affiliates Route (Active only) router.get('/affiliates/active', AffiliateController.listActive); +// News Manager Routes +router.get('/admin/news', authMiddleware, adminOnly, NewsController.list); +router.get('/news/active', NewsController.listActive); + // export module.exports = router; \ No newline at end of file diff --git a/routes/patchRoutes.js b/routes/patchRoutes.js index e4480e8..b75250b 100644 --- a/routes/patchRoutes.js +++ b/routes/patchRoutes.js @@ -10,6 +10,7 @@ const PersonalProfileController = require('../controller/profile/PersonalProfile const PoolController = require('../controller/pool/PoolController'); // <-- new const MatrixController = require('../controller/matrix/MatrixController'); // <-- new const AffiliateController = require('../controller/affiliate/AffiliateController'); // <-- new +const NewsController = require('../controller/news/NewsController'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -53,6 +54,10 @@ router.patch('/admin/affiliates/:id', authMiddleware, adminOnly, upload.single(' // NEW: Update affiliate status only router.patch('/admin/affiliates/:id/status', authMiddleware, adminOnly, AffiliateController.updateStatus); +// News Manager +router.patch('/admin/news/:id', authMiddleware, adminOnly, upload.single('image'), NewsController.update); +router.patch('/admin/news/:id/status', authMiddleware, adminOnly, NewsController.updateStatus); + // Personal profile (self-service) - no admin guard router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic); router.patch('/profile/personal/bank', authMiddleware, PersonalProfileController.updateBank); diff --git a/routes/postRoutes.js b/routes/postRoutes.js index dd61525..c2060b3 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -25,6 +25,7 @@ const MatrixController = require('../controller/matrix/MatrixController'); // Ma const PoolController = require('../controller/pool/PoolController'); const TaxController = require('../controller/tax/taxController'); const AffiliateController = require('../controller/affiliate/AffiliateController'); +const NewsController = require('../controller/news/NewsController'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -134,6 +135,8 @@ router.post('/admin/pools', authMiddleware, adminOnly, PoolController.create); router.post('/tax/vat-rates/import', authMiddleware, adminOnly, upload.single('file'), TaxController.importVatRatesCsv); // NEW: Admin create affiliate with logo upload router.post('/admin/affiliates', authMiddleware, adminOnly, upload.single('logo'), AffiliateController.create); +// NEW: Admin create news with image upload +router.post('/admin/news', authMiddleware, adminOnly, upload.single('image'), NewsController.create); // Existing registration handlers (keep) router.post('/register/personal', (req, res) => { diff --git a/services/news/NewsService.js b/services/news/NewsService.js new file mode 100644 index 0000000..76e06fe --- /dev/null +++ b/services/news/NewsService.js @@ -0,0 +1,51 @@ +const db = require('../../database/database') + +exports.list = async () => { + const [rows] = await db.query('SELECT * FROM news ORDER BY published_at DESC, created_at DESC') + return rows +} + +exports.get = async (idOrSlug) => { + const [rows] = await db.query('SELECT * FROM news WHERE id = ? OR slug = ? LIMIT 1', [idOrSlug, idOrSlug]) + return rows?.[0] || null +} + +exports.create = async (payload) => { + const { + title, + summary, + content, + slug, + category, + object_storage_id, + original_filename, + is_active = 1, + published_at = null, + } = payload + const [res] = await db.query( + 'INSERT INTO news (title, summary, content, slug, category, object_storage_id, original_filename, is_active, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [title, summary, content, slug, category, object_storage_id, original_filename, is_active, published_at] + ) + return res.insertId +} + +exports.update = async (id, payload) => { + const fields = [] + const values = [] + const allowed = ['title','summary','content','slug','category','object_storage_id','original_filename','is_active','published_at'] + for (const key of allowed) { + if (payload[key] !== undefined) { + fields.push(`${key} = ?`) + values.push(payload[key]) + } + } + if (!fields.length) return 0 + values.push(id) + const [res] = await db.query(`UPDATE news SET ${fields.join(', ')} WHERE id = ?`, values) + return res.affectedRows +} + +exports.delete = async (id) => { + const [res] = await db.query('DELETE FROM news WHERE id = ?', [id]) + return res.affectedRows +}