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