news
This commit is contained in:
parent
d408ba89dd
commit
8f3db7a07c
241
controller/news/NewsController.js
Normal file
241
controller/news/NewsController.js
Normal file
@ -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' })
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
35
models/News.js
Normal file
35
models/News.js
Normal file
@ -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
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
|
||||
@ -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) => {
|
||||
|
||||
51
services/news/NewsService.js
Normal file
51
services/news/NewsService.js
Normal file
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user