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: '