Add user-scoped digest test API for delivery verification

swadhinbiswas committed 8 months ago
7ceb46b
1 parent 2ce335e
commit 7ceb46b
5 files changed
+226 -1
▼doc/feature_audit.md
+1-1
184
184
| :--- | :---: | :--- |
185
185
| Smart notifications | ⚠️ | Basic notifications, no AI prioritization |
186
186
| Blocking alerts | ⚠️ | Basic via inbox sections |
187
-| Daily/weekly digests | ⚠️ | Scheduler now timezone-aware with cron endpoint + tests; delivery infrastructure hardening still pending |
187
+| Daily/weekly digests | ⚠️ | Scheduler is timezone-aware with cron endpoint/tests and user-scoped digest test API (`/user/notification-digests/test`); delivery observability/retry depth remains partial |
188
188
| Mentions & subscriptions | ✅ | Implemented |
189
189
| PR-level discussions | ✅ | Full threaded comments |
190
190
| Activity feeds | ✅ | Dashboard activity feed |
191
191
▼src/lib/chat-notifications.ts
+104-0
776
776
failed: number;
777
777
}
778
778
779
+export interface UserDigestRunOptions {
780
+ userId: string;
781
+ period?: "daily" | "weekly";
782
+ now?: Date;
783
+ dryRun?: boolean;
784
+}
785
+
786
+export interface UserDigestRunResult {
787
+ sent: boolean;
788
+ dryRun: boolean;
789
+ period: "daily" | "weekly";
790
+ itemCount: number;
791
+ reason?: "missing_user_or_email" | "empty_digest" | "send_failed" | "missing_digest_settings";
792
+}
793
+
794
+export async function runUserDigest(options: UserDigestRunOptions): Promise<UserDigestRunResult> {
795
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
796
+ const now = options.now ?? new Date();
797
+ const dryRun = options.dryRun ?? true;
798
+
799
+ const setting = await db.query.emailDigestSettings.findFirst({
800
+ where: eq(schema.emailDigestSettings.userId, options.userId),
801
+ });
802
+ if (!setting) {
803
+ return {
804
+ sent: false,
805
+ dryRun,
806
+ period: options.period || "daily",
807
+ itemCount: 0,
808
+ reason: "missing_digest_settings",
809
+ };
810
+ }
811
+
812
+ const period = options.period || ((setting.digestType as "daily" | "weekly" | "none") === "weekly" ? "weekly" : "daily");
813
+ const user = await db.query.users.findFirst({
814
+ where: eq(schema.users.id, options.userId),
815
+ columns: { id: true, email: true, isActive: true },
816
+ });
817
+ if (!user || !user.isActive || !user.email) {
818
+ return {
819
+ sent: false,
820
+ dryRun,
821
+ period,
822
+ itemCount: 0,
823
+ reason: "missing_user_or_email",
824
+ };
825
+ }
826
+
827
+ const prefs = await db.query.notificationPreferences.findMany({
828
+ where: and(
829
+ eq(schema.notificationPreferences.userId, user.id),
830
+ eq(schema.notificationPreferences.emailEnabled, true)
831
+ ),
832
+ columns: { eventType: true },
833
+ });
834
+ const includeEvents = prefs.map((p) => p.eventType as NotificationEvent);
835
+
836
+ const digest = await generateDigestEmail({
837
+ userId: user.id,
838
+ period,
839
+ includeEvents,
840
+ });
841
+
842
+ if (digest.itemCount === 0) {
843
+ return {
844
+ sent: false,
845
+ dryRun,
846
+ period,
847
+ itemCount: 0,
848
+ reason: "empty_digest",
849
+ };
850
+ }
851
+
852
+ if (!dryRun) {
853
+ const sent = await sendPlatformEmail({
854
+ to: user.email,
855
+ subject: digest.subject,
856
+ html: digest.html,
857
+ text: `${digest.subject}\n\nYou have ${digest.itemCount} new notification(s).`,
858
+ });
859
+ if (!sent) {
860
+ return {
861
+ sent: false,
862
+ dryRun,
863
+ period,
864
+ itemCount: digest.itemCount,
865
+ reason: "send_failed",
866
+ };
867
+ }
868
+
869
+ await db
870
+ .update(schema.emailDigestSettings)
871
+ .set({ lastSentAt: now, updatedAt: now })
872
+ .where(eq(schema.emailDigestSettings.id, setting.id));
873
+ }
874
+
875
+ return {
876
+ sent: true,
877
+ dryRun,
878
+ period,
879
+ itemCount: digest.itemCount,
880
+ };
881
+}
882
+
779
883
export async function runDueDigests(options: DigestRunOptions = {}): Promise<DigestRunResult> {
780
884
const db = getDatabase() as NodePgDatabase<typeof schema>;
781
885
const now = options.now ?? new Date();
782
886
▼src/lib/openapi.ts
+28-0
264
264
},
265
265
required: ["mirrorUrl"],
266
266
},
267
+ UserDigestTestRequest: {
268
+ type: "object",
269
+ properties: {
270
+ dryRun: { type: "boolean", default: true },
271
+ period: { type: "string", enum: ["daily", "weekly"] },
272
+ },
273
+ },
267
274
},
268
275
},
269
276
paths: {
908
915
},
909
916
},
910
917
},
918
+ "/user/notification-digests/test": {
919
+ post: {
920
+ tags: ["Notifications"],
921
+ summary: "Run a user digest test",
922
+ description: "Runs a digest generation for the authenticated user. Defaults to dry-run mode unless explicitly disabled.",
923
+ security: [{ bearerAuth: [] }],
924
+ requestBody: {
925
+ required: false,
926
+ content: {
927
+ "application/json": {
928
+ schema: { $ref: "#/components/schemas/UserDigestTestRequest" },
929
+ },
930
+ },
931
+ },
932
+ responses: {
933
+ 200: { description: "Digest test result returned" },
934
+ 400: { description: "Invalid payload", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
935
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
936
+ },
937
+ },
938
+ },
911
939
"/repos/{owner}/{repo}/workflow/states": {
912
940
get: {
913
941
tags: ["Workflow"],
914
942
▼src/pages/api/user/notification-digests/test.ts
+30-0
1
+import type { APIRoute } from "astro";
2
+import { z } from "zod";
3
+import { withErrorHandler } from "@/lib/errors";
4
+import { badRequest, success, unauthorized } from "@/lib/api";
5
+import { runUserDigest } from "@/lib/chat-notifications";
6
+
7
+const testDigestSchema = z.object({
8
+ dryRun: z.boolean().optional(),
9
+ period: z.enum(["daily", "weekly"]).optional(),
10
+});
11
+
12
+export const POST: APIRoute = withErrorHandler(async ({ locals, request }) => {
13
+ const user = locals.user;
14
+ if (!user) return unauthorized();
15
+
16
+ const body = await request.json().catch(() => null);
17
+ const parsed = testDigestSchema.safeParse(body || {});
18
+ if (!parsed.success) {
19
+ return badRequest(parsed.error.issues[0]?.message || "Invalid digest test payload");
20
+ }
21
+
22
+ const result = await runUserDigest({
23
+ userId: user.id,
24
+ dryRun: parsed.data.dryRun ?? true,
25
+ period: parsed.data.period,
26
+ });
27
+
28
+ return success(result);
29
+});
30
+
0
31
▼tests/integration/user-digest-test-route.test.ts
+63-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const { runUserDigestMock } = vi.hoisted(() => ({
4
+ runUserDigestMock: vi.fn(async () => ({
5
+ sent: true,
6
+ dryRun: true,
7
+ period: "daily",
8
+ itemCount: 3,
9
+ })),
10
+}));
11
+
12
+vi.mock("@/lib/chat-notifications", () => ({
13
+ runUserDigest: runUserDigestMock,
14
+}));
15
+
16
+import { POST as testDigestPost } from "@/pages/api/user/notification-digests/test";
17
+
18
+async function readJson(response: Response): Promise<any> {
19
+ return response.json();
20
+}
21
+
22
+describe("user digest test route", () => {
23
+ beforeEach(() => {
24
+ runUserDigestMock.mockResolvedValue({
25
+ sent: true,
26
+ dryRun: true,
27
+ period: "daily",
28
+ itemCount: 3,
29
+ });
30
+ });
31
+
32
+ it("returns 401 when unauthenticated", async () => {
33
+ const response = await testDigestPost({
34
+ locals: {},
35
+ request: new Request("http://localhost/api/user/notification-digests/test", {
36
+ method: "POST",
37
+ }),
38
+ } as any);
39
+
40
+ expect(response.status).toBe(401);
41
+ });
42
+
43
+ it("runs digest test with dryRun true by default", async () => {
44
+ const response = await testDigestPost({
45
+ locals: { user: { id: "user-1" } },
46
+ request: new Request("http://localhost/api/user/notification-digests/test", {
47
+ method: "POST",
48
+ headers: { "content-type": "application/json" },
49
+ body: JSON.stringify({}),
50
+ }),
51
+ } as any);
52
+
53
+ const body = await readJson(response);
54
+ expect(response.status).toBe(200);
55
+ expect(body?.data?.sent).toBe(true);
56
+ expect(runUserDigestMock).toHaveBeenCalledWith({
57
+ userId: "user-1",
58
+ dryRun: true,
59
+ period: undefined,
60
+ });
61
+ });
62
+});
63
+
0
64