CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
sleep-mode.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.
| 46d6165 | 1 | /** |
| 2 | * Block L1 — Sleep Mode. | |
| 3 | * | |
| 4 | * Pitch: "Toggle Sleep Mode. Walk away. Wake up to a digest of what Claude | |
| 5 | * shipped overnight." | |
| 6 | * | |
| 7 | * Sleep Mode is a per-user toggle (`users.sleep_mode_enabled`) that, when on: | |
| 8 | * 1. Bumps the email digest cadence from weekly → daily. | |
| 9 | * 2. Reframes the digest as "what AI did in the last 24h" — PRs auto-merged | |
| 10 | * by the K3 autopilot, issues built from `ai:build` labels, AI reviews | |
| 11 | * posted, AI security scans, and gate failures that auto-repair fixed. | |
| 12 | * 3. Fires at the user-configured UTC hour (`sleep_mode_digest_hour_utc`, | |
| 13 | * default 9). | |
| 14 | * | |
| 15 | * Re-uses `sendEmail` from `src/lib/email.ts` and the locked `escapeHtml` | |
| 16 | * helper from `src/lib/email-digest.ts`. Does NOT modify either module — | |
| 17 | * Block L1 is strictly additive. | |
| 18 | * | |
| 19 | * Contract: every exported function NEVER throws. Failures are logged and | |
| 20 | * surface as either zero-valued reports or `{ok:false, reason}` results. | |
| 21 | */ | |
| 22 | ||
| 23 | import { and, eq, gte, inArray, sql } from "drizzle-orm"; | |
| 24 | import { db } from "../db"; | |
| 25 | import { | |
| 26 | auditLog, | |
| 27 | gateRuns, | |
| 28 | issueComments, | |
| 29 | issues, | |
| 30 | prComments, | |
| 31 | pullRequests, | |
| 32 | repositories, | |
| 33 | users, | |
| 34 | } from "../db/schema"; | |
| 35 | import { sendEmail, type EmailResult } from "./email"; | |
| 36 | import { config } from "./config"; | |
| 37 | import { __internal as digestInternals } from "./email-digest"; | |
| 38 | import { AI_BUILD_MARKER } from "./ai-build-tasks"; | |
| c315551 | 39 | import { computeHoursSaved } from "./ai-hours-saved"; |
| 46d6165 | 40 | |
| 41 | const { escapeHtml } = digestInternals; | |
| 42 | ||
| 43 | /** Default look-back window. Sleep Mode is a daily digest. */ | |
| 44 | const DEFAULT_WINDOW_HOURS = 24; | |
| 45 | /** Per-tick cap on users we'll email — runaway protection. */ | |
| 46 | export const SLEEP_MODE_USER_CAP_PER_TICK = 100; | |
| 47 | /** Cooldown anchored to `users.last_digest_sent_at`. */ | |
| 48 | export const SLEEP_MODE_COOLDOWN_HOURS = 23; | |
| 49 | ||
| 50 | export type SleepModeReport = { | |
| 51 | windowHours: number; | |
| 52 | prsAutoMerged: { number: number; title: string; repo: string }[]; | |
| 53 | issuesBuiltByAi: { number: number; title: string; repo: string; prNumber?: number }[]; | |
| 54 | aiReviewsPosted: number; | |
| 55 | securityIssuesAutoFixed: number; | |
| 56 | gateFailuresAutoRepaired: number; | |
| 57 | /** Derived. Rounded to one decimal. See heuristic constants above. */ | |
| 58 | hoursSaved: number; | |
| 59 | }; | |
| 60 | ||
| 61 | ||
| 62 | /** | |
| 63 | * Compose a Sleep Mode report for one user. Pulls from: | |
| 64 | * - audit_log `auto_merge.merged` → prsAutoMerged | |
| 65 | * - issue_comments matching `AI_BUILD_MARKER` on this user's repos → issuesBuiltByAi | |
| 66 | * - pr_comments where is_ai_review=true on this user's repos → aiReviewsPosted | |
| 67 | * - gate_runs status='repaired' AND gate_name LIKE '%Secret%' → securityIssuesAutoFixed | |
| 68 | * - gate_runs status='repaired' (others) → gateFailuresAutoRepaired | |
| 69 | * | |
| 70 | * Never throws. On DB error, returns a zero-valued report. | |
| 71 | */ | |
| 72 | export async function composeSleepModeReport( | |
| 73 | userId: string, | |
| 74 | opts?: { sinceHoursAgo?: number; now?: Date } | |
| 75 | ): Promise<SleepModeReport> { | |
| 76 | const windowHours = opts?.sinceHoursAgo ?? DEFAULT_WINDOW_HOURS; | |
| 77 | const now = opts?.now ?? new Date(); | |
| 78 | const since = new Date(now.getTime() - windowHours * 60 * 60 * 1000); | |
| 79 | ||
| 80 | const empty: SleepModeReport = { | |
| 81 | windowHours, | |
| 82 | prsAutoMerged: [], | |
| 83 | issuesBuiltByAi: [], | |
| 84 | aiReviewsPosted: 0, | |
| 85 | securityIssuesAutoFixed: 0, | |
| 86 | gateFailuresAutoRepaired: 0, | |
| 87 | hoursSaved: 0, | |
| 88 | }; | |
| 89 | ||
| 90 | try { | |
| 91 | // Resolve the user's repos (owner only — orgs aren't user-toggled). | |
| 92 | const ownedRepos = await db | |
| 93 | .select({ id: repositories.id, name: repositories.name }) | |
| 94 | .from(repositories) | |
| 95 | .where(eq(repositories.ownerId, userId)); | |
| 96 | if (ownedRepos.length === 0) return empty; | |
| 97 | ||
| 98 | const repoIds = ownedRepos.map((r) => r.id); | |
| 99 | const repoNameById = new Map(ownedRepos.map((r) => [r.id, r.name])); | |
| 100 | ||
| 101 | // ----------------------------------------------------------------- | |
| 102 | // PRs auto-merged: audit_log `auto_merge.merged` in window. | |
| 103 | // Join PR for title+number. | |
| 104 | // ----------------------------------------------------------------- | |
| 105 | let prsAutoMerged: SleepModeReport["prsAutoMerged"] = []; | |
| 106 | try { | |
| 107 | const rows = await db | |
| 108 | .select({ | |
| 109 | targetId: auditLog.targetId, | |
| 110 | repositoryId: auditLog.repositoryId, | |
| 111 | }) | |
| 112 | .from(auditLog) | |
| 113 | .where( | |
| 114 | and( | |
| 115 | eq(auditLog.action, "auto_merge.merged"), | |
| 116 | inArray(auditLog.repositoryId, repoIds), | |
| 117 | gte(auditLog.createdAt, since) | |
| 118 | ) | |
| 119 | ) | |
| 120 | .limit(50); | |
| 121 | const prIds = rows | |
| 122 | .map((r) => r.targetId) | |
| 123 | .filter((v): v is string => typeof v === "string" && v.length > 0); | |
| 124 | if (prIds.length > 0) { | |
| 125 | const prRows = await db | |
| 126 | .select({ | |
| 127 | id: pullRequests.id, | |
| 128 | number: pullRequests.number, | |
| 129 | title: pullRequests.title, | |
| 130 | repositoryId: pullRequests.repositoryId, | |
| 131 | }) | |
| 132 | .from(pullRequests) | |
| 133 | .where(inArray(pullRequests.id, prIds)); | |
| 134 | prsAutoMerged = prRows.map((p) => ({ | |
| 135 | number: p.number, | |
| 136 | title: p.title, | |
| 137 | repo: repoNameById.get(p.repositoryId) || "?", | |
| 138 | })); | |
| 139 | } | |
| 140 | } catch (err) { | |
| 141 | console.error("[sleep-mode] prsAutoMerged query failed:", err); | |
| 142 | } | |
| 143 | ||
| 144 | // ----------------------------------------------------------------- | |
| 145 | // Issues built by AI: issue_comments whose body includes the | |
| 146 | // K3 AI-build marker, within the window, on repos this user owns. | |
| 147 | // ----------------------------------------------------------------- | |
| 148 | let issuesBuiltByAi: SleepModeReport["issuesBuiltByAi"] = []; | |
| 149 | try { | |
| 150 | const rows = await db | |
| 151 | .select({ | |
| 152 | issueId: issueComments.issueId, | |
| 153 | createdAt: issueComments.createdAt, | |
| 154 | }) | |
| 155 | .from(issueComments) | |
| 156 | .innerJoin(issues, eq(issues.id, issueComments.issueId)) | |
| 157 | .where( | |
| 158 | and( | |
| 159 | inArray(issues.repositoryId, repoIds), | |
| 160 | gte(issueComments.createdAt, since), | |
| 161 | sql`${issueComments.body} LIKE ${"%" + AI_BUILD_MARKER + "%"}` | |
| 162 | ) | |
| 163 | ) | |
| 164 | .limit(50); | |
| 165 | const issueIds = Array.from(new Set(rows.map((r) => r.issueId))); | |
| 166 | if (issueIds.length > 0) { | |
| 167 | const issueRows = await db | |
| 168 | .select({ | |
| 169 | id: issues.id, | |
| 170 | number: issues.number, | |
| 171 | title: issues.title, | |
| 172 | repositoryId: issues.repositoryId, | |
| 173 | }) | |
| 174 | .from(issues) | |
| 175 | .where(inArray(issues.id, issueIds)); | |
| 176 | issuesBuiltByAi = issueRows.map((i) => ({ | |
| 177 | number: i.number, | |
| 178 | title: i.title, | |
| 179 | repo: repoNameById.get(i.repositoryId) || "?", | |
| 180 | })); | |
| 181 | } | |
| 182 | } catch (err) { | |
| 183 | console.error("[sleep-mode] issuesBuiltByAi query failed:", err); | |
| 184 | } | |
| 185 | ||
| 186 | // ----------------------------------------------------------------- | |
| 187 | // AI reviews posted on this user's repos in the window. | |
| 188 | // pr_comments.is_ai_review=true, joined to pullRequests for repo | |
| 189 | // filter. We count, we don't list — the digest just reports the | |
| 190 | // total. (Per-PR list would balloon the email.) | |
| 191 | // ----------------------------------------------------------------- | |
| 192 | let aiReviewsPosted = 0; | |
| 193 | try { | |
| 194 | const rows = await db | |
| 195 | .select({ c: sql<number>`count(*)::int` }) | |
| 196 | .from(prComments) | |
| 197 | .innerJoin( | |
| 198 | pullRequests, | |
| 199 | eq(pullRequests.id, prComments.pullRequestId) | |
| 200 | ) | |
| 201 | .where( | |
| 202 | and( | |
| 203 | eq(prComments.isAiReview, true), | |
| 204 | inArray(pullRequests.repositoryId, repoIds), | |
| 205 | gte(prComments.createdAt, since) | |
| 206 | ) | |
| 207 | ); | |
| 208 | aiReviewsPosted = Number(rows[0]?.c ?? 0); | |
| 209 | } catch (err) { | |
| 210 | console.error("[sleep-mode] aiReviewsPosted query failed:", err); | |
| 211 | } | |
| 212 | ||
| 213 | // ----------------------------------------------------------------- | |
| 214 | // Gate auto-repairs in window. Split into "security" (secret-scan | |
| 215 | // gates) and "everything else" (test repair, etc). | |
| 216 | // ----------------------------------------------------------------- | |
| 217 | let securityIssuesAutoFixed = 0; | |
| 218 | let gateFailuresAutoRepaired = 0; | |
| 219 | try { | |
| 220 | const rows = await db | |
| 221 | .select({ | |
| 222 | gateName: gateRuns.gateName, | |
| 223 | }) | |
| 224 | .from(gateRuns) | |
| 225 | .where( | |
| 226 | and( | |
| 227 | inArray(gateRuns.repositoryId, repoIds), | |
| 228 | eq(gateRuns.status, "repaired"), | |
| 229 | gte(gateRuns.createdAt, since) | |
| 230 | ) | |
| 231 | ) | |
| 232 | .limit(500); | |
| 233 | for (const r of rows) { | |
| 234 | const lower = (r.gateName || "").toLowerCase(); | |
| 235 | if (lower.includes("secret") || lower.includes("security")) { | |
| 236 | securityIssuesAutoFixed += 1; | |
| 237 | } else { | |
| 238 | gateFailuresAutoRepaired += 1; | |
| 239 | } | |
| 240 | } | |
| 241 | } catch (err) { | |
| 242 | console.error("[sleep-mode] gate-runs query failed:", err); | |
| 243 | } | |
| 244 | ||
| 245 | const hoursSaved = computeHoursSaved({ | |
| 246 | prsAutoMerged: prsAutoMerged.length, | |
| 247 | issuesBuiltByAi: issuesBuiltByAi.length, | |
| 248 | aiReviewsPosted, | |
| c315551 | 249 | aiTriagesPosted: 0, |
| 250 | aiCommitMsgs: 0, | |
| 251 | secretsAutoRepaired: securityIssuesAutoFixed, | |
| 252 | gateAutoRepairs: gateFailuresAutoRepaired, | |
| 46d6165 | 253 | }); |
| 254 | ||
| 255 | return { | |
| 256 | windowHours, | |
| 257 | prsAutoMerged, | |
| 258 | issuesBuiltByAi, | |
| 259 | aiReviewsPosted, | |
| 260 | securityIssuesAutoFixed, | |
| 261 | gateFailuresAutoRepaired, | |
| 262 | hoursSaved, | |
| 263 | }; | |
| 264 | } catch (err) { | |
| 265 | console.error("[sleep-mode] composeSleepModeReport error:", err); | |
| 266 | return empty; | |
| 267 | } | |
| 268 | } | |
| 269 | ||
| 270 | /** | |
| 271 | * Render a Sleep Mode digest. Returns `{subject, text, html}` ready to hand | |
| 272 | * off to `sendEmail`. Pure — no DB, no env, no I/O. | |
| 273 | * | |
| 274 | * HTML escaping is handled here for every user-controlled value so a | |
| 275 | * crafted PR/issue title cannot break out and inject script. The plaintext | |
| 276 | * branch is plaintext by definition (consumers display as-is). | |
| 277 | */ | |
| 278 | export function renderSleepModeDigest( | |
| 279 | report: SleepModeReport, | |
| 280 | opts: { username: string } | |
| 281 | ): { subject: string; text: string; html: string } { | |
| 282 | const username = opts.username; | |
| 283 | const base = config.appBaseUrl || "https://gluecron.com"; | |
| 284 | const total = | |
| 285 | report.prsAutoMerged.length + | |
| 286 | report.issuesBuiltByAi.length + | |
| 287 | report.aiReviewsPosted + | |
| 288 | report.securityIssuesAutoFixed + | |
| 289 | report.gateFailuresAutoRepaired; | |
| 290 | ||
| 291 | const subject = | |
| 292 | total === 0 | |
| 293 | ? `Sleep Mode: a quiet night on Gluecron` | |
| 294 | : `Sleep Mode: while you slept, Claude shipped ${total} thing${total === 1 ? "" : "s"}`; | |
| 295 | ||
| 296 | // ---- Plaintext ---- | |
| 297 | const textLines: string[] = []; | |
| 298 | textLines.push(`Hi ${username},`); | |
| 299 | textLines.push(""); | |
| 300 | if (total === 0) { | |
| 301 | textLines.push( | |
| 302 | `Nothing fired in the last ${report.windowHours} hours. Quiet night.` | |
| 303 | ); | |
| 304 | } else { | |
| 305 | textLines.push( | |
| 306 | `While you slept, Claude opened/merged ${report.prsAutoMerged.length} PR${report.prsAutoMerged.length === 1 ? "" : "s"}, built ${report.issuesBuiltByAi.length} issue${report.issuesBuiltByAi.length === 1 ? "" : "s"}, posted ${report.aiReviewsPosted} AI review${report.aiReviewsPosted === 1 ? "" : "s"}, fixed ${report.securityIssuesAutoFixed} security issue${report.securityIssuesAutoFixed === 1 ? "" : "s"}, and auto-repaired ${report.gateFailuresAutoRepaired} gate failure${report.gateFailuresAutoRepaired === 1 ? "" : "s"}.` | |
| 307 | ); | |
| 308 | textLines.push(""); | |
| 309 | textLines.push(`Estimated time saved: ${report.hoursSaved} hours.`); | |
| 310 | } | |
| 311 | textLines.push(""); | |
| 312 | ||
| 313 | if (report.prsAutoMerged.length > 0) { | |
| 314 | textLines.push("## PRs auto-merged"); | |
| 315 | for (const pr of report.prsAutoMerged.slice(0, 10)) { | |
| 316 | textLines.push(`- ${pr.repo} #${pr.number} ${pr.title}`); | |
| 317 | } | |
| 318 | textLines.push(""); | |
| 319 | } | |
| 320 | if (report.issuesBuiltByAi.length > 0) { | |
| 321 | textLines.push("## Issues built by AI"); | |
| 322 | for (const it of report.issuesBuiltByAi.slice(0, 10)) { | |
| 323 | const tail = it.prNumber ? ` -> PR #${it.prNumber}` : ""; | |
| 324 | textLines.push(`- ${it.repo} #${it.number} ${it.title}${tail}`); | |
| 325 | } | |
| 326 | textLines.push(""); | |
| 327 | } | |
| 328 | if ( | |
| 329 | report.aiReviewsPosted + | |
| 330 | report.securityIssuesAutoFixed + | |
| 331 | report.gateFailuresAutoRepaired > | |
| 332 | 0 | |
| 333 | ) { | |
| 334 | textLines.push("## Automated guardrails"); | |
| 335 | textLines.push(`- AI reviews posted: ${report.aiReviewsPosted}`); | |
| 336 | textLines.push( | |
| 337 | `- Security issues auto-fixed: ${report.securityIssuesAutoFixed}` | |
| 338 | ); | |
| 339 | textLines.push( | |
| 340 | `- Gate failures auto-repaired: ${report.gateFailuresAutoRepaired}` | |
| 341 | ); | |
| 342 | textLines.push(""); | |
| 343 | } | |
| 344 | ||
| 345 | textLines.push("---"); | |
| 346 | textLines.push( | |
| 347 | `Sleep Mode delivers this daily. Toggle off at ${base}/settings.` | |
| 348 | ); | |
| 349 | const text = textLines.join("\n"); | |
| 350 | ||
| 351 | // ---- HTML ---- | |
| 352 | // We do NOT route through email-digest's textToHtml because the Sleep | |
| 353 | // Mode digest has a custom hero block + bespoke styling. We DO use the | |
| 354 | // shared `escapeHtml` for every user-supplied string. | |
| 355 | const html = renderHtml(report, { username, base, total }); | |
| 356 | ||
| 357 | return { subject, text, html }; | |
| 358 | } | |
| 359 | ||
| 360 | function renderHtml( | |
| 361 | report: SleepModeReport, | |
| 362 | ctx: { username: string; base: string; total: number } | |
| 363 | ): string { | |
| 364 | const u = escapeHtml(ctx.username); | |
| 365 | const heroLine = | |
| 366 | ctx.total === 0 | |
| 367 | ? `Nothing fired in the last ${report.windowHours} hours. Quiet night.` | |
| 368 | : `While you slept, Claude opened/merged <strong>${report.prsAutoMerged.length}</strong> PRs, built <strong>${report.issuesBuiltByAi.length}</strong> issues, posted <strong>${report.aiReviewsPosted}</strong> AI reviews, fixed <strong>${report.securityIssuesAutoFixed}</strong> security issues, and auto-repaired <strong>${report.gateFailuresAutoRepaired}</strong> gate failures.`; | |
| 369 | ||
| 370 | const parts: string[] = []; | |
| 371 | parts.push( | |
| 372 | `<html><body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;max-width:640px;margin:0 auto;padding:24px;color:#0f1019;background:#fff">` | |
| 373 | ); | |
| 374 | parts.push( | |
| 375 | `<div style="background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;padding:24px;border-radius:12px;margin-bottom:24px">` + | |
| 376 | `<div style="font-size:11px;letter-spacing:0.18em;text-transform:uppercase;opacity:0.85">Sleep Mode</div>` + | |
| 377 | `<h1 style="margin:8px 0 0;font-size:22px;font-weight:600">Good morning, ${u}.</h1>` + | |
| 378 | `<p style="margin:12px 0 0;font-size:14px;line-height:1.5;opacity:0.95">${heroLine}</p>` + | |
| 379 | (ctx.total > 0 | |
| 380 | ? `<p style="margin:12px 0 0;font-size:14px;font-weight:600">Estimated time saved: ${report.hoursSaved} hours</p>` | |
| 381 | : "") + | |
| 382 | `</div>` | |
| 383 | ); | |
| 384 | ||
| 385 | if (report.prsAutoMerged.length > 0) { | |
| 386 | parts.push( | |
| 387 | `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px;font-size:14px">PRs auto-merged</h3>` | |
| 388 | ); | |
| 389 | parts.push(`<ul style="padding-left:18px;margin:8px 0">`); | |
| 390 | for (const pr of report.prsAutoMerged.slice(0, 10)) { | |
| 391 | parts.push( | |
| 392 | `<li><strong>${escapeHtml(pr.repo)}</strong> #${pr.number} ${escapeHtml(pr.title)}</li>` | |
| 393 | ); | |
| 394 | } | |
| 395 | parts.push(`</ul>`); | |
| 396 | } | |
| 397 | if (report.issuesBuiltByAi.length > 0) { | |
| 398 | parts.push( | |
| 399 | `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px;font-size:14px">Issues built by AI</h3>` | |
| 400 | ); | |
| 401 | parts.push(`<ul style="padding-left:18px;margin:8px 0">`); | |
| 402 | for (const it of report.issuesBuiltByAi.slice(0, 10)) { | |
| 403 | const tail = it.prNumber ? ` → PR #${it.prNumber}` : ""; | |
| 404 | parts.push( | |
| 405 | `<li><strong>${escapeHtml(it.repo)}</strong> #${it.number} ${escapeHtml(it.title)}${tail}</li>` | |
| 406 | ); | |
| 407 | } | |
| 408 | parts.push(`</ul>`); | |
| 409 | } | |
| 410 | if ( | |
| 411 | report.aiReviewsPosted + | |
| 412 | report.securityIssuesAutoFixed + | |
| 413 | report.gateFailuresAutoRepaired > | |
| 414 | 0 | |
| 415 | ) { | |
| 416 | parts.push( | |
| 417 | `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px;font-size:14px">Automated guardrails</h3>` | |
| 418 | ); | |
| 419 | parts.push(`<ul style="padding-left:18px;margin:8px 0">`); | |
| 420 | parts.push(`<li>AI reviews posted: ${report.aiReviewsPosted}</li>`); | |
| 421 | parts.push( | |
| 422 | `<li>Security issues auto-fixed: ${report.securityIssuesAutoFixed}</li>` | |
| 423 | ); | |
| 424 | parts.push( | |
| 425 | `<li>Gate failures auto-repaired: ${report.gateFailuresAutoRepaired}</li>` | |
| 426 | ); | |
| 427 | parts.push(`</ul>`); | |
| 428 | } | |
| 429 | ||
| 430 | parts.push( | |
| 431 | `<hr style="border:none;border-top:1px solid #eee;margin:24px 0" />` | |
| 432 | ); | |
| 433 | parts.push( | |
| 434 | `<p style="font-size:12px;color:#777">Sleep Mode delivers this daily. Toggle off at <a href="${escapeHtml(ctx.base)}/settings">${escapeHtml(ctx.base)}/settings</a>.</p>` | |
| 435 | ); | |
| 436 | parts.push(`</body></html>`); | |
| 437 | return parts.join("\n"); | |
| 438 | } | |
| 439 | ||
| 440 | /** | |
| 441 | * Compose + send a Sleep Mode digest for one user. Stamps | |
| 442 | * `users.last_digest_sent_at` on success so the autopilot cooldown holds | |
| 443 | * for 23h. Never throws. | |
| 444 | * | |
| 445 | * Skips (returns `{ok:false, reason}`) when: | |
| 446 | * - user not found | |
| 447 | * - user.sleepModeEnabled is false | |
| 448 | * - composer returns the zero report AND owner has no repos (graceful) | |
| 449 | * -> we DO still send if they have repos but a quiet window. | |
| 450 | */ | |
| 451 | export async function sendSleepModeDigestForUser( | |
| 452 | userId: string | |
| 453 | ): Promise<{ ok: boolean; reason?: string }> { | |
| 454 | try { | |
| 455 | const [user] = await db | |
| 456 | .select() | |
| 457 | .from(users) | |
| 458 | .where(eq(users.id, userId)) | |
| 459 | .limit(1); | |
| 460 | if (!user) return { ok: false, reason: "user not found" }; | |
| 461 | if (!user.sleepModeEnabled) return { ok: false, reason: "sleep mode off" }; | |
| 462 | ||
| 463 | const report = await composeSleepModeReport(userId); | |
| 464 | const { subject, text, html } = renderSleepModeDigest(report, { | |
| 465 | username: user.username, | |
| 466 | }); | |
| 467 | ||
| 468 | const result: EmailResult = await sendEmail({ | |
| 469 | to: user.email, | |
| 470 | subject, | |
| 471 | text, | |
| 472 | html, | |
| 473 | }); | |
| 474 | if (result.ok) { | |
| 475 | try { | |
| 476 | await db | |
| 477 | .update(users) | |
| e1fc7db | 478 | .set({ lastSleepDigestSentAt: new Date() }) |
| 46d6165 | 479 | .where(eq(users.id, userId)); |
| 480 | } catch (err) { | |
| e1fc7db | 481 | console.error("[sleep-mode] lastSleepDigestSentAt update failed:", err); |
| 46d6165 | 482 | } |
| 483 | return { ok: true }; | |
| 484 | } | |
| 485 | return { | |
| 486 | ok: false, | |
| 487 | reason: result.skipped || result.error || "send failed", | |
| 488 | }; | |
| 489 | } catch (err) { | |
| 490 | console.error("[sleep-mode] sendSleepModeDigestForUser error:", err); | |
| 491 | return { ok: false, reason: "error" }; | |
| 492 | } | |
| 493 | } | |
| 494 | ||
| 495 | /** Test-only surface. */ | |
| 496 | export const __test = { | |
| 497 | DEFAULT_WINDOW_HOURS, | |
| 498 | renderHtml, | |
| 499 | }; |