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
Showing 5 changed files+226-1
▼doc/feature_audit.md
+1-1
| @@ -184,7 +184,7 @@ | ||
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,6 +776,110 @@ export interface DigestRunResult { | ||
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,6 +264,13 @@ export const openApiSpec = { | ||
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,6 +915,27 @@ export const openApiSpec = { | ||
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
| @@ -0,0 +1,30 @@ | ||
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
| @@ -0,0 +1,63 @@ | ||
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 | |