Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitb7ecb14unknown_key

feat: notification center — bell polling, PR/issue comment alerts, helper API

feat: notification center — bell polling, PR/issue comment alerts, helper API

- Add createNotification/getUnreadCount/getNotifications/markRead/markAllRead
  to src/lib/notify.ts, operating on the schema-extensions notifications table
- Fire-and-forget createNotification in issues.tsx and pulls.tsx comment
  handlers to notify issue/PR authors when someone comments on their thread
- Fix /api/notifications/count to return { unread, count } so both the new
  bell poller and any legacy callers work without changes
- Add bellPollerScript to layout.tsx that polls /api/notifications/count every
  60 s and live-updates the nav inbox badge for authenticated users
- Add migration 0077 to backfill type/is_read/actor_id columns on the
  notifications table (schema-extensions variant requires them)

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: 6e41e81
6 files changed+1917b7ecb14ee4eb009a4acdf6975fb0f16f431f1f91
6 changed files+191−7
Addeddrizzle/0077_notifications.sql+20−0View fileUnifiedSplit
1-- Notification Center (Block A7 follow-up).
2--
3-- The initial 0001 migration created the `notifications` table with the
4-- `kind` / `read_at` column set used by src/lib/notify.ts. The inbox
5-- UI (src/routes/notifications.tsx) was later built against the
6-- schema-extensions variant which uses `type` / `is_read` / `actor_id`.
7-- This migration adds the missing columns so both surfaces operate on the
8-- same physical table without a breaking rename.
9--
10-- All columns are additive (IF NOT EXISTS) so this migration is idempotent
11-- and safe to run against databases that already have these columns.
12
13ALTER TABLE "notifications"
14 ADD COLUMN IF NOT EXISTS "type" text,
15 ADD COLUMN IF NOT EXISTS "is_read" boolean NOT NULL DEFAULT false,
16 ADD COLUMN IF NOT EXISTS "actor_id" uuid REFERENCES "users"("id") ON DELETE SET NULL;
17
18-- Index used by the inbox page to find unread notifications quickly.
19CREATE INDEX IF NOT EXISTS "notifications_user_unread_is_read"
20 ON "notifications" ("user_id", "is_read", "created_at" DESC);
Modifiedsrc/lib/notify.ts+86−4View fileUnifiedSplit
88 * preferences. Email failures are logged and swallowed.
99 */
1010
11import { inArray, eq } from "drizzle-orm";
11import { inArray, eq, and, desc, sql } from "drizzle-orm";
1212import { db } from "../db";
13import { notifications, auditLog, users } from "../db/schema";
13import { notifications as notificationsMain, auditLog, users } from "../db/schema";
14import { notifications as notificationsExt } from "../db/schema-extensions";
1415import { sendEmail, absoluteUrl } from "./email";
1516
17// ─── Public helpers (schema-extensions notifications table) ─────────────────
18// These operate on the schema-extensions `notifications` table (which has
19// `type`, `isRead`, `actorId` columns) — the same table the inbox page uses.
20
21/** Create a single in-app notification. Fire-and-forget safe: swallows errors. */
22export async function createNotification(params: {
23 userId: string;
24 type: string;
25 title: string;
26 body?: string;
27 url?: string;
28 repoId?: string;
29}): Promise<void> {
30 try {
31 await db.insert(notificationsExt).values({
32 userId: params.userId,
33 type: params.type,
34 title: params.title,
35 body: params.body,
36 url: params.url,
37 repositoryId: params.repoId,
38 });
39 } catch (err) {
40 console.error("[createNotification] failed:", err);
41 }
42}
43
44/** Return the count of unread notifications for a user. Returns 0 on error. */
45export async function getUnreadCount(userId: string): Promise<number> {
46 try {
47 const [row] = await db
48 .select({ count: sql<number>`count(*)` })
49 .from(notificationsExt)
50 .where(and(eq(notificationsExt.userId, userId), eq(notificationsExt.isRead, false)));
51 return Number(row?.count ?? 0);
52 } catch {
53 return 0;
54 }
55}
56
57/** Return notifications for a user (most recent first). Returns [] on error. */
58export async function getNotifications(
59 userId: string,
60 limit = 50
61): Promise<typeof notificationsExt.$inferSelect[]> {
62 try {
63 return await db
64 .select()
65 .from(notificationsExt)
66 .where(eq(notificationsExt.userId, userId))
67 .orderBy(desc(notificationsExt.createdAt))
68 .limit(limit);
69 } catch {
70 return [];
71 }
72}
73
74/** Mark a single notification as read (only if it belongs to userId). */
75export async function markRead(notificationId: string, userId: string): Promise<void> {
76 try {
77 await db
78 .update(notificationsExt)
79 .set({ isRead: true })
80 .where(and(eq(notificationsExt.id, notificationId), eq(notificationsExt.userId, userId)));
81 } catch (err) {
82 console.error("[markRead] failed:", err);
83 }
84}
85
86/** Mark all of a user's notifications as read. */
87export async function markAllRead(userId: string): Promise<void> {
88 try {
89 await db
90 .update(notificationsExt)
91 .set({ isRead: true })
92 .where(and(eq(notificationsExt.userId, userId), eq(notificationsExt.isRead, false)));
93 } catch (err) {
94 console.error("[markAllRead] failed:", err);
95 }
96}
97
1698export type NotificationKind =
1799 | "mention"
18100 | "review_requested"
148230 }
149231): Promise<void> {
150232 try {
151 await db.insert(notifications).values({
233 await db.insert(notificationsMain).values({
152234 userId,
153235 kind: opts.kind,
154236 title: opts.title,
175257 const unique = Array.from(new Set(userIds));
176258 if (unique.length === 0) return;
177259 try {
178 await db.insert(notifications).values(
260 await db.insert(notificationsMain).values(
179261 unique.map((userId) => ({
180262 userId,
181263 kind: opts.kind,
Modifiedsrc/routes/issues.tsx+13−0View fileUnifiedSplit
16991699 } catch {
17001700 /* SSE is best-effort */
17011701 }
1702 // Notify the issue author — fire-and-forget, never blocks the response.
1703 if (issue.authorId && issue.authorId !== user.id) {
1704 void import("../lib/notify").then(({ createNotification }) =>
1705 createNotification({
1706 userId: issue.authorId,
1707 type: "issue_comment",
1708 title: `New comment on "${issue.title}"`,
1709 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
1710 url: `/${ownerName}/${repoName}/issues/${issueNum}`,
1711 repoId: resolved.repo.id,
1712 })
1713 ).catch(() => { /* never block the response */ });
1714 }
17021715 }
17031716
17041717 if (decision.status === "pending") {
Modifiedsrc/routes/notifications.tsx+6−3View fileUnifiedSplit
786786});
787787
788788// API: Get unread count (for bell icon polling)
789// Returns both `unread` (canonical) and `count` (legacy alias) so the nav
790// bell polling script and any older callers both work without changes.
789791notificationRoutes.get("/api/notifications/count", softAuth, async (c) => {
790792 const user = c.get("user");
791 if (!user) return c.json({ count: 0 });
793 if (!user) return c.json({ unread: 0, count: 0 });
792794
793795 try {
794796 const [result] = await db
795797 .select({ count: sql<number>`count(*)` })
796798 .from(notifications)
797799 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
798 return c.json({ count: result?.count ?? 0 });
800 const unread = Number(result?.count ?? 0);
801 return c.json({ unread, count: unread });
799802 } catch {
800 return c.json({ count: 0 });
803 return c.json({ unread: 0, count: 0 });
801804 }
802805});
803806
Modifiedsrc/routes/pulls.tsx+13−0View fileUnifiedSplit
45454545 } catch {
45464546 /* SSE is best-effort */
45474547 }
4548 // Notify the PR author — fire-and-forget, never blocks the response.
4549 if (pr.authorId && pr.authorId !== user.id) {
4550 void import("../lib/notify").then(({ createNotification }) =>
4551 createNotification({
4552 userId: pr.authorId,
4553 type: "pr_comment",
4554 title: `New comment on "${pr.title}"`,
4555 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
4556 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
4557 repoId: resolved.repo.id,
4558 })
4559 ).catch(() => { /* never block the response */ });
4560 }
45484561 }
45494562
45504563 if (decision.status === "pending") {
Modifiedsrc/views/layout.tsx+53−0View fileUnifiedSplit
427427 <script dangerouslySetInnerHTML={{ __html: toastScript }} />
428428 <script dangerouslySetInnerHTML={{ __html: navScript }} />
429429 <script dangerouslySetInnerHTML={{ __html: navAiDropdownScript }} />
430 {/* Bell badge poller — only rendered for authenticated users.
431 Polls /api/notifications/count every 60 s and updates the badge
432 on the inbox bell icon. Falls back gracefully if the endpoint is
433 unavailable or the user signs out mid-session. */}
434 {user && (
435 <script dangerouslySetInnerHTML={{ __html: bellPollerScript }} />
436 )}
430437 </body>
431438 </html>
432439 );
931938 })();
932939`;
933940
941// Bell poller — updates the inbox badge count every 60 s via /api/notifications/count.
942// Only injected when a user is logged in (checked in the JSX above). Uses the
943// `.nav-inbox-badge` element that the server renders inside `.nav-inbox-btn`.
944// If the badge element is absent (user not logged in, DOM mismatch) it exits
945// cleanly without error.
946export const bellPollerScript = `
947(function() {
948 var badge = document.querySelector('.nav-inbox-btn .nav-inbox-badge');
949 var btn = document.querySelector('.nav-inbox-btn');
950 function updateBadge(n) {
951 if (!btn) return;
952 if (n > 0) {
953 var label = n > 99 ? '99+' : String(n);
954 if (!badge) {
955 badge = document.createElement('span');
956 badge.className = 'nav-inbox-badge';
957 badge.setAttribute('aria-hidden', 'true');
958 btn.appendChild(badge);
959 }
960 badge.textContent = label;
961 badge.style.display = '';
962 btn.setAttribute('aria-label', 'Inbox — ' + n + ' unread');
963 } else {
964 if (badge) badge.style.display = 'none';
965 btn.setAttribute('aria-label', 'Inbox');
966 }
967 }
968 function poll() {
969 fetch('/api/notifications/count', { credentials: 'same-origin', cache: 'no-store' })
970 .then(function(r) { return r.ok ? r.json() : null; })
971 .then(function(d) {
972 if (!d) return;
973 var n = typeof d.unread === 'number' ? d.unread : (typeof d.count === 'number' ? d.count : 0);
974 updateBadge(n);
975 })
976 .catch(function() {});
977 }
978 if (document.readyState === 'loading') {
979 document.addEventListener('DOMContentLoaded', function() { poll(); setInterval(poll, 60000); });
980 } else {
981 poll();
982 setInterval(poll, 60000);
983 }
984})();
985`;
986
934987// AI dropdown — keyboard- and click-accessible menu in the top nav.
935988// CSS handles the hover-open behaviour for pointer users; this script
936989// adds click-to-toggle for touch, Escape-to-close, and outside-click-
937990