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:
@@ -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
|
||||
|
||||
410
gateway/html/shared/notification-bell.js
Normal file
410
gateway/html/shared/notification-bell.js
Normal 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, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -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'],
|
||||
|
||||
/**
|
||||
* 노출할 헤더
|
||||
|
||||
@@ -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 문서
|
||||
|
||||
@@ -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: '알림 생성 중 오류가 발생했습니다.'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
52
system1-factory/api/models/pushSubscriptionModel.js
Normal file
52
system1-factory/api/models/pushSubscriptionModel.js
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 읽지 않은 알림 조회 (본인 알림만)
|
||||
|
||||
14
system1-factory/api/routes/pushSubscriptionRoutes.js
Normal file
14
system1-factory/api/routes/pushSubscriptionRoutes.js
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
41
system1-factory/web/push-sw.js
Normal file
41
system1-factory/web/push-sw.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
63
system2-report/api/utils/notifyHelper.js
Normal file
63
system2-report/api/utils/notifyHelper.js
Normal 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;
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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 시 실행
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
/* 상태 배지 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
/* 통계 카드 */
|
||||
|
||||
41
system2-report/web/push-sw.js
Normal file
41
system2-report/web/push-sw.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
41
system3-nonconformance/web/push-sw.js
Normal file
41
system3-nonconformance/web/push-sw.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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>
|
||||
// 페이지 초기화
|
||||
|
||||
@@ -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>
|
||||
// 페이지 초기화
|
||||
|
||||
@@ -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>
|
||||
// 페이지 초기화
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 표시
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
63
tkpurchase/api/utils/notifyHelper.js
Normal file
63
tkpurchase/api/utils/notifyHelper.js
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
41
tkpurchase/web/push-sw.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
63
tksafety/api/utils/notifyHelper.js
Normal file
63
tksafety/api/utils/notifyHelper.js
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
41
tksafety/web/push-sw.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user