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"; |
| b7b5f75 | 13 | import { notifications as notificationsMain, auditLog, activityFeed, users } from "../db/schema"; |
| b7ecb14 | 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) ───────────────── |
| 45ac593 | 18 | // 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). | |
| b7ecb14 | 26 | |
| 27 | /** Create a single in-app notification. Fire-and-forget safe: swallows errors. */ | |
| 28 | export 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, | |
| 45ac593 | 39 | kind: params.type, // NOT NULL — was omitted, so every insert failed |
| b7ecb14 | 40 | 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. */ | |
| 52 | export async function getUnreadCount(userId: string): Promise<number> { | |
| 53 | try { | |
| 54 | const [row] = await db | |
| 02aa828 | 55 | .select({ count: sql<number>`count(*)::int` }) |
| b7ecb14 | 56 | .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. */ | |
| 65 | export 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). */ | |
| 82 | export 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. */ | |
| 94 | export 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 | ||
| 3ef4c9d | 105 | export 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" | |
| 25a91a6 | 121 | | "deployment_approval" |
| 3ef4c9d | 122 | | "release_published" |
| 534f04a | 123 | | "repo_archived" |
| 124 | | "pr_stale" | |
| e2206a6 | 125 | | "issue_stale" |
| 126 | | "integration_dead"; | |
| 3ef4c9d | 127 | |
| 24cf2ca | 128 | /** 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. */ | |
| 130 | const 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. */ | |
| 138 | function 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 | ||
| 156 | function 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 | ||
| 170 | function 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 | ||
| 178 | async 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 | ||
| 3ef4c9d | 230 | export 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 { | |
| b7ecb14 | 241 | await db.insert(notificationsMain).values({ |
| 3ef4c9d | 242 | 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 | } | |
| 24cf2ca | 252 | await maybeEmail([userId], opts.kind, opts); |
| 3ef4c9d | 253 | } |
| 254 | ||
| 255 | export 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 { | |
| b7ecb14 | 268 | await db.insert(notificationsMain).values( |
| 3ef4c9d | 269 | 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 | } | |
| 24cf2ca | 281 | await maybeEmail(unique, opts.kind, opts); |
| 3ef4c9d | 282 | } |
| 283 | ||
| 284 | export 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 | } | |
| 24cf2ca | 310 | |
| b7b5f75 | 311 | /** |
| 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 | */ | |
| 323 | export 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 | ||
| 24cf2ca | 346 | /** Test-only hook so unit tests can assert the kind→pref mapping. */ |
| 347 | export const __internal = { | |
| 348 | EMAIL_ELIGIBLE, | |
| 349 | prefFor, | |
| 350 | subjectFor, | |
| 351 | bodyFor, | |
| 352 | }; |