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:
Hyungi Ahn
2026-03-17 15:16:14 +09:00
parent 1cef745cc9
commit f548a95767
5 changed files with 187 additions and 3 deletions

View File

@@ -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">