// models/notificationModel.js const { getPool } = require('./userModel'); // 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 전송 헬퍼 — 알림 생성 후 호출 (ntfy 우선, 나머지 Web Push) async function sendPushToUsers(userIds, payload) { const pushModel = require('./pushSubscriptionModel'); const { sendNtfy } = require('../utils/ntfySender'); try { // 1) ntfy 구독자 분리 const ntfyUserIds = userIds && userIds.length > 0 ? await pushModel.getNtfyUserIds(userIds) : await pushModel.getAllNtfyUserIds(); const ntfySet = new Set(ntfyUserIds); // 2) ntfy 병렬 발송 if (ntfyUserIds.length > 0) { await Promise.allSettled( ntfyUserIds.map(uid => sendNtfy(uid, { title: payload.title, body: payload.body, url: payload.url }) ) ); } // 3) Web Push — ntfy 구독자 제외 const wp = getWebPush(); if (!wp) return; let subscriptions; if (userIds && userIds.length > 0) { const webPushUserIds = userIds.filter(id => !ntfySet.has(id)); subscriptions = webPushUserIds.length > 0 ? await pushModel.getByUserIds(webPushUserIds) : []; } else { // broadcast: 전체 구독 가져온 뒤 ntfy 사용자 제외 subscriptions = (await pushModel.getAll()).filter(s => !ntfySet.has(s.user_id)); } 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) { if (err.statusCode === 410 || err.statusCode === 404) { await pushModel.deleteByEndpoint(sub.endpoint).catch(() => {}); } } } } catch (e) { console.error('[notifications] Push 전송 오류:', e.message); } } async function getRecipientIds(notificationType) { const pool = getPool(); const [rows] = await pool.query( `SELECT user_id FROM notification_recipients WHERE notification_type = ? AND is_active = 1`, [notificationType] ); return rows.map(r => r.user_id); } const notificationModel = { // 알림 생성 async create(notificationData) { const pool = getPool(); const { user_id, type, title, message, link_url, reference_type, reference_id, created_by } = notificationData; const [result] = await pool.query( `INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [user_id || null, type || 'system', title, message || null, link_url || null, reference_type || null, reference_id || null, created_by || null] ); this._invalidateCache(user_id); return result.insertId; }, // 읽지 않은 알림 조회 (특정 사용자 또는 전체) async getUnread(userId = null) { const pool = getPool(); const [rows] = await pool.query( `SELECT * FROM notifications WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?) ORDER BY created_at DESC LIMIT 50`, [userId || 0] ); return rows; }, // 전체 알림 조회 (페이징) async getAll(userId = null, page = 1, limit = 20) { const pool = getPool(); const offset = (page - 1) * limit; const [rows] = await pool.query( `SELECT * FROM notifications WHERE (user_id IS NULL OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`, [userId || 0, limit, offset] ); const [[{ total }]] = await pool.query( `SELECT COUNT(*) as total FROM notifications WHERE (user_id IS NULL OR user_id = ?)`, [userId || 0] ); return { notifications: rows, total, page, limit }; }, // 알림 읽음 처리 async markAsRead(notificationId) { const pool = getPool(); const [[row]] = await pool.query('SELECT user_id FROM notifications WHERE notification_id = ?', [notificationId]); const [result] = await pool.query( `UPDATE notifications SET is_read = 1, read_at = NOW() WHERE notification_id = ?`, [notificationId] ); if (row) this._invalidateCache(row.user_id); return result.affectedRows > 0; }, // 모든 알림 읽음 처리 async markAllAsRead(userId = null) { const pool = getPool(); const [result] = await pool.query( `UPDATE notifications SET is_read = 1, read_at = NOW() WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`, [userId || 0] ); this._invalidateCache(userId); return result.affectedRows; }, // 알림 삭제 async delete(notificationId) { const pool = getPool(); const [result] = await pool.query( `DELETE FROM notifications WHERE notification_id = ?`, [notificationId] ); return result.affectedRows > 0; }, // 오래된 알림 삭제 (30일 이상) async deleteOld(days = 30) { const pool = getPool(); const [result] = await pool.query( `DELETE FROM notifications WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)`, [days] ); 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 pool = getPool(); const [[{ count }]] = await pool.query( `SELECT COUNT(*) as count FROM notifications WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`, [userId || 0] ); try { const cache = require('../utils/cache'); await cache.set(cacheKey, count, 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; const recipientIds = await getRecipientIds('repair'); if (recipientIds.length === 0) { const id = await this.create({ type: 'repair', title: `수리 신청: ${equipment_name || '설비'}`, message: `${repair_type} 수리가 신청되었습니다.`, link_url: `/pages/admin/repair-management.html`, reference_type: 'work_issue_reports', reference_id: request_id, created_by }); sendPushToUsers([], { title: `수리 신청: ${equipment_name || '설비'}`, body: `${repair_type} 수리가 신청되었습니다.`, url: `/pages/admin/repair-management.html` }); return id; } const results = []; for (const userId of recipientIds) { const notificationId = await this.create({ user_id: userId, type: 'repair', title: `수리 신청: ${equipment_name || '설비'}`, message: `${repair_type} 수리가 신청되었습니다.`, link_url: `/pages/admin/repair-management.html`, reference_type: 'work_issue_reports', reference_id: request_id, created_by }); results.push(notificationId); } sendPushToUsers(recipientIds, { title: `수리 신청: ${equipment_name || '설비'}`, body: `${repair_type} 수리가 신청되었습니다.`, url: `/pages/admin/repair-management.html` }); return results; }, // 일반 알림 생성 (유형별 지정된 수신자에게 전송) async createTypedNotification(notificationData) { const { type, title, message, link_url, reference_type, reference_id, created_by } = notificationData; const recipientIds = await getRecipientIds(type); if (recipientIds.length === 0) { const id = await this.create({ type, title, message, link_url, reference_type, reference_id, created_by }); sendPushToUsers([], { title, body: message || '', url: link_url || '/' }); return id; } const results = []; for (const userId of recipientIds) { const notificationId = await this.create({ user_id: userId, type, title, message, link_url, reference_type, reference_id, created_by }); results.push(notificationId); } sendPushToUsers(recipientIds, { title, body: message || '', url: link_url || '/' }); return results; }, // 특정 사용자 직접 알림 (target_user_ids 기반, type 브로드캐스트 아님) async createTargetedNotification(notificationData) { const { type, title, message, link_url, reference_type, reference_id, created_by, target_user_ids } = notificationData; const results = []; for (const userId of target_user_ids) { const notificationId = await this.create({ user_id: userId, type, title, message, link_url, reference_type, reference_id, created_by }); results.push(notificationId); } // ntfy + WebPush 발송 sendPushToUsers(target_user_ids, { title, body: message || '', url: link_url || '/' }); return results; } }; module.exports = notificationModel;