포털 인증·계정·사용자 그룹·대시보드·알림 센터·감사 화면·도움말·발송기·스케줄러 (§3·§4·§5·§24)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:26:28 +00:00
co-authored by Claude Fable 5.1
parent 0f11424224
commit c90ac27596
21 changed files with 1005 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
/**
* portal/notifications/api.mjs — 알림 센터 화면·API(§24.1). 현재 그룹의 알림(그룹 알림 + 나에게 온 개인 알림)만 보인다.
* 분류 필터·읽지 않음 필터·읽음 처리·사건 반복 횟수·필수 표시·조치 화면 링크.
*/
import { requireGroup } from '../auth/guard.mjs';
import { listNotifications, markRead, unreadCount } from '../../lib/notify.mjs';
import { t } from '../../lib/i18n.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
export const CATEGORIES = ['billing', 'site', 'domain', 'mail', 'usage', 'backup', 'relocation', 'security', 'incident', 'membership'];
export async function register(app, deps) {
const { store } = deps;
app.static('/ui/notifications', join(HERE, 'public'));
app.get('/notifications', (ctx) => { requireGroup(ctx); return ctx.html(deps.shell(ctx, { title: t('notifications.title'), nav: null, crumbs: [{ label: t('notifications.title') }], scripts: ['/ui/notifications/notifications.js'], body: `<div class="tabs" id="ncats"><a class="on" href="#" data-cat="">${t('notifications.all')}</a>${CATEGORIES.map((c) => `<a href="#" data-cat="${c}">${t('notifications.categories.' + c)}</a>`).join('')}<a href="#" data-unread="1">${t('notifications.unread')}</a></div><div id="nlist">${deps.statePanel('loading')}</div>` })); });
app.get('/api/notifications', (ctx) => { requireGroup(ctx); const list = listNotifications(store, { groupId: ctx.group.id, userId: ctx.user.id, category: ctx.query.category || undefined, unreadOnly: ctx.query.unread === '1', limit: Number(ctx.query.limit || 100) }); return { ok: true, unread: unreadCount(store, ctx.group.id), items: list.map((n) => ({ id: n.id, category: n.category, categoryLabel: t('notifications.categories.' + n.category), severity: n.severity, mandatory: !!n.mandatory, title: n.title, body: n.body, link: n.link, actionRequired: n.action_required, state: n.state, count: n.count, firstAt: n.first_at, lastAt: n.last_at, readAt: n.read_at, emailSentAt: n.email_sent_at })) }; });
app.post('/api/notifications/:id/read', (ctx) => { requireGroup(ctx); return { ok: true, changed: markRead(store, ctx.params.id, ctx.group.id) }; });
app.post('/api/notifications/read-all', (ctx) => { requireGroup(ctx); const n = store.run('UPDATE notifications SET read_at=? WHERE (group_id=? OR (group_id IS NULL AND user_id=?)) AND read_at IS NULL', new Date().toISOString(), ctx.group.id, ctx.user.id).changes; return { ok: true, changed: n }; });
}
@@ -0,0 +1,13 @@
/* portal/notifications/public/notifications.js — 알림 센터: 분류·읽지 않음 필터, 읽음 처리, 사건 반복 횟수, 조치 링크. */
(() => {
const list = document.getElementById('nlist'); const tabs = document.getElementById('ncats'); if (!list) return; let cat = ''; let unread = false;
const SEV = { critical: 'failed', warning: 'warning', info: 'info' };
async function load() {
try { const d = await aw.api('GET', `/api/notifications?category=${encodeURIComponent(cat)}&unread=${unread ? 1 : 0}`); if (!d.items.length) return aw.state(list, 'empty');
list.innerHTML = `<div class="actions" style="margin-bottom:8px"><button class="btn" id="read-all">모두 읽음으로 표시</button></div>` + d.items.map((n) => `<div class="card" style="${n.readAt ? 'opacity:.75' : ''}"><div class="ph-row"><div><span class="badge ${SEV[n.severity] || ''}">${n.severity === 'critical' ? '⚠' : n.severity === 'warning' ? '!' : 'ⓘ'} ${aw.esc(n.categoryLabel)}</span> ${n.mandatory ? '<span class="badge failed">필수</span>' : ''} ${n.count > 1 ? `<span class="badge">${n.count}회 반복</span>` : ''} ${n.state === 'resolved' ? '<span class="badge ok">✔ 해결됨</span>' : ''}</div><span class="sub">${aw.esc(aw.fmt(n.lastAt))}</span></div><h2 style="margin:6px 0">${aw.esc(n.title)}</h2>${n.body ? `<p>${aw.esc(n.body)}</p>` : ''}${n.actionRequired ? `<p><b>필요한 조치:</b> ${aw.esc(n.actionRequired)}</p>` : ''}<div class="actions">${n.link ? `<a class="btn primary" href="${aw.esc(n.link)}">조치 화면으로</a>` : ''}${!n.readAt ? `<button class="btn" data-read="${n.id}">읽음</button>` : ''}</div></div>`).join('');
list.querySelectorAll('[data-read]').forEach((b) => { b.onclick = async () => { await aw.api('POST', `/api/notifications/${b.dataset.read}/read`); load(); }; }); document.getElementById('read-all').onclick = async () => { await aw.api('POST', '/api/notifications/read-all'); load(); };
} catch (err) { aw.state(list, err.status === 403 ? 'forbidden' : 'failed', err.message, { retry: load }); }
}
tabs.addEventListener('click', (e) => { const a = e.target.closest('a'); if (!a) return; e.preventDefault(); tabs.querySelectorAll('a').forEach((x) => x.classList.remove('on')); a.classList.add('on'); if (a.dataset.unread) { unread = true; cat = ''; } else { unread = false; cat = a.dataset.cat || ''; } load(); });
load();
})();