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.tsBlame345 lines · 3 contributors
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
b7ecb14Claude11import { inArray, eq, and, desc, sql } from "drizzle-orm";
3ef4c9dClaude12import { db } from "../db";
b7b5f75ccanty labs13import { notifications as notificationsMain, auditLog, activityFeed, users } from "../db/schema";
b7ecb14Claude14import { notifications as notificationsExt } from "../db/schema-extensions";
24cf2caClaude15import { sendEmail, absoluteUrl } from "./email";
3ef4c9dClaude16
b7ecb14Claude17// ─── Public helpers (schema-extensions notifications table) ─────────────────
18// These operate on the schema-extensions `notifications` table (which has
19// `type`, `isRead`, `actorId` columns) — the same table the inbox page uses.
20
21/** Create a single in-app notification. Fire-and-forget safe: swallows errors. */
22export async function createNotification(params: {
23 userId: string;
24 type: string;
25 title: string;
26 body?: string;
27 url?: string;
28 repoId?: string;
29}): Promise<void> {
30 try {
31 await db.insert(notificationsExt).values({
32 userId: params.userId,
33 type: params.type,
34 title: params.title,
35 body: params.body,
36 url: params.url,
37 repositoryId: params.repoId,
38 });
39 } catch (err) {
40 console.error("[createNotification] failed:", err);
41 }
42}
43
44/** Return the count of unread notifications for a user. Returns 0 on error. */
45export async function getUnreadCount(userId: string): Promise<number> {
46 try {
47 const [row] = await db
48 .select({ count: sql<number>`count(*)` })
49 .from(notificationsExt)
50 .where(and(eq(notificationsExt.userId, userId), eq(notificationsExt.isRead, false)));
51 return Number(row?.count ?? 0);
52 } catch {
53 return 0;
54 }
55}
56
57/** Return notifications for a user (most recent first). Returns [] on error. */
58export async function getNotifications(
59 userId: string,
60 limit = 50
61): Promise<typeof notificationsExt.$inferSelect[]> {
62 try {
63 return await db
64 .select()
65 .from(notificationsExt)
66 .where(eq(notificationsExt.userId, userId))
67 .orderBy(desc(notificationsExt.createdAt))
68 .limit(limit);
69 } catch {
70 return [];
71 }
72}
73
74/** Mark a single notification as read (only if it belongs to userId). */
75export async function markRead(notificationId: string, userId: string): Promise<void> {
76 try {
77 await db
78 .update(notificationsExt)
79 .set({ isRead: true })
80 .where(and(eq(notificationsExt.id, notificationId), eq(notificationsExt.userId, userId)));
81 } catch (err) {
82 console.error("[markRead] failed:", err);
83 }
84}
85
86/** Mark all of a user's notifications as read. */
87export async function markAllRead(userId: string): Promise<void> {
88 try {
89 await db
90 .update(notificationsExt)
91 .set({ isRead: true })
92 .where(and(eq(notificationsExt.userId, userId), eq(notificationsExt.isRead, false)));
93 } catch (err) {
94 console.error("[markAllRead] failed:", err);
95 }
96}
97
3ef4c9dClaude98export type NotificationKind =
99 | "mention"
100 | "review_requested"
101 | "pr_opened"
102 | "pr_merged"
103 | "pr_closed"
104 | "issue_opened"
105 | "issue_closed"
106 | "assigned"
107 | "ai_review"
108 | "gate_failed"
109 | "gate_repaired"
110 | "gate_passed"
111 | "security_alert"
112 | "deploy_success"
113 | "deploy_failed"
25a91a6Claude114 | "deployment_approval"
3ef4c9dClaude115 | "release_published"
534f04aClaude116 | "repo_archived"
117 | "pr_stale"
e2206a6Test User118 | "issue_stale"
119 | "integration_dead";
3ef4c9dClaude120
24cf2caClaude121/** Kinds that can trigger email delivery. Keep this list conservative — any
122 * kind here must map to a user preference column on the users table. */
123const EMAIL_ELIGIBLE: ReadonlySet<NotificationKind> = new Set([
124 "mention",
125 "review_requested",
126 "assigned",
127 "gate_failed",
128]);
129
130/** Map notification kind → user preference column name. */
131function prefFor(kind: NotificationKind):
132 | "notifyEmailOnMention"
133 | "notifyEmailOnAssign"
134 | "notifyEmailOnGateFail"
135 | null {
136 switch (kind) {
137 case "mention":
138 case "review_requested":
139 return "notifyEmailOnMention";
140 case "assigned":
141 return "notifyEmailOnAssign";
142 case "gate_failed":
143 return "notifyEmailOnGateFail";
144 default:
145 return null;
146 }
147}
148
149function subjectFor(kind: NotificationKind, title: string): string {
150 const tag =
151 kind === "gate_failed"
152 ? "[gate failed]"
153 : kind === "assigned"
154 ? "[assigned]"
155 : kind === "review_requested"
156 ? "[review requested]"
157 : kind === "mention"
158 ? "[mention]"
159 : `[${kind}]`;
160 return `${tag} ${title}`.slice(0, 180);
161}
162
163function bodyFor(title: string, body: string | undefined, url: string | undefined): string {
164 const lines = [title];
165 if (body) lines.push("", body);
166 if (url) lines.push("", absoluteUrl(url));
167 lines.push("", "—", "You can opt out of these emails at /settings.");
168 return lines.join("\n");
169}
170
171async function maybeEmail(
172 userIds: string[],
173 kind: NotificationKind,
174 opts: { title: string; body?: string; url?: string }
175): Promise<void> {
176 if (!EMAIL_ELIGIBLE.has(kind)) return;
177 const prefCol = prefFor(kind);
178 if (!prefCol) return;
179 if (userIds.length === 0) return;
180
181 let recipients: Array<{ email: string; pref: boolean }> = [];
182 try {
183 const rows = await db
184 .select({
185 id: users.id,
186 email: users.email,
187 mention: users.notifyEmailOnMention,
188 assign: users.notifyEmailOnAssign,
189 gate: users.notifyEmailOnGateFail,
190 })
191 .from(users)
192 .where(inArray(users.id, userIds));
193 recipients = rows.map((r) => ({
194 email: r.email,
195 pref:
196 prefCol === "notifyEmailOnMention"
197 ? r.mention
198 : prefCol === "notifyEmailOnAssign"
199 ? r.assign
200 : r.gate,
201 }));
202 } catch (err) {
203 console.error("[notify] email recipient lookup failed:", err);
204 return;
205 }
206
207 const subject = subjectFor(kind, opts.title);
208 const text = bodyFor(opts.title, opts.body, opts.url);
209
210 // Fire in parallel; each call swallows its own errors.
211 await Promise.all(
212 recipients
213 .filter((r) => r.pref && r.email)
214 .map((r) =>
215 sendEmail({ to: r.email, subject, text }).catch((err) => {
216 console.error("[notify] sendEmail threw:", err);
217 return { ok: false as const, provider: "none" as const };
218 })
219 )
220 );
221}
222
3ef4c9dClaude223export async function notify(
224 userId: string,
225 opts: {
226 kind: NotificationKind;
227 title: string;
228 body?: string;
229 url?: string;
230 repositoryId?: string;
231 }
232): Promise<void> {
233 try {
b7ecb14Claude234 await db.insert(notificationsMain).values({
3ef4c9dClaude235 userId,
236 kind: opts.kind,
237 title: opts.title,
238 body: opts.body,
239 url: opts.url,
240 repositoryId: opts.repositoryId,
241 });
242 } catch (err) {
243 console.error("[notify] failed:", err);
244 }
24cf2caClaude245 await maybeEmail([userId], opts.kind, opts);
3ef4c9dClaude246}
247
248export async function notifyMany(
249 userIds: string[],
250 opts: {
251 kind: NotificationKind;
252 title: string;
253 body?: string;
254 url?: string;
255 repositoryId?: string;
256 }
257): Promise<void> {
258 const unique = Array.from(new Set(userIds));
259 if (unique.length === 0) return;
260 try {
b7ecb14Claude261 await db.insert(notificationsMain).values(
3ef4c9dClaude262 unique.map((userId) => ({
263 userId,
264 kind: opts.kind,
265 title: opts.title,
266 body: opts.body,
267 url: opts.url,
268 repositoryId: opts.repositoryId,
269 }))
270 );
271 } catch (err) {
272 console.error("[notify] batch failed:", err);
273 }
24cf2caClaude274 await maybeEmail(unique, opts.kind, opts);
3ef4c9dClaude275}
276
277export async function audit(opts: {
278 userId?: string | null;
279 repositoryId?: string | null;
280 action: string;
281 targetType?: string;
282 targetId?: string;
283 ip?: string;
284 userAgent?: string;
285 metadata?: Record<string, unknown>;
286}): Promise<void> {
287 try {
288 await db.insert(auditLog).values({
289 userId: opts.userId ?? null,
290 repositoryId: opts.repositoryId ?? null,
291 action: opts.action,
292 targetType: opts.targetType,
293 targetId: opts.targetId,
294 ip: opts.ip,
295 userAgent: opts.userAgent,
296 metadata: opts.metadata ? JSON.stringify(opts.metadata) : null,
297 });
298 } catch (err) {
299 // Audit must never break the primary flow
300 console.error("[audit] failed:", err);
301 }
302}
24cf2caClaude303
b7b5f75ccanty labs304/**
305 * Log a repo-scoped activity_feed row — the per-repo timeline rendered on
306 * the dashboard, repo page, and pulse view (distinct from `audit()`'s
307 * compliance-oriented audit_log). Canonical `action` vocabulary (see
308 * `formatAction` in src/routes/dashboard.tsx and the activityFeed table
309 * comment in schema.ts): push, pr_open, pr_merge, comment, issue_open,
310 * issue_close, star, fork. `targetId` conventions some routes already
311 * depend on: action="push" reads targetId as the commit SHA
312 * (src/routes/web.tsx getRecentPush, src/routes/push-watch.tsx).
313 *
314 * Fire-and-forget safe: swallows errors, same contract as `audit()`.
315 */
316export async function logActivity(opts: {
317 repositoryId: string;
318 userId?: string | null;
319 action: string;
320 targetType?: string;
321 targetId?: string;
322 metadata?: Record<string, unknown>;
323}): Promise<void> {
324 try {
325 await db.insert(activityFeed).values({
326 repositoryId: opts.repositoryId,
327 userId: opts.userId ?? null,
328 action: opts.action,
329 targetType: opts.targetType,
330 targetId: opts.targetId,
331 metadata: opts.metadata ? JSON.stringify(opts.metadata) : null,
332 });
333 } catch (err) {
334 // Activity feed must never break the primary flow
335 console.error("[activity] failed:", err);
336 }
337}
338
24cf2caClaude339/** Test-only hook so unit tests can assert the kind→pref mapping. */
340export const __internal = {
341 EMAIL_ELIGIBLE,
342 prefFor,
343 subjectFor,
344 bodyFor,
345};