This commit is contained in:
DeathKaioken 2025-12-13 11:16:56 +01:00
commit b73f8b9b4b
8 changed files with 364 additions and 0 deletions

View 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' })
}
}

View File

@ -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
View 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

View File

@ -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;

View File

@ -20,6 +20,7 @@ const PoolController = require('../controller/pool/PoolController');
const TaxController = require('../controller/tax/taxController');
const AffiliateController = require('../controller/affiliate/AffiliateController');
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
const NewsController = require('../controller/news/NewsController');
// small helpers copied from original files
function adminOnly(req, res, next) {
@ -153,4 +154,9 @@ router.get('/abonements/mine', authMiddleware, AbonemmentController.getMine);
router.get('/abonements/:id/history', authMiddleware, AbonemmentController.getHistory);
router.get('/admin/abonements', authMiddleware, adminOnly, AbonemmentController.adminList);
// News Manager Routes
router.get('/admin/news', authMiddleware, adminOnly, NewsController.list);
router.get('/news/active', NewsController.listActive);
// export
module.exports = router;

View File

@ -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);

View File

@ -26,6 +26,7 @@ const PoolController = require('../controller/pool/PoolController');
const TaxController = require('../controller/tax/taxController');
const AffiliateController = require('../controller/affiliate/AffiliateController');
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
const NewsController = require('../controller/news/NewsController');
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() });
@ -156,6 +157,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);
// Abonement POSTs
router.post('/abonements/subscribe', authMiddleware, AbonemmentController.subscribe);

View 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
}