CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
smart-digest.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.
| cc34156 | 1 | /** |
| 2 | * Smart Morning Digest — AI-curated daily developer notification queue. | |
| 3 | * | |
| 4 | * Replaces notification spam with a single, prioritised digest delivered | |
| 5 | * once per day via the in-app notification system AND surfaced on /digest. | |
| 6 | * | |
| 7 | * Data sources (last 48h / 24h where noted): | |
| 8 | * 1. Unread notifications (48h) | |
| 9 | * 2. Open PRs where the user is a requested reviewer | |
| 10 | * 3. Open PRs authored by the user with unread comments | |
| 11 | * 4. Failed gate runs for repos the user owns (24h) | |
| 12 | * 5. Dependency-update PRs on user's repos | |
| 13 | * | |
| 14 | * Claude Sonnet 4.6 prioritises the items and writes the headline + insight. | |
| 15 | * Never throws — every path is wrapped in try/catch. | |
| 16 | */ | |
| 17 | ||
| 18 | import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm"; | |
| 19 | import { db } from "../db"; | |
| 20 | import { | |
| 21 | gateRuns, | |
| 22 | issues, | |
| 23 | notifications, | |
| 24 | prComments, | |
| 25 | prReviews, | |
| 26 | pullRequests, | |
| 27 | repositories, | |
| 28 | users, | |
| 29 | } from "../db/schema"; | |
| 30 | import { | |
| 31 | getAnthropic, | |
| 32 | isAiAvailable, | |
| 33 | MODEL_SONNET, | |
| 34 | extractText, | |
| 35 | parseJsonResponse, | |
| 36 | } from "./ai-client"; | |
| 37 | import { config } from "./config"; | |
| 38 | ||
| 39 | // --------------------------------------------------------------------------- | |
| 40 | // Public interfaces | |
| 41 | // --------------------------------------------------------------------------- | |
| 42 | ||
| 43 | export interface DigestItem { | |
| 44 | priority: "blocking" | "important" | "fyi"; | |
| 45 | type: | |
| 46 | | "pr_review" | |
| 47 | | "pr_comment" | |
| 48 | | "ci_failure" | |
| 49 | | "mention" | |
| 50 | | "dep_update" | |
| 51 | | "new_issue"; | |
| 52 | title: string; | |
| 53 | /** e.g. "waiting 2 days · 3 files changed" */ | |
| 54 | subtitle: string; | |
| 55 | url: string; | |
| 56 | repoName: string; | |
| 57 | } | |
| 58 | ||
| 59 | export interface SmartDigest { | |
| 60 | userId: string; | |
| 61 | generatedAt: string; | |
| 62 | headline: string; | |
| 63 | queue: DigestItem[]; | |
| 64 | stats: { | |
| 65 | prsReviewed: number; | |
| 66 | issuesClosed: number; | |
| 67 | commitsThisWeek: number; | |
| 68 | }; | |
| 69 | insight?: string; | |
| 70 | } | |
| 71 | ||
| 72 | // --------------------------------------------------------------------------- | |
| 73 | // Internal data loader | |
| 74 | // --------------------------------------------------------------------------- | |
| 75 | ||
| 76 | interface RawItem { | |
| 77 | type: DigestItem["type"]; | |
| 78 | title: string; | |
| 79 | subtitle: string; | |
| 80 | url: string; | |
| 81 | repoName: string; | |
| 82 | createdAt: Date; | |
| 83 | } | |
| 84 | ||
| 85 | async function loadRawItems( | |
| 86 | userId: string, | |
| 87 | now: Date | |
| 88 | ): Promise<RawItem[]> { | |
| 89 | const base = config.appBaseUrl || "https://gluecron.com"; | |
| 90 | const items: RawItem[] = []; | |
| 91 | ||
| 92 | // --- 1. Unread notifications (last 48h) --- | |
| 93 | const since48h = new Date(now.getTime() - 48 * 60 * 60 * 1000); | |
| 94 | try { | |
| 95 | const notifs = await db | |
| 96 | .select() | |
| 97 | .from(notifications) | |
| 98 | .where( | |
| 99 | and( | |
| 100 | eq(notifications.userId, userId), | |
| 101 | gte(notifications.createdAt, since48h), | |
| 102 | sql`${notifications.readAt} IS NULL` | |
| 103 | ) | |
| 104 | ) | |
| 105 | .orderBy(desc(notifications.createdAt)) | |
| 106 | .limit(20); | |
| 107 | ||
| 108 | for (const n of notifs) { | |
| 109 | const ageH = Math.round( | |
| 110 | (now.getTime() - new Date(n.createdAt).getTime()) / 3_600_000 | |
| 111 | ); | |
| 112 | items.push({ | |
| 113 | type: n.kind === "mention" ? "mention" : n.kind === "gate_failed" ? "ci_failure" : "pr_review", | |
| 114 | title: n.title || "(untitled)", | |
| 115 | subtitle: `${n.kind} · ${ageH}h ago`, | |
| 116 | url: n.url ? (n.url.startsWith("http") ? n.url : `${base}${n.url}`) : `${base}/inbox`, | |
| 117 | repoName: "—", | |
| 118 | createdAt: new Date(n.createdAt), | |
| 119 | }); | |
| 120 | } | |
| 121 | } catch (err) { | |
| 122 | console.error("[smart-digest] notifications query failed:", err); | |
| 123 | } | |
| 124 | ||
| 125 | // --- 2. PRs where user is requested reviewer (open) --- | |
| 126 | try { | |
| 127 | // We use pr_reviews to detect review requests: look for PRs where user | |
| 128 | // has a review_requested state and the PR is still open. | |
| 129 | const reviewRows = await db | |
| 130 | .select({ | |
| 131 | pr: pullRequests, | |
| 132 | repoName: repositories.name, | |
| 133 | ownerUsername: users.username, | |
| 134 | }) | |
| 135 | .from(prReviews) | |
| 136 | .innerJoin(pullRequests, eq(pullRequests.id, prReviews.pullRequestId)) | |
| 137 | .innerJoin( | |
| 138 | repositories, | |
| 139 | eq(repositories.id, pullRequests.repositoryId) | |
| 140 | ) | |
| 141 | .innerJoin(users, eq(users.id, repositories.ownerId)) | |
| 142 | .where( | |
| 143 | and( | |
| 144 | eq(prReviews.reviewerId, userId), | |
| 145 | eq(pullRequests.state, "open"), | |
| 146 | // Only include PRs they haven't reviewed yet (no approved/changes_requested) | |
| 147 | sql`${prReviews.state} NOT IN ('approved','changes_requested')` | |
| 148 | ) | |
| 149 | ) | |
| 150 | .orderBy(desc(pullRequests.createdAt)) | |
| 151 | .limit(10); | |
| 152 | ||
| 153 | for (const row of reviewRows) { | |
| 154 | const ageH = Math.round( | |
| 155 | (now.getTime() - new Date(row.pr.createdAt).getTime()) / 3_600_000 | |
| 156 | ); | |
| 157 | const ageDays = Math.floor(ageH / 24); | |
| 158 | const ageLabel = ageDays >= 1 ? `waiting ${ageDays}d` : `waiting ${ageH}h`; | |
| 159 | items.push({ | |
| 160 | type: "pr_review", | |
| 161 | title: `Review requested: ${row.pr.title}`, | |
| 162 | subtitle: `${ageLabel} · ${row.repoName}`, | |
| 163 | url: `${base}/${row.ownerUsername}/${row.repoName}/pulls/${row.pr.number}`, | |
| 164 | repoName: row.repoName, | |
| 165 | createdAt: new Date(row.pr.createdAt), | |
| 166 | }); | |
| 167 | } | |
| 168 | } catch (err) { | |
| 169 | console.error("[smart-digest] review-requested query failed:", err); | |
| 170 | } | |
| 171 | ||
| 172 | // --- 3. Open PRs authored by user with unread comments --- | |
| 173 | try { | |
| 174 | const authoredPrs = await db | |
| 175 | .select({ | |
| 176 | pr: pullRequests, | |
| 177 | repoName: repositories.name, | |
| 178 | ownerUsername: users.username, | |
| 179 | commentCount: sql<number>`count(${prComments.id})::int`, | |
| 180 | }) | |
| 181 | .from(pullRequests) | |
| 182 | .innerJoin( | |
| 183 | repositories, | |
| 184 | eq(repositories.id, pullRequests.repositoryId) | |
| 185 | ) | |
| 186 | .innerJoin(users, eq(users.id, repositories.ownerId)) | |
| 187 | .innerJoin(prComments, eq(prComments.pullRequestId, pullRequests.id)) | |
| 188 | .where( | |
| 189 | and( | |
| 190 | eq(pullRequests.authorId, userId), | |
| 191 | eq(pullRequests.state, "open"), | |
| 192 | ne(prComments.authorId, userId), | |
| 193 | gte(prComments.createdAt, since48h) | |
| 194 | ) | |
| 195 | ) | |
| 196 | .groupBy(pullRequests.id, repositories.name, users.username) | |
| 197 | .orderBy(desc(pullRequests.updatedAt)) | |
| 198 | .limit(10); | |
| 199 | ||
| 200 | for (const row of authoredPrs) { | |
| 201 | const count = row.commentCount || 0; | |
| 202 | if (count === 0) continue; | |
| 203 | items.push({ | |
| 204 | type: "pr_comment", | |
| 205 | title: `New comments on your PR: ${row.pr.title}`, | |
| 206 | subtitle: `${count} new comment${count === 1 ? "" : "s"} · ${row.repoName}`, | |
| 207 | url: `${base}/${row.ownerUsername}/${row.repoName}/pulls/${row.pr.number}`, | |
| 208 | repoName: row.repoName, | |
| 209 | createdAt: new Date(row.pr.updatedAt), | |
| 210 | }); | |
| 211 | } | |
| 212 | } catch (err) { | |
| 213 | console.error("[smart-digest] pr-comments query failed:", err); | |
| 214 | } | |
| 215 | ||
| 216 | // --- 4. Failed gate runs for repos the user owns (last 24h) --- | |
| 217 | const since24h = new Date(now.getTime() - 24 * 60 * 60 * 1000); | |
| 218 | try { | |
| 219 | const ownedRepos = await db | |
| 220 | .select({ id: repositories.id, name: repositories.name }) | |
| 221 | .from(repositories) | |
| 222 | .where(eq(repositories.ownerId, userId)); | |
| 223 | ||
| 224 | if (ownedRepos.length > 0) { | |
| 225 | const repoIds = ownedRepos.map((r) => r.id); | |
| 226 | const repoById = new Map(ownedRepos.map((r) => [r.id, r.name])); | |
| 227 | ||
| 228 | const failedGates = await db | |
| 229 | .select() | |
| 230 | .from(gateRuns) | |
| 231 | .where( | |
| 232 | and( | |
| 233 | inArray(gateRuns.repositoryId, repoIds), | |
| 234 | eq(gateRuns.status, "failed"), | |
| 235 | gte(gateRuns.createdAt, since24h) | |
| 236 | ) | |
| 237 | ) | |
| 238 | .orderBy(desc(gateRuns.createdAt)) | |
| 239 | .limit(10); | |
| 240 | ||
| 241 | for (const gate of failedGates) { | |
| 242 | const repoName = repoById.get(gate.repositoryId) || "?"; | |
| 243 | items.push({ | |
| 244 | type: "ci_failure", | |
| 245 | title: `Gate failed: ${gate.gateName} in ${repoName}`, | |
| 246 | subtitle: `commit ${gate.commitSha.slice(0, 7)} · ${repoName}`, | |
| 247 | url: `${base}/${userId}/${repoName}/pulls`, | |
| 248 | repoName, | |
| 249 | createdAt: new Date(gate.createdAt), | |
| 250 | }); | |
| 251 | } | |
| 252 | ||
| 253 | // --- 5. Dependency update PRs --- | |
| 254 | if (repoIds.length > 0) { | |
| 255 | const depPrs = await db | |
| 256 | .select({ | |
| 257 | pr: pullRequests, | |
| 258 | repoName: repositories.name, | |
| 259 | }) | |
| 260 | .from(pullRequests) | |
| 261 | .innerJoin( | |
| 262 | repositories, | |
| 263 | eq(repositories.id, pullRequests.repositoryId) | |
| 264 | ) | |
| 265 | .where( | |
| 266 | and( | |
| 267 | inArray(pullRequests.repositoryId, repoIds), | |
| 268 | eq(pullRequests.state, "open"), | |
| 269 | sql`${pullRequests.headBranch} LIKE 'gluecron/dep-update%'` | |
| 270 | ) | |
| 271 | ) | |
| 272 | .orderBy(desc(pullRequests.createdAt)) | |
| 273 | .limit(5); | |
| 274 | ||
| 275 | for (const row of depPrs) { | |
| 276 | items.push({ | |
| 277 | type: "dep_update", | |
| 278 | title: `Dependency update ready: ${row.pr.title}`, | |
| 279 | subtitle: `auto-PR · ${row.repoName}`, | |
| 280 | url: `${base}/${userId}/${row.repoName}/pulls/${row.pr.number}`, | |
| 281 | repoName: row.repoName, | |
| 282 | createdAt: new Date(row.pr.createdAt), | |
| 283 | }); | |
| 284 | } | |
| 285 | } | |
| 286 | } | |
| 287 | } catch (err) { | |
| 288 | console.error("[smart-digest] gate/dep-update query failed:", err); | |
| 289 | } | |
| 290 | ||
| 291 | return items; | |
| 292 | } | |
| 293 | ||
| 294 | // --------------------------------------------------------------------------- | |
| 295 | // Stats loader | |
| 296 | // --------------------------------------------------------------------------- | |
| 297 | ||
| 298 | async function loadStats( | |
| 299 | userId: string, | |
| 300 | now: Date | |
| 301 | ): Promise<SmartDigest["stats"]> { | |
| 302 | const since7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); | |
| 303 | ||
| 304 | let prsReviewed = 0; | |
| 305 | let issuesClosed = 0; | |
| 306 | const commitsThisWeek = 0; // Git log not easily queryable via SQL; default 0 | |
| 307 | ||
| 308 | try { | |
| 309 | const [reviewCountRow] = await db | |
| 310 | .select({ count: sql<number>`count(*)::int` }) | |
| 311 | .from(prReviews) | |
| 312 | .where( | |
| 313 | and( | |
| 314 | eq(prReviews.reviewerId, userId), | |
| 315 | gte(prReviews.createdAt, since7d), | |
| 316 | sql`${prReviews.state} IN ('approved','changes_requested')` | |
| 317 | ) | |
| 318 | ); | |
| 319 | prsReviewed = reviewCountRow?.count || 0; | |
| 320 | } catch { | |
| 321 | /* swallow */ | |
| 322 | } | |
| 323 | ||
| 324 | try { | |
| 325 | const [issueCountRow] = await db | |
| 326 | .select({ count: sql<number>`count(*)::int` }) | |
| 327 | .from(issues) | |
| 328 | .where( | |
| 329 | and( | |
| 330 | gte(issues.updatedAt, since7d), | |
| 331 | eq(issues.state, "closed") | |
| 332 | ) | |
| 333 | ); | |
| 334 | issuesClosed = issueCountRow?.count || 0; | |
| 335 | } catch { | |
| 336 | /* swallow */ | |
| 337 | } | |
| 338 | ||
| 339 | return { prsReviewed, issuesClosed, commitsThisWeek }; | |
| 340 | } | |
| 341 | ||
| 342 | // --------------------------------------------------------------------------- | |
| 343 | // Claude call | |
| 344 | // --------------------------------------------------------------------------- | |
| 345 | ||
| 346 | interface ClaudeDigestResponse { | |
| 347 | headline: string; | |
| 348 | queue: Array<{ | |
| 349 | priority: "blocking" | "important" | "fyi"; | |
| 350 | type: DigestItem["type"]; | |
| 351 | title: string; | |
| 352 | subtitle: string; | |
| 353 | url: string; | |
| 354 | repoName: string; | |
| 355 | }>; | |
| 356 | insight?: string; | |
| 357 | } | |
| 358 | ||
| 359 | async function callClaude( | |
| 360 | rawItems: RawItem[], | |
| 361 | stats: SmartDigest["stats"], | |
| 362 | username: string | |
| 363 | ): Promise<ClaudeDigestResponse | null> { | |
| 364 | if (!isAiAvailable()) return null; | |
| 365 | try { | |
| 366 | const client = getAnthropic(); | |
| 367 | const itemsJson = JSON.stringify( | |
| 368 | rawItems.map((i) => ({ | |
| 369 | type: i.type, | |
| 370 | title: i.title, | |
| 371 | subtitle: i.subtitle, | |
| 372 | url: i.url, | |
| 373 | repoName: i.repoName, | |
| 374 | })), | |
| 375 | null, | |
| 376 | 2 | |
| 377 | ); | |
| 378 | const statsJson = JSON.stringify(stats, null, 2); | |
| 379 | ||
| 380 | const prompt = `You are a developer productivity assistant for ${username}. Create a morning digest. | |
| 381 | ||
| 382 | Their pending items: | |
| 383 | ${itemsJson} | |
| 384 | ||
| 385 | Their recent stats (last 7 days): | |
| 386 | ${statsJson} | |
| 387 | ||
| 388 | Return JSON only (no markdown wrapper): | |
| 389 | { | |
| 390 | "headline": "...", | |
| 391 | "queue": [{"priority": "blocking"|"important"|"fyi", "type": "pr_review"|"pr_comment"|"ci_failure"|"mention"|"dep_update"|"new_issue", "title": "...", "subtitle": "...", "url": "...", "repoName": "..."}, ...], | |
| 392 | "insight": "..." | |
| 393 | } | |
| 394 | ||
| 395 | Rules: | |
| 396 | - Max 8 items in queue | |
| 397 | - blocking = someone explicitly waiting on this person, or a CI failure blocking a deploy | |
| 398 | - important = needs action today | |
| 399 | - fyi = nice to know | |
| 400 | - headline should be specific and human ("PR #45 is blocking a deploy" not "You have notifications") | |
| 401 | - insight should be personal and brief (skip if nothing interesting) | |
| 402 | - If no items, headline = "All caught up — nothing needs your attention right now" | |
| 403 | - Return valid JSON only, no commentary`; | |
| 404 | ||
| 405 | const msg = await client.messages.create({ | |
| 406 | model: MODEL_SONNET, | |
| 407 | max_tokens: 1024, | |
| 408 | messages: [{ role: "user", content: prompt }], | |
| 409 | }); | |
| 410 | ||
| 411 | const text = extractText(msg); | |
| 412 | const parsed = parseJsonResponse<ClaudeDigestResponse>(text); | |
| 413 | return parsed; | |
| 414 | } catch (err) { | |
| 415 | console.error("[smart-digest] Claude call failed:", err); | |
| 416 | return null; | |
| 417 | } | |
| 418 | } | |
| 419 | ||
| 420 | // --------------------------------------------------------------------------- | |
| 421 | // Fallback (no AI) | |
| 422 | // --------------------------------------------------------------------------- | |
| 423 | ||
| 424 | function buildFallbackDigest( | |
| 425 | rawItems: RawItem[], | |
| 426 | stats: SmartDigest["stats"], | |
| 427 | userId: string | |
| 428 | ): Omit<SmartDigest, "userId" | "generatedAt" | "stats"> { | |
| 429 | const queue: DigestItem[] = rawItems.slice(0, 8).map((item) => ({ | |
| 430 | priority: | |
| 431 | item.type === "ci_failure" | |
| 432 | ? "blocking" | |
| 433 | : item.type === "pr_review" | |
| 434 | ? "important" | |
| 435 | : "fyi", | |
| 436 | type: item.type, | |
| 437 | title: item.title, | |
| 438 | subtitle: item.subtitle, | |
| 439 | url: item.url, | |
| 440 | repoName: item.repoName, | |
| 441 | })); | |
| 442 | ||
| 443 | const blockingCount = queue.filter((i) => i.priority === "blocking").length; | |
| 444 | const importantCount = queue.filter((i) => i.priority === "important").length; | |
| 445 | let headline = "All caught up — nothing needs your attention right now"; | |
| 446 | if (queue.length > 0) { | |
| 447 | if (blockingCount > 0) { | |
| 448 | headline = `${blockingCount} blocking item${blockingCount === 1 ? "" : "s"} need${blockingCount === 1 ? "s" : ""} your attention`; | |
| 449 | } else if (importantCount > 0) { | |
| 450 | headline = `${importantCount} item${importantCount === 1 ? "" : "s"} to action today`; | |
| 451 | } else { | |
| 452 | headline = `${queue.length} item${queue.length === 1 ? "" : "s"} in your queue`; | |
| 453 | } | |
| 454 | } | |
| 455 | ||
| 456 | return { headline, queue }; | |
| 457 | } | |
| 458 | ||
| 459 | // --------------------------------------------------------------------------- | |
| 460 | // Public API | |
| 461 | // --------------------------------------------------------------------------- | |
| 462 | ||
| 463 | /** | |
| 464 | * Compose a smart digest for the given user. Never throws. | |
| 465 | * Returns null if the user is not found or any fatal error occurs. | |
| 466 | */ | |
| 467 | export async function composeSmartDigest( | |
| 468 | userId: string | |
| 469 | ): Promise<SmartDigest | null> { | |
| 470 | try { | |
| 471 | const [user] = await db | |
| 472 | .select() | |
| 473 | .from(users) | |
| 474 | .where(eq(users.id, userId)) | |
| 475 | .limit(1); | |
| 476 | if (!user) return null; | |
| 477 | ||
| 478 | const now = new Date(); | |
| 479 | const [rawItems, stats] = await Promise.all([ | |
| 480 | loadRawItems(userId, now), | |
| 481 | loadStats(userId, now), | |
| 482 | ]); | |
| 483 | ||
| 484 | let headline = "All caught up — nothing needs your attention right now"; | |
| 485 | let queue: DigestItem[] = []; | |
| 486 | let insight: string | undefined; | |
| 487 | ||
| 488 | const claudeResult = await callClaude(rawItems, stats, user.username); | |
| 489 | if (claudeResult) { | |
| 490 | headline = claudeResult.headline || headline; | |
| 491 | queue = (claudeResult.queue || []).slice(0, 8) as DigestItem[]; | |
| 492 | insight = claudeResult.insight || undefined; | |
| 493 | } else { | |
| 494 | const fallback = buildFallbackDigest(rawItems, stats, userId); | |
| 495 | headline = fallback.headline; | |
| 496 | queue = fallback.queue; | |
| 497 | } | |
| 498 | ||
| 499 | return { | |
| 500 | userId, | |
| 501 | generatedAt: now.toISOString(), | |
| 502 | headline, | |
| 503 | queue, | |
| 504 | stats, | |
| 505 | insight, | |
| 506 | }; | |
| 507 | } catch (err) { | |
| 508 | console.error("[smart-digest] composeSmartDigest error:", err); | |
| 509 | return null; | |
| 510 | } | |
| 511 | } | |
| 512 | ||
| 513 | /** Minimum hours between digests. */ | |
| 514 | const SMART_DIGEST_COOLDOWN_HOURS = 20; | |
| 515 | ||
| 516 | /** | |
| 517 | * Compose + store a single notification for the user. | |
| 518 | * Updates `last_smart_digest_sent_at`. Respects the 20h cooldown. | |
| 519 | * Never throws. | |
| 520 | */ | |
| 521 | export async function sendSmartDigest(userId: string): Promise<void> { | |
| 522 | try { | |
| 523 | const [user] = await db | |
| 524 | .select() | |
| 525 | .from(users) | |
| 526 | .where(eq(users.id, userId)) | |
| 527 | .limit(1); | |
| 528 | if (!user) return; | |
| 529 | ||
| 530 | // Cooldown check — skip if sent recently | |
| 531 | const lastSent = user.lastSmartDigestSentAt as Date | null; | |
| 532 | if (lastSent) { | |
| 533 | const hoursSince = | |
| 534 | (Date.now() - new Date(lastSent).getTime()) / 3_600_000; | |
| 535 | if (hoursSince < SMART_DIGEST_COOLDOWN_HOURS) { | |
| 536 | return; | |
| 537 | } | |
| 538 | } | |
| 539 | ||
| 540 | const digest = await composeSmartDigest(userId); | |
| 541 | if (!digest) return; | |
| 542 | ||
| 543 | // Insert a single 'digest' notification with full JSON in body | |
| 544 | await db.insert(notifications).values({ | |
| 545 | userId, | |
| 546 | kind: "digest", | |
| 547 | title: digest.headline, | |
| 548 | body: JSON.stringify(digest), | |
| 549 | url: "/digest", | |
| 550 | }); | |
| 551 | ||
| 552 | // Update timestamp | |
| 553 | await db | |
| 554 | .update(users) | |
| 555 | .set({ lastSmartDigestSentAt: new Date() }) | |
| 556 | .where(eq(users.id, userId)); | |
| 557 | } catch (err) { | |
| 558 | console.error("[smart-digest] sendSmartDigest error:", err); | |
| 559 | } | |
| 560 | } | |
| 561 | ||
| 562 | /** | |
| 563 | * Send smart digests to all opted-in users. Called from the autopilot loop. | |
| 564 | * Never throws. | |
| 565 | */ | |
| 566 | export async function sendSmartDigestsToAll(): Promise<void> { | |
| 567 | try { | |
| 568 | const candidates = await db | |
| 569 | .select({ id: users.id }) | |
| 570 | .from(users) | |
| 571 | .where( | |
| 572 | sql`(${users.notifyEmailDigestWeekly} = true OR ${users.notifySmartDigest} = true)` | |
| 573 | ) | |
| 574 | .limit(200); | |
| 575 | ||
| 576 | for (const candidate of candidates) { | |
| 577 | try { | |
| 578 | await sendSmartDigest(candidate.id); | |
| 579 | } catch (err) { | |
| 580 | console.error( | |
| 581 | `[smart-digest] per-user error for user=${candidate.id}:`, | |
| 582 | err | |
| 583 | ); | |
| 584 | } | |
| 585 | } | |
| 586 | } catch (err) { | |
| 587 | console.error("[smart-digest] sendSmartDigestsToAll error:", err); | |
| 588 | } | |
| 589 | } |