feat: 실시간 알림 시스템 (Web Push + 알림 벨 + 서비스간 알림 연동)

- Phase 1: 모든 서비스 헤더에 알림 벨 UI 추가 (notification-bell.js)
- Phase 2: VAPID Web Push 구독/전송 (push-sw.js, pushSubscription API)
- Phase 3: 내부 알림 API + notifyHelper로 서비스간 알림 연동
  - tksafety: 출입 승인/반려, 안전교육 완료, 방문자 체크인
  - tkpurchase: 일용공 신청, 작업보고서 제출
  - system2-report: 신고 접수/확인/처리완료
- Phase 4: 30일 이상 알림 자동 정리 cron, Redis 캐싱
- CORS에 tkuser/tkpurchase/tksafety 서브도메인 추가
- HTML cache busting 버전 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-13 15:01:44 +09:00
parent 1ad82fd52c
commit 7fd646e9ba
102 changed files with 1446 additions and 94 deletions

View File

@@ -103,6 +103,10 @@ 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}
volumes:
- system1_uploads:/usr/src/app/uploads
- system1_logs:/usr/src/app/logs
@@ -172,6 +176,7 @@ services:
- M_PROJECT_USERNAME=${M_PROJECT_USERNAME:-api_service}
- M_PROJECT_PASSWORD=${M_PROJECT_PASSWORD:-}
- M_PROJECT_DEFAULT_PROJECT_ID=${M_PROJECT_DEFAULT_PROJECT_ID:-1}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
volumes:
- system2_uploads:/usr/src/app/uploads
- system2_logs:/usr/src/app/logs
@@ -306,6 +311,7 @@ services:
- DB_PASSWORD=${MYSQL_PASSWORD}
- DB_NAME=${MYSQL_DATABASE:-hyungi}
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
depends_on:
mariadb:
condition: service_healthy
@@ -346,6 +352,7 @@ services:
- DB_PASSWORD=${MYSQL_PASSWORD}
- DB_NAME=${MYSQL_DATABASE:-hyungi}
- SSO_JWT_SECRET=${SSO_JWT_SECRET}
- INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY}
depends_on:
mariadb:
condition: service_healthy

View File

@@ -0,0 +1,410 @@
/**
* 공유 알림 벨 — 모든 서비스 헤더에 자동 삽입
*
* 사용법: initAuth() 성공 후 동적 <script> 로드
* const s = document.createElement('script');
* s.src = '/shared/notification-bell.js?v=1';
* document.head.appendChild(s);
*
* 요구사항: SSOAuth (nav-header.js) 또는 getToken() 함수 존재
*/
(function () {
'use strict';
/* ========== Config ========== */
var POLL_INTERVAL = 60000; // 60초
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';
})();
var API_BASE = API_ORIGIN + '/api/notifications';
var PUSH_API_BASE = API_ORIGIN + '/api/push';
/* ========== Token helper ========== */
function _getToken() {
if (window.SSOAuth && window.SSOAuth.getToken) return window.SSOAuth.getToken();
if (typeof getToken === 'function') return getToken();
// cookie fallback
var m = document.cookie.match(/(?:^|; )sso_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
function _authFetch(url, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
var token = _getToken();
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
return fetch(url, opts);
}
/* ========== State ========== */
var pollTimer = null;
var unreadCount = 0;
var dropdownOpen = false;
var pushSubscribed = false;
/* ========== UI: Bell injection ========== */
function injectBell() {
var header = document.querySelector('header');
if (!header) return;
// 벨 컨테이너
var wrapper = document.createElement('div');
wrapper.id = 'notif-bell-wrapper';
wrapper.style.cssText = 'position:relative;display:inline-flex;align-items:center;cursor:pointer;margin-left:12px;';
wrapper.innerHTML =
'<div id="notif-bell-btn" style="position:relative;padding:6px;" title="알림">' +
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#4B5563;">' +
'<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>' +
'<path d="M13.73 21a2 2 0 0 1-3.46 0"/>' +
'</svg>' +
'<span id="notif-badge" style="display:none;position:absolute;top:0;right:0;background:#EF4444;color:#fff;font-size:11px;font-weight:600;min-width:18px;height:18px;line-height:18px;text-align:center;border-radius:9px;padding:0 4px;">0</span>' +
'</div>' +
'<div id="notif-dropdown" style="display:none;position:absolute;top:100%;right:0;width:340px;max-height:420px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;box-shadow:0 10px 25px rgba(0,0,0,.15);z-index:9999;overflow:hidden;margin-top:4px;">' +
'<div style="padding:12px 16px;border-bottom:1px solid #F3F4F6;display:flex;justify-content:space-between;align-items:center;">' +
'<span style="font-weight:600;font-size:14px;color:#111827;">알림</span>' +
'<div style="display:flex;gap:8px;align-items:center;">' +
'<button id="notif-push-toggle" style="font-size:12px;color:#6B7280;background:none;border:1px solid #D1D5DB;border-radius:4px;padding:2px 8px;cursor:pointer;" title="Push 알림 설정">🔔 Push</button>' +
'<button id="notif-read-all" style="font-size:12px;color:#3B82F6;background:none;border:none;cursor:pointer;">모두 읽음</button>' +
'</div>' +
'</div>' +
'<div id="notif-list" style="max-height:300px;overflow-y:auto;"></div>' +
'<a id="notif-view-all" href="' + _getAllNotificationsUrl() + '" style="display:block;text-align:center;padding:10px;font-size:13px;color:#3B82F6;text-decoration:none;border-top:1px solid #F3F4F6;">전체보기</a>' +
'</div>';
// 삽입 위치: header 내부, 우측 영역 찾기
var rightArea = header.querySelector('.flex.items-center.gap-3') ||
header.querySelector('.flex.items-center.gap-4') ||
header.querySelector('.flex.items-center.space-x-4') ||
header.querySelector('[class*="items-center"]');
if (rightArea) {
// 로그아웃 버튼 앞에 삽입
var logoutBtn = rightArea.querySelector('button[onclick*="Logout"], button[onclick*="logout"]');
if (logoutBtn) {
rightArea.insertBefore(wrapper, logoutBtn);
} else {
rightArea.insertBefore(wrapper, rightArea.firstChild);
}
} else {
header.appendChild(wrapper);
}
// Event listeners
document.getElementById('notif-bell-btn').addEventListener('click', toggleDropdown);
document.getElementById('notif-read-all').addEventListener('click', markAllRead);
document.getElementById('notif-push-toggle').addEventListener('click', handlePushToggle);
// 외부 클릭 시 닫기
document.addEventListener('click', function (e) {
if (dropdownOpen && !wrapper.contains(e.target)) {
closeDropdown();
}
});
}
function _getAllNotificationsUrl() {
var h = window.location.hostname;
if (h.includes('technicalkorea.net')) return 'https://tkfb.technicalkorea.net/pages/admin/notifications.html';
return window.location.protocol + '//' + h + ':30080/pages/admin/notifications.html';
}
/* ========== UI: Badge ========== */
function updateBadge(count) {
unreadCount = count;
var badge = document.getElementById('notif-badge');
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
}
/* ========== UI: Dropdown ========== */
function toggleDropdown(e) {
e && e.stopPropagation();
if (dropdownOpen) { closeDropdown(); return; }
openDropdown();
}
function openDropdown() {
dropdownOpen = true;
document.getElementById('notif-dropdown').style.display = 'block';
loadNotifications();
updatePushToggleUI();
}
function closeDropdown() {
dropdownOpen = false;
document.getElementById('notif-dropdown').style.display = 'none';
}
function loadNotifications() {
var list = document.getElementById('notif-list');
if (!list) return;
list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">로딩 중...</div>';
_authFetch(API_BASE + '/unread')
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success || !data.data || data.data.length === 0) {
list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">새 알림이 없습니다</div>';
return;
}
var items = data.data.slice(0, DROPDOWN_LIMIT);
list.innerHTML = items.map(function (n) {
var timeAgo = _timeAgo(n.created_at);
var typeLabel = _typeLabel(n.type);
return '<div class="notif-item" data-id="' + n.notification_id + '" data-url="' + _escAttr(n.link_url || '') + '" style="padding:10px 16px;border-bottom:1px solid #F9FAFB;cursor:pointer;transition:background .15s;" onmouseover="this.style.background=\'#F9FAFB\'" onmouseout="this.style.background=\'transparent\'">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
'<div style="flex:1;min-width:0;">' +
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
'<span style="font-size:10px;background:#EFF6FF;color:#3B82F6;padding:1px 6px;border-radius:3px;font-weight:500;">' + _escHtml(typeLabel) + '</span>' +
'<span style="font-size:11px;color:#9CA3AF;">' + _escHtml(timeAgo) + '</span>' +
'</div>' +
'<div style="font-size:13px;font-weight:500;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + _escHtml(n.title) + '</div>' +
(n.message ? '<div style="font-size:12px;color:#6B7280;margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + _escHtml(n.message) + '</div>' : '') +
'</div>' +
'<div style="width:8px;height:8px;border-radius:50%;background:#3B82F6;flex-shrink:0;margin-top:6px;margin-left:8px;"></div>' +
'</div>' +
'</div>';
}).join('');
// 클릭 이벤트
list.querySelectorAll('.notif-item').forEach(function (el) {
el.addEventListener('click', function () {
var id = el.getAttribute('data-id');
var url = el.getAttribute('data-url');
_authFetch(API_BASE + '/' + id + '/read', { method: 'POST' })
.then(function () { fetchUnreadCount(); })
.catch(function () {});
if (url) {
// 같은 서비스 내 URL이면 직접 이동, 아니면 새 탭
if (url.startsWith('/')) window.location.href = url;
else window.open(url, '_blank');
}
closeDropdown();
});
});
})
.catch(function () {
list.innerHTML = '<div style="padding:20px;text-align:center;color:#EF4444;font-size:13px;">알림을 불러올 수 없습니다</div>';
});
}
function markAllRead(e) {
e && e.stopPropagation();
_authFetch(API_BASE + '/read-all', { method: 'POST' })
.then(function () {
updateBadge(0);
var list = document.getElementById('notif-list');
if (list) list.innerHTML = '<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">새 알림이 없습니다</div>';
})
.catch(function () {});
}
/* ========== Polling ========== */
function fetchUnreadCount() {
_authFetch(API_BASE + '/unread/count')
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) updateBadge(data.data.count);
})
.catch(function () {});
}
function startPolling() {
fetchUnreadCount();
pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
/* ========== Web Push ========== */
function handlePushToggle(e) {
e && e.stopPropagation();
if (pushSubscribed) {
unsubscribePush();
} else {
subscribePush();
}
}
function subscribePush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
alert('이 브라우저는 Push 알림을 지원하지 않습니다.');
return;
}
// VAPID 공개키 가져오기
fetch(PUSH_API_BASE + '/vapid-public-key')
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success || !data.data.vapidPublicKey) {
alert('Push 설정을 불러올 수 없습니다.');
return;
}
var vapidKey = data.data.vapidPublicKey;
return navigator.serviceWorker.getRegistration('/').then(function (reg) {
if (!reg) {
// push-sw.js 등록
return navigator.serviceWorker.register('/push-sw.js', { scope: '/' });
}
return reg;
}).then(function (reg) {
return reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(vapidKey)
});
}).then(function (subscription) {
// 서버에 구독 저장
return _authFetch(PUSH_API_BASE + '/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: subscription.toJSON() })
});
}).then(function (r) { return r.json(); })
.then(function (result) {
if (result.success) {
pushSubscribed = true;
stopPolling(); // Push 구독 성공 → 폴링 중단
updatePushToggleUI();
}
});
})
.catch(function (err) {
console.error('[notification-bell] Push subscribe error:', err);
if (err.name === 'NotAllowedError') {
alert('알림 권한이 거부되었습니다. 브라우저 설정에서 허용해주세요.');
}
});
}
function unsubscribePush() {
navigator.serviceWorker.getRegistration('/').then(function (reg) {
if (!reg) return;
return reg.pushManager.getSubscription();
}).then(function (sub) {
if (!sub) return;
var endpoint = sub.endpoint;
return sub.unsubscribe().then(function () {
return _authFetch(PUSH_API_BASE + '/unsubscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: endpoint })
});
});
}).then(function () {
pushSubscribed = false;
startPolling(); // Push 해제 → 폴링 복구
updatePushToggleUI();
}).catch(function (err) {
console.error('[notification-bell] Push unsubscribe error:', err);
});
}
function checkPushStatus() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
navigator.serviceWorker.getRegistration('/').then(function (reg) {
if (!reg) return;
return reg.pushManager.getSubscription();
}).then(function (sub) {
if (sub) {
pushSubscribed = true;
stopPolling(); // 이미 구독 상태면 폴링 불필요
// Push로 뱃지만 갱신 (초기 1회)
fetchUnreadCount();
}
}).catch(function () {});
}
function updatePushToggleUI() {
var btn = document.getElementById('notif-push-toggle');
if (!btn) return;
if (pushSubscribed) {
btn.textContent = '🔕 Push 해제';
btn.style.borderColor = '#EF4444';
btn.style.color = '#EF4444';
} else {
btn.textContent = '🔔 Push';
btn.style.borderColor = '#D1D5DB';
btn.style.color = '#6B7280';
}
}
/* ========== Push SW message handler ========== */
function listenForPushMessages() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.addEventListener('message', function (e) {
if (e.data && e.data.type === 'NOTIFICATION_RECEIVED') {
// Push 수신 시 뱃지 즉시 갱신
fetchUnreadCount();
}
});
}
/* ========== Helpers ========== */
function _timeAgo(dateStr) {
if (!dateStr) return '';
var now = Date.now();
var then = new Date(dateStr).getTime();
var diff = Math.floor((now - then) / 1000);
if (diff < 60) return '방금 전';
if (diff < 3600) return Math.floor(diff / 60) + '분 전';
if (diff < 86400) return Math.floor(diff / 3600) + '시간 전';
if (diff < 604800) return Math.floor(diff / 86400) + '일 전';
return dateStr.substring(0, 10);
}
function _typeLabel(type) {
var map = { system: '시스템', repair: '수리', safety: '안전', maintenance: '구매' };
return map[type] || type || '알림';
}
function _escHtml(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function _escAttr(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _urlBase64ToUint8Array(base64String) {
var padding = '='.repeat((4 - base64String.length % 4) % 4);
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
var raw = atob(base64);
var arr = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}
/* ========== Init ========== */
function init() {
// 토큰 없으면 동작하지 않음
if (!_getToken()) return;
injectBell();
startPolling();
checkPushStatus();
listenForPushMessages();
}
// DOM ready 또는 즉시 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -13,9 +13,12 @@ const logger = require('../utils/logger');
* 허용된 Origin 목록
*/
const allowedOrigins = [
'https://tkfb.technicalkorea.net', // Gateway (프로덕션)
'https://tkreport.technicalkorea.net', // System 2
'https://tkqc.technicalkorea.net', // System 3
'https://tkfb.technicalkorea.net', // Gateway (프로덕션)
'https://tkreport.technicalkorea.net', // System 2
'https://tkqc.technicalkorea.net', // System 3
'https://tkuser.technicalkorea.net', // User Management
'https://tkpurchase.technicalkorea.net', // Purchase Management
'https://tksafety.technicalkorea.net', // Safety Management
'http://localhost:20000', // 웹 UI (로컬)
'http://localhost:30080', // 웹 UI (Docker)
'http://localhost:3005', // API 서버
@@ -77,7 +80,7 @@ const corsOptions = {
/**
* 허용된 헤더
*/
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Internal-Service-Key'],
/**
* 노출할 헤더

View File

@@ -51,6 +51,7 @@ function setupRoutes(app) {
const departmentRoutes = require('../routes/departmentRoutes');
const patrolRoutes = require('../routes/patrolRoutes');
const notificationRoutes = require('../routes/notificationRoutes');
const pushSubscriptionRoutes = require('../routes/pushSubscriptionRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
@@ -105,7 +106,9 @@ function setupRoutes(app) {
'/api/setup/migrate-existing-data',
'/api/setup/check-data-status',
'/api/monthly-status/calendar',
'/api/monthly-status/daily-details'
'/api/monthly-status/daily-details',
'/api/push/vapid-public-key',
'/api/notifications/internal'
];
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
@@ -157,6 +160,7 @@ function setupRoutes(app) {
app.use('/api/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
app.use('/api/notifications', notificationRoutes); // 알림 시스템
app.use('/api/push', pushSubscriptionRoutes); // Push 구독
app.use('/api', uploadBgRoutes);
// Swagger API 문서

View File

@@ -159,6 +159,44 @@ const notificationController = {
message: '알림 생성 중 오류가 발생했습니다.'
});
}
},
// 내부 서비스용 알림 생성 (X-Internal-Service-Key 인증)
async createInternal(req, res) {
try {
const serviceKey = req.headers['x-internal-service-key'];
if (!serviceKey || serviceKey !== process.env.INTERNAL_SERVICE_KEY) {
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
}
const { type, title, message, link_url, reference_type, reference_id, created_by } = req.body;
if (!title) {
return res.status(400).json({ success: false, message: '알림 제목은 필수입니다.' });
}
const results = await notificationModel.createTypedNotification({
type: type || 'system',
title,
message,
link_url,
reference_type,
reference_id,
created_by
});
res.json({
success: true,
message: '알림이 생성되었습니다.',
data: { notification_ids: Array.isArray(results) ? results : [results] }
});
} catch (error) {
console.error('내부 알림 생성 오류:', error);
res.status(500).json({
success: false,
message: '알림 생성 중 오류가 발생했습니다.'
});
}
}
};

View File

@@ -0,0 +1,49 @@
// controllers/pushSubscriptionController.js
const pushSubscriptionModel = require('../models/pushSubscriptionModel');
const pushSubscriptionController = {
// VAPID 공개키 반환 (인증 불필요)
async getVapidPublicKey(req, res) {
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
if (!vapidPublicKey) {
return res.status(500).json({ success: false, message: 'VAPID 키가 설정되지 않았습니다.' });
}
res.json({ success: true, data: { vapidPublicKey } });
},
// Push 구독 저장
async subscribe(req, res) {
try {
const userId = req.user?.id;
const { subscription } = req.body;
if (!subscription || !subscription.endpoint || !subscription.keys) {
return res.status(400).json({ success: false, message: '유효한 구독 정보가 필요합니다.' });
}
await pushSubscriptionModel.subscribe(userId, subscription);
res.json({ success: true, message: 'Push 구독이 등록되었습니다.' });
} catch (error) {
console.error('Push 구독 오류:', error);
res.status(500).json({ success: false, message: 'Push 구독 중 오류가 발생했습니다.' });
}
},
// Push 구독 해제
async unsubscribe(req, res) {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ success: false, message: 'endpoint가 필요합니다.' });
}
await pushSubscriptionModel.unsubscribe(endpoint);
res.json({ success: true, message: 'Push 구독이 해제되었습니다.' });
} catch (error) {
console.error('Push 구독 해제 오류:', error);
res.status(500).json({ success: false, message: 'Push 구독 해제 중 오류가 발생했습니다.' });
}
}
};
module.exports = pushSubscriptionController;

View File

@@ -0,0 +1,13 @@
-- Push 구독 테이블 생성
-- 실행: docker exec -i tk-mariadb mysql -uhyungi_user -p'hyung-ddfdf3-D341@' hyungi < db/migrations/20260313001000_create_push_subscriptions.sql
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
endpoint VARCHAR(1000) NOT NULL,
p256dh VARCHAR(500) NOT NULL,
auth VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_endpoint (endpoint(500)),
INDEX idx_push_user (user_id)
);

View File

@@ -120,4 +120,21 @@ 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,60 @@
// models/notificationModel.js
const { getDb } = require('../dbPool');
// Web Push (lazy init)
let webpush = null;
let vapidConfigured = false;
function getWebPush() {
if (!webpush) {
try {
webpush = require('web-push');
if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) {
webpush.setVapidDetails(
process.env.VAPID_SUBJECT || 'mailto:admin@technicalkorea.net',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
vapidConfigured = true;
}
} catch (e) {
console.warn('[notifications] web-push 모듈 로드 실패:', e.message);
}
}
return vapidConfigured ? webpush : null;
}
// Push 전송 헬퍼 — 알림 생성 후 호출
async function sendPushToUsers(userIds, payload) {
const wp = getWebPush();
if (!wp) return;
try {
const pushModel = require('./pushSubscriptionModel');
const subscriptions = userIds && userIds.length > 0
? await pushModel.getByUserIds(userIds)
: await pushModel.getAll(); // broadcast
const payloadStr = JSON.stringify(payload);
for (const sub of subscriptions) {
try {
await wp.sendNotification({
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth }
}, payloadStr);
} catch (err) {
// 만료 구독 (410 Gone, 404 Not Found) 자동 정리
if (err.statusCode === 410 || err.statusCode === 404) {
await pushModel.deleteByEndpoint(sub.endpoint).catch(() => {});
}
}
}
} catch (e) {
console.error('[notifications] Push 전송 오류:', e.message);
}
}
// 순환 참조를 피하기 위해 함수 내에서 require
async function getRecipientIds(notificationType) {
const db = await getDb();
@@ -24,6 +78,9 @@ const notificationModel = {
[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;
},
@@ -66,10 +123,13 @@ 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(
`UPDATE notifications SET is_read = 1, read_at = NOW() WHERE notification_id = ?`,
[notificationId]
);
if (row) this._invalidateCache(row.user_id);
return result.affectedRows > 0;
},
@@ -81,6 +141,7 @@ const notificationModel = {
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
this._invalidateCache(userId);
return result.affectedRows;
},
@@ -104,17 +165,39 @@ const notificationModel = {
return result.affectedRows;
},
// 읽지 않은 알림 개수
// 읽지 않은 알림 개수 (캐싱)
async getUnreadCount(userId = null) {
const cacheKey = `notif:unread:${userId || 0}`;
try {
const cache = require('../utils/cache');
const cached = await cache.get(cacheKey);
if (cached !== null && cached !== undefined) return cached;
} catch (e) { /* 무시 */ }
const db = await getDb();
const [[{ count }]] = await db.query(
`SELECT COUNT(*) as count FROM notifications
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
try {
const cache = require('../utils/cache');
await cache.set(cacheKey, count, 30); // TTL 30초
} catch (e) { /* 무시 */ }
return count;
},
// 캐시 무효화
_invalidateCache(userId) {
try {
const cache = require('../utils/cache');
cache.del(`notif:unread:${userId || 0}`).catch(() => {});
if (userId) cache.del('notif:unread:0').catch(() => {});
} catch (e) { /* 무시 */ }
},
// 수리 신청 알림 생성 헬퍼 (지정된 수신자에게 전송)
async createRepairNotification(repairData) {
const { equipment_id, equipment_name, repair_type, request_id, created_by } = repairData;
@@ -124,7 +207,7 @@ const notificationModel = {
if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림 (user_id = null)
return await this.create({
const id = await this.create({
type: 'repair',
title: `수리 신청: ${equipment_name || '설비'}`,
message: `${repair_type} 수리가 신청되었습니다.`,
@@ -133,6 +216,13 @@ const notificationModel = {
reference_id: request_id,
created_by
});
// Push (broadcast)
sendPushToUsers([], {
title: `수리 신청: ${equipment_name || '설비'}`,
body: `${repair_type} 수리가 신청되었습니다.`,
url: `/pages/admin/repair-management.html`
});
return id;
}
// 지정된 수신자 각각에게 알림 생성
@@ -151,6 +241,13 @@ const notificationModel = {
results.push(notificationId);
}
// Push 전송
sendPushToUsers(recipientIds, {
title: `수리 신청: ${equipment_name || '설비'}`,
body: `${repair_type} 수리가 신청되었습니다.`,
url: `/pages/admin/repair-management.html`
});
return results;
},
@@ -163,7 +260,7 @@ const notificationModel = {
if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림
return await this.create({
const id = await this.create({
type,
title,
message,
@@ -172,6 +269,9 @@ const notificationModel = {
reference_id,
created_by
});
// Push (broadcast)
sendPushToUsers([], { title, body: message || '', url: link_url || '/' });
return id;
}
// 지정된 수신자 각각에게 알림 생성
@@ -190,6 +290,9 @@ const notificationModel = {
results.push(notificationId);
}
// Push 전송
sendPushToUsers(recipientIds, { title, body: message || '', url: link_url || '/' });
return results;
}
};

View File

@@ -0,0 +1,52 @@
// models/pushSubscriptionModel.js
const { getDb } = require('../dbPool');
const pushSubscriptionModel = {
async subscribe(userId, subscription) {
const db = await getDb();
const { endpoint, keys } = subscription;
await db.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)`,
[userId, endpoint, keys.p256dh, keys.auth]
);
},
async unsubscribe(endpoint) {
const db = await getDb();
await db.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]);
},
async getByUserId(userId) {
const db = await getDb();
const [rows] = await db.query(
'SELECT * FROM push_subscriptions WHERE user_id = ?',
[userId]
);
return rows;
},
async getByUserIds(userIds) {
if (!userIds || userIds.length === 0) return [];
const db = await getDb();
const [rows] = await db.query(
'SELECT * FROM push_subscriptions WHERE user_id IN (?)',
[userIds]
);
return rows;
},
async getAll() {
const db = await getDb();
const [rows] = await db.query('SELECT * FROM push_subscriptions');
return rows;
},
async deleteByEndpoint(endpoint) {
const db = await getDb();
await db.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]);
}
};
module.exports = pushSubscriptionModel;

View File

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

View File

@@ -4,7 +4,10 @@ const router = express.Router();
const notificationController = require('../controllers/notificationController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
// 모든 알림 라우트는 인증 필요
// 내부 서비스용 알림 생성 (X-Internal-Service-Key 인증, JWT 불필요)
router.post('/internal', notificationController.createInternal);
// 이하 모든 라우트는 JWT 인증 필요
router.use(requireAuth);
// 읽지 않은 알림 조회 (본인 알림만)

View File

@@ -0,0 +1,14 @@
// routes/pushSubscriptionRoutes.js
const express = require('express');
const router = express.Router();
const pushController = require('../controllers/pushSubscriptionController');
const { requireAuth } = require('../middlewares/auth');
// VAPID 공개키 (인증 불필요)
router.get('/vapid-public-key', pushController.getVapidPublicKey);
// 구독/해제 (인증 필요)
router.post('/subscribe', requireAuth, pushController.subscribe);
router.delete('/unsubscribe', requireAuth, pushController.unsubscribe);
module.exports = router;

View File

@@ -190,7 +190,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">

View File

@@ -324,7 +324,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="/js/department-management.js"></script>
<script>initAuth();</script>

View File

@@ -314,7 +314,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">

View File

@@ -190,7 +190,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">

View File

@@ -329,7 +329,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script type="module" src="/js/issue-category-manage.js"></script>
<script>initAuth();</script>

View File

@@ -375,7 +375,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script>
let currentPage = 1;

View File

@@ -384,7 +384,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script>
let allProjects = [];

View File

@@ -487,7 +487,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script>
let currentReportId = null;

View File

@@ -285,7 +285,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script>
let workTypes = [];

View File

@@ -431,7 +431,7 @@
</div>
<!-- 작업장 관리 모듈 (리팩토링된 구조) -->
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="/js/workplace-management/state.js?v=1"></script>
<script src="/js/workplace-management/utils.js?v=1"></script>

View File

@@ -328,7 +328,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>

View File

@@ -222,7 +222,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>

View File

@@ -70,7 +70,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>

View File

@@ -474,7 +474,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>

View File

@@ -265,7 +265,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>

View File

@@ -353,7 +353,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script type="module" src="/js/vacation-allocation.js" defer></script>
<script>initAuth();</script>

View File

@@ -123,7 +123,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>

View File

@@ -123,7 +123,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>

View File

@@ -205,7 +205,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>

View File

@@ -117,7 +117,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>

View File

@@ -276,7 +276,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>

View File

@@ -138,7 +138,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/static/js/tkfb-dashboard.js"></script>
</body>
</html>

View File

@@ -323,7 +323,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script type="module" src="/js/modern-dashboard.js?v=10"></script>
<script type="module" src="/js/group-leader-dashboard.js?v=1"></script>

View File

@@ -209,7 +209,7 @@
}, 50);
})();
</script>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="/js/daily-patrol.js?v=6"></script>
<script>initAuth();</script>

View File

@@ -304,7 +304,7 @@
}, 50);
})();
</script>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="/js/zone-detail.js?v=6"></script>
<script>initAuth();</script>

View File

@@ -320,7 +320,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script type="module" src="/js/my-profile.js"></script>
<script>initAuth();</script>

View File

@@ -390,7 +390,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script type="module" src="/js/change-password.js"></script>
<script>initAuth();</script>

View File

@@ -277,7 +277,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script type="module" src="/js/work-analysis.js?v=5"></script>

View File

@@ -90,7 +90,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/static/js/tkfb-nonconformity.js"></script>
</body>
</html>

View File

@@ -189,7 +189,7 @@
</div>
<!-- 공통 모듈 -->
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="/js/common/utils.js?v=1"></script>
<script src="/js/common/base-state.js?v=1"></script>

View File

@@ -149,7 +149,7 @@
</div>
</div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="/js/common/utils.js?v=1"></script>
<script src="/js/common/base-state.js?v=1"></script>

View File

@@ -843,7 +843,7 @@
</div>
<!-- Scripts -->
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<!-- 공통 모듈 -->
<script src="/js/common/utils.js?v=2"></script>

View File

@@ -296,7 +296,7 @@
</div>
<!-- 공통 모듈 -->
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="/js/common/utils.js?v=2"></script>
<script src="/js/common/base-state.js?v=2"></script>

View File

@@ -560,7 +560,7 @@
<!-- 토스트 -->
<div class="toast-container" id="toastContainer"></div>
<script src="/static/js/tkfb-core.js"></script>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/js/api-base.js?v=3"></script>
<script src="/js/common/utils.js?v=2"></script>
<script src="/js/common/base-state.js?v=2"></script>

View File

@@ -0,0 +1,41 @@
// 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

@@ -1,6 +1,8 @@
/* ===== 서비스 워커 해제 ===== */
/* ===== 서비스 워커 해제 (push-sw.js 제외) ===== */
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); });
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) {
if (!r.active || !r.active.scriptURL.includes('push-sw.js')) { r.unregister(); }
}); });
if (typeof caches !== 'undefined') { caches.keys().then(function(ns) { ns.forEach(function(n) { caches.delete(n); }); }); }
}
@@ -276,6 +278,16 @@ async function initAuth() {
const overlay = document.getElementById('mobileOverlay');
if (overlay) overlay.addEventListener('click', toggleMobileMenu);
// 알림 벨 로드
_loadNotificationBell();
setTimeout(() => document.querySelector('.fade-in')?.classList.add('visible'), 50);
return true;
}
/* ===== 알림 벨 ===== */
function _loadNotificationBell() {
const s = document.createElement('script');
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=1';
document.head.appendChild(s);
}

View File

@@ -5,6 +5,7 @@
const workIssueModel = require('../models/workIssueModel');
const imageUploadService = require('../services/imageUploadService');
const mProjectService = require('../services/mProjectService');
const notify = require('../utils/notifyHelper');
// ==================== 신고 카테고리 관리 ====================
@@ -191,6 +192,17 @@ exports.createReport = async (req, res) => {
const reportId = await workIssueModel.createReport(reportData);
// 알림: 신고 접수
notify.send({
type: 'safety',
title: '신고 접수',
message: `${catInfo ? catInfo.category_name : '문제'} 신고가 접수되었습니다.`,
link_url: '/pages/safety-report-list.html',
reference_type: 'work_issue_reports',
reference_id: reportId,
created_by: req.user.id || req.user.user_id
});
res.status(201).json({
success: true,
message: '문제 신고가 등록되었습니다.',
@@ -398,6 +410,18 @@ exports.receiveReport = async (req, res) => {
try {
const { id } = req.params;
await workIssueModel.receiveReport(id, req.user.user_id);
// 알림: 신고 상태 변경 → 접수됨
notify.send({
type: 'safety',
title: '신고 접수 확인',
message: '신고가 접수되어 처리가 시작됩니다.',
link_url: '/pages/safety-report-list.html',
reference_type: 'work_issue_reports',
reference_id: parseInt(id),
created_by: req.user.user_id
});
res.json({ success: true, message: '신고가 접수되었습니다.' });
} catch (err) {
console.error('신고 접수 실패:', err);
@@ -442,6 +466,18 @@ exports.completeReport = async (req, res) => {
if (resolution_photos[1]) resolution_photo_path2 = await imageUploadService.saveBase64Image(resolution_photos[1], 'resolution');
await workIssueModel.completeReport(id, { resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by: req.user.user_id });
// 알림: 신고 처리 완료
notify.send({
type: 'safety',
title: '신고 처리 완료',
message: '신고 처리가 완료되었습니다.',
link_url: '/pages/safety-report-list.html',
reference_type: 'work_issue_reports',
reference_id: parseInt(id),
created_by: req.user.user_id
});
res.json({ success: true, message: '처리가 완료되었습니다.' });
} catch (err) {
console.error('처리 완료 실패:', err);

View File

@@ -0,0 +1,63 @@
// utils/notifyHelper.js — 공용 알림 헬퍼
// system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http');
const NOTIFY_URL = 'http://system1-api:3005/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,10 +1,14 @@
// /js/api-base.js
// API 기본 설정 및 보안 유틸리티 - System 2 (신고 시스템)
// 서비스 워커 해제 (캐시 간섭으로 인한 인증 루프 방지)
// 서비스 워커 해제 (push-sw.js 제외)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(registrations) {
registrations.forEach(function(registration) { registration.unregister(); });
registrations.forEach(function(registration) {
if (!registration.active || !registration.active.scriptURL.includes('push-sw.js')) {
registration.unregister();
}
});
});
if (typeof caches !== 'undefined') {
caches.keys().then(function(names) {
@@ -145,5 +149,13 @@ if ('serviceWorker' in navigator) {
return response.json();
};
// 알림 벨 로드
window._loadNotificationBell = function() {
var h = window.location.hostname;
var s = document.createElement('script');
s.src = (h.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=1';
document.head.appendChild(s);
};
console.log('[System2] API 설정 완료:', window.API_BASE_URL);
})();

View File

@@ -90,6 +90,9 @@
var token = window.getSSOToken ? window.getSSOToken() : null;
if (token && !localStorage.getItem('sso_token')) localStorage.setItem('sso_token', token);
console.log('[System2] 인증 확인:', currentUser.username);
// 알림 벨 로드
if (window._loadNotificationBell) window._loadNotificationBell();
}
// DOMContentLoaded 시 실행

View File

@@ -6,8 +6,8 @@
<title>AI 신고 도우미 | (주)테크니컬코리아</title>
<link rel="icon" type="image/png" href="/img/favicon.png">
<link rel="stylesheet" href="/css/chat-report.css?v=3">
<script src="/js/api-base.js?v=20260309"></script>
<script src="/js/app-init.js?v=20260309" defer></script>
<script src="/js/api-base.js?v=20260313"></script>
<script src="/js/app-init.js?v=20260313" defer></script>
</head>
<body>
<!-- Header -->

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js?v=20260309"></script>
<script src="/js/app-init.js?v=20260309" defer></script>
<script src="/js/api-base.js?v=20260313"></script>
<script src="/js/app-init.js?v=20260313" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 상태 배지 */

View File

@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>신고 등록 | (주)테크니컬코리아</title>
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js?v=20260309"></script>
<script src="/js/app-init.js?v=20260309" defer></script>
<script src="/js/api-base.js?v=20260313"></script>
<script src="/js/app-init.js?v=20260313" defer></script>
<style>
* { box-sizing: border-box; }
body {

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js?v=20260309"></script>
<script src="/js/app-init.js?v=20260309" defer></script>
<script src="/js/api-base.js?v=20260313"></script>
<script src="/js/app-init.js?v=20260313" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 통계 카드 */

View File

@@ -0,0 +1,41 @@
// 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

@@ -276,7 +276,7 @@
<script src="/static/js/components/common-header.js?v=20260308"></script>
<script src="/static/js/core/page-manager.js?v=20260308"></script>
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
<script src="/static/js/utils/toast.js?v=20260308"></script>
<script src="/static/js/components/mobile-bottom-nav.js?v=20260308"></script>

View File

@@ -301,6 +301,6 @@
<script src="/static/js/utils/date-utils.js"></script>
<script src="/static/js/utils/image-utils.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/app.js?v=20260313"></script>
</body>
</html>

View File

@@ -111,7 +111,7 @@
<script src="/static/js/components/common-header.js?v=20260308"></script>
<script src="/static/js/core/page-manager.js?v=20260308"></script>
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
<script src="/static/js/utils/photo-modal.js?v=20260308"></script>
<script src="/static/js/utils/toast.js?v=20260308"></script>

View File

@@ -201,7 +201,7 @@
<script src="/static/js/components/common-header.js?v=20260308"></script>
<script src="/static/js/core/page-manager.js?v=20260308"></script>
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
<script src="/static/js/utils/photo-modal.js?v=20260308"></script>
<script src="/static/js/utils/toast.js?v=20260308"></script>

View File

@@ -554,7 +554,7 @@
<script src="/static/js/components/common-header.js?v=20260308"></script>
<script src="/static/js/core/page-manager.js?v=20260308"></script>
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
<script src="/static/js/utils/photo-modal.js?v=20260308"></script>
<script src="/static/js/utils/toast.js?v=20260308"></script>

View File

@@ -373,7 +373,7 @@
<script src="/static/js/components/common-header.js?v=20260308"></script>
<script src="/static/js/core/page-manager.js?v=20260308"></script>
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script src="/static/js/components/mobile-calendar.js?v=20260308"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
<script src="/static/js/utils/photo-modal.js?v=20260308"></script>

View File

@@ -342,7 +342,7 @@
<script src="/static/js/components/common-header.js?v=20260308"></script>
<script src="/static/js/core/page-manager.js?v=20260308"></script>
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
<script src="/static/js/utils/photo-modal.js?v=20260308"></script>
<script src="/static/js/utils/toast.js?v=20260308"></script>

View File

@@ -186,7 +186,7 @@
<!-- 스크립트 -->
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script src="/static/js/core/permissions.js?v=20260308"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
<script src="/static/js/m/m-common.js?v=20260309"></script>

View File

@@ -194,7 +194,7 @@
<!-- 스크립트 -->
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script src="/static/js/core/permissions.js?v=20260308"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
<script src="/static/js/m/m-common.js?v=20260309"></script>

View File

@@ -170,7 +170,7 @@
<!-- 스크립트 -->
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script src="/static/js/core/permissions.js?v=20260308"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
<script src="/static/js/m/m-common.js?v=20260309"></script>

View File

@@ -0,0 +1,41 @@
// 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

@@ -185,7 +185,7 @@
<script src="/static/js/core/permissions.js?v=20260308"></script>
<script src="/static/js/components/common-header.js?v=20260308"></script>
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script>
let projects = [];

View File

@@ -72,7 +72,7 @@
<script src="/static/js/core/permissions.js?v=20260308"></script>
<script src="/static/js/components/common-header.js?v=20260308"></script>
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script>
// 페이지 초기화

View File

@@ -71,7 +71,7 @@
<script src="/static/js/core/permissions.js?v=20260308"></script>
<script src="/static/js/components/common-header.js?v=20260308"></script>
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script>
// 페이지 초기화

View File

@@ -173,7 +173,7 @@
<script src="/static/js/core/permissions.js?v=20260308"></script>
<script src="/static/js/components/common-header.js?v=20260308"></script>
<script src="/static/js/api.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
<script src="/static/js/core/auth-manager.js?v=20260313"></script>
<script>
// 페이지 초기화

View File

@@ -33,6 +33,9 @@ class App {
// 라우터 초기화
this.initializeRouter();
// 알림 벨 로드
this._loadNotificationBell();
// 대시보드 데이터 로드
await this.loadDashboardData();
@@ -392,6 +395,16 @@ class App {
}
}
/**
* 알림 벨 로드
*/
_loadNotificationBell() {
var h = window.location.hostname;
var s = document.createElement('script');
s.src = (h.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=1';
document.head.appendChild(s);
}
/**
* 로딩 표시
*/

View File

@@ -1,7 +1,11 @@
// 서비스 워커 해제 (캐시 간섭으로 인한 인증 루프 방지)
// 서비스 워커 해제 (push-sw.js 제외)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(registrations) {
registrations.forEach(function(registration) { registration.unregister(); });
registrations.forEach(function(registration) {
if (!registration.active || !registration.active.scriptURL.includes('push-sw.js')) {
registration.unregister();
}
});
});
if (typeof caches !== 'undefined') {
caches.keys().then(function(names) {

View File

@@ -281,7 +281,9 @@ class PagePreloader {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
if (!registration.active || !registration.active.scriptURL.includes('push-sw.js')) {
await registration.unregister();
}
}
// 모든 캐시 삭제
const cacheNames = await caches.keys();

View File

@@ -1,5 +1,6 @@
const dayLaborModel = require('../models/dayLaborModel');
const { getPool } = require('../models/partnerModel');
const notify = require('../utils/notifyHelper');
// 일용직 요청 목록
async function list(req, res) {
@@ -47,6 +48,18 @@ async function create(req, res) {
requester_id: req.user.user_id || req.user.id
};
const row = await dayLaborModel.create(data);
// 알림: 일용공 신청
notify.send({
type: 'maintenance',
title: '일용공 신청',
message: `${work_date} ${worker_count}명 일용공 신청`,
link_url: '/daylabor.html',
reference_type: 'day_labor_requests',
reference_id: row.id || row.request_id,
created_by: data.requester_id
});
res.status(201).json({ success: true, data: row });
} catch (err) {
console.error('DayLabor create error:', err);

View File

@@ -1,6 +1,7 @@
const workReportModel = require('../models/workReportModel');
const checkinModel = require('../models/checkinModel');
const ExcelJS = require('exceljs');
const notify = require('../utils/notifyHelper');
// 작업보고 목록
async function list(req, res) {
@@ -90,6 +91,18 @@ async function create(req, res) {
workers: workers || []
};
const row = await workReportModel.create(data);
// 알림: 작업 보고서 제출
notify.send({
type: 'maintenance',
title: '작업 보고서 제출',
message: `${report_date} 작업 보고서가 제출되었습니다.`,
link_url: '/workreport.html',
reference_type: 'work_reports',
reference_id: row.id || row.report_id,
created_by: data.reporter_id
});
res.status(201).json({ success: true, data: row });
} catch (err) {
console.error('WorkReport create error:', err);

View File

@@ -0,0 +1,63 @@
// utils/notifyHelper.js — 공용 알림 헬퍼
// system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http');
const NOTIFY_URL = 'http://system1-api:3005/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

@@ -130,7 +130,7 @@
</div>
</div>
<script src="/static/js/tkpurchase-core.js?v=20260312"></script>
<script src="/static/js/tkpurchase-core.js?v=20260313b"></script>
<script src="/static/js/tkpurchase-accounts.js?v=20260312"></script>
<script>initAccountsPage();</script>
</body>

View File

@@ -144,7 +144,7 @@
</div>
</div>
<script src="/static/js/tkpurchase-core.js?v=20260312"></script>
<script src="/static/js/tkpurchase-core.js?v=20260313b"></script>
<script src="/static/js/tkpurchase-daylabor.js?v=20260312"></script>
<script>initDayLaborPage();</script>
</body>

View File

@@ -85,7 +85,7 @@
</div>
</div>
<script src="/static/js/tkpurchase-core.js?v=20260312"></script>
<script src="/static/js/tkpurchase-core.js?v=20260313b"></script>
<script src="/static/js/tkpurchase-dashboard.js?v=20260312"></script>
<script>initDashboard();</script>
</body>

View File

@@ -81,7 +81,7 @@
</div>
</div>
<script src="/static/js/tkpurchase-core.js?v=20260313a"></script>
<script src="/static/js/tkpurchase-core.js?v=20260313b"></script>
<script src="/static/js/tkpurchase-partner-portal.js?v=20260313a"></script>
<script>initPartnerPortal();</script>
</body>

View File

@@ -291,7 +291,7 @@
</div>
</div>
<script src="/static/js/tkpurchase-core.js?v=20260312"></script>
<script src="/static/js/tkpurchase-core.js?v=20260313b"></script>
<script src="/static/js/tkpurchase-partner.js?v=20260312"></script>
<script>initPartnerPage();</script>
</body>

41
tkpurchase/web/push-sw.js Normal file
View File

@@ -0,0 +1,41 @@
// 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

@@ -271,7 +271,7 @@
</div>
</div>
<script src="/static/js/tkpurchase-core.js?v=20260313a"></script>
<script src="/static/js/tkpurchase-core.js?v=20260313b"></script>
<script src="/static/js/tkpurchase-schedule.js?v=20260313a"></script>
<script>initSchedulePage();</script>
</body>

View File

@@ -1,6 +1,8 @@
/* ===== 서비스 워커 해제 ===== */
/* ===== 서비스 워커 해제 (push-sw.js 제외) ===== */
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); });
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) {
if (!r.active || !r.active.scriptURL.includes('push-sw.js')) { r.unregister(); }
}); });
if (typeof caches !== 'undefined') { caches.keys().then(function(ns) { ns.forEach(function(n) { caches.delete(n); }); }); }
}
@@ -143,6 +145,17 @@ function initAuth() {
if (nameEl) nameEl.textContent = dn;
if (avatarEl) avatarEl.textContent = dn.charAt(0).toUpperCase();
renderNavbar();
// 알림 벨 로드
_loadNotificationBell();
setTimeout(() => document.querySelector('.fade-in')?.classList.add('visible'), 50);
return true;
}
/* ===== 알림 벨 ===== */
function _loadNotificationBell() {
const s = document.createElement('script');
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=1';
document.head.appendChild(s);
}

View File

@@ -109,7 +109,7 @@
</div>
</div>
<script src="/static/js/tkpurchase-core.js?v=20260312"></script>
<script src="/static/js/tkpurchase-core.js?v=20260313b"></script>
<script src="/static/js/tkpurchase-workreport.js?v=20260312"></script>
<script>initWorkReportPage();</script>
</body>

View File

@@ -1,4 +1,5 @@
const visitRequestModel = require('../models/visitRequestModel');
const notify = require('../utils/notifyHelper');
// ==================== 출입 신청 관리 ====================
@@ -104,6 +105,21 @@ exports.approveVisitRequest = async (req, res) => {
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
}
// 알림: 신청자에게 승인 알림
const request = await visitRequestModel.getVisitRequestById(req.params.id).catch(() => null);
if (request) {
notify.send({
type: 'safety',
title: '출입 신청 승인',
message: `${request.visitor_company || ''} 출입 신청이 승인되었습니다.`,
link_url: '/visit-request.html',
reference_type: 'visit_requests',
reference_id: parseInt(req.params.id),
created_by: req.user.user_id
});
}
res.json({ success: true, message: '출입 신청이 승인되었습니다.' });
} catch (err) {
console.error('출입 신청 승인 오류:', err);
@@ -121,6 +137,21 @@ exports.rejectVisitRequest = async (req, res) => {
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
}
// 알림: 신청자에게 반려 알림
const request = await visitRequestModel.getVisitRequestById(req.params.id).catch(() => null);
if (request) {
notify.send({
type: 'safety',
title: '출입 신청 반려',
message: `${request.visitor_company || ''} 출입 신청이 반려되었습니다. 사유: ${rejectionData.rejection_reason}`,
link_url: '/visit-request.html',
reference_type: 'visit_requests',
reference_id: parseInt(req.params.id),
created_by: req.user.user_id
});
}
res.json({ success: true, message: '출입 신청이 반려되었습니다.' });
} catch (err) {
console.error('출입 신청 반려 오류:', err);
@@ -273,6 +304,17 @@ exports.completeTraining = async (req, res) => {
console.error('출입 신청 상태 업데이트 오류:', statusErr);
}
// 알림: 안전교육 완료
notify.send({
type: 'safety',
title: '안전교육 완료',
message: '안전교육이 완료되었습니다.',
link_url: '/training.html',
reference_type: 'training_records',
reference_id: parseInt(trainingId),
created_by: req.user.user_id
});
res.json({ success: true, message: '안전교육이 완료되었습니다.' });
} catch (err) {
console.error('안전교육 완료 처리 오류:', err);
@@ -326,6 +368,21 @@ exports.checkIn = async (req, res) => {
if (result.error) {
return res.status(result.status).json({ success: false, message: result.error });
}
// 알림: 방문자 체크인
const request = await visitRequestModel.getVisitRequestById(req.params.id).catch(() => null);
if (request) {
notify.send({
type: 'safety',
title: '방문자 체크인',
message: `${request.visitor_company || ''} ${request.visitor_name || ''} 체크인`,
link_url: '/visit-management.html',
reference_type: 'visit_requests',
reference_id: parseInt(req.params.id),
created_by: req.user.user_id
});
}
res.json({ success: true, message: '체크인되었습니다.' });
} catch (err) {
console.error('체크인 오류:', err);

View File

@@ -0,0 +1,63 @@
// utils/notifyHelper.js — 공용 알림 헬퍼
// system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http');
const NOTIFY_URL = 'http://system1-api:3005/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

@@ -154,7 +154,7 @@
</div>
</div>
<script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-core.js?v=3"></script>
<script src="/static/js/tksafety-checklist.js"></script>
<script>initChecklistPage();</script>
</body>

View File

@@ -187,7 +187,7 @@
</div>
</div>
<script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-core.js?v=3"></script>
<script src="/static/js/tksafety-education.js?v=20260313"></script>
<script>initEducationPage();</script>
</body>

View File

@@ -103,7 +103,7 @@
</div>
</div>
<script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-core.js?v=3"></script>
<script src="/static/js/tksafety-entry-dashboard.js?v=1"></script>
<script>initEntryDashboard();</script>
</body>

View File

@@ -274,7 +274,7 @@
</div>
</div>
<script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-core.js?v=3"></script>
<script src="/static/js/tksafety-visit.js?v=20260313"></script>
<script>initVisitPage();</script>
</body>

41
tksafety/web/push-sw.js Normal file
View File

@@ -0,0 +1,41 @@
// 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

@@ -1,6 +1,8 @@
/* ===== 서비스 워커 해제 ===== */
/* ===== 서비스 워커 해제 (push-sw.js 제외) ===== */
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); });
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) {
if (!r.active || !r.active.scriptURL.includes('push-sw.js')) { r.unregister(); }
}); });
if (typeof caches !== 'undefined') { caches.keys().then(function(ns) { ns.forEach(function(n) { caches.delete(n); }); }); }
}
@@ -134,6 +136,17 @@ function initAuth() {
if (nameEl) nameEl.textContent = dn;
if (avatarEl) avatarEl.textContent = dn.charAt(0).toUpperCase();
renderNavbar();
// 알림 벨 로드
_loadNotificationBell();
setTimeout(() => document.querySelector('.fade-in')?.classList.add('visible'), 50);
return true;
}
/* ===== 알림 벨 ===== */
function _loadNotificationBell() {
const s = document.createElement('script');
s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=1';
document.head.appendChild(s);
}

View File

@@ -133,7 +133,7 @@
</div>
</div>
<script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-core.js?v=3"></script>
<script src="/static/js/tksafety-training.js"></script>
<script>initTrainingPage();</script>
</body>

View File

@@ -181,7 +181,7 @@
</div>
</div>
<script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-core.js?v=3"></script>
<script src="/static/js/tksafety-visit-management.js?v=2"></script>
<script>initVisitManagementPage();</script>
</body>

View File

@@ -145,7 +145,7 @@
</div>
</div>
<script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-core.js?v=3"></script>
<script src="/static/js/tksafety-visit-request.js?v=2"></script>
<script>initVisitRequestPage();</script>
</body>

View File

@@ -1740,7 +1740,7 @@
</div>
<!-- JS: Core (config, token, api, toast, helpers, init) -->
<script src="/static/js/tkuser-core.js?v=20260313"></script>
<script src="/static/js/tkuser-core.js?v=20260313a"></script>
<!-- JS: Tabs -->
<script src="/static/js/tkuser-tabs.js?v=20260313"></script>
<!-- JS: Individual modules -->

Some files were not shown because too many files have changed in this diff Show More