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 | ||
| 24cf2ca | 11 | import { inArray, eq } from "drizzle-orm"; |
| 3ef4c9d | 12 | import { db } from "../db"; |
| 24cf2ca | 13 | import { notifications, auditLog, users } from "../db/schema"; |
| 14 | import { sendEmail, absoluteUrl } from "./email"; | |
| 3ef4c9d | 15 | |
| 16 | export type NotificationKind = | |
| 17 | | "mention" | |
| 18 | | "review_requested" | |
| 19 | | "pr_opened" | |
| 20 | | "pr_merged" | |
| 21 | | "pr_closed" | |
| 22 | | "issue_opened" | |
| 23 | | "issue_closed" | |
| 24 | | "assigned" | |
| 25 | | "ai_review" | |
| 26 | | "gate_failed" | |
| 27 | | "gate_repaired" | |
| 28 | | "gate_passed" | |
| 29 | | "security_alert" | |
| 30 | | "deploy_success" | |
| 31 | | "deploy_failed" | |
| 25a91a6 | 32 | | "deployment_approval" |
| 3ef4c9d | 33 | | "release_published" |
| 534f04a | 34 | | "repo_archived" |
| 35 | | "pr_stale" | |
| 36 | | "issue_stale"; | |
| 3ef4c9d | 37 | |
| 24cf2ca | 38 | /** Kinds that can trigger email delivery. Keep this list conservative — any |
| 39 | * kind here must map to a user preference column on the users table. */ | |
| 40 | const EMAIL_ELIGIBLE: ReadonlySet<NotificationKind> = new Set([ | |
| 41 | "mention", | |
| 42 | "review_requested", | |
| 43 | "assigned", | |
| 44 | "gate_failed", | |
| 45 | ]); | |
| 46 | ||
| 47 | /** Map notification kind → user preference column name. */ | |
| 48 | function prefFor(kind: NotificationKind): | |
| 49 | | "notifyEmailOnMention" | |
| 50 | | "notifyEmailOnAssign" | |
| 51 | | "notifyEmailOnGateFail" | |
| 52 | | null { | |
| 53 | switch (kind) { | |
| 54 | case "mention": | |
| 55 | case "review_requested": | |
| 56 | return "notifyEmailOnMention"; | |
| 57 | case "assigned": | |
| 58 | return "notifyEmailOnAssign"; | |
| 59 | case "gate_failed": | |
| 60 | return "notifyEmailOnGateFail"; | |
| 61 | default: | |
| 62 | return null; | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | function subjectFor(kind: NotificationKind, title: string): string { | |
| 67 | const tag = | |
| 68 | kind === "gate_failed" | |
| 69 | ? "[gate failed]" | |
| 70 | : kind === "assigned" | |
| 71 | ? "[assigned]" | |
| 72 | : kind === "review_requested" | |
| 73 | ? "[review requested]" | |
| 74 | : kind === "mention" | |
| 75 | ? "[mention]" | |
| 76 | : `[${kind}]`; | |
| 77 | return `${tag} ${title}`.slice(0, 180); | |
| 78 | } | |
| 79 | ||
| 80 | function bodyFor(title: string, body: string | undefined, url: string | undefined): string { | |
| 81 | const lines = [title]; | |
| 82 | if (body) lines.push("", body); | |
| 83 | if (url) lines.push("", absoluteUrl(url)); | |
| 84 | lines.push("", "—", "You can opt out of these emails at /settings."); | |
| 85 | return lines.join("\n"); | |
| 86 | } | |
| 87 | ||
| 88 | async function maybeEmail( | |
| 89 | userIds: string[], | |
| 90 | kind: NotificationKind, | |
| 91 | opts: { title: string; body?: string; url?: string } | |
| 92 | ): Promise<void> { | |
| 93 | if (!EMAIL_ELIGIBLE.has(kind)) return; | |
| 94 | const prefCol = prefFor(kind); | |
| 95 | if (!prefCol) return; | |
| 96 | if (userIds.length === 0) return; | |
| 97 | ||
| 98 | let recipients: Array<{ email: string; pref: boolean }> = []; | |
| 99 | try { | |
| 100 | const rows = await db | |
| 101 | .select({ | |
| 102 | id: users.id, | |
| 103 | email: users.email, | |
| 104 | mention: users.notifyEmailOnMention, | |
| 105 | assign: users.notifyEmailOnAssign, | |
| 106 | gate: users.notifyEmailOnGateFail, | |
| 107 | }) | |
| 108 | .from(users) | |
| 109 | .where(inArray(users.id, userIds)); | |
| 110 | recipients = rows.map((r) => ({ | |
| 111 | email: r.email, | |
| 112 | pref: | |
| 113 | prefCol === "notifyEmailOnMention" | |
| 114 | ? r.mention | |
| 115 | : prefCol === "notifyEmailOnAssign" | |
| 116 | ? r.assign | |
| 117 | : r.gate, | |
| 118 | })); | |
| 119 | } catch (err) { | |
| 120 | console.error("[notify] email recipient lookup failed:", err); | |
| 121 | return; | |
| 122 | } | |
| 123 | ||
| 124 | const subject = subjectFor(kind, opts.title); | |
| 125 | const text = bodyFor(opts.title, opts.body, opts.url); | |
| 126 | ||
| 127 | // Fire in parallel; each call swallows its own errors. | |
| 128 | await Promise.all( | |
| 129 | recipients | |
| 130 | .filter((r) => r.pref && r.email) | |
| 131 | .map((r) => | |
| 132 | sendEmail({ to: r.email, subject, text }).catch((err) => { | |
| 133 | console.error("[notify] sendEmail threw:", err); | |
| 134 | return { ok: false as const, provider: "none" as const }; | |
| 135 | }) | |
| 136 | ) | |
| 137 | ); | |
| 138 | } | |
| 139 | ||
| 3ef4c9d | 140 | export async function notify( |
| 141 | userId: string, | |
| 142 | opts: { | |
| 143 | kind: NotificationKind; | |
| 144 | title: string; | |
| 145 | body?: string; | |
| 146 | url?: string; | |
| 147 | repositoryId?: string; | |
| 148 | } | |
| 149 | ): Promise<void> { | |
| 150 | try { | |
| 151 | await db.insert(notifications).values({ | |
| 152 | userId, | |
| 153 | kind: opts.kind, | |
| 154 | title: opts.title, | |
| 155 | body: opts.body, | |
| 156 | url: opts.url, | |
| 157 | repositoryId: opts.repositoryId, | |
| 158 | }); | |
| 159 | } catch (err) { | |
| 160 | console.error("[notify] failed:", err); | |
| 161 | } | |
| 24cf2ca | 162 | await maybeEmail([userId], opts.kind, opts); |
| 3ef4c9d | 163 | } |
| 164 | ||
| 165 | export async function notifyMany( | |
| 166 | userIds: string[], | |
| 167 | opts: { | |
| 168 | kind: NotificationKind; | |
| 169 | title: string; | |
| 170 | body?: string; | |
| 171 | url?: string; | |
| 172 | repositoryId?: string; | |
| 173 | } | |
| 174 | ): Promise<void> { | |
| 175 | const unique = Array.from(new Set(userIds)); | |
| 176 | if (unique.length === 0) return; | |
| 177 | try { | |
| 178 | await db.insert(notifications).values( | |
| 179 | unique.map((userId) => ({ | |
| 180 | userId, | |
| 181 | kind: opts.kind, | |
| 182 | title: opts.title, | |
| 183 | body: opts.body, | |
| 184 | url: opts.url, | |
| 185 | repositoryId: opts.repositoryId, | |
| 186 | })) | |
| 187 | ); | |
| 188 | } catch (err) { | |
| 189 | console.error("[notify] batch failed:", err); | |
| 190 | } | |
| 24cf2ca | 191 | await maybeEmail(unique, opts.kind, opts); |
| 3ef4c9d | 192 | } |
| 193 | ||
| 194 | export async function audit(opts: { | |
| 195 | userId?: string | null; | |
| 196 | repositoryId?: string | null; | |
| 197 | action: string; | |
| 198 | targetType?: string; | |
| 199 | targetId?: string; | |
| 200 | ip?: string; | |
| 201 | userAgent?: string; | |
| 202 | metadata?: Record<string, unknown>; | |
| 203 | }): Promise<void> { | |
| 204 | try { | |
| 205 | await db.insert(auditLog).values({ | |
| 206 | userId: opts.userId ?? null, | |
| 207 | repositoryId: opts.repositoryId ?? null, | |
| 208 | action: opts.action, | |
| 209 | targetType: opts.targetType, | |
| 210 | targetId: opts.targetId, | |
| 211 | ip: opts.ip, | |
| 212 | userAgent: opts.userAgent, | |
| 213 | metadata: opts.metadata ? JSON.stringify(opts.metadata) : null, | |
| 214 | }); | |
| 215 | } catch (err) { | |
| 216 | // Audit must never break the primary flow | |
| 217 | console.error("[audit] failed:", err); | |
| 218 | } | |
| 219 | } | |
| 24cf2ca | 220 | |
| 221 | /** Test-only hook so unit tests can assert the kind→pref mapping. */ | |
| 222 | export const __internal = { | |
| 223 | EMAIL_ELIGIBLE, | |
| 224 | prefFor, | |
| 225 | subjectFor, | |
| 226 | bodyFor, | |
| 227 | }; |