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.
| 3ef4c9d | 1 | /** |
| 2 | * Notifications + audit log helpers. | |
| 3 | * Swallows DB failures so notifications never break the primary request path. | |
| 24cf2ca | 4 | * |
| 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. | |
| 3ef4c9d | 9 | */ |
| 10 | ||
| b7ecb14 | 11 | import { inArray, eq, and, desc, sql } from "drizzle-orm"; |
| 3ef4c9d | 12 | import { db } from "../db"; |
| b7ecb14 | 13 | import { notifications as notificationsMain, auditLog, users } from "../db/schema"; |
| 14 | import { notifications as notificationsExt } from "../db/schema-extensions"; | |
| 24cf2ca | 15 | import { sendEmail, absoluteUrl } from "./email"; |
| 3ef4c9d | 16 | |
| b7ecb14 | 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. */ | |
| 22 | export 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. */ | |
| 45 | export 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. */ | |
| 58 | export 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). */ | |
| 75 | export 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. */ | |
| 87 | export 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 | ||
| 3ef4c9d | 98 | export 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" | |
| 25a91a6 | 114 | | "deployment_approval" |
| 3ef4c9d | 115 | | "release_published" |
| 534f04a | 116 | | "repo_archived" |
| 117 | | "pr_stale" | |
| 118 | | "issue_stale"; | |
| 3ef4c9d | 119 | |
| 24cf2ca | 120 | /** 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. */ | |
| 122 | const 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. */ | |
| 130 | function 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 | ||
| 148 | function 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 | ||
| 162 | function 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 | ||
| 170 | async 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 | ||
| 3ef4c9d | 222 | export 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 { | |
| b7ecb14 | 233 | await db.insert(notificationsMain).values({ |
| 3ef4c9d | 234 | 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 | } | |
| 24cf2ca | 244 | await maybeEmail([userId], opts.kind, opts); |
| 3ef4c9d | 245 | } |
| 246 | ||
| 247 | export 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 { | |
| b7ecb14 | 260 | await db.insert(notificationsMain).values( |
| 3ef4c9d | 261 | 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 | } | |
| 24cf2ca | 273 | await maybeEmail(unique, opts.kind, opts); |
| 3ef4c9d | 274 | } |
| 275 | ||
| 276 | export 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 | } | |
| 24cf2ca | 302 | |
| 303 | /** Test-only hook so unit tests can assert the kind→pref mapping. */ | |
| 304 | export const __internal = { | |
| 305 | EMAIL_ELIGIBLE, | |
| 306 | prefFor, | |
| 307 | subjectFor, | |
| 308 | bodyFor, | |
| 309 | }; |