Add blocking notification summary API
swadhinbiswas committed 8 months ago
c9f745e
1 parent b6de549
commit c9f745e
4 files changed
+178 -1
Showing 4 changed files+178-1
▼doc/feature_audit.md
+1-1
| @@ -183,7 +183,7 @@ | ||
183 | 183 | | Feature | Status | Implementation Notes | |
184 | 184 | | :--- | :---: | :--- | |
185 | 185 | | Smart notifications | ⚠️ | API now supports smart priority scoring/sorting via `GET /notifications?prioritize=true`; deeper ML personalization and channel-level routing remain partial | |
186 | -| Blocking alerts | ⚠️ | Blocking-focused notification feed filter is now available via `GET /notifications?filter=blocking`; prioritization/automation depth remains partial | | |
186 | +| Blocking alerts | ⚠️ | Blocking-focused notification feed + summary APIs are available (`GET /notifications?filter=blocking`, `GET /notifications/blocking/summary`); automated escalation/routing depth remains partial | | |
187 | 187 | | Daily/weekly digests | ⚠️ | Scheduler is timezone-aware with cron endpoint/tests, user-scoped digest test API (`/user/notification-digests/test`), and bounded retry/observability metrics (`maxRetries`, `retried`, `recovered`); provider-level delivery analytics and dead-letter tooling remain partial | |
188 | 188 | | Mentions & subscriptions | ✅ | Implemented | |
189 | 189 | | PR-level discussions | ✅ | Full threaded comments | |
190 | 190 | |
▼src/lib/openapi.ts
+13-0
| @@ -1098,6 +1098,19 @@ export const openApiSpec = { | ||
1098 | 1098 | }, |
1099 | 1099 | }, |
1100 | 1100 | }, |
1101 | + "/notifications/blocking/summary": { | |
1102 | + get: { | |
1103 | + tags: ["Notifications"], | |
1104 | + summary: "Get blocking notification summary", | |
1105 | + description: "Returns aggregate blocking-alert counts and top prioritized recent blocking notifications for the authenticated user.", | |
1106 | + security: [{ bearerAuth: [] }], | |
1107 | + responses: { | |
1108 | + 200: { description: "Blocking summary returned" }, | |
1109 | + 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1110 | + 500: { description: "Summary load failed", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1111 | + }, | |
1112 | + }, | |
1113 | + }, | |
1101 | 1114 | "/repos/templates": { |
1102 | 1115 | get: { |
1103 | 1116 | tags: ["Repositories"], |
1104 | 1117 | |
▼src/pages/api/notifications/blocking/summary.ts
+86-0
| @@ -0,0 +1,86 @@ | ||
1 | +import { type APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, desc, eq, or } from "drizzle-orm"; | |
4 | +import { getDatabase, schema } from "@/db"; | |
5 | +import { notifications } from "@/db/schema"; | |
6 | +import { getUserFromRequest } from "@/lib/auth"; | |
7 | +import { scoreNotificationPriority } from "@/lib/notification-priority"; | |
8 | +import { success, unauthorized, serverError } from "@/lib/api"; | |
9 | + | |
10 | +const BLOCKING_NOTIFICATION_TYPES = [ | |
11 | + "ci_failed", | |
12 | + "review_request", | |
13 | + "security_alert", | |
14 | + "merge_conflict", | |
15 | + "merge_blocked", | |
16 | +]; | |
17 | + | |
18 | +const BLOCKING_NOTIFICATION_REASONS = [ | |
19 | + "ci_failed", | |
20 | + "review_requested", | |
21 | + "security_alert", | |
22 | + "merge_conflict", | |
23 | + "merge_blocked", | |
24 | +]; | |
25 | + | |
26 | +export const GET: APIRoute = async ({ request }) => { | |
27 | + try { | |
28 | + const tokenPayload = await getUserFromRequest(request); | |
29 | + if (!tokenPayload) return unauthorized(); | |
30 | + | |
31 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
32 | + const blocking = await db.query.notifications.findMany({ | |
33 | + where: and( | |
34 | + eq(notifications.userId, tokenPayload.userId), | |
35 | + eq(notifications.isArchived, false), | |
36 | + or( | |
37 | + ...BLOCKING_NOTIFICATION_TYPES.map((type) => eq(notifications.type, type)), | |
38 | + ...BLOCKING_NOTIFICATION_REASONS.map((reason) => eq(notifications.reason, reason)) | |
39 | + )! | |
40 | + ), | |
41 | + orderBy: [desc(notifications.createdAt)], | |
42 | + limit: 100, | |
43 | + with: { | |
44 | + repository: { | |
45 | + columns: { id: true, name: true, slug: true }, | |
46 | + }, | |
47 | + }, | |
48 | + }); | |
49 | + | |
50 | + const scored = blocking.map((item) => { | |
51 | + const priority = scoreNotificationPriority(item); | |
52 | + return { | |
53 | + ...item, | |
54 | + priority: priority.priority, | |
55 | + priorityScore: priority.score, | |
56 | + isBlocking: priority.isBlocking, | |
57 | + }; | |
58 | + }); | |
59 | + | |
60 | + scored.sort((a, b) => { | |
61 | + if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore; | |
62 | + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | |
63 | + }); | |
64 | + | |
65 | + const countsByType: Record<string, number> = {}; | |
66 | + const countsByReason: Record<string, number> = {}; | |
67 | + | |
68 | + for (const item of scored) { | |
69 | + const type = item.type || "unknown"; | |
70 | + const reason = item.reason || "unknown"; | |
71 | + countsByType[type] = (countsByType[type] || 0) + 1; | |
72 | + countsByReason[reason] = (countsByReason[reason] || 0) + 1; | |
73 | + } | |
74 | + | |
75 | + return success({ | |
76 | + totalBlocking: scored.length, | |
77 | + unreadBlockingCount: scored.filter((item) => !item.isRead).length, | |
78 | + countsByType, | |
79 | + countsByReason, | |
80 | + topPriority: scored[0]?.priority || null, | |
81 | + items: scored.slice(0, 20), | |
82 | + }); | |
83 | + } catch (error) { | |
84 | + return serverError("Failed to load blocking notification summary"); | |
85 | + } | |
86 | +}; | |
0 | 87 | |
▼tests/unit/notifications-blocking-summary-route.test.ts
+78-0
| @@ -0,0 +1,78 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { getUserFromRequestMock } = vi.hoisted(() => ({ | |
4 | + getUserFromRequestMock: vi.fn(), | |
5 | +})); | |
6 | + | |
7 | +let mockDb: any; | |
8 | + | |
9 | +vi.mock("@/lib/auth", () => ({ | |
10 | + getUserFromRequest: getUserFromRequestMock, | |
11 | +})); | |
12 | + | |
13 | +vi.mock("@/db", async () => { | |
14 | + const actual = await vi.importActual<typeof import("@/db")>("@/db"); | |
15 | + return { | |
16 | + ...actual, | |
17 | + getDatabase: () => mockDb, | |
18 | + }; | |
19 | +}); | |
20 | + | |
21 | +import { GET as blockingSummaryGet } from "@/pages/api/notifications/blocking/summary"; | |
22 | + | |
23 | +describe("notifications blocking summary route", () => { | |
24 | + beforeEach(() => { | |
25 | + getUserFromRequestMock.mockReset(); | |
26 | + mockDb = { | |
27 | + query: { | |
28 | + notifications: { | |
29 | + findMany: vi.fn(), | |
30 | + }, | |
31 | + }, | |
32 | + }; | |
33 | + }); | |
34 | + | |
35 | + it("returns unauthorized when not authenticated", async () => { | |
36 | + getUserFromRequestMock.mockResolvedValue(null); | |
37 | + | |
38 | + const response = await blockingSummaryGet({ | |
39 | + request: new Request("http://localhost/api/notifications/blocking/summary"), | |
40 | + } as any); | |
41 | + | |
42 | + expect(response.status).toBe(401); | |
43 | + }); | |
44 | + | |
45 | + it("returns blocking summary aggregates", async () => { | |
46 | + getUserFromRequestMock.mockResolvedValue({ userId: "user-1" }); | |
47 | + mockDb.query.notifications.findMany.mockResolvedValue([ | |
48 | + { | |
49 | + id: "n1", | |
50 | + type: "security_alert", | |
51 | + reason: "security_alert", | |
52 | + isRead: false, | |
53 | + isArchived: false, | |
54 | + createdAt: new Date("2026-02-19T00:00:00Z"), | |
55 | + }, | |
56 | + { | |
57 | + id: "n2", | |
58 | + type: "ci_failed", | |
59 | + reason: "ci_failed", | |
60 | + isRead: true, | |
61 | + isArchived: false, | |
62 | + createdAt: new Date("2026-02-18T00:00:00Z"), | |
63 | + }, | |
64 | + ]); | |
65 | + | |
66 | + const response = await blockingSummaryGet({ | |
67 | + request: new Request("http://localhost/api/notifications/blocking/summary"), | |
68 | + } as any); | |
69 | + const body = await response.json(); | |
70 | + | |
71 | + expect(response.status).toBe(200); | |
72 | + expect(body?.data?.totalBlocking).toBe(2); | |
73 | + expect(body?.data?.unreadBlockingCount).toBe(1); | |
74 | + expect(body?.data?.countsByType?.security_alert).toBe(1); | |
75 | + expect(body?.data?.topPriority).toBe("critical"); | |
76 | + expect(body?.data?.items).toHaveLength(2); | |
77 | + }); | |
78 | +}); | |
0 | 79 | |