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.tsBlame225 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"
34 | "repo_archived";
35
24cf2caClaude36/** 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. */
38const 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. */
46function 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
64function 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
78function 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
86async 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
3ef4c9dClaude138export 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 }
24cf2caClaude160 await maybeEmail([userId], opts.kind, opts);
3ef4c9dClaude161}
162
163export 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 }
24cf2caClaude189 await maybeEmail(unique, opts.kind, opts);
3ef4c9dClaude190}
191
192export 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}
24cf2caClaude218
219/** Test-only hook so unit tests can assert the kind→pref mapping. */
220export const __internal = {
221 EMAIL_ELIGIBLE,
222 prefFor,
223 subjectFor,
224 bodyFor,
225};