Add blocking notification summary API

swadhinbiswas committed 8 months ago
c9f745e
1 parent b6de549
commit c9f745e
4 files changed
+178 -1
▼doc/feature_audit.md
+1-1
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
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
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
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