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