api: list notifications newest-first over a cursor
The inbox had nothing real to render, so notifications now have an authenticated read endpoint scoped to the JWT user. It returns the newest rows first through a limit and cursor so the screen can page without a count, and serializes only the fields the inbox needs.
Pedro Lucas Porcellis porcellis@eletrotupi.com · 11 days ago
1b8d5d79f03d61a20e683b0da33bbd8abe43bf86 Browse filesParents
30f98b8Changed files
8
Additions
+266
Deletions
0
/dev/null → api/src/controllers/notifications.ts
1 +
import { Response, NextFunction } from "express";
2 +
import { AuthenticatedRequest } from "@app/middleware/auth";
3 +
import { getNotificationsByUserId } from "@app/services/notification.service";
4 +
import { NotificationsQuerySchema } from "@app/schemas";
5 +
import { formatValidationError } from "@app/lib/errors/validationError";
6 +
7 +
export const NotificationsController = {
8 +
index: async (
9 +
req: AuthenticatedRequest,
10 +
res: Response,
11 +
next: NextFunction,
12 +
) => {
13 +
try {
14 +
const parsedQuery = NotificationsQuerySchema.safeParse(req.query);
15 +
16 +
if (!parsedQuery.success) {
17 +
return res.status(400).json(formatValidationError(parsedQuery.error));
18 +
}
19 +
20 +
const { limit, cursor } = parsedQuery.data;
21 +
22 +
const page = await getNotificationsByUserId(req.userId!, {
23 +
limit,
24 +
cursor,
25 +
});
26 +
27 +
return res.status(200).json(page);
28 +
} catch (err) {
29 +
next(err);
30 +
}
31 +
},
32 +
};
api/src/createApp.ts
1 1
import express from "express";
2 2
import cors from "cors";
3 3
import bodyParser from "body-parser";
4 4
import mainRouter from "@app/routes/main";
5 5
import userRouter from "@app/routes/users";
9 9
import sleepRecordRouter from "@app/routes/sleepRecords";
10 10
import insightRouter from "@app/routes/insights";
11 11
import careActionsRouter from "@app/routes/careActions";
12 12
import medicineRegimensRouter from "@app/routes/medicineRegimens";
13 13
import exportsRouter from "@app/routes/exports";
14 +
import notificationRouter from "@app/routes/notifications";
14 15
import reportRouter from "@app/reports/report.router";
15 16
import { errorHandler } from "@app/middleware/errorHandler";
16 17
import { logger } from "@app/lib/logger";
17 18
import { PrismaClient } from "@prisma/client";
18 19
import {
66 67
app.use("/triggers", triggerRouter);
67 68
app.use("/insights", insightRouter);
68 69
app.use("/care-actions", careActionsRouter);
69 70
app.use("/medicine-regimens", medicineRegimensRouter);
70 71
app.use("/exports", exportsRouter);
72 +
app.use("/notifications", notificationRouter);
71 73
app.use("/reports", reportRouter);
72 74
73 75
app.use(errorHandler); // always last
74 76
75 77
// TODO: flesh the actual page here
86 88
87 89
const shutdown = async () => {
88 90
await Promise.all([closeAllWorkers(), closeAllQueues()]);
89 91
process.exit(0);
90 92
};
/dev/null → api/src/lib/serializers/notification.ts
1 +
import { Prisma } from '@prisma/client';
2 +
3 +
// DB-level projection of what a notification leaves the API as -- the inbox
4 +
// needs the rendered text, the moment it was meant for, its window and what it
5 +
// relates to. The owning user and the delivery timestamp stay server-side.
6 +
export const publicNotificationSelect = {
7 +
id: true,
8 +
kind: true,
9 +
title: true,
10 +
body: true,
11 +
scheduledFor: true,
12 +
expiresAt: true,
13 +
regimenId: true,
14 +
createdAt: true,
15 +
} satisfies Prisma.NotificationSelect;
16 +
17 +
export type PublicNotification = Prisma.NotificationGetPayload<{
18 +
select: typeof publicNotificationSelect;
19 +
}>;
/dev/null → api/src/routes/notifications.ts
1 +
import { Router } from "express";
2 +
import { requireAuth } from "@app/middleware/auth";
3 +
import { NotificationsController } from "@app/controllers/notifications";
4 +
5 +
const router = Router();
6 +
7 +
router.get("/", requireAuth, NotificationsController.index);
8 +
9 +
export default router;
api/src/schemas/index.ts
1 1
export {
2 2
CreateMoodSchema,
3 3
MoodComponentSchema,
4 4
} from "@app/schemas/mood.schema";
5 5
43 43
InsightsQuerySchema,
44 44
type InsightsQueryInput,
45 45
} from "@app/schemas/insight.schema";
46 46
47 47
export {
48 +
NotificationsQuerySchema,
49 +
type NotificationsQueryInput,
50 +
} from "@app/schemas/notification.schema";
51 +
52 +
export {
48 53
LinkMoodSchema,
49 54
type LinkMoodInput,
50 55
} from "@app/schemas/triggerMoodLink.schema";
51 56
52 57
export {
60 65
CreateMedicineRegimenSchema,
61 66
type CreateMedicineRegimenInput,
62 67
UpdateMedicineRegimenSchema,
63 68
type UpdateMedicineRegimenInput,
64 69
} from "@app/schemas/medicineRegimen.schema";
/dev/null → api/src/schemas/notification.schema.ts
1 +
import { z } from 'zod';
2 +
3 +
export const NotificationsQuerySchema = z.object({
4 +
limit: z.coerce.number().int().positive().max(100).optional(),
5 +
cursor: z.coerce.number().int().positive().optional(),
6 +
});
7 +
8 +
export type NotificationsQueryInput = z.infer<typeof NotificationsQuerySchema>;
api/src/services/notification.service.ts
1 1
import { Notification, NotificationKind } from "@prisma/client";
2 2
import { endOfDay } from "date-fns";
3 3
4 4
import { prisma } from "@app/lib/prisma";
5 5
import { APP_TIMEZONE } from "@app/lib/appTime";
6 6
import { logger } from "@app/lib/logger";
7 7
import { sendPushNotification } from "@app/lib/sendPushNotification";
8 +
import {
9 +
publicNotificationSelect,
10 +
PublicNotification,
11 +
} from "@app/lib/serializers/notification";
8 12
9 13
export type CreateNotificationInput = {
10 14
userId: number;
11 15
kind: NotificationKind;
12 16
title: string;
107 111
return prisma.notification.update({
108 112
where: { id: notification.id },
109 113
data: { pushedAt: new Date() },
110 114
});
111 115
};
116 +
117 +
const DEFAULT_NOTIFICATIONS_LIMIT = 30;
118 +
119 +
export type NotificationsPage = {
120 +
notifications: PublicNotification[];
121 +
nextCursor: number | null;
122 +
};
123 +
124 +
// Ids are monotonic, so ordering by id is newest-first and gives the cursor a
125 +
// stable, unique key. One extra row is fetched to know whether more remain
126 +
export const getNotificationsByUserId = async (
127 +
userId: number,
128 +
params?: { limit?: number; cursor?: number }
129 +
): Promise<NotificationsPage> => {
130 +
const limit = params?.limit ?? DEFAULT_NOTIFICATIONS_LIMIT;
131 +
132 +
const rows = await prisma.notification.findMany({
133 +
where: {
134 +
userId,
135 +
...(params?.cursor !== undefined && { id: { lt: params.cursor } }),
136 +
},
137 +
orderBy: { id: "desc" },
138 +
take: limit + 1,
139 +
select: publicNotificationSelect,
140 +
});
141 +
142 +
const hasMore = rows.length > limit;
143 +
const notifications = hasMore ? rows.slice(0, limit) : rows;
144 +
145 +
return {
146 +
notifications,
147 +
nextCursor: hasMore ? notifications[notifications.length - 1].id : null,
148 +
};
149 +
};
/dev/null → api/tests/notifications/list.test.ts
1 +
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 +
import request from 'supertest'
3 +
import { NotificationKind } from '@prisma/client'
4 +
import { createApp } from '@app/createApp'
5 +
import { buildPrisma, cleanupTestDb, createTestUser, generateTestToken } from '../test-helper'
6 +
7 +
const app = createApp()
8 +
const db = buildPrisma()
9 +
10 +
afterAll(() => db.$disconnect())
11 +
beforeEach(() => cleanupTestDb(db))
12 +
13 +
async function seedNotification(
14 +
userId: number,
15 +
overrides: {
16 +
kind?: NotificationKind
17 +
title?: string
18 +
body?: string
19 +
scheduledFor?: Date
20 +
regimenId?: number
21 +
} = {},
22 +
) {
23 +
return db.notification.create({
24 +
data: {
25 +
userId,
26 +
kind: overrides.kind ?? NotificationKind.DAILY_REMINDER,
27 +
title: overrides.title ?? 'Como você está?',
28 +
body: overrides.body ?? 'Registre seu humor para acompanhar sua saúde.',
29 +
scheduledFor: overrides.scheduledFor ?? new Date(),
30 +
regimenId: overrides.regimenId ?? null,
31 +
},
32 +
})
33 +
}
34 +
35 +
describe('GET /notifications', () => {
36 +
it('requires authentication', async () => {
37 +
const res = await request(app).get('/notifications')
38 +
39 +
expect(res.status).toBe(401)
40 +
})
41 +
42 +
it('returns an empty page when the user has none', async () => {
43 +
const user = await createTestUser(db)
44 +
const token = generateTestToken(user.id, user.email)
45 +
46 +
const res = await request(app)
47 +
.get('/notifications')
48 +
.set('Authorization', `Bearer ${token}`)
49 +
50 +
expect(res.status).toBe(200)
51 +
expect(res.body.notifications).toEqual([])
52 +
expect(res.body.nextCursor).toBeNull()
53 +
})
54 +
55 +
it("returns the user's notifications newest first", async () => {
56 +
const user = await createTestUser(db)
57 +
const token = generateTestToken(user.id, user.email)
58 +
59 +
const first = await seedNotification(user.id, { title: 'Primeira' })
60 +
const second = await seedNotification(user.id, { title: 'Segunda' })
61 +
const third = await seedNotification(user.id, { title: 'Terceira' })
62 +
63 +
const res = await request(app)
64 +
.get('/notifications')
65 +
.set('Authorization', `Bearer ${token}`)
66 +
67 +
expect(res.status).toBe(200)
68 +
expect(res.body.notifications.map((n: { id: number }) => n.id)).toEqual([
69 +
third.id,
70 +
second.id,
71 +
first.id,
72 +
])
73 +
})
74 +
75 +
it("never returns another user's notifications", async () => {
76 +
const user = await createTestUser(db)
77 +
const other = await createTestUser(db, { email: 'other@example.com' })
78 +
const token = generateTestToken(user.id, user.email)
79 +
80 +
await seedNotification(user.id, { title: 'Minha' })
81 +
await seedNotification(other.id, { title: 'Dele' })
82 +
83 +
const res = await request(app)
84 +
.get('/notifications')
85 +
.set('Authorization', `Bearer ${token}`)
86 +
87 +
expect(res.status).toBe(200)
88 +
expect(res.body.notifications).toHaveLength(1)
89 +
expect(res.body.notifications[0].title).toBe('Minha')
90 +
})
91 +
92 +
it('pages newest first with a cursor', async () => {
93 +
const user = await createTestUser(db)
94 +
const token = generateTestToken(user.id, user.email)
95 +
96 +
const oldest = await seedNotification(user.id, { title: 'Mais antiga' })
97 +
const middle = await seedNotification(user.id, { title: 'Do meio' })
98 +
const newest = await seedNotification(user.id, { title: 'Mais nova' })
99 +
100 +
const firstPage = await request(app)
101 +
.get('/notifications?limit=2')
102 +
.set('Authorization', `Bearer ${token}`)
103 +
104 +
expect(firstPage.status).toBe(200)
105 +
expect(firstPage.body.notifications.map((n: { id: number }) => n.id)).toEqual([
106 +
newest.id,
107 +
middle.id,
108 +
])
109 +
expect(firstPage.body.nextCursor).toBe(middle.id)
110 +
111 +
const secondPage = await request(app)
112 +
.get(`/notifications?limit=2&cursor=${firstPage.body.nextCursor}`)
113 +
.set('Authorization', `Bearer ${token}`)
114 +
115 +
expect(secondPage.status).toBe(200)
116 +
expect(secondPage.body.notifications.map((n: { id: number }) => n.id)).toEqual([
117 +
oldest.id,
118 +
])
119 +
expect(secondPage.body.nextCursor).toBeNull()
120 +
})
121 +
122 +
it('serializes only the fields the inbox needs', async () => {
123 +
const user = await createTestUser(db)
124 +
const token = generateTestToken(user.id, user.email)
125 +
126 +
await seedNotification(user.id, { title: 'Como você está?' })
127 +
128 +
const res = await request(app)
129 +
.get('/notifications')
130 +
.set('Authorization', `Bearer ${token}`)
131 +
132 +
const [notification] = res.body.notifications
133 +
expect(notification).toMatchObject({
134 +
kind: 'DAILY_REMINDER',
135 +
title: 'Como você está?',
136 +
body: 'Registre seu humor para acompanhar sua saúde.',
137 +
regimenId: null,
138 +
})
139 +
expect(notification).not.toHaveProperty('userId')
140 +
expect(notification).not.toHaveProperty('pushedAt')
141 +
})
142 +
143 +
it('rejects an out-of-range limit', async () => {
144 +
const user = await createTestUser(db)
145 +
const token = generateTestToken(user.id, user.email)
146 +
147 +
const res = await request(app)
148 +
.get('/notifications?limit=0')
149 +
.set('Authorization', `Bearer ${token}`)
150 +
151 +
expect(res.status).toBe(400)
152 +
})
153 +
})