Files
AriWeb-Suite/lib/notify.mjs
T

60 lines
5.0 KiB
JavaScript
Raw Normal View History

/**
* lib/notify.mjs — 알림 센터(§24.1).
*
* 분류(category): billing | site | domain | mail | usage | backup | relocation | security | incident | membership | ops
* 중요도(severity): info | warning | critical. mandatory=1 은 필수 알림(서비스 중단·보안·결제·데이터 삭제 예정) → 외부 이메일도 보낸다.
* 같은 원인의 반복 알림은 incident_key 로 하나의 사건으로 묶어 count·last_at·title/body 를 갱신한다(§24.1).
* link 는 사용자가 조치할 정확한 화면(§2.3). group_id 가 없으면 운영자 알림.
* 외부 이메일은 setMailer(fn) 로 끼운 발송기(포털이 AriMail/Resend 로 보냄)가 담당하며, 실패해도 알림 자체는 남는다.
*/
import { newId } from './crypto.mjs';
import { redact } from './redact.mjs';
import { nowIso, toJson, parseJson } from './db.mjs';
export const MIGRATIONS = [{ id: 'lib_notify_0001', sql: `
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY, group_id TEXT, user_id TEXT, category TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'info', mandatory INTEGER NOT NULL DEFAULT 0,
incident_key TEXT, title TEXT NOT NULL, body TEXT, link TEXT, subject_type TEXT, subject_id TEXT, action_required TEXT,
state TEXT NOT NULL DEFAULT 'open', count INTEGER NOT NULL DEFAULT 1, first_at TEXT NOT NULL, last_at TEXT NOT NULL, read_at TEXT, resolved_at TEXT, email_sent_at TEXT, email_error TEXT, detail_json TEXT
);
CREATE INDEX IF NOT EXISTS notif_group ON notifications(group_id, state, last_at);
CREATE INDEX IF NOT EXISTS notif_incident ON notifications(incident_key, state);` }];
let mailer = null;
/** 외부 이메일 발송기 등록: async ({groupId, userId, title, body, link}) => void */
export function setMailer(fn) { mailer = fn; }
/**
* 알림 만들기(또는 같은 사건 갱신).
* @returns {{id:string, merged:boolean}}
*/
export function notify(store, n) {
const now = nowIso(); const detail = toJson(redact(n.detail || null));
if (n.incidentKey) {
const open = store.get('SELECT id, count FROM notifications WHERE incident_key=? AND state=\'open\'', n.incidentKey);
if (open) { store.run('UPDATE notifications SET count=count+1, last_at=?, title=?, body=?, link=COALESCE(?, link), severity=?, action_required=?, detail_json=?, read_at=NULL WHERE id=?', now, n.title, n.body || null, n.link || null, n.severity || 'info', n.actionRequired || null, detail, open.id); return { id: open.id, merged: true }; }
}
const id = newId('n_');
store.run('INSERT INTO notifications(id, group_id, user_id, category, severity, mandatory, incident_key, title, body, link, subject_type, subject_id, action_required, state, count, first_at, last_at, detail_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,\'open\',1,?,?,?)',
id, n.groupId || null, n.userId || null, n.category, n.severity || 'info', n.mandatory ? 1 : 0, n.incidentKey || null, n.title, n.body || null, n.link || null, n.subjectType || null, n.subjectId || null, n.actionRequired || null, now, now, detail);
// 필수 알림·critical 은 외부 이메일(§24.1). 발송 실패는 email_error 에 남긴다(알림은 유지).
if ((n.mandatory || n.severity === 'critical' || n.email) && mailer) {
Promise.resolve().then(() => mailer({ groupId: n.groupId, userId: n.userId, title: n.title, body: n.body, link: n.link }))
.then(() => store.run('UPDATE notifications SET email_sent_at=? WHERE id=?', nowIso(), id))
.catch((e) => { try { store.run('UPDATE notifications SET email_error=? WHERE id=?', String(e.message || e).slice(0, 300), id); } catch (_) {} });
}
return { id, merged: false };
}
/** 사건 종료(같은 incident_key 의 열린 알림을 resolved 로) */
export function resolveIncident(store, incidentKey, note = '') { return store.run('UPDATE notifications SET state=\'resolved\', resolved_at=?, body=CASE WHEN ?<>\'\' THEN ? ELSE body END WHERE incident_key=? AND state=\'open\'', nowIso(), note, note, incidentKey).changes; }
/** 목록(그룹 기준, 최신순). unreadOnly / category 필터 */
export function listNotifications(store, { groupId, userId, category, unreadOnly = false, limit = 100 } = {}) {
const w = []; const p = [];
if (groupId !== undefined) { w.push(groupId === null ? 'group_id IS NULL' : '(group_id=? OR (group_id IS NULL AND user_id=?))'); if (groupId !== null) p.push(groupId, userId || ''); }
if (category) { w.push('category=?'); p.push(category); }
if (unreadOnly) w.push('read_at IS NULL');
return store.all(`SELECT * FROM notifications ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY last_at DESC LIMIT ?`, ...p, Math.min(limit, 500)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) }));
}
export function markRead(store, id, groupId) { return store.run('UPDATE notifications SET read_at=? WHERE id=? AND (group_id=? OR group_id IS NULL) AND read_at IS NULL', nowIso(), id, groupId || null).changes; }
export function unreadCount(store, groupId) { return store.get('SELECT COUNT(*) AS n FROM notifications WHERE group_id=? AND read_at IS NULL AND state=\'open\'', groupId)?.n || 0; }