Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

notifications.tsxBlame320 lines · 1 contributor
3ef4c9dClaude1/**
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
12import { Hono } from "hono";
13import { and, desc, eq, isNull, sql } from "drizzle-orm";
14import { db } from "../db";
15import { notifications, repositories, users } from "../db/schema";
16import { Layout } from "../views/layout";
17import { requireAuth, softAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19
20const notificationsRoute = new Hono<AuthEnv>();
21
22notificationsRoute.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 */
28export 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
40function 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
74function 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
89notificationsRoute.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
255notificationsRoute.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
269notificationsRoute.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
284notificationsRoute.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
299notificationsRoute.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
305notificationsRoute.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
320export default notificationsRoute;