1import type { Hono } from "hono";2import { html } from "../pages/html.ts";3import { relativeTime } from "../pages/layout.ts";4import { NOTIFICATION_KINDS, type NotificationKind, setting } from "../server/db.ts";5import { type AdminContext, adminPage, formField, textFields } from "./console.ts";6import { saveSettings } from "./store.ts";78type Notification = { id: number; text: string; href: string; created_at: number; read: number };910export const LABELS: Record<NotificationKind, string> = {11 issue_filed: "an issue is filed",12 issue_followup: "a reporter adds a follow-up",13 sync_failed: "a repo's sync starts failing",14 backup_failed: "a backup starts failing",15};161718export function registerNotifications(app: Hono, ctx: AdminContext): void {19 app.get("/admin/notifications", (c) => {20 if (c.req.query("format") === "json") {21 const unread =22 ctx.kepr23 .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")24 .get()?.n ?? 0;25 const recent = ctx.kepr26 .query<Notification, []>(27 "SELECT id, text, href, created_at, read FROM notifications ORDER BY id DESC LIMIT 5",28 )29 .all();30 return c.json({31 unread,32 notifications: recent.map(({ id, text, created_at, read }) => ({33 id,34 text,35 read,36 when: relativeTime(created_at),37 })),38 });39 }4041 const rows = ctx.kepr42 .query<Notification, []>(43 "SELECT id, text, href, created_at, read FROM notifications ORDER BY id DESC",44 )45 .all();46 const row = (n: Notification) =>47 html`<a class="issue-row${n.read ? "" : " notification-unread"}" href="/admin/notifications/${String(n.id)}">48<span class="issue-title">${n.text}</span>49<span class="issue-meta mono">${n.read ? "" : "unread · "}${relativeTime(n.created_at)}</span></a>`;50 const kind = (k: NotificationKind) =>51 html`<label class="issues-label"><input type="checkbox" name="${k}" value="1"${52 setting(ctx.kepr, `notify_off_${k}`) ? "" : html` checked`53 }> ${LABELS[k]}</label>`;54 const body = html`<form method="post" action="/admin/notifications">55${formField(c)}<input type="hidden" name="action" value="read_all">56<button class="issues-submit" type="submit">mark all read</button></form>57<section class="repo-section">58${rows.length > 0 ? rows.map(row) : html`<p class="muted mono save-empty">nothing yet</p>`}59</section>60<section class="repo-section"><h2 class="section-label mono">tell me when</h2>61<form class="issues-form" method="post" action="/admin/notifications">62${formField(c)}<input type="hidden" name="action" value="preferences">63${NOTIFICATION_KINDS.map(kind)}64<button class="issues-submit" type="submit">save</button>65</form></section>`;66 return c.html(adminPage(c, ctx, "notifications", body));67 });6869 app.get("/admin/notifications/:id{[0-9]{1,9}}", (c) => {70 const found = ctx.kepr71 .query<{ href: string }, [number]>(72 "UPDATE notifications SET read = 1 WHERE id = ? RETURNING href",73 )74 .get(Number(c.req.param("id")));75 return found ? c.redirect(found.href, 303) : c.notFound();76 });7778 app.post("/admin/notifications", async (c) => {79 const fields = await textFields(c);80 if (fields.action === "read_all") {81 ctx.kepr.run("UPDATE notifications SET read = 1 WHERE read = 0");82 } else if (fields.action === "preferences") {83 84 saveSettings(85 ctx.kepr,86 Object.fromEntries(87 NOTIFICATION_KINDS.map((k) => [`notify_off_${k}`, fields[k] === "1" ? "" : "1"]),88 ),89 );90 } else return c.text("Unknown action.", 400);91 return c.redirect("/admin/notifications?saved=1", 303);92 });93}