notifications.ts

3.6 KB · raw

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: "someone replies to an issue",13	sync_failed: "a repo's sync starts failing",14	backup_failed: "a backup starts failing",15};1617/** §24's Notifications section: the list, opening one, mark all read, and which kinds to raise. */18export 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			// A box left unticked is a kind switched off; the setting records only that.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}