From a19911445391cbaeaade2688adb0ab4617d4b202 Mon Sep 17 00:00:00 2001 From: 2620227 Date: Wed, 15 Jul 2026 14:10:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(notifications):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A2=85=20+=20=EA=B3=B5=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 헤더 프로필 왼쪽 알림 종: 드롭다운·안읽음 배지·60초 폴링(최초 폴링 도입) - 내 프로젝트 좋아요/즐겨찾기/댓글 시 소유자에게 알림(본인 제외·재토글 도배 방지) - 관리자 공지: /announcements 목록 + 작성/수정/삭제, 등록 시 전 직원에게 fan-out 알림 - notifications/announcements 테이블(멱등) + requireAdmin 가드 + bell/megaphone 아이콘 Co-Authored-By: Claude Opus 4.8 (1M context) --- public/css/components.css | 25 +++++++ public/css/layout.css | 34 +++++++++ public/js/announcements.js | 31 ++++++++ public/js/main.js | 4 + public/js/notifications.js | 150 +++++++++++++++++++++++++++++++++++++ schema.sql | 28 +++++++ src/auth.js | 9 +++ src/db.js | 109 +++++++++++++++++++++++++++ src/helpers.js | 4 + src/server.js | 60 ++++++++++++++- views/_header.ejs | 23 ++++++ views/announcements.ejs | 76 +++++++++++++++++++ 12 files changed, 549 insertions(+), 4 deletions(-) create mode 100644 public/js/announcements.js create mode 100644 public/js/notifications.js create mode 100644 views/announcements.ejs diff --git a/public/css/components.css b/public/css/components.css index 0dffbf9..792b037 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -191,3 +191,28 @@ .crop-zoom { margin-top: 14px; display: flex; align-items: center; gap: 10px; color: var(--fg2); } .crop-zoom input[type=range] { flex: 1; accent-color: var(--link); } .crop-hint { margin: 10px 0 0; font-size: 11.5px; color: var(--fg3); line-height: 1.5; } + +/* ===================== 공지사항(📢) 페이지 ===================== */ +/* 작성/수정 공용 폼 */ +.ann-form { display: flex; flex-direction: column; gap: 10px; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 12px; padding: 16px; } +.ann-form-new { margin-bottom: 24px; } +.ann-input-title, .ann-input-body { + width: 100%; box-sizing: border-box; font-family: inherit; font-size: 14px; color: var(--fg); + background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; outline: none; +} +.ann-input-title { font-weight: 600; } +.ann-input-body { resize: vertical; line-height: 1.55; } +.ann-input-title:focus, .ann-input-body:focus { border-color: var(--link); } +.ann-form-actions { display: flex; align-items: center; gap: 8px; } + +.ann-empty { padding: 28px; text-align: center; color: var(--fg2); font-size: 14px; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 12px; } + +.ann-list { display: flex; flex-direction: column; gap: 14px; } +.ann-item { background: var(--card); border: 1px solid var(--border-soft); border-radius: 12px; padding: 18px 20px; } +.ann-item-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.ann-item-title { margin: 0; font-size: 16px; font-weight: 700; color: var(--fg); word-break: break-word; } +.ann-item-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; } +.ann-del:hover { color: var(--danger); border-color: var(--danger); } +.ann-meta { margin-top: 5px; font-size: 12px; color: var(--fg2); } +.ann-body { margin-top: 12px; } +.ann-item .ann-edit { margin-top: 4px; } diff --git a/public/css/layout.css b/public/css/layout.css index 7f9a2a3..98e1794 100644 --- a/public/css/layout.css +++ b/public/css/layout.css @@ -64,6 +64,40 @@ html[data-aidev-theme="dark"] .theme-ico-sun { display: inline-flex; } .usermenu-item:hover { background: var(--panel); } .usermenu-ico { display: inline-flex; color: var(--fg2); } +/* ===================== 알림(🔔) 종 ===================== */ +.notif { position: relative; } +.notif-btn { position: relative; } /* 배지 기준 */ +/* 안 읽은 수 배지 — 종 우상단 빨강 pill */ +.notif-badge { + position: absolute; top: -4px; right: -4px; min-width: 16px; height: 16px; padding: 0 4px; + display: inline-flex; align-items: center; justify-content: center; + background: var(--danger); color: #fff; font-size: 10px; font-weight: 700; line-height: 1; + border-radius: 999px; border: 1px solid var(--hbg); box-sizing: border-box; +} +.notif-badge[hidden] { display: none; } +/* 드롭다운 패널 — 사용자 메뉴 팝오버와 동일 패턴(더 넓게) */ +.notif-pop { + position: absolute; top: 46px; right: 0; width: min(340px, calc(100vw - 32px)); + background: var(--card); border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 8px 24px var(--shadow-strong); padding: 6px; z-index: 320; animation: aidev-pop .14s ease-out; +} +.notif-pop[hidden] { display: none; } +.notif-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 8px; border-bottom: 1px solid var(--divider); margin-bottom: 4px; } +.notif-title { font-size: 13px; font-weight: 700; color: var(--fg); } +.notif-readall { background: transparent; border: 0; cursor: pointer; font-family: inherit; font-size: 12px; color: var(--link); padding: 2px 4px; } +.notif-readall:hover { text-decoration: underline; } +.notif-list { max-height: 360px; overflow-y: auto; } +.notif-item { display: flex; gap: 9px; align-items: flex-start; padding: 9px 10px; border-radius: 8px; text-decoration: none; color: var(--fg); } +.notif-item:hover { background: var(--panel); } +.notif-item.unread { background: var(--attn-bg); } +.notif-item-ico { flex: 0 0 auto; font-size: 15px; line-height: 1.3; } +.notif-item-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.notif-item-text { font-size: 12.5px; line-height: 1.4; color: var(--fg); word-break: break-word; } +.notif-item-time { font-size: 11px; color: var(--fg3); } +.notif-empty { padding: 22px 10px; text-align: center; color: var(--fg3); font-size: 12.5px; } +.notif-foot { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 9px; margin-top: 4px; border-top: 1px solid var(--divider); font-size: 12.5px; font-weight: 600; color: var(--link); text-decoration: none; } +.notif-foot:hover { background: var(--panel); border-radius: 6px; } + /* ===================== 메인 / 섹션 ===================== */ .main { max-width: 1180px; margin: 0 auto; padding: 32px 24px 64px; } .section { margin-bottom: 40px; } diff --git a/public/js/announcements.js b/public/js/announcements.js new file mode 100644 index 0000000..cf91529 --- /dev/null +++ b/public/js/announcements.js @@ -0,0 +1,31 @@ +// 공지 페이지 — 항목별 인라인 수정 폼 토글. (삭제 확인은 comments.js 의 [data-confirm] 핸들러가 처리) +// 문서 위임이라 공지 페이지가 아닌 곳에서 로드돼도 해당 요소가 없으면 무동작 → 전 페이지 안전. +import { $ } from "./util.js"; + +export function initAnnouncements() { + document.addEventListener("click", (e) => { + // 수정 열기: 보기 숨기고 수정 폼 표시. + const eb = e.target.closest("[data-ann-edit]"); + if (eb) { + const id = eb.getAttribute("data-ann-edit"); + const view = $('[data-ann-view="' + id + '"]'); + const form = $('[data-ann-editform="' + id + '"]'); + if (view) view.hidden = true; + if (form) { + form.hidden = false; + const t = $(".ann-input-title", form); + if (t) t.focus(); + } + return; + } + // 수정 취소: 폼 숨기고 보기 복원. + const cb = e.target.closest("[data-ann-cancel]"); + if (cb) { + const id = cb.getAttribute("data-ann-cancel"); + const view = $('[data-ann-view="' + id + '"]'); + const form = $('[data-ann-editform="' + id + '"]'); + if (form) form.hidden = true; + if (view) view.hidden = false; + } + }); +} diff --git a/public/js/main.js b/public/js/main.js index 49398c0..aa6c8ff 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -6,6 +6,8 @@ import { initReactions } from "./reactions.js"; import { initModals, openDetail } from "./modals.js"; import { initComments } from "./comments.js"; import { initFeed, initMyFilter } from "./feed.js"; +import { initNotifications } from "./notifications.js"; +import { initAnnouncements } from "./announcements.js"; // 서버가 로 전달한 1회성 신호 처리 function initSignals() { @@ -50,6 +52,8 @@ function boot() { initComments(); initFeed(); initMyFilter(); + initNotifications(); + initAnnouncements(); initSignals(); consoleArt(); } diff --git a/public/js/notifications.js b/public/js/notifications.js new file mode 100644 index 0000000..4d33815 --- /dev/null +++ b/public/js/notifications.js @@ -0,0 +1,150 @@ +// 알림(🔔) 종 — 드롭다운 열기/닫기 · 목록 fetch · 안 읽은 배지 · 60초 폴링. +// 서버 API: GET /api/notifications → { unread, items }, POST /api/notifications/read-all. +import { $, toast } from "./util.js"; + +const POLL_MS = 60000; // 배지 폴링 주기(60초) + +// 종류별 아이콘(가벼운 이모지) · 알림 문구 · 이동 링크. +const ICONS = { heart: "❤️", star: "⭐", comment: "💬", announcement: "📢" }; + +function itemText(n) { + const who = n.actor_name || "누군가"; + const proj = n.project_title || "프로젝트"; + if (n.kind === "heart") return who + '님이 «' + proj + '»에 좋아요를 남겼어요'; + if (n.kind === "star") return who + '님이 «' + proj + '»을(를) 즐겨찾기했어요'; + if (n.kind === "comment") return who + '님이 «' + proj + '»에 댓글을 남겼어요'; + if (n.kind === "announcement") return "새 공지: " + (n.announcement_title || ""); + return "새 알림"; +} + +function itemHref(n) { + if (n.kind === "announcement") return "/announcements"; + if (n.project_id) return "/projects/" + n.project_id; + return "#"; +} + +// 상대 시간(방금/N분 전/N시간 전/N일 전/날짜). +function relTime(iso) { + const t = new Date(iso).getTime(); + if (!t) return ""; + const s = Math.max(0, (Date.now() - t) / 1000); + if (s < 60) return "방금"; + if (s < 3600) return Math.floor(s / 60) + "분 전"; + if (s < 86400) return Math.floor(s / 3600) + "시간 전"; + if (s < 7 * 86400) return Math.floor(s / 86400) + "일 전"; + const d = new Date(t); + return d.getFullYear() + "." + String(d.getMonth() + 1).padStart(2, "0") + "." + String(d.getDate()).padStart(2, "0"); +} + +// 목록 렌더 — textContent 로만 채워 XSS 방지. +function renderList(listEl, items) { + listEl.textContent = ""; + if (!items || !items.length) { + const empty = document.createElement("div"); + empty.className = "notif-empty"; + empty.textContent = "아직 알림이 없어요"; + listEl.appendChild(empty); + return; + } + items.forEach((n) => { + const a = document.createElement("a"); + a.className = "notif-item" + (n.is_read ? "" : " unread"); + a.href = itemHref(n); + const ico = document.createElement("span"); + ico.className = "notif-item-ico"; + ico.textContent = ICONS[n.kind] || "🔔"; + const body = document.createElement("span"); + body.className = "notif-item-body"; + const text = document.createElement("span"); + text.className = "notif-item-text"; + text.textContent = itemText(n); + const time = document.createElement("span"); + time.className = "notif-item-time"; + time.textContent = relTime(n.created_at); + body.appendChild(text); + body.appendChild(time); + a.appendChild(ico); + a.appendChild(body); + listEl.appendChild(a); + }); +} + +function setBadge(badgeEl, n) { + const count = +n || 0; + if (count > 0) { + badgeEl.textContent = count > 99 ? "99+" : String(count); + badgeEl.hidden = false; + } else { + badgeEl.hidden = true; + } +} + +function apiGet() { + return fetch("/api/notifications", { headers: { "X-Requested-With": "fetch", "Accept": "application/json" } }) + .then((r) => (r.ok ? r.json() : Promise.reject(r.status))); +} + +function apiReadAll() { + return fetch("/api/notifications/read-all", { + method: "POST", + headers: { "X-Requested-With": "fetch", "Accept": "application/json" }, + }).then((r) => (r.ok ? r.json() : Promise.reject(r.status))); +} + +export function initNotifications() { + const btn = $("#notifBtn"), pop = $("#notifPop"), badge = $("#notifBadge"), + list = $("#notifList"), readAll = $("#notifReadAll"); + if (!btn || !pop || !badge || !list) return; // 비로그인 등 종이 없으면 무동작 + + // 드롭다운 열 때: 목록을 받아 렌더한 뒤 모두 읽음 처리 → 배지 초기화. + function openPanel() { + apiGet() + .then((data) => { + renderList(list, data.items); + setBadge(badge, data.unread); + if (data.unread > 0) apiReadAll().then(() => setBadge(badge, 0)).catch(() => {}); + }) + .catch(() => { + list.textContent = ""; + const err = document.createElement("div"); + err.className = "notif-empty"; + err.textContent = "알림을 불러오지 못했어요"; + list.appendChild(err); + }); + } + + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const willOpen = pop.hidden; + pop.hidden = !willOpen; + btn.setAttribute("aria-expanded", willOpen ? "true" : "false"); + if (willOpen) openPanel(); + }); + + // 바깥 클릭 시 닫기(사용자 메뉴와 동일 패턴). + document.addEventListener("click", (e) => { + if (!pop.hidden && !pop.contains(e.target) && !btn.contains(e.target)) { + pop.hidden = true; + btn.setAttribute("aria-expanded", "false"); + } + }); + + if (readAll) { + readAll.addEventListener("click", (e) => { + e.stopPropagation(); + apiReadAll() + .then(() => { + setBadge(badge, 0); + list.querySelectorAll(".notif-item.unread").forEach((el) => el.classList.remove("unread")); + }) + .catch(() => toast("처리에 실패했어요. 다시 시도해 주세요.")); + }); + } + + // 최초 1회 + 주기적 폴링: 배지 숫자만 갱신(드롭다운이 닫혀 있을 때). + function refreshBadge() { + apiGet().then((data) => { if (pop.hidden) setBadge(badge, data.unread); }).catch(() => {}); + } + refreshBadge(); + setInterval(refreshBadge, POLL_MS); +} diff --git a/schema.sql b/schema.sql index bfcf46a..94f32b6 100644 --- a/schema.sql +++ b/schema.sql @@ -64,3 +64,31 @@ CREATE TABLE IF NOT EXISTS stars ( created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (project_id, sabun) ); + +-- 공지 (관리자가 작성해 전 직원에게 알림으로 전달. 별도 모아보기 페이지 /announcements) +CREATE TABLE IF NOT EXISTS announcements ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + author_sabun text NOT NULL REFERENCES users(sabun), + title text NOT NULL, + body text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_announcements_created ON announcements(created_at DESC); + +-- 알림 (통합 인박스) — 반응(❤️/⭐)·댓글·공지를 한 테이블로. +-- recipient_sabun: 받는 사람 / actor_sabun: 행위자(공지는 작성 관리자, 시스템성이면 NULL 가능) +-- kind: 'heart' | 'star' | 'comment' | 'announcement' +-- project_id / announcement_id: 알림이 가리키는 대상(각각 삭제 시 CASCADE 로 정리) +CREATE TABLE IF NOT EXISTS notifications ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + recipient_sabun text NOT NULL REFERENCES users(sabun), + actor_sabun text REFERENCES users(sabun), + kind text NOT NULL, + project_id bigint REFERENCES projects(id) ON DELETE CASCADE, + announcement_id bigint REFERENCES announcements(id) ON DELETE CASCADE, + is_read boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); +-- 종 드롭다운/배지 조회: 받는 사람별 최신순. +CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_sabun, created_at DESC); diff --git a/src/auth.js b/src/auth.js index befbc06..11239ed 100644 --- a/src/auth.js +++ b/src/auth.js @@ -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("관리자만 접근할 수 있습니다.")); +} diff --git a/src/db.js b/src/db.js index 9b76041..89a1321 100644 --- a/src/db.js +++ b/src/db.js @@ -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; +} diff --git a/src/helpers.js b/src/helpers.js index 1003ea8..d9ae775 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -29,6 +29,10 @@ const OCTICONS = { trash: '', book: '', pencil: '', + // 종(알림) — Octicon bell-16 + bell: '', + // 확성기(공지) — Octicon megaphone-16 + megaphone: '', }; // 인라인 SVG 아이콘 문자열. fill 기본은 currentColor(부모 색 상속). diff --git a/src/server.js b/src/server.js index 39d627b..c0bd15b 100644 --- a/src/server.js +++ b/src/server.js @@ -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 { diff --git a/views/_header.ejs b/views/_header.ejs index d7597d2..8bca4b0 100644 --- a/views/_header.ejs +++ b/views/_header.ejs @@ -45,6 +45,10 @@ <%- icon("sun", { size: 16 }) %> + + <%- icon("megaphone", { size: 15 }) %>공지 + + <%- icon("book", { size: 15 }) %>매뉴얼 @@ -53,6 +57,24 @@ <%- icon("lock", { size: 11 }) %>관리자 <% } %> + +
+ + +
+
<%- icon("repo", { size: 15 }) %>내 프로필 + <%- icon("megaphone", { size: 15 }) %>공지사항 <%- icon("signout", { size: 15 }) %>로그아웃 diff --git a/views/announcements.ejs b/views/announcements.ejs new file mode 100644 index 0000000..ea59751 --- /dev/null +++ b/views/announcements.ejs @@ -0,0 +1,76 @@ +<%- include("_header") %> +<% + // 뷰 로컬 헬퍼 (빌드 없이 EJS 스크립틀릿으로) + function fdate(d) { var x = new Date(d); return x.getFullYear() + "." + String(x.getMonth() + 1).padStart(2, "0") + "." + String(x.getDate()).padStart(2, "0"); } + function edited(a) { try { return (+new Date(a.updated_at) - +new Date(a.created_at)) > 1000; } catch (e) { return false; } } +%> + +
+
+
+
+

공지사항

+ 관리자 공지 모아보기 +
+
+ + <% if (user.isAdmin) { %> + +
+ + +
+ +
+
+ <% } %> + + <% if (!announcements.length) { %> +
아직 등록된 공지가 없습니다.
+ <% } %> + +
+ <% announcements.forEach(function (a) { %> +
+
+
+

<%= a.title %>

+ <% if (user.isAdmin) { %> +
+ +
+ +
+
+ <% } %> +
+
+ <%= a.author_name %> · <%= fdate(a.created_at) %><% if (edited(a)) { %> · 수정됨<% } %> +
+ <% if (a.body && a.body.trim()) { %> +
<%- renderMarkdown(a.body) %>
+ <% } %> +
+ + <% if (user.isAdmin) { %> + + + <% } %> +
+ <% }); %> +
+
+
+ +<%- include("_footer") %> + + + +