feat(tkuser): 알림 수신자 탭에 ntfy 구독 관리 추가
- notificationRecipientModel에 ntfy CRUD 메서드 추가 (같은 DB 직접 쿼리) - ntfy 라우트 3개 추가 (GET/POST/DELETE, /:type 위에 배치) - 알림 수신자 탭 상단에 ntfy 구독 관리 카드 렌더링 - ntfy 추가 모달에 앱 설정 안내 문구 포함 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,52 @@ const notificationRecipientController = {
|
||||
}
|
||||
},
|
||||
|
||||
// === ntfy 구독 관리 ===
|
||||
|
||||
// ntfy 구독자 목록
|
||||
getNtfySubscribers: async (req, res) => {
|
||||
try {
|
||||
const subscribers = await notificationRecipientModel.getNtfySubscribers();
|
||||
res.json({ success: true, data: subscribers });
|
||||
} catch (error) {
|
||||
console.error('ntfy 구독자 조회 오류:', error);
|
||||
res.status(500).json({ success: false, error: 'ntfy 구독자 조회 실패' });
|
||||
}
|
||||
},
|
||||
|
||||
// ntfy 구독 등록 (관리자)
|
||||
addNtfySubscription: async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
if (!userId) {
|
||||
return res.status(400).json({ success: false, error: '사용자 ID가 필요합니다.' });
|
||||
}
|
||||
if (!await checkNrPermission(req.user)) {
|
||||
return res.status(403).json({ success: false, error: '알림 수신자 관리 권한이 없습니다.' });
|
||||
}
|
||||
await notificationRecipientModel.addNtfySubscription(Number(userId));
|
||||
res.json({ success: true, message: 'ntfy 구독이 등록되었습니다.' });
|
||||
} catch (error) {
|
||||
console.error('ntfy 구독 등록 오류:', error);
|
||||
res.status(500).json({ success: false, error: 'ntfy 구독 등록 실패' });
|
||||
}
|
||||
},
|
||||
|
||||
// ntfy 구독 해제 (관리자)
|
||||
removeNtfySubscription: async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
if (!await checkNrPermission(req.user)) {
|
||||
return res.status(403).json({ success: false, error: '알림 수신자 관리 권한이 없습니다.' });
|
||||
}
|
||||
await notificationRecipientModel.removeNtfySubscription(Number(userId));
|
||||
res.json({ success: true, message: 'ntfy 구독이 해제되었습니다.' });
|
||||
} catch (error) {
|
||||
console.error('ntfy 구독 해제 오류:', error);
|
||||
res.status(500).json({ success: false, error: 'ntfy 구독 해제 실패' });
|
||||
}
|
||||
},
|
||||
|
||||
// 유형별 수신자 일괄 설정
|
||||
setRecipients: async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -138,6 +138,29 @@ const notificationRecipientModel = {
|
||||
return !!row;
|
||||
},
|
||||
|
||||
// === ntfy 구독 관리 ===
|
||||
|
||||
async getNtfySubscribers() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(`
|
||||
SELECT ns.user_id, ns.created_at, u.username, u.name as user_name
|
||||
FROM ntfy_subscriptions ns
|
||||
JOIN sso_users u ON u.user_id = ns.user_id
|
||||
ORDER BY u.name
|
||||
`);
|
||||
return rows;
|
||||
},
|
||||
|
||||
async addNtfySubscription(userId) {
|
||||
const db = getPool();
|
||||
await db.query('INSERT IGNORE INTO ntfy_subscriptions (user_id) VALUES (?)', [userId]);
|
||||
},
|
||||
|
||||
async removeNtfySubscription(userId) {
|
||||
const db = getPool();
|
||||
await db.query('DELETE FROM ntfy_subscriptions WHERE user_id = ?', [userId]);
|
||||
},
|
||||
|
||||
// 유형별 수신자 일괄 설정
|
||||
async setRecipients(notificationType, userIds, createdBy = null) {
|
||||
const db = getPool();
|
||||
|
||||
@@ -13,6 +13,11 @@ router.get('/types', controller.getTypes);
|
||||
// 전체 수신자 목록 (유형별 그룹화)
|
||||
router.get('/', controller.getAll);
|
||||
|
||||
// ntfy 구독 관리 (/:type보다 위에 배치해야 "ntfy"를 type으로 잡지 않음)
|
||||
router.get('/ntfy', controller.getNtfySubscribers);
|
||||
router.post('/ntfy/:userId', controller.addNtfySubscription);
|
||||
router.delete('/ntfy/:userId', controller.removeNtfySubscription);
|
||||
|
||||
// 유형별 수신자 조회
|
||||
router.get('/:type', controller.getByType);
|
||||
|
||||
|
||||
@@ -1582,6 +1582,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ntfy 구독 추가 모달 -->
|
||||
<div id="ntfyAddModal" class="hidden fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center p-4" onclick="if(event.target===this)closeNtfyAddModal()">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">ntfy 구독 등록</h3>
|
||||
<button onclick="closeNtfyAddModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||
<p class="text-xs text-amber-700"><i class="fas fa-info-circle mr-1"></i>등록된 사용자가 실제로 알림을 받으려면 ntfy 앱을 설치하고 본인 토픽을 구독해야 합니다. 앱 설정은 각 사용자가 알림 벨의 ntfy 버튼을 통해 안내받을 수 있습니다.</p>
|
||||
</div>
|
||||
<div id="ntfyAddUserList" class="max-h-[50vh] overflow-y-auto space-y-1 mb-4"></div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick="closeNtfyAddModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
|
||||
<button type="button" onclick="submitNtfyAdd()" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 협력업체 등록 모달 -->
|
||||
<div id="addPartnerModalTkuser" class="hidden fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center p-4" onclick="if(event.target===this)closeAddPartnerTkuser()">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-lg w-full p-6">
|
||||
@@ -2023,7 +2041,7 @@
|
||||
<script src="/static/js/tkuser-partners.js?v=2026031601"></script>
|
||||
<script src="/static/js/tkuser-vendors.js?v=2026031401"></script>
|
||||
<script src="/static/js/tkuser-consumables.js?v=2026031602"></script>
|
||||
<script src="/static/js/tkuser-notificationRecipients.js?v=2026031401"></script>
|
||||
<script src="/static/js/tkuser-notificationRecipients.js?v=2026031701"></script>
|
||||
<!-- Boot -->
|
||||
<script>init();</script>
|
||||
</body>
|
||||
|
||||
@@ -4,19 +4,22 @@ let nrData = {}; // { type: { label, recipients: [...] } }
|
||||
let nrTypes = {}; // { type: label }
|
||||
let nrCategories = {}; // { category: { label, icon, types: [...] } }
|
||||
let nrAllUsers = []; // 사용자 목록 (수신자 추가용)
|
||||
let ntfySubscribers = []; // ntfy 구독자 목록
|
||||
|
||||
async function loadNotificationRecipientsTab() {
|
||||
if (nrLoaded) return;
|
||||
try {
|
||||
const [typesRes, allRes, usersRes] = await Promise.all([
|
||||
const [typesRes, allRes, usersRes, ntfyRes] = await Promise.all([
|
||||
api('/notification-recipients/types'),
|
||||
api('/notification-recipients'),
|
||||
api('/users?status=active')
|
||||
api('/users?status=active'),
|
||||
api('/notification-recipients/ntfy')
|
||||
]);
|
||||
nrTypes = typesRes.data?.types || typesRes.data || {};
|
||||
nrCategories = typesRes.data?.categories || {};
|
||||
nrData = allRes.data || {};
|
||||
nrAllUsers = (usersRes.data || usersRes || []).filter(u => u.status !== 'inactive');
|
||||
ntfySubscribers = ntfyRes.data || [];
|
||||
nrLoaded = true;
|
||||
renderNrTab();
|
||||
} catch (e) {
|
||||
@@ -33,6 +36,10 @@ function renderNrTab() {
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// ntfy 구독 관리 카드 (최상단)
|
||||
html += renderNtfyCard();
|
||||
|
||||
const categoryKeys = Object.keys(nrCategories);
|
||||
|
||||
if (categoryKeys.length > 0) {
|
||||
@@ -67,6 +74,91 @@ function renderNrTab() {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/* ===== ntfy 구독 관리 카드 ===== */
|
||||
function renderNtfyCard() {
|
||||
return `
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-6 border-l-4 border-blue-400">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-800">
|
||||
<i class="fas fa-mobile-alt text-blue-500 mr-2"></i>ntfy 앱 알림
|
||||
<span class="ml-2 text-xs font-normal text-gray-400">${ntfySubscribers.length}명</span>
|
||||
</h3>
|
||||
<button onclick="openNtfyAddModal()" class="px-3 py-1 bg-blue-600 text-white rounded-lg text-xs hover:bg-blue-700">
|
||||
<i class="fas fa-plus mr-1"></i>추가
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mb-3">ntfy 앱으로 푸시 알림을 받을 사용자를 관리합니다.</p>
|
||||
<div class="flex flex-wrap gap-2" id="ntfySubscriberList">
|
||||
${ntfySubscribers.length === 0
|
||||
? '<span class="text-gray-400 text-sm">구독자 없음</span>'
|
||||
: ntfySubscribers.map(s => `
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 rounded-full text-sm">
|
||||
<span class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold">${escapeHtml((s.user_name || s.username || '?')[0])}</span>
|
||||
${escapeHtml(s.user_name || s.username)}
|
||||
<button onclick="removeNtfySubscriber(${s.user_id})" class="text-gray-400 hover:text-red-500 ml-1" title="해제">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
`).join('')
|
||||
}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openNtfyAddModal() {
|
||||
const currentIds = ntfySubscribers.map(s => s.user_id);
|
||||
const available = nrAllUsers.filter(u => !currentIds.includes(u.user_id));
|
||||
|
||||
const listEl = document.getElementById('ntfyAddUserList');
|
||||
if (available.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-gray-400 text-sm text-center py-4">추가 가능한 사용자가 없습니다.</p>';
|
||||
} else {
|
||||
listEl.innerHTML = available.map(u => `
|
||||
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" value="${u.user_id}" class="ntfyAddCheck rounded">
|
||||
<span class="text-sm">${escapeHtml(u.name || u.username)}</span>
|
||||
<span class="text-xs text-gray-400">${escapeHtml(u.username)}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('ntfyAddModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeNtfyAddModal() {
|
||||
document.getElementById('ntfyAddModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function submitNtfyAdd() {
|
||||
const checked = [...document.querySelectorAll('.ntfyAddCheck:checked')].map(c => Number(c.value));
|
||||
if (checked.length === 0) { showToast('사용자를 선택해주세요.', 'error'); return; }
|
||||
|
||||
try {
|
||||
for (const userId of checked) {
|
||||
await api(`/notification-recipients/ntfy/${userId}`, { method: 'POST' });
|
||||
}
|
||||
showToast(`${checked.length}명 ntfy 구독 등록 완료`);
|
||||
closeNtfyAddModal();
|
||||
nrLoaded = false;
|
||||
await loadNotificationRecipientsTab();
|
||||
} catch (e) {
|
||||
showToast('ntfy 구독 등록 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNtfySubscriber(userId) {
|
||||
if (!confirm('이 사용자의 ntfy 구독을 해제하시겠습니까?')) return;
|
||||
try {
|
||||
await api(`/notification-recipients/ntfy/${userId}`, { method: 'DELETE' });
|
||||
showToast('ntfy 구독 해제 완료');
|
||||
nrLoaded = false;
|
||||
await loadNotificationRecipientsTab();
|
||||
} catch (e) {
|
||||
showToast('ntfy 구독 해제 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 유형별 수신자 카드 ===== */
|
||||
function renderNrTypeCard(type, label, recipients) {
|
||||
return `
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-3">
|
||||
|
||||
Reference in New Issue
Block a user