noti
This commit is contained in:
@@ -8,6 +8,7 @@ const path = require('path');
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const repository = require('./src/repository');
|
||||
const notificationRepository = require('./src/notification-repository');
|
||||
const mailer = require('./src/mailer');
|
||||
const { closePool, getPool } = require('./src/db');
|
||||
const notiflixVersion = require('notiflix/package.json').version;
|
||||
@@ -123,6 +124,7 @@ app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ limit: '32kb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
|
||||
app.use(applyPublicApiCors);
|
||||
@@ -524,6 +526,55 @@ function sanitizeReturnTo(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeNotificationActionUrl(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return '';
|
||||
if (
|
||||
text.length > 1000
|
||||
|| !text.startsWith('/')
|
||||
|| text.startsWith('//')
|
||||
|| text.includes('\\')
|
||||
|| /[\u0000-\u001F\u007F]/.test(text)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeNotificationSeverity(value) {
|
||||
const severity = String(value || 'info').trim().toLowerCase();
|
||||
return ['info', 'success', 'warning', 'error'].includes(severity) ? severity : null;
|
||||
}
|
||||
|
||||
function isUuid(value) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
String(value || '').trim()
|
||||
);
|
||||
}
|
||||
|
||||
function getNotificationActorName(user) {
|
||||
return String(user?.name || user?.username || '').trim();
|
||||
}
|
||||
|
||||
async function safelyCreateNotification(label, callback) {
|
||||
try {
|
||||
return await callback();
|
||||
} catch (error) {
|
||||
console.error(`Cannot create notification (${label}):`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function safelyLoadNotificationContext(label, callback) {
|
||||
try {
|
||||
return await callback();
|
||||
} catch (error) {
|
||||
console.error(`Cannot load notification context (${label}):`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseUrl(req) {
|
||||
const requestBaseUrl = getRequestBaseUrl(req);
|
||||
if (process.env.APP_BASE_URL) {
|
||||
@@ -851,6 +902,15 @@ function requireAdmin(req, res, next) {
|
||||
redirectWithNotice(res, '/', 'failure', 'Bạn cần quyền Admin để quản lý user.');
|
||||
}
|
||||
|
||||
function requireAdminApi(req, res, next) {
|
||||
if (req.currentUser && req.currentUser.role === 'Admin') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(403).json({ error: 'Bạn cần quyền Admin để thực hiện thao tác này.' });
|
||||
}
|
||||
|
||||
function normalizePackageType(value) {
|
||||
const packageType = String(value || 'deb').toLowerCase();
|
||||
return ['deb', 'apt', 'docker'].includes(packageType) ? packageType : 'deb';
|
||||
@@ -1497,6 +1557,92 @@ exit 1
|
||||
app.use(requireAuthenticated);
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
app.get('/api/notifications', asyncRoute(async (req, res) => {
|
||||
const countOnly = String(req.query.countOnly || '').toLowerCase() === 'true';
|
||||
const result = countOnly
|
||||
? await notificationRepository.getUnreadCount(req.currentUser.id)
|
||||
: await notificationRepository.listNotifications(req.currentUser.id, {
|
||||
limit: req.query.limit,
|
||||
unreadOnly: String(req.query.unreadOnly || '').toLowerCase() === 'true'
|
||||
});
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
app.post('/api/notifications/read-all', asyncRoute(async (req, res) => {
|
||||
const result = await notificationRepository.markAllNotificationsRead(req.currentUser.id);
|
||||
if (!result.available) {
|
||||
res.status(503).json({ error: 'Tính năng thông báo chưa được kích hoạt trong database.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
app.post('/api/notifications/:id/read', asyncRoute(async (req, res) => {
|
||||
if (!isUuid(req.params.id)) {
|
||||
res.status(400).json({ error: 'Notification id không hợp lệ.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await notificationRepository.markNotificationRead(req.currentUser.id, req.params.id);
|
||||
if (!result.available) {
|
||||
res.status(503).json({ error: 'Tính năng thông báo chưa được kích hoạt trong database.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.updated) {
|
||||
res.status(404).json({ error: 'Không tìm thấy thông báo.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
app.post('/api/notifications/broadcasts', requireAdminApi, asyncRoute(async (req, res) => {
|
||||
const title = String(req.body.title || '').trim();
|
||||
const message = String(req.body.message || '').trim();
|
||||
const severity = normalizeNotificationSeverity(req.body.severity);
|
||||
const actionUrl = sanitizeNotificationActionUrl(req.body.actionUrl);
|
||||
|
||||
if (!title || title.length > 200) {
|
||||
res.status(400).json({ error: 'Tiêu đề bắt buộc và không được vượt quá 200 ký tự.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message || message.length > 1000) {
|
||||
res.status(400).json({ error: 'Nội dung bắt buộc và không được vượt quá 1000 ký tự.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!severity) {
|
||||
res.status(400).json({ error: 'Mức độ thông báo không hợp lệ.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionUrl === null) {
|
||||
res.status(400).json({ error: 'Đường dẫn phải là đường dẫn nội bộ bắt đầu bằng /.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await notificationRepository.createSystemBroadcast({
|
||||
actorUserId: req.currentUser.id,
|
||||
actorName: getNotificationActorName(req.currentUser),
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
actionUrl
|
||||
});
|
||||
|
||||
if (!result.available) {
|
||||
res.status(503).json({ error: 'Chưa có bảng dbo.Notifications. Hãy chạy migration 04_notifications.sql trước.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
app.get('/agent', requireAdmin, asyncRoute(async (req, res) => {
|
||||
const pageData = await repository.getPageData(req.currentUser);
|
||||
const agentPackages = await listAgentPackages();
|
||||
@@ -1824,6 +1970,14 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
||||
};
|
||||
|
||||
await repository.addPackageVersion(versionInput);
|
||||
await safelyCreateNotification('package version available', () => (
|
||||
notificationRepository.notifyPackageVersionAvailable({
|
||||
packageId: versionInput.packageId,
|
||||
version: versionInput.version,
|
||||
actorUserId: req.currentUser.id,
|
||||
actorName: getNotificationActorName(req.currentUser)
|
||||
})
|
||||
));
|
||||
|
||||
redirectWithNotice(res, `/packages/${req.body.packageId}`, 'success', 'Đã cập nhật version mới và đặt làm latest.');
|
||||
} catch (error) {
|
||||
@@ -1833,6 +1987,15 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
||||
const replacedVersionId = await repository.replacePackageVersionArtifact(versionInput);
|
||||
|
||||
if (replacedVersionId) {
|
||||
await safelyCreateNotification('package version artifact updated', () => (
|
||||
notificationRepository.notifyPackageVersionAvailable({
|
||||
packageId: versionInput.packageId,
|
||||
version: versionInput.version,
|
||||
isArtifactUpdate: true,
|
||||
actorUserId: req.currentUser.id,
|
||||
actorName: getNotificationActorName(req.currentUser)
|
||||
})
|
||||
));
|
||||
redirectWithNotice(
|
||||
res,
|
||||
`/packages/${versionInput.packageId}`,
|
||||
@@ -1857,8 +2020,28 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
||||
}));
|
||||
|
||||
app.post('/packages/:id/delete', asyncRoute(async (req, res) => {
|
||||
const notificationImpact = await safelyLoadNotificationContext('package delete', () => (
|
||||
notificationRepository.getPackageImpact(req.params.id)
|
||||
));
|
||||
const deleted = await repository.deletePackage(req.params.id);
|
||||
|
||||
if (deleted && notificationImpact) {
|
||||
await safelyCreateNotification('package deleted', () => (
|
||||
notificationRepository.createNotificationsForUsers({
|
||||
recipientUserIds: notificationImpact.recipientUserIds,
|
||||
actorUserId: req.currentUser.id,
|
||||
actorName: getNotificationActorName(req.currentUser),
|
||||
eventType: 'package.deleted',
|
||||
severity: 'error',
|
||||
title: `Package ${notificationImpact.packageCode} đã bị xóa`,
|
||||
message: 'Các liên kết application sử dụng package này đã bị gỡ. Hãy kiểm tra lại cấu hình application liên quan.',
|
||||
actionUrl: '/applications',
|
||||
entityType: 'package',
|
||||
entityId: notificationImpact.packageId
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
redirectWithNotice(
|
||||
res,
|
||||
'/packages',
|
||||
@@ -1876,8 +2059,28 @@ app.post('/package-versions/:id/latest', asyncRoute(async (req, res) => {
|
||||
|
||||
app.post('/package-versions/:id/delete', asyncRoute(async (req, res) => {
|
||||
const returnTo = sanitizeReturnTo(req.body.returnTo || '/packages');
|
||||
const notificationImpact = await safelyLoadNotificationContext('package version delete', () => (
|
||||
notificationRepository.getPackageVersionImpact(req.params.id)
|
||||
));
|
||||
const result = await repository.deletePackageVersion(req.params.id);
|
||||
|
||||
if (result.deleted && notificationImpact) {
|
||||
await safelyCreateNotification('package version deleted', () => (
|
||||
notificationRepository.createNotificationsForUsers({
|
||||
recipientUserIds: notificationImpact.recipientUserIds,
|
||||
actorUserId: req.currentUser.id,
|
||||
actorName: getNotificationActorName(req.currentUser),
|
||||
eventType: 'package.version_deleted',
|
||||
severity: 'warning',
|
||||
title: `Version ${notificationImpact.version} của ${notificationImpact.packageCode} đã bị xóa`,
|
||||
message: 'Các liên kết application sử dụng chính version này đã bị gỡ. Hãy kiểm tra lại application liên quan.',
|
||||
actionUrl: '/applications',
|
||||
entityType: 'package',
|
||||
entityId: notificationImpact.packageId
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
redirectWithNotice(
|
||||
res,
|
||||
result.packageId ? `/packages/${result.packageId}` : returnTo,
|
||||
@@ -2037,6 +2240,16 @@ app.post('/applications/:id/release', asyncRoute(async (req, res) => {
|
||||
const returnTo = sanitizeReturnTo(req.body.returnTo || `/applications/${applicationId}`);
|
||||
const updated = await repository.updateApplicationStatus(applicationId, 'Released');
|
||||
|
||||
if (updated) {
|
||||
await safelyCreateNotification('application released', () => (
|
||||
notificationRepository.notifyApplicationReleased({
|
||||
applicationId,
|
||||
actorUserId: req.currentUser.id,
|
||||
actorName: getNotificationActorName(req.currentUser)
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
redirectWithNotice(
|
||||
res,
|
||||
returnTo,
|
||||
@@ -2237,6 +2450,13 @@ app.get('/builder', asyncRoute(async (req, res) => {
|
||||
app.use(async (error, req, res, next) => {
|
||||
console.error(error);
|
||||
|
||||
if (req.path.startsWith('/api/notifications')) {
|
||||
res.status(500).json({
|
||||
error: 'Không thể xử lý thông báo lúc này. Vui lòng thử lại sau.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pageData = await repository.getPageData(req.currentUser).catch(() => ({
|
||||
currentUser: req.currentUser || { name: 'Guest', role: 'Guest', email: '' },
|
||||
stats: {
|
||||
|
||||
Reference in New Issue
Block a user