CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
notifications.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3ef4c9d | 1 | /** |
| 2 | * Notifications — the central inbox for every event the user needs to act on. | |
| 3 | * | |
| 4 | * GET /notifications — full inbox UI, filterable (all / unread / mentions) | |
| 5 | * POST /notifications/:id/read — mark single notification read | |
| 6 | * POST /notifications/read-all — mark every unread notification read | |
| 7 | * POST /notifications/:id/delete — dismiss a notification | |
| 8 | * GET /api/notifications/unread — JSON count for nav badge | |
| 9 | * GET /api/notifications — JSON list for third-party integrations | |
| 10 | */ | |
| 11 | ||
| 12 | import { Hono } from "hono"; | |
| 13 | import { and, desc, eq, isNull, sql } from "drizzle-orm"; | |
| 14 | import { db } from "../db"; | |
| 15 | import { notifications, repositories, users } from "../db/schema"; | |
| 16 | import { Layout } from "../views/layout"; | |
| 17 | import { requireAuth, softAuth } from "../middleware/auth"; | |
| 18 | import type { AuthEnv } from "../middleware/auth"; | |
| 19 | ||
| 20 | const notificationsRoute = new Hono<AuthEnv>(); | |
| 21 | ||
| 22 | notificationsRoute.use("*", softAuth); | |
| 23 | ||
| 24 | /** | |
| 25 | * Cheap count of unread notifications — used by the nav badge. | |
| 26 | * Returns 0 if the user is not logged in or DB is unreachable. | |
| 27 | */ | |
| 28 | export async function getUnreadCount(userId: string): Promise<number> { | |
| 29 | try { | |
| 30 | const [row] = await db | |
| 31 | .select({ c: sql<number>`count(*)::int` }) | |
| 32 | .from(notifications) | |
| 33 | .where(and(eq(notifications.userId, userId), isNull(notifications.readAt))); | |
| 34 | return row?.c ?? 0; | |
| 35 | } catch { | |
| 36 | return 0; | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | function kindBadge(kind: string): { label: string; color: string } { | |
| 41 | switch (kind) { | |
| 42 | case "mention": | |
| 43 | return { label: "@", color: "#58a6ff" }; | |
| 44 | case "review_requested": | |
| 45 | return { label: "review", color: "#d29922" }; | |
| 46 | case "pr_opened": | |
| 47 | case "pr_merged": | |
| 48 | case "pr_closed": | |
| 49 | return { label: "PR", color: "#986ee2" }; | |
| 50 | case "issue_opened": | |
| 51 | case "issue_closed": | |
| 52 | return { label: "issue", color: "#3fb950" }; | |
| 53 | case "gate_failed": | |
| 54 | return { label: "gate", color: "#f85149" }; | |
| 55 | case "gate_repaired": | |
| 56 | return { label: "repaired", color: "#bc8cff" }; | |
| 57 | case "gate_passed": | |
| 58 | return { label: "green", color: "#3fb950" }; | |
| 59 | case "security_alert": | |
| 60 | return { label: "security", color: "#f85149" }; | |
| 61 | case "deploy_success": | |
| 62 | return { label: "deploy", color: "#3fb950" }; | |
| 63 | case "deploy_failed": | |
| 64 | return { label: "deploy", color: "#f85149" }; | |
| 65 | case "release_published": | |
| 66 | return { label: "release", color: "#58a6ff" }; | |
| 67 | case "ai_review": | |
| 68 | return { label: "ai", color: "#bc8cff" }; | |
| 69 | default: | |
| 70 | return { label: kind, color: "#8b949e" }; | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | function formatRelative(date: Date | string): string { | |
| 75 | const d = typeof date === "string" ? new Date(date) : date; | |
| 76 | const diffMs = Date.now() - d.getTime(); | |
| 77 | const diffMins = Math.floor(diffMs / 60000); | |
| 78 | if (diffMins < 1) return "just now"; | |
| 79 | if (diffMins < 60) return `${diffMins}m ago`; | |
| 80 | const diffHours = Math.floor(diffMins / 60); | |
| 81 | if (diffHours < 24) return `${diffHours}h ago`; | |
| 82 | const diffDays = Math.floor(diffHours / 24); | |
| 83 | if (diffDays < 30) return `${diffDays}d ago`; | |
| 84 | return d.toLocaleDateString(); | |
| 85 | } | |
| 86 | ||
| 87 | // ---------- Web UI ---------- | |
| 88 | ||
| 89 | notificationsRoute.get("/notifications", requireAuth, async (c) => { | |
| 90 | const user = c.get("user")!; | |
| 91 | const filter = c.req.query("filter") || "all"; // all | unread | mentions | |
| 92 | ||
| 93 | let rows: Array<{ | |
| 94 | id: string; | |
| 95 | kind: string; | |
| 96 | title: string; | |
| 97 | body: string | null; | |
| 98 | url: string | null; | |
| 99 | readAt: Date | null; | |
| 100 | createdAt: Date; | |
| 101 | repoName: string | null; | |
| 102 | repoOwner: string | null; | |
| 103 | }> = []; | |
| 104 | ||
| 105 | try { | |
| 106 | const owners = users; | |
| 107 | const base = db | |
| 108 | .select({ | |
| 109 | id: notifications.id, | |
| 110 | kind: notifications.kind, | |
| 111 | title: notifications.title, | |
| 112 | body: notifications.body, | |
| 113 | url: notifications.url, | |
| 114 | readAt: notifications.readAt, | |
| 115 | createdAt: notifications.createdAt, | |
| 116 | repoName: repositories.name, | |
| 117 | repoOwner: owners.username, | |
| 118 | }) | |
| 119 | .from(notifications) | |
| 120 | .leftJoin(repositories, eq(notifications.repositoryId, repositories.id)) | |
| 121 | .leftJoin(owners, eq(repositories.ownerId, owners.id)); | |
| 122 | ||
| 123 | if (filter === "unread") { | |
| 124 | rows = await base | |
| 125 | .where( | |
| 126 | and(eq(notifications.userId, user.id), isNull(notifications.readAt)) | |
| 127 | ) | |
| 128 | .orderBy(desc(notifications.createdAt)) | |
| 129 | .limit(100); | |
| 130 | } else if (filter === "mentions") { | |
| 131 | rows = await base | |
| 132 | .where( | |
| 133 | and( | |
| 134 | eq(notifications.userId, user.id), | |
| 135 | eq(notifications.kind, "mention") | |
| 136 | ) | |
| 137 | ) | |
| 138 | .orderBy(desc(notifications.createdAt)) | |
| 139 | .limit(100); | |
| 140 | } else { | |
| 141 | rows = await base | |
| 142 | .where(eq(notifications.userId, user.id)) | |
| 143 | .orderBy(desc(notifications.createdAt)) | |
| 144 | .limit(100); | |
| 145 | } | |
| 146 | } catch (err) { | |
| 147 | console.error("[notifications] list:", err); | |
| 148 | } | |
| 149 | ||
| 150 | const unreadCount = rows.filter((r) => !r.readAt).length; | |
| 151 | ||
| 152 | return c.html( | |
| 153 | <Layout title="Notifications" user={user}> | |
| 154 | <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px"> | |
| 155 | <h2>Notifications</h2> | |
| 156 | {unreadCount > 0 && ( | |
| 157 | <form method="POST" action="/notifications/read-all"> | |
| 158 | <button type="submit" class="btn btn-sm"> | |
| 159 | Mark all as read | |
| 160 | </button> | |
| 161 | </form> | |
| 162 | )} | |
| 163 | </div> | |
| 164 | ||
| 165 | <div class="issue-tabs" style="margin-bottom: 16px"> | |
| 166 | <a href="/notifications" class={filter === "all" ? "active" : ""}> | |
| 167 | All | |
| 168 | </a> | |
| 169 | <a | |
| 170 | href="/notifications?filter=unread" | |
| 171 | class={filter === "unread" ? "active" : ""} | |
| 172 | > | |
| 173 | Unread | |
| 174 | </a> | |
| 175 | <a | |
| 176 | href="/notifications?filter=mentions" | |
| 177 | class={filter === "mentions" ? "active" : ""} | |
| 178 | > | |
| 179 | Mentions | |
| 180 | </a> | |
| 181 | </div> | |
| 182 | ||
| 183 | {rows.length === 0 ? ( | |
| 184 | <div class="empty-state"> | |
| 185 | <h2>Inbox zero</h2> | |
| 186 | <p>You're all caught up.</p> | |
| 187 | </div> | |
| 188 | ) : ( | |
| 189 | <div class="notification-list"> | |
| 190 | {rows.map((n) => { | |
| 191 | const badge = kindBadge(n.kind); | |
| 192 | const unread = !n.readAt; | |
| 193 | return ( | |
| 194 | <div class={`notification-item${unread ? " unread" : ""}`}> | |
| 195 | <span | |
| 196 | class="notification-badge" | |
| 197 | style={`background: ${badge.color}20; color: ${badge.color}; border-color: ${badge.color}`} | |
| 198 | > | |
| 199 | {badge.label} | |
| 200 | </span> | |
| 201 | <div class="notification-body"> | |
| 202 | <div class="notification-title"> | |
| 203 | {n.url ? ( | |
| 204 | <a href={n.url}>{n.title}</a> | |
| 205 | ) : ( | |
| 206 | <span>{n.title}</span> | |
| 207 | )} | |
| 208 | </div> | |
| 209 | {n.body && ( | |
| 210 | <div class="notification-desc">{n.body}</div> | |
| 211 | )} | |
| 212 | <div class="notification-meta"> | |
| 213 | {n.repoOwner && n.repoName && ( | |
| 214 | <> | |
| 215 | <a href={`/${n.repoOwner}/${n.repoName}`}> | |
| 216 | {n.repoOwner}/{n.repoName} | |
| 217 | </a> | |
| 218 | <span> · </span> | |
| 219 | </> | |
| 220 | )} | |
| 221 | <span>{formatRelative(n.createdAt)}</span> | |
| 222 | </div> | |
| 223 | </div> | |
| 224 | <div class="notification-actions"> | |
| 225 | {unread && ( | |
| 226 | <form method="POST" action={`/notifications/${n.id}/read`}> | |
| 227 | <button | |
| 228 | type="submit" | |
| 229 | class="btn btn-sm" | |
| 230 | title="Mark as read" | |
| 231 | > | |
| 232 | {"\u2713"} | |
| 233 | </button> | |
| 234 | </form> | |
| 235 | )} | |
| 236 | <form method="POST" action={`/notifications/${n.id}/delete`}> | |
| 237 | <button | |
| 238 | type="submit" | |
| 239 | class="btn btn-sm" | |
| 240 | title="Dismiss" | |
| 241 | > | |
| 242 | {"\u00D7"} | |
| 243 | </button> | |
| 244 | </form> | |
| 245 | </div> | |
| 246 | </div> | |
| 247 | ); | |
| 248 | })} | |
| 249 | </div> | |
| 250 | )} | |
| 251 | </Layout> | |
| 252 | ); | |
| 253 | }); | |
| 254 | ||
| 255 | notificationsRoute.post("/notifications/:id/read", requireAuth, async (c) => { | |
| 256 | const user = c.get("user")!; | |
| 257 | const id = c.req.param("id"); | |
| 258 | try { | |
| 259 | await db | |
| 260 | .update(notifications) | |
| 261 | .set({ readAt: new Date() }) | |
| 262 | .where(and(eq(notifications.id, id), eq(notifications.userId, user.id))); | |
| 263 | } catch (err) { | |
| 264 | console.error("[notifications] mark read:", err); | |
| 265 | } | |
| 266 | return c.redirect("/notifications"); | |
| 267 | }); | |
| 268 | ||
| 269 | notificationsRoute.post("/notifications/read-all", requireAuth, async (c) => { | |
| 270 | const user = c.get("user")!; | |
| 271 | try { | |
| 272 | await db | |
| 273 | .update(notifications) | |
| 274 | .set({ readAt: new Date() }) | |
| 275 | .where( | |
| 276 | and(eq(notifications.userId, user.id), isNull(notifications.readAt)) | |
| 277 | ); | |
| 278 | } catch (err) { | |
| 279 | console.error("[notifications] read-all:", err); | |
| 280 | } | |
| 281 | return c.redirect("/notifications"); | |
| 282 | }); | |
| 283 | ||
| 284 | notificationsRoute.post("/notifications/:id/delete", requireAuth, async (c) => { | |
| 285 | const user = c.get("user")!; | |
| 286 | const id = c.req.param("id"); | |
| 287 | try { | |
| 288 | await db | |
| 289 | .delete(notifications) | |
| 290 | .where(and(eq(notifications.id, id), eq(notifications.userId, user.id))); | |
| 291 | } catch (err) { | |
| 292 | console.error("[notifications] delete:", err); | |
| 293 | } | |
| 294 | return c.redirect(c.req.header("referer") || "/notifications"); | |
| 295 | }); | |
| 296 | ||
| 297 | // ---------- JSON API ---------- | |
| 298 | ||
| 299 | notificationsRoute.get("/api/notifications/unread", requireAuth, async (c) => { | |
| 300 | const user = c.get("user")!; | |
| 301 | const count = await getUnreadCount(user.id); | |
| 302 | return c.json({ count }); | |
| 303 | }); | |
| 304 | ||
| 305 | notificationsRoute.get("/api/notifications", requireAuth, async (c) => { | |
| 306 | const user = c.get("user")!; | |
| 307 | try { | |
| 308 | const rows = await db | |
| 309 | .select() | |
| 310 | .from(notifications) | |
| 311 | .where(eq(notifications.userId, user.id)) | |
| 312 | .orderBy(desc(notifications.createdAt)) | |
| 313 | .limit(100); | |
| 314 | return c.json(rows); | |
| 315 | } catch { | |
| 316 | return c.json([]); | |
| 317 | } | |
| 318 | }); | |
| 319 | ||
| 320 | export default notificationsRoute; |