Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

inbox.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

inbox.tsxBlame1071 lines · 1 contributor
e9aa4d8Claude1/**
2 * `/inbox` — Unified developer inbox.
3 *
4 * One screen, every signal that needs the user's attention. Replaces
5 * what Slack / email do for developers: a single chronological timeline
6 * of @mentions, review requests, CI failures, AI findings, and
7 * auto-merge events from every repo the user touches.
8 *
9 * Sources (all best-effort — any one failing returns empty rows rather
10 * than 500'ing the whole page):
11 * - mentions: pr_comments + issue_comments where body matches @username
12 * - review: open PRs in user's repos with no AI verdict yet
13 * - ci: workflow_runs status='failure' in user's repos, last 24h
14 * - ai (findings): repo_advisory_alerts on user's repos (status='open')
15 * - ai (auto-merge): pull_requests merged where mergedAt is set, in user's repos
16 *
17 * Filter tabs: all | mentions | review | ci | ai
18 * Scoped CSS under `.inbox-*`. Cap to 100 rows after merge + sort desc.
19 *
20 * The pure helpers `mergeAndCapInboxRows` and `filterInboxRows` are
21 * exported for unit testing — the route handler itself runs DB I/O and
22 * is best-effort by design.
23 */
24
25import { Hono } from "hono";
26import { eq, and, desc, inArray, sql, isNull, isNotNull, gte } from "drizzle-orm";
27import { db } from "../db";
28import {
29 pullRequests,
30 repositories,
31 users,
32 repoCollaborators,
33 prComments,
34 issues,
35 issueComments,
36 repoAdvisoryAlerts,
37 securityAdvisories,
38 workflowRuns,
39 workflows,
40} from "../db/schema";
41import { notifications as notifTable } from "../db/schema-extensions";
42import { Layout } from "../views/layout";
43import { softAuth, requireAuth } from "../middleware/auth";
44import type { AuthEnv } from "../middleware/auth";
45
46const inboxRoutes = new Hono<AuthEnv>();
47inboxRoutes.use("*", softAuth);
48
49// ---------------------------------------------------------------------------
50// Types — kept narrow & explicit so the merge step has a single shape.
51// ---------------------------------------------------------------------------
52
53export type InboxKind = "mention" | "review" | "ci" | "ai-finding" | "ai-merge";
54
55export interface InboxRow {
56 id: string; // stable per-row id (kind + source row id)
57 kind: InboxKind;
58 title: string; // primary text shown on the row
59 sourceText: string; // owner/repo#N or workflow-run-N
60 sourceUrl: string; // where the row opens to
61 createdAt: Date;
62 body?: string | null; // optional secondary line (comment snippet, etc)
63}
64
65// ---------------------------------------------------------------------------
66// Pure helpers — unit-tested.
67// ---------------------------------------------------------------------------
68
69/**
70 * Merge rows from every source, sort by timestamp desc, cap to `cap`.
71 * Defensive against undefined arrays so a missing source becomes [].
72 */
73export function mergeAndCapInboxRows(
74 sources: Array<InboxRow[] | undefined | null>,
75 cap = 100
76): InboxRow[] {
77 const merged: InboxRow[] = [];
78 for (const src of sources) {
79 if (!src) continue;
80 for (const row of src) merged.push(row);
81 }
82 merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
83 return merged.slice(0, cap);
84}
85
86/** Tab predicate. `all` returns everything; `ai` covers both AI kinds. */
87export function filterInboxRows(rows: InboxRow[], tab: InboxFilter): InboxRow[] {
88 if (tab === "all") return rows;
89 if (tab === "mentions") return rows.filter((r) => r.kind === "mention");
90 if (tab === "review") return rows.filter((r) => r.kind === "review");
91 if (tab === "ci") return rows.filter((r) => r.kind === "ci");
92 if (tab === "ai")
93 return rows.filter((r) => r.kind === "ai-finding" || r.kind === "ai-merge");
94 return rows;
95}
96
97export type InboxFilter = "all" | "mentions" | "review" | "ci" | "ai";
98
99const VALID_FILTERS: InboxFilter[] = ["all", "mentions", "review", "ci", "ai"];
100
101function relTime(d: Date): string {
102 const diff = Date.now() - d.getTime();
103 const s = Math.floor(diff / 1000);
104 if (s < 60) return `${s}s ago`;
105 const m = Math.floor(s / 60);
106 if (m < 60) return `${m}m ago`;
107 const h = Math.floor(m / 60);
108 if (h < 24) return `${h}h ago`;
109 const days = Math.floor(h / 24);
110 if (days < 30) return `${days}d ago`;
111 const months = Math.floor(days / 30);
112 if (months < 12) return `${months}mo ago`;
113 return `${Math.floor(months / 12)}y ago`;
114}
115
116// ---------------------------------------------------------------------------
117// Scoped CSS — all classes prefixed `.inbox-` so nothing leaks. Mirrors the
118// pulls-dashboard visual language: gradient hairline + orb + clamp() title.
119// ---------------------------------------------------------------------------
120const styles = `
121 .inbox-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
122
123 .inbox-hero {
124 position: relative;
125 margin-bottom: var(--space-5);
126 padding: var(--space-5) var(--space-6);
127 background: var(--bg-elevated);
128 border: 1px solid var(--border);
129 border-radius: 16px;
130 overflow: hidden;
131 }
132 .inbox-hero::before {
133 content: '';
134 position: absolute;
135 top: 0; left: 0; right: 0;
136 height: 2px;
137 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
138 opacity: 0.7;
139 pointer-events: none;
140 }
141 .inbox-orb {
142 position: absolute;
143 inset: -30% -10% auto auto;
144 width: 380px; height: 380px;
145 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
146 filter: blur(80px);
147 opacity: 0.7;
148 pointer-events: none;
149 }
150 .inbox-hero-inner { position: relative; z-index: 1; max-width: 760px; }
151 .inbox-eyebrow {
152 font-size: 12.5px;
153 color: var(--text-muted);
154 margin-bottom: 8px;
155 letter-spacing: 0.04em;
156 text-transform: uppercase;
157 font-weight: 600;
158 }
159 .inbox-title {
160 font-family: var(--font-display);
161 font-size: clamp(28px, 4vw, 40px);
162 font-weight: 800;
163 letter-spacing: -0.028em;
164 line-height: 1.05;
165 margin: 0 0 10px;
166 color: var(--text-strong);
167 }
168 .inbox-title-grad {
169 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
170 -webkit-background-clip: text;
171 background-clip: text;
172 -webkit-text-fill-color: transparent;
173 color: transparent;
174 }
175 .inbox-sub {
176 font-size: 15px;
177 color: var(--text-muted);
178 margin: 0;
179 line-height: 1.5;
180 }
181
182 .inbox-stats {
183 display: flex;
184 gap: 24px;
185 margin-top: 16px;
186 flex-wrap: wrap;
187 }
188 .inbox-stat {
189 display: flex;
190 flex-direction: column;
191 gap: 2px;
192 }
193 .inbox-stat-n {
194 font-family: var(--font-display);
195 font-size: 22px;
196 font-weight: 700;
197 color: var(--text-strong);
198 font-variant-numeric: tabular-nums;
199 }
200 .inbox-stat-l {
201 font-size: 11.5px;
202 color: var(--text-muted);
203 letter-spacing: 0.02em;
204 text-transform: uppercase;
205 }
206
207 .inbox-tabs {
208 display: inline-flex;
209 background: var(--bg-elevated);
210 border: 1px solid var(--border);
211 border-radius: 9999px;
212 padding: 4px;
213 gap: 2px;
214 margin-bottom: var(--space-4);
215 flex-wrap: wrap;
216 }
217 .inbox-tab {
218 display: inline-flex;
219 align-items: center;
220 gap: 6px;
221 padding: 7px 14px;
222 border-radius: 9999px;
223 font-size: 13.5px;
224 font-weight: 500;
225 color: var(--text-muted);
226 text-decoration: none;
227 transition: color 120ms ease, background 120ms ease;
228 }
229 .inbox-tab:hover { color: var(--text-strong); text-decoration: none; }
230 .inbox-tab.is-active {
231 background: rgba(140,109,255,0.14);
232 color: var(--text-strong);
233 }
234 .inbox-tab-count {
235 font-variant-numeric: tabular-nums;
236 font-size: 11.5px;
237 background: rgba(255,255,255,0.04);
238 padding: 1px 7px;
239 border-radius: 9999px;
240 }
241 .inbox-tab.is-active .inbox-tab-count {
242 background: rgba(140,109,255,0.22);
243 color: var(--text);
244 }
245
246 .inbox-list {
247 list-style: none;
248 margin: 0;
249 padding: 0;
250 border: 1px solid var(--border);
251 border-radius: 12px;
252 overflow: hidden;
253 background: var(--bg-elevated);
254 }
255 .inbox-row {
256 display: flex;
257 align-items: flex-start;
258 gap: 14px;
259 padding: 14px 18px;
260 border-bottom: 1px solid var(--border);
261 transition: background 120ms ease;
262 }
263 .inbox-row:last-child { border-bottom: none; }
264 .inbox-row:hover { background: rgba(140,109,255,0.04); }
265 .inbox-row-icon {
266 flex-shrink: 0;
267 width: 30px;
268 height: 30px;
269 border-radius: 9px;
270 background: var(--bg-secondary, var(--bg));
271 border: 1px solid var(--border);
272 display: inline-flex;
273 align-items: center;
274 justify-content: center;
275 font-size: 14px;
276 color: var(--text);
277 margin-top: 1px;
278 }
279 .inbox-row-icon.is-mention { color: #fbbf24; background: rgba(251,191,36,0.10); border-color: rgba(251,191,36,0.25); }
280 .inbox-row-icon.is-review { color: #60a5fa; background: rgba(96,165,250,0.10); border-color: rgba(96,165,250,0.25); }
281 .inbox-row-icon.is-ci { color: #f87171; background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.25); }
282 .inbox-row-icon.is-ai {
283 color: #fff;
284 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
285 border-color: rgba(140,109,255,0.55);
286 box-shadow: 0 0 12px rgba(140,109,255,0.40);
287 }
288 .inbox-row-icon.is-ci .inbox-ci-dot {
289 width: 8px; height: 8px;
290 border-radius: 9999px;
291 background: #f87171;
292 box-shadow: 0 0 8px rgba(248,113,113,0.55);
293 }
294
295 .inbox-row-main { flex: 1; min-width: 0; }
296 .inbox-row-title {
297 font-family: var(--font-display);
298 font-size: 14.5px;
299 font-weight: 600;
300 line-height: 1.4;
301 letter-spacing: -0.012em;
302 margin: 0 0 4px;
303 color: var(--text-strong);
304 display: flex;
305 flex-wrap: wrap;
306 align-items: center;
307 gap: 8px;
308 }
309 .inbox-row-title a {
310 color: var(--text-strong);
311 text-decoration: none;
312 }
313 .inbox-row-title a:hover { color: var(--accent); }
314 .inbox-row-body {
315 font-size: 13px;
316 color: var(--text-muted);
317 line-height: 1.5;
318 margin: 0 0 4px;
319 }
320 .inbox-row-meta {
321 font-size: 12px;
322 color: var(--text-muted);
323 font-variant-numeric: tabular-nums;
324 display: flex;
325 gap: 8px;
326 flex-wrap: wrap;
327 align-items: center;
328 }
329 .inbox-row-meta .inbox-sep { opacity: 0.45; }
330 .inbox-row-source {
331 font-family: var(--font-mono);
332 color: var(--text);
333 }
334 .inbox-row-source:hover { color: var(--accent); text-decoration: none; }
335
336 .inbox-row-kind {
337 display: inline-flex;
338 align-items: center;
339 padding: 1px 8px;
340 font-size: 10.5px;
341 font-weight: 700;
342 letter-spacing: 0.04em;
343 text-transform: uppercase;
344 border-radius: 9999px;
345 }
346 .inbox-row-kind.is-mention { background: rgba(251,191,36,0.13); color: #fbbf24; }
347 .inbox-row-kind.is-review { background: rgba(96,165,250,0.13); color: #93c5fd; }
348 .inbox-row-kind.is-ci { background: rgba(248,113,113,0.13); color: #fca5a5; }
349 .inbox-row-kind.is-ai-finding {
350 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.18));
351 color: #d6c7ff;
352 }
353 .inbox-row-kind.is-ai-merge {
354 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.18));
355 color: #d6c7ff;
356 }
357
358 .inbox-row-actions {
359 flex-shrink: 0;
360 display: inline-flex;
361 align-items: center;
362 gap: 6px;
363 }
364 .inbox-mark {
365 display: inline-flex;
366 align-items: center;
367 gap: 4px;
368 padding: 5px 10px;
369 font-size: 12px;
370 font-weight: 500;
371 color: var(--text-muted);
372 background: transparent;
373 border: 1px solid transparent;
374 border-radius: 9999px;
375 cursor: pointer;
376 text-decoration: none;
377 transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
378 }
379 .inbox-mark:hover {
380 color: var(--text-strong);
381 background: rgba(140,109,255,0.08);
382 border-color: rgba(140,109,255,0.30);
383 text-decoration: none;
384 }
385
386 .inbox-empty {
387 padding: 60px 20px;
388 text-align: center;
389 border: 1px dashed var(--border-strong);
390 border-radius: 14px;
391 background: var(--bg-elevated);
392 position: relative;
393 overflow: hidden;
394 }
395 .inbox-empty::before {
396 content: '';
397 position: absolute;
398 inset: -20% -10% auto auto;
399 width: 320px; height: 320px;
400 background: radial-gradient(circle, rgba(140,109,255,0.10), transparent 70%);
401 filter: blur(60px);
402 pointer-events: none;
403 }
404 .inbox-empty-title {
405 font-family: var(--font-display);
406 font-size: 22px;
407 font-weight: 700;
408 color: var(--text-strong);
409 margin: 0 0 6px;
410 position: relative;
411 }
412 .inbox-empty-sub {
413 color: var(--text-muted);
414 font-size: 14px;
415 margin: 0;
416 position: relative;
417 }
418
419 @media (max-width: 720px) {
420 .inbox-hero { padding: 24px 20px; }
421 .inbox-row { padding: 12px 14px; flex-direction: column; }
422 .inbox-row-actions { align-self: flex-end; }
423 }
424`;
425
426// ---------------------------------------------------------------------------
427// Icon glyphs (inline SVG for a/i/c; emoji-ish glyph for mention).
428// ---------------------------------------------------------------------------
429function KindIcon({ kind }: { kind: InboxKind }) {
430 if (kind === "mention") {
431 return (
432 <span class="inbox-row-icon is-mention" aria-hidden="true">@</span>
433 );
434 }
435 if (kind === "review") {
436 return (
437 <span class="inbox-row-icon is-review" aria-hidden="true">
438 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
439 <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
440 <circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" />
441 </svg>
442 </span>
443 );
444 }
445 if (kind === "ci") {
446 return (
447 <span class="inbox-row-icon is-ci" aria-hidden="true">
448 <span class="inbox-ci-dot" />
449 </span>
450 );
451 }
452 // ai-finding | ai-merge — gradient sparkle
453 return (
454 <span class="inbox-row-icon is-ai" aria-hidden="true">
455 <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
456 <path d="M8 1.5l1.6 4.4 4.4 1.6-4.4 1.6L8 13.5 6.4 9.1 2 7.5l4.4-1.6L8 1.5z" />
457 </svg>
458 </span>
459 );
460}
461
462function KindLabel(kind: InboxKind): string {
463 if (kind === "mention") return "Mention";
464 if (kind === "review") return "Review";
465 if (kind === "ci") return "CI failure";
466 if (kind === "ai-finding") return "AI finding";
467 return "Auto-merge";
468}
469
470// ---------------------------------------------------------------------------
471// Best-effort source loaders. Every one swallows errors → returns [].
472// ---------------------------------------------------------------------------
473
474/**
475 * Compute the set of repos relevant to the user: owned + accepted collab.
476 * Returns [] on any error; the page degrades gracefully.
477 */
478async function getUserRepoIds(userId: string): Promise<string[]> {
479 try {
480 const owned = await db
481 .select({ id: repositories.id })
482 .from(repositories)
483 .where(eq(repositories.ownerId, userId));
484 const collab = await db
485 .select({ id: repoCollaborators.repositoryId })
486 .from(repoCollaborators)
487 .where(
488 and(
489 eq(repoCollaborators.userId, userId),
490 isNotNull(repoCollaborators.acceptedAt)
491 )
492 );
493 const set = new Set<string>();
494 for (const r of owned) set.add(r.id);
495 for (const r of collab) set.add(r.id);
496 return Array.from(set);
497 } catch {
498 return [];
499 }
500}
501
502interface RepoMeta {
503 name: string;
504 ownerUsername: string;
505}
506
507/** Map repo.id → {name, ownerUsername} for source-link rendering. */
508async function getRepoMetaMap(
509 repoIds: string[]
510): Promise<Map<string, RepoMeta>> {
511 const map = new Map<string, RepoMeta>();
512 if (repoIds.length === 0) return map;
513 try {
514 const rows = await db
515 .select({
516 id: repositories.id,
517 name: repositories.name,
518 ownerUsername: users.username,
519 })
520 .from(repositories)
521 .innerJoin(users, eq(users.id, repositories.ownerId))
522 .where(inArray(repositories.id, repoIds));
523 for (const r of rows) {
524 map.set(r.id, { name: r.name, ownerUsername: r.ownerUsername });
525 }
526 } catch {
527 /* skip */
528 }
529 return map;
530}
531
532/**
533 * @mentions in pr_comments + issue_comments. We do a case-insensitive
534 * `body ILIKE '%@username%'` match against the last 200 comments per
535 * source. Cheap and accurate enough — false positives (e.g. "@username"
536 * inside a code block) are tolerable for an inbox.
537 */
538async function loadMentions(
539 username: string
540): Promise<InboxRow[]> {
541 const rows: InboxRow[] = [];
542 const needle = `%@${username}%`;
543 // PR comments
544 try {
545 const prRows = await db
546 .select({
547 id: prComments.id,
548 body: prComments.body,
549 createdAt: prComments.createdAt,
550 prId: prComments.pullRequestId,
551 prNumber: pullRequests.number,
552 repoId: pullRequests.repositoryId,
553 })
554 .from(prComments)
555 .innerJoin(pullRequests, eq(pullRequests.id, prComments.pullRequestId))
556 .where(sql`${prComments.body} ILIKE ${needle}`)
557 .orderBy(desc(prComments.createdAt))
558 .limit(50);
559 const repoIds = Array.from(new Set(prRows.map((r) => r.repoId)));
560 const meta = await getRepoMetaMap(repoIds);
561 for (const r of prRows) {
562 const m = meta.get(r.repoId);
563 if (!m) continue;
564 const snippet = (r.body || "").slice(0, 140);
565 rows.push({
566 id: `mention-pr-${r.id}`,
567 kind: "mention",
568 title: `Mentioned in ${m.ownerUsername}/${m.name}#${r.prNumber}`,
569 body: snippet,
570 sourceText: `${m.ownerUsername}/${m.name}#${r.prNumber}`,
571 sourceUrl: `/${m.ownerUsername}/${m.name}/pulls/${r.prNumber}`,
572 createdAt: r.createdAt,
573 });
574 }
575 } catch {
576 /* skip */
577 }
578 // Issue comments
579 try {
580 const issueRows = await db
581 .select({
582 id: issueComments.id,
583 body: issueComments.body,
584 createdAt: issueComments.createdAt,
585 issueId: issueComments.issueId,
586 issueNumber: issues.number,
587 repoId: issues.repositoryId,
588 })
589 .from(issueComments)
590 .innerJoin(issues, eq(issues.id, issueComments.issueId))
591 .where(sql`${issueComments.body} ILIKE ${needle}`)
592 .orderBy(desc(issueComments.createdAt))
593 .limit(50);
594 const repoIds = Array.from(new Set(issueRows.map((r) => r.repoId)));
595 const meta = await getRepoMetaMap(repoIds);
596 for (const r of issueRows) {
597 const m = meta.get(r.repoId);
598 if (!m) continue;
599 const snippet = (r.body || "").slice(0, 140);
600 rows.push({
601 id: `mention-issue-${r.id}`,
602 kind: "mention",
603 title: `Mentioned in ${m.ownerUsername}/${m.name}#${r.issueNumber}`,
604 body: snippet,
605 sourceText: `${m.ownerUsername}/${m.name}#${r.issueNumber}`,
606 sourceUrl: `/${m.ownerUsername}/${m.name}/issues/${r.issueNumber}`,
607 createdAt: r.createdAt,
608 });
609 }
610 } catch {
611 /* skip */
612 }
613 return rows;
614}
615
616/**
617 * Review requests = open PRs in user's repos that have NO AI review
618 * comment yet (isAiReview=true). Filters out the user's own PRs since
619 * you don't review your own work.
620 */
621async function loadReviewRequests(
622 userId: string,
623 repoIds: string[]
624): Promise<InboxRow[]> {
625 if (repoIds.length === 0) return [];
626 const rows: InboxRow[] = [];
627 try {
628 const candidates = await db
629 .select({
630 prId: pullRequests.id,
631 prNumber: pullRequests.number,
632 prTitle: pullRequests.title,
633 prAuthorId: pullRequests.authorId,
634 prUpdatedAt: pullRequests.updatedAt,
635 repoId: pullRequests.repositoryId,
636 })
637 .from(pullRequests)
638 .where(
639 and(
640 inArray(pullRequests.repositoryId, repoIds),
641 eq(pullRequests.state, "open"),
642 eq(pullRequests.isDraft, false)
643 )
644 )
645 .orderBy(desc(pullRequests.updatedAt))
646 .limit(100);
647 if (candidates.length === 0) return [];
648 const meta = await getRepoMetaMap(
649 Array.from(new Set(candidates.map((c) => c.repoId)))
650 );
651 // Find which PRs already have an AI review.
652 const prIds = candidates.map((c) => c.prId);
653 let aiSet = new Set<string>();
654 try {
655 const aiRows = await db
656 .select({ prId: prComments.pullRequestId })
657 .from(prComments)
658 .where(
659 and(
660 inArray(prComments.pullRequestId, prIds),
661 eq(prComments.isAiReview, true)
662 )
663 );
664 aiSet = new Set(aiRows.map((r) => r.prId));
665 } catch {
666 /* keep empty set — treat all as awaiting */
667 }
668 for (const c of candidates) {
669 if (c.prAuthorId === userId) continue; // don't review your own
670 if (aiSet.has(c.prId)) continue; // already reviewed
671 const m = meta.get(c.repoId);
672 if (!m) continue;
673 rows.push({
674 id: `review-${c.prId}`,
675 kind: "review",
676 title: c.prTitle,
677 sourceText: `${m.ownerUsername}/${m.name}#${c.prNumber}`,
678 sourceUrl: `/${m.ownerUsername}/${m.name}/pulls/${c.prNumber}`,
679 createdAt: c.prUpdatedAt,
680 });
681 }
682 } catch {
683 /* skip */
684 }
685 return rows;
686}
687
688/**
689 * CI failures = workflow_runs with status='failure' in user's repos
690 * over the last 24h. Falls back gracefully if the workflow tables
691 * don't exist (e.g. older deployments).
692 */
693async function loadCiFailures(repoIds: string[]): Promise<InboxRow[]> {
694 if (repoIds.length === 0) return [];
695 const rows: InboxRow[] = [];
696 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
697 try {
698 const runs = await db
699 .select({
700 id: workflowRuns.id,
701 runNumber: workflowRuns.runNumber,
702 status: workflowRuns.status,
703 conclusion: workflowRuns.conclusion,
704 repoId: workflowRuns.repositoryId,
705 workflowId: workflowRuns.workflowId,
706 createdAt: workflowRuns.createdAt,
707 workflowName: workflows.name,
708 })
709 .from(workflowRuns)
710 .innerJoin(workflows, eq(workflows.id, workflowRuns.workflowId))
711 .where(
712 and(
713 inArray(workflowRuns.repositoryId, repoIds),
714 eq(workflowRuns.status, "failure"),
715 gte(workflowRuns.createdAt, cutoff)
716 )
717 )
718 .orderBy(desc(workflowRuns.createdAt))
719 .limit(50);
720 const meta = await getRepoMetaMap(
721 Array.from(new Set(runs.map((r) => r.repoId)))
722 );
723 for (const r of runs) {
724 const m = meta.get(r.repoId);
725 if (!m) continue;
726 rows.push({
727 id: `ci-${r.id}`,
728 kind: "ci",
729 title: `${r.workflowName} failed`,
730 body: r.conclusion || null,
731 sourceText: `${m.ownerUsername}/${m.name} · run #${r.runNumber}`,
732 sourceUrl: `/${m.ownerUsername}/${m.name}/actions/runs/${r.id}`,
733 createdAt: r.createdAt,
734 });
735 }
736 } catch {
737 /* table may not exist — skip */
738 }
739 return rows;
740}
741
742/**
743 * AI findings = open security advisory alerts on user's repos. The
744 * advisory row carries the human-readable summary + severity.
745 */
746async function loadAiFindings(repoIds: string[]): Promise<InboxRow[]> {
747 if (repoIds.length === 0) return [];
748 const rows: InboxRow[] = [];
749 try {
750 const alerts = await db
751 .select({
752 id: repoAdvisoryAlerts.id,
753 repoId: repoAdvisoryAlerts.repositoryId,
754 dependencyName: repoAdvisoryAlerts.dependencyName,
755 createdAt: repoAdvisoryAlerts.createdAt,
756 summary: securityAdvisories.summary,
757 severity: securityAdvisories.severity,
758 ghsaId: securityAdvisories.ghsaId,
759 })
760 .from(repoAdvisoryAlerts)
761 .innerJoin(
762 securityAdvisories,
763 eq(securityAdvisories.id, repoAdvisoryAlerts.advisoryId)
764 )
765 .where(
766 and(
767 inArray(repoAdvisoryAlerts.repositoryId, repoIds),
768 eq(repoAdvisoryAlerts.status, "open")
769 )
770 )
771 .orderBy(desc(repoAdvisoryAlerts.createdAt))
772 .limit(50);
773 const meta = await getRepoMetaMap(
774 Array.from(new Set(alerts.map((a) => a.repoId)))
775 );
776 for (const a of alerts) {
777 const m = meta.get(a.repoId);
778 if (!m) continue;
779 rows.push({
780 id: `ai-find-${a.id}`,
781 kind: "ai-finding",
782 title: `${a.severity?.toUpperCase() || "ADVISORY"}: ${a.summary}`,
783 body: `${a.dependencyName}${a.ghsaId ? ` · ${a.ghsaId}` : ""}`,
784 sourceText: `${m.ownerUsername}/${m.name}`,
785 sourceUrl: `/${m.ownerUsername}/${m.name}/security/advisories`,
786 createdAt: a.createdAt,
787 });
788 }
789 } catch {
790 /* skip */
791 }
792 return rows;
793}
794
795/**
796 * Auto-merge events = PRs in user's repos that have been merged
797 * (mergedAt is set). We use mergedAt as the timestamp so it sorts
798 * correctly into the timeline.
799 */
800async function loadAutoMergeEvents(repoIds: string[]): Promise<InboxRow[]> {
801 if (repoIds.length === 0) return [];
802 const rows: InboxRow[] = [];
803 const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
804 try {
805 const merged = await db
806 .select({
807 id: pullRequests.id,
808 number: pullRequests.number,
809 title: pullRequests.title,
810 mergedAt: pullRequests.mergedAt,
811 repoId: pullRequests.repositoryId,
812 })
813 .from(pullRequests)
814 .where(
815 and(
816 inArray(pullRequests.repositoryId, repoIds),
817 eq(pullRequests.state, "merged"),
818 isNotNull(pullRequests.mergedAt),
819 gte(pullRequests.mergedAt, cutoff)
820 )
821 )
822 .orderBy(desc(pullRequests.mergedAt))
823 .limit(50);
824 const meta = await getRepoMetaMap(
825 Array.from(new Set(merged.map((r) => r.repoId)))
826 );
827 for (const r of merged) {
828 const m = meta.get(r.repoId);
829 if (!m || !r.mergedAt) continue;
830 rows.push({
831 id: `ai-merge-${r.id}`,
832 kind: "ai-merge",
833 title: `Auto-merged: ${r.title}`,
834 sourceText: `${m.ownerUsername}/${m.name}#${r.number}`,
835 sourceUrl: `/${m.ownerUsername}/${m.name}/pulls/${r.number}`,
836 createdAt: r.mergedAt,
837 });
838 }
839 } catch {
840 /* skip */
841 }
842 return rows;
843}
844
845/**
846 * Unread notification count for the hero / nav badge. Defensive — the
847 * table may not exist on older deploys, in which case we report 0.
848 */
849async function getUnreadNotifCount(userId: string): Promise<number> {
850 try {
851 const [r] = await db
852 .select({ count: sql<number>`count(*)` })
853 .from(notifTable)
854 .where(and(eq(notifTable.userId, userId), eq(notifTable.isRead, false)));
855 return r?.count ?? 0;
856 } catch {
857 return 0;
858 }
859}
860
861// ---------------------------------------------------------------------------
862// GET /inbox — the unified surface.
863// ---------------------------------------------------------------------------
864inboxRoutes.get("/inbox", requireAuth, async (c) => {
865 const user = c.get("user")!;
866 const raw = c.req.query("filter") || "all";
867 const filter: InboxFilter = (VALID_FILTERS as string[]).includes(raw)
868 ? (raw as InboxFilter)
869 : "all";
870
871 const repoIds = await getUserRepoIds(user.id);
872
873 // Load every source in parallel — each is best-effort.
874 const [mentionRows, reviewRows, ciRows, aiFindingRows, aiMergeRows] =
875 await Promise.all([
876 loadMentions(user.username),
877 loadReviewRequests(user.id, repoIds),
878 loadCiFailures(repoIds),
879 loadAiFindings(repoIds),
880 loadAutoMergeEvents(repoIds),
881 ]);
882
883 const all = mergeAndCapInboxRows(
884 [mentionRows, reviewRows, ciRows, aiFindingRows, aiMergeRows],
885 100
886 );
887 const visible = filterInboxRows(all, filter);
888
889 // Counters for the hero + tab strip — derived from the full (pre-filter)
890 // dataset so the badges reflect everything, not just the active tab.
891 const counts = {
892 total: all.length,
893 mentions: all.filter((r) => r.kind === "mention").length,
894 review: all.filter((r) => r.kind === "review").length,
895 ci: all.filter((r) => r.kind === "ci").length,
896 ai: all.filter((r) => r.kind === "ai-finding" || r.kind === "ai-merge")
897 .length,
898 };
899
900 return c.html(
901 <Layout
902 title="Inbox · Gluecron"
903 user={user}
904 notificationCount={counts.total}
905 >
906 <div class="inbox-wrap">
907 <section class="inbox-hero">
908 <div class="inbox-orb" aria-hidden="true" />
909 <div class="inbox-hero-inner">
910 <div class="inbox-eyebrow">
911 Unified inbox · live ·{" "}
912 <span style="color:var(--accent);font-weight:600">
913 {user.username}
914 </span>
915 </div>
916 <h1 class="inbox-title">
917 <span class="inbox-title-grad">Everything that needs you.</span>
918 </h1>
919 <p class="inbox-sub">
920 One screen for every signal worth your attention. @mentions,
921 review requests, CI failures, AI findings, and auto-merge
922 events — from every repo you touch, sorted by what just
923 happened.
924 </p>
925 <div class="inbox-stats">
926 <div class="inbox-stat">
927 <div class="inbox-stat-n">{counts.total}</div>
928 <div class="inbox-stat-l">Total</div>
929 </div>
930 <div class="inbox-stat">
931 <div class="inbox-stat-n">{counts.mentions}</div>
932 <div class="inbox-stat-l">Mentions</div>
933 </div>
934 <div class="inbox-stat">
935 <div class="inbox-stat-n">{counts.review}</div>
936 <div class="inbox-stat-l">Review</div>
937 </div>
938 <div class="inbox-stat">
939 <div class="inbox-stat-n">{counts.ci}</div>
940 <div class="inbox-stat-l">CI</div>
941 </div>
942 <div class="inbox-stat">
943 <div class="inbox-stat-n">{counts.ai}</div>
944 <div class="inbox-stat-l">AI</div>
945 </div>
946 </div>
947 </div>
948 </section>
949
950 <nav class="inbox-tabs" aria-label="Inbox filters">
951 <a
952 href="/inbox?filter=all"
953 class={"inbox-tab " + (filter === "all" ? "is-active" : "")}
954 >
955 All <span class="inbox-tab-count">{counts.total}</span>
956 </a>
957 <a
958 href="/inbox?filter=mentions"
959 class={"inbox-tab " + (filter === "mentions" ? "is-active" : "")}
960 >
961 Mentions <span class="inbox-tab-count">{counts.mentions}</span>
962 </a>
963 <a
964 href="/inbox?filter=review"
965 class={"inbox-tab " + (filter === "review" ? "is-active" : "")}
966 >
967 Review <span class="inbox-tab-count">{counts.review}</span>
968 </a>
969 <a
970 href="/inbox?filter=ci"
971 class={"inbox-tab " + (filter === "ci" ? "is-active" : "")}
972 >
973 CI <span class="inbox-tab-count">{counts.ci}</span>
974 </a>
975 <a
976 href="/inbox?filter=ai"
977 class={"inbox-tab " + (filter === "ai" ? "is-active" : "")}
978 >
979 AI <span class="inbox-tab-count">{counts.ai}</span>
980 </a>
981 </nav>
982
983 {visible.length === 0 ? (
984 <div class="inbox-empty">
985 <h2 class="inbox-empty-title">
986 {filter === "all"
987 ? "Inbox zero. Nicely done."
988 : filter === "mentions"
989 ? "No mentions right now."
990 : filter === "review"
991 ? "Nothing waiting on your review."
992 : filter === "ci"
993 ? "No CI failures in the last 24h."
994 : "No AI findings or auto-merges right now."}
995 </h2>
996 <p class="inbox-empty-sub">
997 {filter === "all"
998 ? "When something needs your attention — a mention, a review request, a CI failure, an AI advisory — it'll land here in real time."
999 : "Switch tabs to see other signals, or check back in a few minutes."}
1000 </p>
1001 </div>
1002 ) : (
1003 <ul class="inbox-list">
1004 {visible.map((row) => {
1005 const kindCls =
1006 row.kind === "mention"
1007 ? "is-mention"
1008 : row.kind === "review"
1009 ? "is-review"
1010 : row.kind === "ci"
1011 ? "is-ci"
1012 : row.kind === "ai-finding"
1013 ? "is-ai-finding"
1014 : "is-ai-merge";
1015 return (
1016 <li class="inbox-row" data-kind={row.kind}>
1017 <KindIcon kind={row.kind} />
1018 <div class="inbox-row-main">
1019 <h3 class="inbox-row-title">
1020 <a href={row.sourceUrl}>{row.title}</a>
1021 <span class={"inbox-row-kind " + kindCls}>
1022 {KindLabel(row.kind)}
1023 </span>
1024 </h3>
1025 {row.body && (
1026 <p class="inbox-row-body">
1027 {row.body.length > 200
1028 ? row.body.slice(0, 200) + "…"
1029 : row.body}
1030 </p>
1031 )}
1032 <div class="inbox-row-meta">
1033 <a class="inbox-row-source" href={row.sourceUrl}>
1034 {row.sourceText}
1035 </a>
1036 <span class="inbox-sep">·</span>
1037 <span>{relTime(row.createdAt)}</span>
1038 </div>
1039 </div>
1040 <div class="inbox-row-actions">
1041 {/* Best-effort dismiss: route to /notifications which
1042 is where the read-state ledger actually lives. */}
1043 <a
1044 href="/notifications"
1045 class="inbox-mark"
1046 title="Mark read"
1047 >
1048 Mark read
1049 </a>
1050 </div>
1051 </li>
1052 );
1053 })}
1054 </ul>
1055 )}
1056 </div>
1057 <style dangerouslySetInnerHTML={{ __html: styles }} />
1058 </Layout>
1059 );
1060});
1061
1062// Tiny JSON endpoint the nav-link badge could poll for a live count.
1063// Same shape as /api/notifications/count so the client could swap easily.
1064inboxRoutes.get("/api/inbox/count", softAuth, async (c) => {
1065 const user = c.get("user");
1066 if (!user) return c.json({ count: 0 });
1067 const n = await getUnreadNotifCount(user.id);
1068 return c.json({ count: n });
1069});
1070
1071export default inboxRoutes;