Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

notify.tsBlame227 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"
25a91a6Claude32 | "deployment_approval"
3ef4c9dClaude33 | "release_published"
534f04aClaude34 | "repo_archived"
35 | "pr_stale"
36 | "issue_stale";
3ef4c9dClaude37
24cf2caClaude38/** 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. */
40const 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. */
48function 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
66function 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
80function 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
88async 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
3ef4c9dClaude140export 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 }
24cf2caClaude162 await maybeEmail([userId], opts.kind, opts);
3ef4c9dClaude163}
164
165export 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 }
24cf2caClaude191 await maybeEmail(unique, opts.kind, opts);
3ef4c9dClaude192}
193
194export 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}
24cf2caClaude220
221/** Test-only hook so unit tests can assert the kind→pref mapping. */
222export const __internal = {
223 EMAIL_ELIGIBLE,
224 prefFor,
225 subjectFor,
226 bodyFor,
227};