eletrotupi / tcc/ commit / 1b8d5d7

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 files
Parents 30f98b8
Changed 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 +
})