feat(notifications): 알림 종 + 공지 기능 추가

- 헤더 프로필 왼쪽 알림 종: 드롭다운·안읽음 배지·60초 폴링(최초 폴링 도입)
- 내 프로젝트 좋아요/즐겨찾기/댓글 시 소유자에게 알림(본인 제외·재토글 도배 방지)
- 관리자 공지: /announcements 목록 + 작성/수정/삭제, 등록 시 전 직원에게 fan-out 알림
- notifications/announcements 테이블(멱등) + requireAdmin 가드 + bell/megaphone 아이콘

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 14:10:04 +09:00
parent 2ca23a00b9
commit a199114453
12 changed files with 549 additions and 4 deletions

View File

@@ -82,3 +82,12 @@ export function requireAuth(req, res, next) {
// OIDC 미준비(discovery 실패)면 안내 페이지로 폴백
return res.redirect("/login?error=" + encodeURIComponent("로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요."));
}
// 관리자 전용 가드. requireAuth 뒤(또는 로그인 상태)에서 관리자만 통과.
// 관리자 판별은 Keycloak 그룹 기준(req.session.user.isAdmin). 공지 작성/수정/삭제 등에 사용.
export function requireAdmin(req, res, next) {
if (req.session.user && req.session.user.isAdmin) return next();
if (!req.session.user) return requireAuth(req, res, next); // 미로그인은 로그인부터
if (req.path.startsWith("/api/")) return res.status(403).json({ error: "forbidden" });
return res.redirect("/?toast=" + encodeURIComponent("관리자만 접근할 수 있습니다."));
}

109
src/db.js
View File

@@ -200,3 +200,112 @@ export async function toggleStar({ projectId, sabun }) {
const { rows } = await q(`SELECT count(*)::int AS c FROM stars WHERE project_id=$1`, [projectId]);
return { on, count: rows[0].c };
}
// ===================== 알림(🔔) =====================
// 프로젝트에 반응(❤️/⭐)·댓글이 달리면 프로젝트 주인에게 알림.
// 본인 행위(주인===행위자)엔 알림하지 않는다. 같은 (받는사람,행위자,프로젝트,종류)의
// "안 읽은" 알림이 이미 있으면 skip → 좋아요 껐다 켜기 반복 도배 방지.
export async function notifyProjectOwner({ projectId, actor, kind }) {
const { rows } = await q(`SELECT owner_sabun FROM projects WHERE id=$1`, [projectId]);
const owner = rows[0] && rows[0].owner_sabun;
if (!owner || owner === actor) return;
await q(
`INSERT INTO notifications (recipient_sabun, actor_sabun, kind, project_id)
SELECT $1, $2, $3, $4
WHERE NOT EXISTS (
SELECT 1 FROM notifications
WHERE recipient_sabun=$1 AND actor_sabun=$2 AND kind=$3 AND project_id=$4 AND NOT is_read
)`,
[owner, actor, kind, projectId]
);
}
// 종 드롭다운용 최근 알림 목록. 행위자 이름/프로젝트 제목/공지 제목을 함께 조인.
export async function listNotifications(sabun, limit = 20) {
const { rows } = await q(
`SELECT n.*, a.name AS actor_name, p.title AS project_title, an.title AS announcement_title
FROM notifications n
LEFT JOIN users a ON a.sabun = n.actor_sabun
LEFT JOIN projects p ON p.id = n.project_id
LEFT JOIN announcements an ON an.id = n.announcement_id
WHERE n.recipient_sabun=$1
ORDER BY n.created_at DESC
LIMIT $2`,
[sabun, limit]
);
return rows;
}
// 안 읽은 알림 수(배지).
export async function countUnread(sabun) {
const { rows } = await q(
`SELECT count(*)::int AS c FROM notifications WHERE recipient_sabun=$1 AND NOT is_read`,
[sabun]
);
return rows[0].c;
}
// 안 읽은 알림 모두 읽음 처리. 처리 행 수 반환.
export async function markAllRead(sabun) {
const { rowCount } = await q(
`UPDATE notifications SET is_read=true WHERE recipient_sabun=$1 AND NOT is_read`,
[sabun]
);
return rowCount;
}
// ===================== 공지(📢) =====================
// 공지 목록(최신순). 작성자 이름 조인.
export async function listAnnouncements(limit = 100) {
const { rows } = await q(
`SELECT an.*, u.name AS author_name
FROM announcements an JOIN users u ON u.sabun=an.author_sabun
ORDER BY an.created_at DESC
LIMIT $1`,
[limit]
);
return rows;
}
// 단일 공지.
export async function getAnnouncement(id) {
const { rows } = await q(
`SELECT an.*, u.name AS author_name
FROM announcements an JOIN users u ON u.sabun=an.author_sabun
WHERE an.id=$1`,
[id]
);
return rows[0] || null;
}
// 공지 등록 → 새 id 반환. 이어서 작성자를 제외한 전 직원에게 알림 fan-out.
export async function addAnnouncement({ author, title, body }) {
const { rows } = await q(
`INSERT INTO announcements (author_sabun, title, body) VALUES ($1,$2,$3) RETURNING id`,
[author, title, body || ""]
);
const id = rows[0].id;
await q(
`INSERT INTO notifications (recipient_sabun, actor_sabun, kind, announcement_id)
SELECT sabun, $1, 'announcement', $2 FROM users WHERE sabun <> $1`,
[author, id]
);
return id;
}
// 공지 수정(관리자). 적용 행 수 반환.
export async function updateAnnouncement({ id, title, body }) {
const { rowCount } = await q(
`UPDATE announcements SET title=$2, body=$3, updated_at=now() WHERE id=$1`,
[id, title, body || ""]
);
return rowCount;
}
// 공지 삭제(관리자). 연결된 notifications 는 FK ON DELETE CASCADE 로 함께 삭제. 삭제 행 수 반환.
export async function deleteAnnouncement({ id }) {
const { rowCount } = await q(`DELETE FROM announcements WHERE id=$1`, [id]);
return rowCount;
}

View File

@@ -29,6 +29,10 @@ const OCTICONS = {
trash: '<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/>',
book: '<path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/>',
pencil: '<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/>',
// 종(알림) — Octicon bell-16
bell: '<path d="M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16ZM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.516 1.516 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947Zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.017.017 0 0 0-.003.01l.001.006c0 .002.002.004.004.006l.006.004.007.001h10.964l.007-.001.006-.004.004-.006.001-.007a.017.017 0 0 0-.003-.01l-1.703-2.554a1.745 1.745 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5Z"/>',
// 확성기(공지) — Octicon megaphone-16
megaphone: '<path d="M3.75 8H2.5A1.5 1.5 0 0 1 1 6.5v-1A1.5 1.5 0 0 1 2.5 4h1.25l6.767-2.71a.75.75 0 0 1 1.03.696v8.028a.75.75 0 0 1-1.03.696L3.75 8Zm-1.25-2.5v1c0 .009.007.016.016.016H3.5V4.484h-.984A.016.016 0 0 0 2.5 4.5Zm2.5.06v.88l5.75 2.302V3.258L5 5.56ZM4.5 9.25a.75.75 0 0 1 .728.568l.542 2.17c.048.19.09.38.09.512 0 .8-.649 1.5-1.5 1.5h-.375A1.5 1.5 0 0 1 3 12.5c0-.283.036-.55.09-.762l.542-2.17a.75.75 0 0 1 .728-.568h.14Z"/>',
};
// 인라인 SVG 아이콘 문자열. fill 기본은 currentColor(부모 색 상속).

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
import path from "path";
import { config } from "./config.js";
import { toolsForUser } from "./sites.js";
import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady, logoutUrl } from "./auth.js";
import { initOidc, loginRedirect, handleCallback, requireAuth, requireAdmin, oidcReady, logoutUrl } from "./auth.js";
import { icon, identicon, identiconSvg, avatar, adminBadge, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js";
import { fetchMainLang, fetchReadme } from "./gitea.js";
import * as db from "./db.js";
@@ -243,9 +243,12 @@ app.get("/projects/:id", requireAuth, async (req, res) => {
// --- 반응: ❤️ 좋아요 / ⭐ 즐겨찾기 ---
async function reactionHandler(kind, req, res) {
const me = req.session.user.sabun;
const result = await (kind === "heart"
? db.toggleHeart({ projectId: req.params.id, sabun: req.session.user.sabun })
: db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun }));
? db.toggleHeart({ projectId: req.params.id, sabun: me })
: db.toggleStar({ projectId: req.params.id, sabun: me }));
// 새로 켠 경우에만 프로젝트 주인에게 알림(본인 프로젝트/중복은 db 에서 걸러짐).
if (result.on) await db.notifyProjectOwner({ projectId: req.params.id, actor: me, kind });
if (isAjax(req)) return res.json(result);
res.redirect(safeBack(req.get("Referer"), "/"));
}
@@ -282,12 +285,14 @@ app.post("/projects/:id/comments", requireAuth, async (req, res) => {
const body = (req.body.body || "").trim();
if (!body) return res.redirect(back);
const parentId = req.body.parent_id ? Number(req.body.parent_id) : null;
await db.addComment({
const created = await db.addComment({
projectId: req.params.id,
author: req.session.user.sabun,
body,
parentId: parentId || null,
});
// 댓글이 실제로 등록됐으면 프로젝트 주인에게 알림(본인 댓글/중복은 db 에서 걸러짐).
if (created) await db.notifyProjectOwner({ projectId: req.params.id, actor: req.session.user.sabun, kind: "comment" });
// 이스터에그 신호
let egg = "";
if (/\blgtm\b/i.test(body)) egg = "lgtm";
@@ -302,6 +307,53 @@ app.post("/comments/:id/delete", requireAuth, async (req, res) => {
res.redirect(safeBack(req.body.return_to, req.get("Referer") || "/"));
});
// --- 알림(🔔) API — 종 드롭다운/폴링 공용 ---
// 최근 알림 목록 + 안 읽은 수. app.js 가 X-Requested-With:fetch 로 호출.
app.get("/api/notifications", requireAuth, async (req, res) => {
const me = req.session.user.sabun;
const [items, unread] = await Promise.all([db.listNotifications(me, 20), db.countUnread(me)]);
res.json({ unread, items });
});
// 안 읽은 알림 모두 읽음 처리 → { unread: 0 }
app.post("/api/notifications/read-all", requireAuth, async (req, res) => {
await db.markAllRead(req.session.user.sabun);
res.json({ unread: 0 });
});
// --- 공지(📢) 모아보기 페이지 (전체 열람 가능, 작성 폼은 관리자에게만) ---
app.get("/announcements", requireAuth, async (req, res) => {
const announcements = await db.listAnnouncements();
res.render("announcements", {
announcements,
toast: typeof req.query.toast === "string" ? req.query.toast : "",
});
});
// --- 공지 등록 (관리자) ---
app.post("/announcements", requireAdmin, async (req, res) => {
const title = String(req.body.title || "").trim();
const body = String(req.body.body || "").trim();
if (!title) return res.redirect("/announcements?toast=" + encodeURIComponent("제목을 입력해주세요"));
await db.addAnnouncement({ author: req.session.user.sabun, title, body });
res.redirect("/announcements?toast=" + encodeURIComponent("공지가 등록되었습니다"));
});
// --- 공지 수정 (관리자) ---
app.post("/announcements/:id/update", requireAdmin, async (req, res) => {
const title = String(req.body.title || "").trim();
const body = String(req.body.body || "").trim();
if (!title) return res.redirect("/announcements?toast=" + encodeURIComponent("제목을 입력해주세요"));
const n = await db.updateAnnouncement({ id: req.params.id, title, body });
res.redirect("/announcements?toast=" + encodeURIComponent(n ? "공지가 수정되었습니다" : "공지를 찾을 수 없습니다"));
});
// --- 공지 삭제 (관리자) ---
app.post("/announcements/:id/delete", requireAdmin, async (req, res) => {
const n = await db.deleteAnnouncement({ id: req.params.id });
res.redirect("/announcements?toast=" + encodeURIComponent(n ? "공지가 삭제되었습니다" : "공지를 찾을 수 없습니다"));
});
// --- 부팅 ---
const start = async () => {
try {