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

notify.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

notify.tsBlame309 lines · 1 contributor
3ef4c9dClaude1/**
2 * Notifications + audit log helpers.
3 * Swallows DB failures so notifications never break the primary request path.
24cf2caClaude4 *
5 * Email fan-out (Block A8):
6 * For certain kinds (mention / assigned / gate_failed / review_requested)
7 * we ALSO send an email, if the recipient has opted in via their profile
8 * preferences. Email failures are logged and swallowed.
3ef4c9dClaude9 */
10
b7ecb14Claude11import { inArray, eq, and, desc, sql } from "drizzle-orm";
3ef4c9dClaude12import { db } from "../db";
b7ecb14Claude13import { notifications as notificationsMain, auditLog, users } from "../db/schema";
14import { notifications as notificationsExt } from "../db/schema-extensions";
24cf2caClaude15import { sendEmail, absoluteUrl } from "./email";
3ef4c9dClaude16
b7ecb14Claude17// ─── 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
3ef4c9dClaude98export type NotificationKind =
99 | "mention"
100 | "review_requested"
101 | "pr_opened"
102 | "pr_merged"
103 | "pr_closed"
104 | "issue_opened"
105 | "issue_closed"
106 | "assigned"
107 | "ai_review"
108 | "gate_failed"
109 | "gate_repaired"
110 | "gate_passed"
111 | "security_alert"
112 | "deploy_success"
113 | "deploy_failed"
25a91a6Claude114 | "deployment_approval"
3ef4c9dClaude115 | "release_published"
534f04aClaude116 | "repo_archived"
117 | "pr_stale"
118 | "issue_stale";
3ef4c9dClaude119
24cf2caClaude120/** Kinds that can trigger email delivery. Keep this list conservative — any
121 * kind here must map to a user preference column on the users table. */
122const EMAIL_ELIGIBLE: ReadonlySet<NotificationKind> = new Set([
123 "mention",
124 "review_requested",
125 "assigned",
126 "gate_failed",
127]);
128
129/** Map notification kind → user preference column name. */
130function prefFor(kind: NotificationKind):
131 | "notifyEmailOnMention"
132 | "notifyEmailOnAssign"
133 | "notifyEmailOnGateFail"
134 | null {
135 switch (kind) {
136 case "mention":
137 case "review_requested":
138 return "notifyEmailOnMention";
139 case "assigned":
140 return "notifyEmailOnAssign";
141 case "gate_failed":
142 return "notifyEmailOnGateFail";
143 default:
144 return null;
145 }
146}
147
148function subjectFor(kind: NotificationKind, title: string): string {
149 const tag =
150 kind === "gate_failed"
151 ? "[gate failed]"
152 : kind === "assigned"
153 ? "[assigned]"
154 : kind === "review_requested"
155 ? "[review requested]"
156 : kind === "mention"
157 ? "[mention]"
158 : `[${kind}]`;
159 return `${tag} ${title}`.slice(0, 180);
160}
161
162function bodyFor(title: string, body: string | undefined, url: string | undefined): string {
163 const lines = [title];
164 if (body) lines.push("", body);
165 if (url) lines.push("", absoluteUrl(url));
166 lines.push("", "—", "You can opt out of these emails at /settings.");
167 return lines.join("\n");
168}
169
170async function maybeEmail(
171 userIds: string[],
172 kind: NotificationKind,
173 opts: { title: string; body?: string; url?: string }
174): Promise<void> {
175 if (!EMAIL_ELIGIBLE.has(kind)) return;
176 const prefCol = prefFor(kind);
177 if (!prefCol) return;
178 if (userIds.length === 0) return;
179
180 let recipients: Array<{ email: string; pref: boolean }> = [];
181 try {
182 const rows = await db
183 .select({
184 id: users.id,
185 email: users.email,
186 mention: users.notifyEmailOnMention,
187 assign: users.notifyEmailOnAssign,
188 gate: users.notifyEmailOnGateFail,
189 })
190 .from(users)
191 .where(inArray(users.id, userIds));
192 recipients = rows.map((r) => ({
193 email: r.email,
194 pref:
195 prefCol === "notifyEmailOnMention"
196 ? r.mention
197 : prefCol === "notifyEmailOnAssign"
198 ? r.assign
199 : r.gate,
200 }));
201 } catch (err) {
202 console.error("[notify] email recipient lookup failed:", err);
203 return;
204 }
205
206 const subject = subjectFor(kind, opts.title);
207 const text = bodyFor(opts.title, opts.body, opts.url);
208
209 // Fire in parallel; each call swallows its own errors.
210 await Promise.all(
211 recipients
212 .filter((r) => r.pref && r.email)
213 .map((r) =>
214 sendEmail({ to: r.email, subject, text }).catch((err) => {
215 console.error("[notify] sendEmail threw:", err);
216 return { ok: false as const, provider: "none" as const };
217 })
218 )
219 );
220}
221
3ef4c9dClaude222export async function notify(
223 userId: string,
224 opts: {
225 kind: NotificationKind;
226 title: string;
227 body?: string;
228 url?: string;
229 repositoryId?: string;
230 }
231): Promise<void> {
232 try {
b7ecb14Claude233 await db.insert(notificationsMain).values({
3ef4c9dClaude234 userId,
235 kind: opts.kind,
236 title: opts.title,
237 body: opts.body,
238 url: opts.url,
239 repositoryId: opts.repositoryId,
240 });
241 } catch (err) {
242 console.error("[notify] failed:", err);
243 }
24cf2caClaude244 await maybeEmail([userId], opts.kind, opts);
3ef4c9dClaude245}
246
247export async function notifyMany(
248 userIds: string[],
249 opts: {
250 kind: NotificationKind;
251 title: string;
252 body?: string;
253 url?: string;
254 repositoryId?: string;
255 }
256): Promise<void> {
257 const unique = Array.from(new Set(userIds));
258 if (unique.length === 0) return;
259 try {
b7ecb14Claude260 await db.insert(notificationsMain).values(
3ef4c9dClaude261 unique.map((userId) => ({
262 userId,
263 kind: opts.kind,
264 title: opts.title,
265 body: opts.body,
266 url: opts.url,
267 repositoryId: opts.repositoryId,
268 }))
269 );
270 } catch (err) {
271 console.error("[notify] batch failed:", err);
272 }
24cf2caClaude273 await maybeEmail(unique, opts.kind, opts);
3ef4c9dClaude274}
275
276export async function audit(opts: {
277 userId?: string | null;
278 repositoryId?: string | null;
279 action: string;
280 targetType?: string;
281 targetId?: string;
282 ip?: string;
283 userAgent?: string;
284 metadata?: Record<string, unknown>;
285}): Promise<void> {
286 try {
287 await db.insert(auditLog).values({
288 userId: opts.userId ?? null,
289 repositoryId: opts.repositoryId ?? null,
290 action: opts.action,
291 targetType: opts.targetType,
292 targetId: opts.targetId,
293 ip: opts.ip,
294 userAgent: opts.userAgent,
295 metadata: opts.metadata ? JSON.stringify(opts.metadata) : null,
296 });
297 } catch (err) {
298 // Audit must never break the primary flow
299 console.error("[audit] failed:", err);
300 }
301}
24cf2caClaude302
303/** Test-only hook so unit tests can assert the kind→pref mapping. */
304export const __internal = {
305 EMAIL_ELIGIBLE,
306 prefFor,
307 subjectFor,
308 bodyFor,
309};