Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame352 lines · 4 contributors
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";
b7b5f75ccanty labs13import { notifications as notificationsMain, auditLog, activityFeed, users } from "../db/schema";
b7ecb14Claude14import { notifications as notificationsExt } from "../db/schema-extensions";
24cf2caClaude15import { sendEmail, absoluteUrl } from "./email";
3ef4c9dClaude16
b7ecb14Claude17// ─── Public helpers (schema-extensions notifications table) ─────────────────
45ac593ccantynz-alt18// There is one `notifications` table. It carries two parallel representations
19// of the same facts — `kind`/`read_at` (original) and `type`/`is_read` (added
20// by migration 0089) — because 0089 added columns rather than migrating data.
21// A second Drizzle definition in schema-extensions.ts described only the 0089
22// half, which hid the fact that `kind` is NOT NULL: this insert supplied only
23// `type`, so EVERY notification created through here violated the constraint
24// and was swallowed by the catch below. Write both until the representations
25// are consolidated (which needs a backfill).
b7ecb14Claude26
27/** Create a single in-app notification. Fire-and-forget safe: swallows errors. */
28export async function createNotification(params: {
29 userId: string;
30 type: string;
31 title: string;
32 body?: string;
33 url?: string;
34 repoId?: string;
35}): Promise<void> {
36 try {
37 await db.insert(notificationsExt).values({
38 userId: params.userId,
45ac593ccantynz-alt39 kind: params.type, // NOT NULL — was omitted, so every insert failed
b7ecb14Claude40 type: params.type,
41 title: params.title,
42 body: params.body,
43 url: params.url,
44 repositoryId: params.repoId,
45 });
46 } catch (err) {
47 console.error("[createNotification] failed:", err);
48 }
49}
50
51/** Return the count of unread notifications for a user. Returns 0 on error. */
52export async function getUnreadCount(userId: string): Promise<number> {
53 try {
54 const [row] = await db
02aa828ccantynz-alt55 .select({ count: sql<number>`count(*)::int` })
b7ecb14Claude56 .from(notificationsExt)
57 .where(and(eq(notificationsExt.userId, userId), eq(notificationsExt.isRead, false)));
58 return Number(row?.count ?? 0);
59 } catch {
60 return 0;
61 }
62}
63
64/** Return notifications for a user (most recent first). Returns [] on error. */
65export async function getNotifications(
66 userId: string,
67 limit = 50
68): Promise<typeof notificationsExt.$inferSelect[]> {
69 try {
70 return await db
71 .select()
72 .from(notificationsExt)
73 .where(eq(notificationsExt.userId, userId))
74 .orderBy(desc(notificationsExt.createdAt))
75 .limit(limit);
76 } catch {
77 return [];
78 }
79}
80
81/** Mark a single notification as read (only if it belongs to userId). */
82export async function markRead(notificationId: string, userId: string): Promise<void> {
83 try {
84 await db
85 .update(notificationsExt)
86 .set({ isRead: true })
87 .where(and(eq(notificationsExt.id, notificationId), eq(notificationsExt.userId, userId)));
88 } catch (err) {
89 console.error("[markRead] failed:", err);
90 }
91}
92
93/** Mark all of a user's notifications as read. */
94export async function markAllRead(userId: string): Promise<void> {
95 try {
96 await db
97 .update(notificationsExt)
98 .set({ isRead: true })
99 .where(and(eq(notificationsExt.userId, userId), eq(notificationsExt.isRead, false)));
100 } catch (err) {
101 console.error("[markAllRead] failed:", err);
102 }
103}
104
3ef4c9dClaude105export type NotificationKind =
106 | "mention"
107 | "review_requested"
108 | "pr_opened"
109 | "pr_merged"
110 | "pr_closed"
111 | "issue_opened"
112 | "issue_closed"
113 | "assigned"
114 | "ai_review"
115 | "gate_failed"
116 | "gate_repaired"
117 | "gate_passed"
118 | "security_alert"
119 | "deploy_success"
120 | "deploy_failed"
25a91a6Claude121 | "deployment_approval"
3ef4c9dClaude122 | "release_published"
534f04aClaude123 | "repo_archived"
124 | "pr_stale"
e2206a6Test User125 | "issue_stale"
126 | "integration_dead";
3ef4c9dClaude127
24cf2caClaude128/** Kinds that can trigger email delivery. Keep this list conservative — any
129 * kind here must map to a user preference column on the users table. */
130const EMAIL_ELIGIBLE: ReadonlySet<NotificationKind> = new Set([
131 "mention",
132 "review_requested",
133 "assigned",
134 "gate_failed",
135]);
136
137/** Map notification kind → user preference column name. */
138function prefFor(kind: NotificationKind):
139 | "notifyEmailOnMention"
140 | "notifyEmailOnAssign"
141 | "notifyEmailOnGateFail"
142 | null {
143 switch (kind) {
144 case "mention":
145 case "review_requested":
146 return "notifyEmailOnMention";
147 case "assigned":
148 return "notifyEmailOnAssign";
149 case "gate_failed":
150 return "notifyEmailOnGateFail";
151 default:
152 return null;
153 }
154}
155
156function subjectFor(kind: NotificationKind, title: string): string {
157 const tag =
158 kind === "gate_failed"
159 ? "[gate failed]"
160 : kind === "assigned"
161 ? "[assigned]"
162 : kind === "review_requested"
163 ? "[review requested]"
164 : kind === "mention"
165 ? "[mention]"
166 : `[${kind}]`;
167 return `${tag} ${title}`.slice(0, 180);
168}
169
170function bodyFor(title: string, body: string | undefined, url: string | undefined): string {
171 const lines = [title];
172 if (body) lines.push("", body);
173 if (url) lines.push("", absoluteUrl(url));
174 lines.push("", "—", "You can opt out of these emails at /settings.");
175 return lines.join("\n");
176}
177
178async function maybeEmail(
179 userIds: string[],
180 kind: NotificationKind,
181 opts: { title: string; body?: string; url?: string }
182): Promise<void> {
183 if (!EMAIL_ELIGIBLE.has(kind)) return;
184 const prefCol = prefFor(kind);
185 if (!prefCol) return;
186 if (userIds.length === 0) return;
187
188 let recipients: Array<{ email: string; pref: boolean }> = [];
189 try {
190 const rows = await db
191 .select({
192 id: users.id,
193 email: users.email,
194 mention: users.notifyEmailOnMention,
195 assign: users.notifyEmailOnAssign,
196 gate: users.notifyEmailOnGateFail,
197 })
198 .from(users)
199 .where(inArray(users.id, userIds));
200 recipients = rows.map((r) => ({
201 email: r.email,
202 pref:
203 prefCol === "notifyEmailOnMention"
204 ? r.mention
205 : prefCol === "notifyEmailOnAssign"
206 ? r.assign
207 : r.gate,
208 }));
209 } catch (err) {
210 console.error("[notify] email recipient lookup failed:", err);
211 return;
212 }
213
214 const subject = subjectFor(kind, opts.title);
215 const text = bodyFor(opts.title, opts.body, opts.url);
216
217 // Fire in parallel; each call swallows its own errors.
218 await Promise.all(
219 recipients
220 .filter((r) => r.pref && r.email)
221 .map((r) =>
222 sendEmail({ to: r.email, subject, text }).catch((err) => {
223 console.error("[notify] sendEmail threw:", err);
224 return { ok: false as const, provider: "none" as const };
225 })
226 )
227 );
228}
229
3ef4c9dClaude230export async function notify(
231 userId: string,
232 opts: {
233 kind: NotificationKind;
234 title: string;
235 body?: string;
236 url?: string;
237 repositoryId?: string;
238 }
239): Promise<void> {
240 try {
b7ecb14Claude241 await db.insert(notificationsMain).values({
3ef4c9dClaude242 userId,
243 kind: opts.kind,
244 title: opts.title,
245 body: opts.body,
246 url: opts.url,
247 repositoryId: opts.repositoryId,
248 });
249 } catch (err) {
250 console.error("[notify] failed:", err);
251 }
24cf2caClaude252 await maybeEmail([userId], opts.kind, opts);
3ef4c9dClaude253}
254
255export async function notifyMany(
256 userIds: string[],
257 opts: {
258 kind: NotificationKind;
259 title: string;
260 body?: string;
261 url?: string;
262 repositoryId?: string;
263 }
264): Promise<void> {
265 const unique = Array.from(new Set(userIds));
266 if (unique.length === 0) return;
267 try {
b7ecb14Claude268 await db.insert(notificationsMain).values(
3ef4c9dClaude269 unique.map((userId) => ({
270 userId,
271 kind: opts.kind,
272 title: opts.title,
273 body: opts.body,
274 url: opts.url,
275 repositoryId: opts.repositoryId,
276 }))
277 );
278 } catch (err) {
279 console.error("[notify] batch failed:", err);
280 }
24cf2caClaude281 await maybeEmail(unique, opts.kind, opts);
3ef4c9dClaude282}
283
284export async function audit(opts: {
285 userId?: string | null;
286 repositoryId?: string | null;
287 action: string;
288 targetType?: string;
289 targetId?: string;
290 ip?: string;
291 userAgent?: string;
292 metadata?: Record<string, unknown>;
293}): Promise<void> {
294 try {
295 await db.insert(auditLog).values({
296 userId: opts.userId ?? null,
297 repositoryId: opts.repositoryId ?? null,
298 action: opts.action,
299 targetType: opts.targetType,
300 targetId: opts.targetId,
301 ip: opts.ip,
302 userAgent: opts.userAgent,
303 metadata: opts.metadata ? JSON.stringify(opts.metadata) : null,
304 });
305 } catch (err) {
306 // Audit must never break the primary flow
307 console.error("[audit] failed:", err);
308 }
309}
24cf2caClaude310
b7b5f75ccanty labs311/**
312 * Log a repo-scoped activity_feed row — the per-repo timeline rendered on
313 * the dashboard, repo page, and pulse view (distinct from `audit()`'s
314 * compliance-oriented audit_log). Canonical `action` vocabulary (see
315 * `formatAction` in src/routes/dashboard.tsx and the activityFeed table
316 * comment in schema.ts): push, pr_open, pr_merge, comment, issue_open,
317 * issue_close, star, fork. `targetId` conventions some routes already
318 * depend on: action="push" reads targetId as the commit SHA
319 * (src/routes/web.tsx getRecentPush, src/routes/push-watch.tsx).
320 *
321 * Fire-and-forget safe: swallows errors, same contract as `audit()`.
322 */
323export async function logActivity(opts: {
324 repositoryId: string;
325 userId?: string | null;
326 action: string;
327 targetType?: string;
328 targetId?: string;
329 metadata?: Record<string, unknown>;
330}): Promise<void> {
331 try {
332 await db.insert(activityFeed).values({
333 repositoryId: opts.repositoryId,
334 userId: opts.userId ?? null,
335 action: opts.action,
336 targetType: opts.targetType,
337 targetId: opts.targetId,
338 metadata: opts.metadata ? JSON.stringify(opts.metadata) : null,
339 });
340 } catch (err) {
341 // Activity feed must never break the primary flow
342 console.error("[activity] failed:", err);
343 }
344}
345
24cf2caClaude346/** Test-only hook so unit tests can assert the kind→pref mapping. */
347export const __internal = {
348 EMAIL_ELIGIBLE,
349 prefFor,
350 subjectFor,
351 bodyFor,
352};