feat(tkuser): 알림 시스템 이관 system1-factory → tkuser

- Phase 1: tkuser에 알림 CRUD, Push/ntfy 발송, 내부 알림 API 추가
- Phase 2: notifyHelper URL을 tkuser-api:3000으로 전환 (system2, tkpurchase, tksafety, system1)
- Phase 3: notification-bell.js API 도메인 tkuser로 변경 + 캐시 버스팅 v=4
- Phase 4: system1에서 알림 코드 제거 (routes, controllers, models, utils)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-17 15:56:41 +09:00
parent afa10c044f
commit 84cf222b81
30 changed files with 244 additions and 172 deletions

View File

@@ -103,14 +103,7 @@ services:
- REDIS_PORT=6379
- WEATHER_API_URL=${WEATHER_API_URL:-}
- WEATHER_API_KEY=${WEATHER_API_KEY:-}
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@technicalkorea.net}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80}
- NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN}
- NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net}
- TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net}
volumes:
- system1_uploads:/usr/src/app/uploads
- system1_logs:/usr/src/app/logs
@@ -274,6 +267,14 @@ services:
- DB_PASSWORD=${MYSQL_PASSWORD}
- DB_NAME=${MYSQL_DATABASE:-hyungi}
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@technicalkorea.net}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80}
- NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN}
- NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net}
- TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net}
volumes:
- system1_uploads:/usr/src/app/uploads
depends_on:

View File

@@ -16,8 +16,8 @@
var DROPDOWN_LIMIT = 5;
var API_ORIGIN = (function () {
var h = window.location.hostname;
if (h.includes('technicalkorea.net')) return 'https://tkfb.technicalkorea.net';
return window.location.protocol + '//' + h + ':30005';
if (h.includes('technicalkorea.net')) return 'https://tkuser.technicalkorea.net';
return window.location.protocol + '//' + h + ':30300';
})();
var API_BASE = API_ORIGIN + '/api/notifications';
var PUSH_API_BASE = API_ORIGIN + '/api/push';

View File

@@ -50,8 +50,6 @@ function setupRoutes(app) {
const workIssueRoutes = require('../routes/workIssueRoutes');
const departmentRoutes = require('../routes/departmentRoutes');
const patrolRoutes = require('../routes/patrolRoutes');
const notificationRoutes = require('../routes/notificationRoutes');
const pushSubscriptionRoutes = require('../routes/pushSubscriptionRoutes');
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
const purchaseRoutes = require('../routes/purchaseRoutes');
const settlementRoutes = require('../routes/settlementRoutes');
@@ -112,8 +110,6 @@ function setupRoutes(app) {
'/api/setup/check-data-status',
'/api/monthly-status/calendar',
'/api/monthly-status/daily-details',
'/api/push/vapid-public-key',
'/api/notifications/internal'
];
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
@@ -164,8 +160,6 @@ function setupRoutes(app) {
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
app.use('/api/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
app.use('/api/notifications', notificationRoutes); // 알림 시스템
app.use('/api/push', pushSubscriptionRoutes); // Push 구독
app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청
app.use('/api/purchases', purchaseRoutes); // 구매 내역
app.use('/api/settlements', settlementRoutes); // 월간 정산

View File

@@ -120,21 +120,4 @@ process.on('uncaughtException', (error) => {
}
})();
// 오래된 알림 정리 cron (매일 03:00 KST)
(function scheduleNotificationCleanup() {
const notificationModel = require('./models/notificationModel');
function runCleanup() {
const now = new Date();
const kstHour = (now.getUTCHours() + 9) % 24;
if (kstHour === 3 && now.getMinutes() < 1) {
notificationModel.deleteOld(30).then(count => {
if (count > 0) logger.info(`오래된 알림 ${count}건 정리 완료`);
}).catch(err => {
logger.error('알림 정리 실패:', { error: err.message });
});
}
}
setInterval(runCleanup, 60000); // 1분마다 확인
})();
module.exports = app;

View File

@@ -1,6 +1,6 @@
// models/equipmentModel.js
const { getDb } = require('../dbPool');
const notificationModel = require('./notificationModel');
const notifyHelper = require('../utils/notifyHelper');
const EquipmentModel = {
// CREATE - 설비 생성
@@ -669,17 +669,16 @@ const EquipmentModel = {
['repair_needed', requestData.equipment_id]
);
try {
await notificationModel.createRepairNotification({
equipment_id: requestData.equipment_id,
equipment_name: requestData.equipment_name || '설비',
repair_type: requestData.repair_type || '일반 수리',
request_id: result.insertId,
created_by: requestData.reported_by
});
} catch (notifError) {
// 알림 생성 실패해도 수리 신청은 성공으로 처리
}
// fire-and-forget: 알림 실패가 수리 신청을 블로킹하면 안 됨
notifyHelper.send({
type: 'repair',
title: `수리 신청: ${requestData.equipment_name || '설비'}`,
message: `${requestData.repair_type || '일반 수리'} 수리가 신청되었습니다.`,
link_url: '/pages/admin/repair-management.html',
reference_type: 'work_issue_reports',
reference_id: result.insertId,
created_by: requestData.reported_by
}).catch(() => {});
return {
report_id: result.insertId,

View File

@@ -92,19 +92,15 @@ const PurchaseModel = {
} catch (err) {
console.error('[purchase] 설비 자동 등록 실패:', err.message);
// admin 알림 전송
try {
const notificationModel = require('./notificationModel');
await notificationModel.createTypedNotification({
type: 'equipment',
title: `설비 자동 등록 실패: ${purchaseData.item_name}`,
message: `구매 완료 후 설비 자동 등록에 실패했습니다. 수동으로 등록해주세요. 오류: ${err.message}`,
link_url: '/pages/admin/equipments.html',
created_by: purchaseData.purchaser_id
});
} catch (notifErr) {
console.error('[purchase] 설비 등록 실패 알림 전송 오류:', notifErr.message);
}
// fire-and-forget: admin 알림 전송
const notifyHelper = require('../utils/notifyHelper');
notifyHelper.send({
type: 'equipment',
title: `설비 자동 등록 실패: ${purchaseData.item_name}`,
message: `구매 완료 후 설비 자동 등록 실패했습니다. 수동으로 등록해주세요. 오류: ${err.message}`,
link_url: '/pages/admin/equipments.html',
created_by: purchaseData.purchaser_id
}).catch(() => {});
return { success: false, error: err.message };
}

View File

@@ -39,8 +39,7 @@
"qrcode": "^1.5.4",
"redis": "^5.9.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"web-push": "^3.6.7"
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@types/jest": "^29.5.12",

View File

@@ -0,0 +1,63 @@
// utils/notifyHelper.js — 공용 알림 헬퍼
// tkuser-api의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http');
const NOTIFY_URL = 'http://tkuser-api:3000/api/notifications/internal';
const SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';
const notifyHelper = {
/**
* 알림 전송
* @param {Object} opts
* @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system)
* @param {string} opts.title - 알림 제목
* @param {string} [opts.message] - 알림 내용
* @param {string} [opts.link_url] - 클릭 시 이동 URL
* @param {string} [opts.reference_type] - 연관 테이블명
* @param {number} [opts.reference_id] - 연관 레코드 ID
* @param {number} [opts.created_by] - 생성자 user_id
*/
async send(opts) {
try {
const body = JSON.stringify(opts);
const url = new URL(NOTIFY_URL);
return new Promise((resolve) => {
const req = http.request({
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Internal-Service-Key': SERVICE_KEY,
'Content-Length': Buffer.byteLength(body)
},
timeout: 5000
}, (res) => {
res.resume(); // drain
resolve(true);
});
req.on('error', (err) => {
console.error('[notifyHelper] 알림 전송 실패:', err.message);
resolve(false);
});
req.on('timeout', () => {
req.destroy();
console.error('[notifyHelper] 알림 전송 타임아웃');
resolve(false);
});
req.write(body);
req.end();
});
} catch (err) {
console.error('[notifyHelper] 알림 전송 오류:', err.message);
return false;
}
}
};
module.exports = notifyHelper;

View File

@@ -1,41 +0,0 @@
// Push Notification Service Worker
// 캐싱 없음 — Push 수신 전용
self.addEventListener('push', function(event) {
var data = { title: '알림', body: '새 알림이 있습니다.', url: '/' };
if (event.data) {
try { data = Object.assign(data, event.data.json()); } catch(e) {}
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/static/img/icon-192.png',
badge: '/static/img/badge-72.png',
data: { url: data.url || '/' },
tag: 'tk-notification-' + Date.now(),
renotify: true
})
);
// 메인 페이지에 뱃지 갱신 신호 전송
self.clients.matchAll({ type: 'window' }).then(function(clients) {
clients.forEach(function(client) {
client.postMessage({ type: 'NOTIFICATION_RECEIVED' });
});
});
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
var url = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then(function(clients) {
for (var i = 0; i < clients.length; i++) {
if (clients[i].url.includes(self.location.origin)) {
clients[i].navigate(url);
return clients[i].focus();
}
}
return self.clients.openWindow(url);
})
);
});

View File

@@ -299,7 +299,7 @@ async function initAuth() {
/* ===== 알림 벨 ===== */
function _loadNotificationBell() {
const s = document.createElement('script');
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=3';
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=4';
document.head.appendChild(s);
}

View File

@@ -2,7 +2,7 @@
// system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http');
const NOTIFY_URL = 'http://system1-api:3005/api/notifications/internal';
const NOTIFY_URL = 'http://tkuser-api:3000/api/notifications/internal';
const SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';
const notifyHelper = {

View File

@@ -153,7 +153,7 @@ if ('serviceWorker' in navigator) {
window._loadNotificationBell = function() {
var h = window.location.hostname;
var s = document.createElement('script');
s.src = (h.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=3';
s.src = (h.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=4';
document.head.appendChild(s);
};

View File

@@ -401,7 +401,7 @@ class App {
_loadNotificationBell() {
var h = window.location.hostname;
var s = document.createElement('script');
s.src = (h.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=3';
s.src = (h.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=4';
document.head.appendChild(s);
}

View File

@@ -2,7 +2,7 @@
// system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http');
const NOTIFY_URL = 'http://system1-api:3005/api/notifications/internal';
const NOTIFY_URL = 'http://tkuser-api:3000/api/notifications/internal';
const SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';
const notifyHelper = {

View File

@@ -195,7 +195,7 @@ function initAuth() {
/* ===== 알림 벨 ===== */
function _loadNotificationBell() {
const s = document.createElement('script');
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=3';
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=4';
document.head.appendChild(s);
}

View File

@@ -2,7 +2,7 @@
// system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http');
const NOTIFY_URL = 'http://system1-api:3005/api/notifications/internal';
const NOTIFY_URL = 'http://tkuser-api:3000/api/notifications/internal';
const SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';
const notifyHelper = {

View File

@@ -173,7 +173,7 @@ function initAuth() {
/* ===== 알림 벨 ===== */
function _loadNotificationBell() {
const s = document.createElement('script');
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=3';
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=4';
document.head.appendChild(s);
}

View File

@@ -164,7 +164,7 @@ function initAuth() {
/* ===== 알림 벨 ===== */
function _loadNotificationBell() {
const s = document.createElement('script');
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=3';
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=4';
document.head.appendChild(s);
}

View File

@@ -21,6 +21,8 @@ const partnerRoutes = require('./routes/partnerRoutes');
const vendorRoutes = require('./routes/vendorRoutes');
const consumableItemRoutes = require('./routes/consumableItemRoutes');
const notificationRecipientRoutes = require('./routes/notificationRecipientRoutes');
const notificationRoutes = require('./routes/notificationRoutes');
const pushSubscriptionRoutes = require('./routes/pushSubscriptionRoutes');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -31,6 +33,8 @@ const allowedOrigins = [
'https://tkqc.technicalkorea.net',
'https://tkuser.technicalkorea.net',
'https://tkpurchase.technicalkorea.net',
'https://tksafety.technicalkorea.net',
'https://tksupport.technicalkorea.net',
];
if (process.env.NODE_ENV === 'development') {
allowedOrigins.push('http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280');
@@ -64,6 +68,8 @@ app.use('/api/partners', partnerRoutes);
app.use('/api/vendors', vendorRoutes);
app.use('/api/consumable-items', consumableItemRoutes);
app.use('/api/notification-recipients', notificationRecipientRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/push', pushSubscriptionRoutes);
// 404
app.use((req, res) => {
@@ -83,4 +89,21 @@ app.listen(PORT, () => {
console.log(`tkuser-api running on port ${PORT}`);
});
// 오래된 알림 정리 cron (매일 03:00 KST)
(function scheduleNotificationCleanup() {
const notificationModel = require('./models/notificationModel');
function runCleanup() {
const now = new Date();
const kstHour = (now.getUTCHours() + 9) % 24;
if (kstHour === 3 && now.getMinutes() < 1) {
notificationModel.deleteOld(30).then(count => {
if (count > 0) console.log(`오래된 알림 ${count}건 정리 완료`);
}).catch(err => {
console.error('알림 정리 실패:', err.message);
});
}
}
setInterval(runCleanup, 60000);
})();
module.exports = app;

View File

@@ -76,4 +76,31 @@ function requireAdminOrPermission(pageName) {
};
}
module.exports = { extractToken, requireAuth, requireAdmin, requireAdminOrPermission };
/**
* 최소 권한 레벨 체크 미들웨어
* worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
*/
const ACCESS_LEVELS = { worker: 1, group_leader: 2, support_team: 3, admin: 4, system: 5 };
function requireMinLevel(minLevel) {
return (req, res, next) => {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ success: false, error: '인증이 필요합니다' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
const userLevel = ACCESS_LEVELS[decoded.access_level] || ACCESS_LEVELS[decoded.role] || 0;
const requiredLevel = ACCESS_LEVELS[minLevel] || 999;
if (userLevel < requiredLevel) {
return res.status(403).json({ success: false, error: `${minLevel} 이상의 권한이 필요합니다` });
}
next();
} catch {
return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' });
}
};
}
module.exports = { extractToken, requireAuth, requireAdmin, requireAdminOrPermission, requireMinLevel };

View File

@@ -1,5 +1,5 @@
// models/notificationModel.js
const { getDb } = require('../dbPool');
const { getPool } = require('./userModel');
// Web Push (lazy init)
let webpush = null;
@@ -79,10 +79,9 @@ async function sendPushToUsers(userIds, payload) {
}
}
// 순환 참조를 피하기 위해 함수 내에서 require
async function getRecipientIds(notificationType) {
const db = await getDb();
const [rows] = await db.query(
const pool = getPool();
const [rows] = await pool.query(
`SELECT user_id FROM notification_recipients
WHERE notification_type = ? AND is_active = 1`,
[notificationType]
@@ -93,16 +92,15 @@ async function getRecipientIds(notificationType) {
const notificationModel = {
// 알림 생성
async create(notificationData) {
const db = await getDb();
const pool = getPool();
const { user_id, type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
const [result] = await db.query(
const [result] = await pool.query(
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[user_id || null, type || 'system', title, message || null, link_url || null, reference_type || null, reference_id || null, created_by || null]
);
// Redis 캐시 무효화
this._invalidateCache(user_id);
return result.insertId;
@@ -110,8 +108,8 @@ const notificationModel = {
// 읽지 않은 알림 조회 (특정 사용자 또는 전체)
async getUnread(userId = null) {
const db = await getDb();
const [rows] = await db.query(
const pool = getPool();
const [rows] = await pool.query(
`SELECT * FROM notifications
WHERE is_read = 0
AND (user_id IS NULL OR user_id = ?)
@@ -124,10 +122,10 @@ const notificationModel = {
// 전체 알림 조회 (페이징)
async getAll(userId = null, page = 1, limit = 20) {
const db = await getDb();
const pool = getPool();
const offset = (page - 1) * limit;
const [rows] = await db.query(
const [rows] = await pool.query(
`SELECT * FROM notifications
WHERE (user_id IS NULL OR user_id = ?)
ORDER BY created_at DESC
@@ -135,7 +133,7 @@ const notificationModel = {
[userId || 0, limit, offset]
);
const [[{ total }]] = await db.query(
const [[{ total }]] = await pool.query(
`SELECT COUNT(*) as total FROM notifications
WHERE (user_id IS NULL OR user_id = ?)`,
[userId || 0]
@@ -146,10 +144,9 @@ const notificationModel = {
// 알림 읽음 처리
async markAsRead(notificationId) {
const db = await getDb();
// 읽음 처리 전 user_id 조회 (캐시 무효화용)
const [[row]] = await db.query('SELECT user_id FROM notifications WHERE notification_id = ?', [notificationId]);
const [result] = await db.query(
const pool = getPool();
const [[row]] = await pool.query('SELECT user_id FROM notifications WHERE notification_id = ?', [notificationId]);
const [result] = await pool.query(
`UPDATE notifications SET is_read = 1, read_at = NOW() WHERE notification_id = ?`,
[notificationId]
);
@@ -159,8 +156,8 @@ const notificationModel = {
// 모든 알림 읽음 처리
async markAllAsRead(userId = null) {
const db = await getDb();
const [result] = await db.query(
const pool = getPool();
const [result] = await pool.query(
`UPDATE notifications SET is_read = 1, read_at = NOW()
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0]
@@ -171,8 +168,8 @@ const notificationModel = {
// 알림 삭제
async delete(notificationId) {
const db = await getDb();
const [result] = await db.query(
const pool = getPool();
const [result] = await pool.query(
`DELETE FROM notifications WHERE notification_id = ?`,
[notificationId]
);
@@ -181,8 +178,8 @@ const notificationModel = {
// 오래된 알림 삭제 (30일 이상)
async deleteOld(days = 30) {
const db = await getDb();
const [result] = await db.query(
const pool = getPool();
const [result] = await pool.query(
`DELETE FROM notifications WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
[days]
);
@@ -198,8 +195,8 @@ const notificationModel = {
if (cached !== null && cached !== undefined) return cached;
} catch (e) { /* 무시 */ }
const db = await getDb();
const [[{ count }]] = await db.query(
const pool = getPool();
const [[{ count }]] = await pool.query(
`SELECT COUNT(*) as count FROM notifications
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0]
@@ -207,7 +204,7 @@ const notificationModel = {
try {
const cache = require('../utils/cache');
await cache.set(cacheKey, count, 30); // TTL 30초
await cache.set(cacheKey, count, 30);
} catch (e) { /* 무시 */ }
return count;
@@ -226,11 +223,9 @@ const notificationModel = {
async createRepairNotification(repairData) {
const { equipment_id, equipment_name, repair_type, request_id, created_by } = repairData;
// 수리 알림 수신자 목록 가져오기
const recipientIds = await getRecipientIds('repair');
if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림 (user_id = null)
const id = await this.create({
type: 'repair',
title: `수리 신청: ${equipment_name || '설비'}`,
@@ -240,7 +235,6 @@ const notificationModel = {
reference_id: request_id,
created_by
});
// Push (broadcast)
sendPushToUsers([], {
title: `수리 신청: ${equipment_name || '설비'}`,
body: `${repair_type} 수리가 신청되었습니다.`,
@@ -249,7 +243,6 @@ const notificationModel = {
return id;
}
// 지정된 수신자 각각에게 알림 생성
const results = [];
for (const userId of recipientIds) {
const notificationId = await this.create({
@@ -265,7 +258,6 @@ const notificationModel = {
results.push(notificationId);
}
// Push 전송
sendPushToUsers(recipientIds, {
title: `수리 신청: ${equipment_name || '설비'}`,
body: `${repair_type} 수리가 신청되었습니다.`,
@@ -279,11 +271,9 @@ const notificationModel = {
async createTypedNotification(notificationData) {
const { type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
// 해당 유형의 수신자 목록 가져오기
const recipientIds = await getRecipientIds(type);
if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림
const id = await this.create({
type,
title,
@@ -293,12 +283,10 @@ const notificationModel = {
reference_id,
created_by
});
// Push (broadcast)
sendPushToUsers([], { title, body: message || '', url: link_url || '/' });
return id;
}
// 지정된 수신자 각각에게 알림 생성
const results = [];
for (const userId of recipientIds) {
const notificationId = await this.create({
@@ -314,7 +302,6 @@ const notificationModel = {
results.push(notificationId);
}
// Push 전송
sendPushToUsers(recipientIds, { title, body: message || '', url: link_url || '/' });
return results;

View File

@@ -1,11 +1,11 @@
// models/pushSubscriptionModel.js
const { getDb } = require('../dbPool');
const { getPool } = require('./userModel');
const pushSubscriptionModel = {
async subscribe(userId, subscription) {
const db = await getDb();
const pool = getPool();
const { endpoint, keys } = subscription;
await db.query(
await pool.query(
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), p256dh = VALUES(p256dh), auth = VALUES(auth)`,
@@ -14,13 +14,13 @@ const pushSubscriptionModel = {
},
async unsubscribe(endpoint) {
const db = await getDb();
await db.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]);
const pool = getPool();
await pool.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]);
},
async getByUserId(userId) {
const db = await getDb();
const [rows] = await db.query(
const pool = getPool();
const [rows] = await pool.query(
'SELECT * FROM push_subscriptions WHERE user_id = ?',
[userId]
);
@@ -29,8 +29,8 @@ const pushSubscriptionModel = {
async getByUserIds(userIds) {
if (!userIds || userIds.length === 0) return [];
const db = await getDb();
const [rows] = await db.query(
const pool = getPool();
const [rows] = await pool.query(
'SELECT * FROM push_subscriptions WHERE user_id IN (?)',
[userIds]
);
@@ -38,22 +38,22 @@ const pushSubscriptionModel = {
},
async getAll() {
const db = await getDb();
const [rows] = await db.query('SELECT * FROM push_subscriptions');
const pool = getPool();
const [rows] = await pool.query('SELECT * FROM push_subscriptions');
return rows;
},
async deleteByEndpoint(endpoint) {
const db = await getDb();
await db.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]);
const pool = getPool();
await pool.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]);
},
// === ntfy 구독 관련 ===
async getNtfyUserIds(userIds) {
if (!userIds || userIds.length === 0) return [];
const db = await getDb();
const [rows] = await db.query(
const pool = getPool();
const [rows] = await pool.query(
'SELECT user_id FROM ntfy_subscriptions WHERE user_id IN (?)',
[userIds]
);
@@ -61,27 +61,27 @@ const pushSubscriptionModel = {
},
async getAllNtfyUserIds() {
const db = await getDb();
const [rows] = await db.query('SELECT user_id FROM ntfy_subscriptions');
const pool = getPool();
const [rows] = await pool.query('SELECT user_id FROM ntfy_subscriptions');
return rows.map(r => r.user_id);
},
async ntfySubscribe(userId) {
const db = await getDb();
await db.query(
const pool = getPool();
await pool.query(
'INSERT IGNORE INTO ntfy_subscriptions (user_id) VALUES (?)',
[userId]
);
},
async ntfyUnsubscribe(userId) {
const db = await getDb();
await db.query('DELETE FROM ntfy_subscriptions WHERE user_id = ?', [userId]);
const pool = getPool();
await pool.query('DELETE FROM ntfy_subscriptions WHERE user_id = ?', [userId]);
},
async isNtfySubscribed(userId) {
const db = await getDb();
const [rows] = await db.query(
const pool = getPool();
const [rows] = await pool.query(
'SELECT 1 FROM ntfy_subscriptions WHERE user_id = ? LIMIT 1',
[userId]
);

View File

@@ -13,6 +13,8 @@
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.14.1"
"mysql2": "^3.14.1",
"node-cache": "^5.1.2",
"web-push": "^3.6.7"
}
}

View File

@@ -2,7 +2,7 @@
const express = require('express');
const router = express.Router();
const notificationController = require('../controllers/notificationController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
const { requireAuth, requireMinLevel } = require('../middleware/auth');
// 내부 서비스용 알림 생성 (X-Internal-Service-Key 인증, JWT 불필요)
router.post('/internal', notificationController.createInternal);

View File

@@ -2,7 +2,7 @@
const express = require('express');
const router = express.Router();
const pushController = require('../controllers/pushSubscriptionController');
const { requireAuth } = require('../middlewares/auth');
const { requireAuth } = require('../middleware/auth');
// VAPID 공개키 (인증 불필요)
router.get('/vapid-public-key', pushController.getVapidPublicKey);

View File

@@ -0,0 +1,39 @@
// utils/cache.js - NodeCache 기반 간소화 캐시
const NodeCache = require('node-cache');
const memoryCache = new NodeCache({
stdTTL: 600,
checkperiod: 120,
useClones: false
});
const get = async (key) => {
try {
return memoryCache.get(key) || null;
} catch (error) {
console.warn(`캐시 조회 오류 (${key}):`, error.message);
return null;
}
};
const set = async (key, value, ttl = 600) => {
try {
memoryCache.set(key, value, ttl);
return true;
} catch (error) {
console.warn(`캐시 저장 오류 (${key}):`, error.message);
return false;
}
};
const del = async (key) => {
try {
memoryCache.del(key);
return true;
} catch (error) {
console.warn(`캐시 삭제 오류 (${key}):`, error.message);
return false;
}
};
module.exports = { get, set, del };

View File

@@ -211,7 +211,7 @@ async function init() {
/* ===== 알림 벨 ===== */
function _loadNotificationBell() {
const s = document.createElement('script');
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=3';
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=4';
document.head.appendChild(s);
}