api: record notifications before pushing them
Both reminder processors now write the notification first and let the push be a gated side-effect, so a muted or tokenless user still gets a record and a delivery failure can't lose it. Each row carries the time its occurrence was due, computed in the app timezone, so its window cannot slip onto the following day
Pedro Lucas Porcellis porcellis@eletrotupi.com · 11 days ago
dafa6f8a8e3b0e51605fc953a979d50ff6085b26 Browse filesParents
f1edb15Changed files
9
Additions
+351
Deletions
-78
/dev/null → api/src/lib/appTime.ts
1 +
import { tz } from "@date-fns/tz";
2 +
import { set } from "date-fns";
3 +
4 +
export const APP_TIMEZONE = tz("America/Sao_Paulo");
5 +
6 +
// The instant, on the app-timezone day containing `reference`, at "HH:MM"
7 +
export function appTimeToday(time: string, reference: Date = new Date()): Date {
8 +
const [hours, minutes] = time.split(":").map(Number);
9 +
10 +
return set(reference, { hours, minutes, seconds: 0, milliseconds: 0 }, {
11 +
in: APP_TIMEZONE,
12 +
});
13 +
}
api/src/lib/queue/processors/dailyReminders.ts
1 1
import { Job } from "bullmq";
2 -
import { prisma } from "@app/lib/prisma";
3 -
import { sendPushNotification } from "@app/lib/sendPushNotification";
2 +
import { NotificationKind } from "@prisma/client";
3 +
4 +
import { createNotification } from "@app/services/notification.service";
4 5
import { DailyReminderPayload } from "@app/lib/queue/types";
6 +
import { appTimeToday } from "@app/lib/appTime";
5 7
import { logger } from "@app/lib/logger";
6 8
7 9
export async function dailyReminderProcessor(job: Job): Promise<void> {
8 -
const { userId } = job.data as DailyReminderPayload;
10 +
const { userId, scheduledTime } = job.data as DailyReminderPayload;
9 11
10 12
logger.info({ jobId: job.id, userId }, "[daily-reminder] job started");
11 13
12 -
const user = await prisma.user.findUnique({
13 -
where: { id: userId },
14 -
select: { pushToken: true, notificationsEnabled: true },
15 -
});
16 -
17 -
if (!user) {
18 -
logger.warn({ userId }, "[daily-reminder] user not found, skipping");
19 -
20 -
return;
21 -
}
22 -
23 -
if (!user.notificationsEnabled) {
24 -
logger.warn({ userId }, "[daily-reminder] notifications disabled, skipping");
25 -
26 -
return;
27 -
}
28 -
29 -
if (!user.pushToken) {
30 -
logger.warn({ userId }, "[daily-reminder] no pushToken, skipping");
31 -
32 -
return;
33 -
}
34 -
35 -
// TODO: These should be a side-effect from a new entry on the notifications
36 -
// table
37 -
await sendPushNotification({
38 -
token: user.pushToken,
14 +
await createNotification({
15 +
userId,
16 +
kind: NotificationKind.DAILY_REMINDER,
39 17
title: "Como você está?",
40 18
body: "Registre seu humor para acompanhar sua saúde.",
41 -
data: { screen: "moods" },
19 +
scheduledFor: appTimeToday(scheduledTime),
42 20
});
43 21
44 -
logger.info({ userId }, "[daily-reminder] push sent");
22 +
logger.info({ userId }, "[daily-reminder] notification stored");
45 23
}
api/src/lib/queue/processors/medicineReminders.ts
1 1
import { Job } from "bullmq";
2 -
import { prisma } from "@app/lib/prisma";
3 -
import { sendPushNotification } from "@app/lib/sendPushNotification";
2 +
import { NotificationKind } from "@prisma/client";
3 +
4 +
import { createNotification } from "@app/services/notification.service";
4 5
import { MedicineReminderPayload } from "@app/lib/queue/types";
6 +
import { appTimeToday } from "@app/lib/appTime";
5 7
import { logger } from "@app/lib/logger";
6 8
7 9
export async function medicineReminderProcessor(job: Job): Promise<void> {
8 -
const { userId, regimenId, medicineName, dosage } =
10 +
const { userId, regimenId, medicineName, dosage, scheduledTime } =
9 11
job.data as MedicineReminderPayload;
10 12
11 13
logger.info(
12 14
{ jobId: job.id, userId, regimenId, medicine: medicineName },
13 15
"[medicine-reminder] job started",
14 16
);
15 17
16 -
const user = await prisma.user.findUnique({
17 -
where: { id: userId },
18 -
select: { pushToken: true },
19 -
});
20 -
21 -
if (!user) {
22 -
logger.warn({ userId }, "[medicine-reminder] user not found, skipping");
23 -
return;
24 -
}
25 -
if (!user.pushToken) {
26 -
logger.warn({ userId }, "[medicine-reminder] no pushToken, skipping");
27 -
return;
28 -
}
29 -
30 -
await sendPushNotification({
31 -
token: user.pushToken,
18 +
await createNotification({
19 +
userId,
20 +
kind: NotificationKind.MEDICINE_REMINDER,
32 21
title: `Hora de tomar ${medicineName}`,
33 22
body: dosage,
34 -
data: { screen: "medicine-regimens", regimenId },
23 +
scheduledFor: appTimeToday(scheduledTime),
24 +
regimenId,
35 25
});
36 26
37 -
logger.info({ userId }, "[medicine-reminder] push sent");
27 +
logger.info({ userId, regimenId }, "[medicine-reminder] notification stored");
38 28
}
api/src/lib/queue/types.ts
1 1
// Mail queue
2 2
export enum MailJobName {
3 3
WelcomeEmail = "mail:welcome",
4 4
PasswordReset = "mail:password-reset",
5 5
ActivateAccountEmail = "mail:activate-account",
61 61
Send = "daily-reminder:send",
62 62
}
63 63
64 64
export type DailyReminderPayload = {
65 65
userId: number;
66 +
scheduledTime: string;
66 67
};
67 68
68 69
// Export queue
69 70
export enum ExportJobName {
70 71
Data = "export:data",
76 77
77 78
export type ExportJobData = {
78 79
name: ExportJobName.Data;
79 80
data: ExportPayload;
80 81
};
api/src/services/dailyReminder.sync.ts
1 1
import { User } from "@prisma/client";
2 2
import { getQueue } from "@app/lib/queue/QueueRegistry";
3 3
import { DailyReminderJobName } from "@app/lib/queue/types";
4 4
import { logger } from "@app/lib/logger";
5 5
36 36
37 37
const cron = timeToCron(user.dailyReminderTime);
38 38
39 39
await queue.add(
40 40
DailyReminderJobName.Send,
41 -
{ userId: user.id },
41 +
{ userId: user.id, scheduledTime: user.dailyReminderTime },
42 42
{
43 43
repeat: { pattern: cron, tz: "America/Sao_Paulo" },
44 44
jobId: `daily-reminder-user-${user.id}`,
45 45
}
46 46
);
48 48
logger.info(
49 49
{ userId: user.id, time: user.dailyReminderTime, cron },
50 50
"[daily-sync] scheduled daily reminder",
51 51
);
52 52
}
/dev/null → api/src/services/notification.service.ts
1 +
import { Notification, NotificationKind } from "@prisma/client";
2 +
import { endOfDay } from "date-fns";
3 +
4 +
import { prisma } from "@app/lib/prisma";
5 +
import { APP_TIMEZONE } from "@app/lib/appTime";
6 +
import { logger } from "@app/lib/logger";
7 +
import { sendPushNotification } from "@app/lib/sendPushNotification";
8 +
9 +
export type CreateNotificationInput = {
10 +
userId: number;
11 +
kind: NotificationKind;
12 +
title: string;
13 +
body: string;
14 +
scheduledFor: Date;
15 +
regimenId?: number | null;
16 +
};
17 +
18 +
const endOfAppDay = (scheduledFor: Date): Date =>
19 +
endOfDay(scheduledFor, { in: APP_TIMEZONE });
20 +
21 +
// The only place a kind's behaviour is described: how long it stays relevant,
22 +
// and where tapping it should lead
23 +
type NotificationRule = {
24 +
expiresAt: (scheduledFor: Date) => Date | null;
25 +
screen?: string;
26 +
};
27 +
28 +
const notificationRules: Record<NotificationKind, NotificationRule> = {
29 +
[NotificationKind.DAILY_REMINDER]: {
30 +
expiresAt: endOfAppDay,
31 +
screen: "moods",
32 +
},
33 +
[NotificationKind.MEDICINE_REMINDER]: {
34 +
expiresAt: endOfAppDay,
35 +
screen: "medicine-regimens",
36 +
},
37 +
[NotificationKind.INSIGHT_ALERT]: {
38 +
expiresAt: () => null,
39 +
},
40 +
};
41 +
42 +
export const createNotification = async (
43 +
input: CreateNotificationInput
44 +
): Promise<Notification | null> => {
45 +
const { userId, kind, title, body, scheduledFor, regimenId = null } = input;
46 +
47 +
const user = await prisma.user.findUnique({
48 +
where: { id: userId },
49 +
select: { pushToken: true, notificationsEnabled: true },
50 +
});
51 +
52 +
if (!user) {
53 +
logger.warn({ userId, kind }, "[notification] user not found, skipping");
54 +
55 +
return null;
56 +
}
57 +
58 +
const rule = notificationRules[kind];
59 +
60 +
const notification = await prisma.notification.create({
61 +
data: {
62 +
userId,
63 +
kind,
64 +
title,
65 +
body,
66 +
scheduledFor,
67 +
regimenId,
68 +
expiresAt: rule.expiresAt(scheduledFor),
69 +
},
70 +
});
71 +
72 +
if (!user.notificationsEnabled || !user.pushToken) {
73 +
logger.info(
74 +
{ notificationId: notification.id, userId, kind },
75 +
"[notification] stored without push"
76 +
);
77 +
78 +
return notification;
79 +
}
80 +
81 +
const data = {
82 +
...(rule.screen ? { screen: rule.screen } : {}),
83 +
...(regimenId !== null ? { regimenId } : {}),
84 +
};
85 +
86 +
try {
87 +
await sendPushNotification({
88 +
token: user.pushToken,
89 +
title,
90 +
body,
91 +
data,
92 +
});
93 +
} catch (error) {
94 +
logger.error(
95 +
{ notificationId: notification.id, userId, err: error },
96 +
"[notification] push failed"
97 +
);
98 +
99 +
return notification;
100 +
}
101 +
102 +
logger.info(
103 +
{ notificationId: notification.id, userId, kind },
104 +
"[notification] push sent"
105 +
);
106 +
107 +
return prisma.notification.update({
108 +
where: { id: notification.id },
109 +
data: { pushedAt: new Date() },
110 +
});
111 +
};
api/tests/jobs/push-notifications.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
2 2
import { medicineReminderProcessor } from '@app/lib/queue/processors/medicineReminders'
3 3
import { dailyReminderProcessor } from '@app/lib/queue/processors/dailyReminders'
4 4
import { buildPrisma, cleanupTestDb, createTestUser } from '../test-helper'
5 5
18 18
await cleanupTestDb(db)
19 19
mockedSend.mockClear()
20 20
})
21 21
22 22
describe('medicineReminderProcessor', () => {
23 -
it('sends a push notification to a user with a push token', async () => {
24 -
const user = await createTestUser(db, { pushToken: 'ExponentPushToken[abc123]' })
23 +
it('stores a medicine notification for the dose time and pushes it', async () => {
24 +
const user = await createTestUser(db, {
25 +
pushToken: 'ExponentPushToken[abc123]',
26 +
})
27 +
const regimen = await db.medicineRegimen.create({
28 +
data: {
29 +
name: 'Sertralina',
30 +
dosage: '50mg',
31 +
periodicity: 'DAILY',
32 +
scheduledAt: ['08:00'],
33 +
userId: user.id,
34 +
},
35 +
})
25 36
26 37
await medicineReminderProcessor({
27 38
data: {
28 39
userId: user.id,
29 -
regimenId: 1,
40 +
regimenId: regimen.id,
30 41
medicineName: 'Sertralina',
31 42
dosage: '50mg',
32 43
scheduledTime: '08:00',
33 44
},
34 45
} as any)
35 46
47 +
const row = await db.notification.findFirst()
48 +
expect(row).toMatchObject({
49 +
userId: user.id,
50 +
kind: 'MEDICINE_REMINDER',
51 +
title: 'Hora de tomar Sertralina',
52 +
body: '50mg',
53 +
regimenId: regimen.id,
54 +
})
55 +
// 08:00 in São Paulo (UTC-3)
56 +
expect(row?.scheduledFor.getUTCHours()).toBe(11)
57 +
expect(row?.scheduledFor.getUTCMinutes()).toBe(0)
58 +
expect(row?.pushedAt).not.toBeNull()
59 +
36 60
expect(mockedSend).toHaveBeenCalledOnce()
37 61
expect(mockedSend).toHaveBeenCalledWith(
38 62
expect.objectContaining({
39 63
token: 'ExponentPushToken[abc123]',
40 64
title: 'Hora de tomar Sertralina',
41 65
body: '50mg',
42 -
data: expect.objectContaining({ screen: 'medicine-regimens' }),
66 +
data: expect.objectContaining({
67 +
screen: 'medicine-regimens',
68 +
regimenId: regimen.id,
69 +
}),
43 70
})
44 71
)
45 72
})
46 73
47 -
it('skips when the user has no push token', async () => {
74 +
it('stores the notification without pushing when the user has no token', async () => {
48 75
const user = await createTestUser(db) // no pushToken
76 +
const regimen = await db.medicineRegimen.create({
77 +
data: {
78 +
name: 'Ritalina',
79 +
dosage: '10mg',
80 +
periodicity: 'DAILY',
81 +
scheduledAt: ['12:00'],
82 +
userId: user.id,
83 +
},
84 +
})
49 85
50 86
await medicineReminderProcessor({
51 87
data: {
52 88
userId: user.id,
53 -
regimenId: 1,
89 +
regimenId: regimen.id,
54 90
medicineName: 'Ritalina',
55 91
dosage: '10mg',
56 -
scheduledTime: '12:00'
92 +
scheduledTime: '12:00',
57 93
},
58 94
} as any)
59 95
60 96
expect(mockedSend).not.toHaveBeenCalled()
97 +
const row = await db.notification.findFirst()
98 +
expect(row?.kind).toBe('MEDICINE_REMINDER')
99 +
expect(row?.pushedAt).toBeNull()
61 100
})
62 101
63 -
it('skips when the user does not exist', async () => {
102 +
it('stores nothing when the user does not exist', async () => {
64 103
await medicineReminderProcessor({
65 104
data: {
66 105
userId: 999999,
67 106
regimenId: 1,
68 107
medicineName: 'X',
69 108
dosage: 'Y',
70 -
scheduledTime: '08:00'
109 +
scheduledTime: '08:00',
71 110
},
72 111
} as any)
73 112
74 113
expect(mockedSend).not.toHaveBeenCalled()
114 +
expect(await db.notification.count()).toBe(0)
75 115
})
76 116
})
77 117
78 118
describe('dailyReminderProcessor', () => {
79 -
it('sends a notification when user allows it and token is present', async () => {
119 +
it('stores a daily notification for the reminder time and pushes it', async () => {
80 120
const user = await createTestUser(db, {
81 121
pushToken: 'ExponentPushToken[daily123]',
82 122
notificationsEnabled: true,
83 123
})
84 124
85 -
await dailyReminderProcessor({ data: { userId: user.id } } as any)
125 +
await dailyReminderProcessor({
126 +
data: { userId: user.id, scheduledTime: '20:00' },
127 +
} as any)
128 +
129 +
const row = await db.notification.findFirst()
130 +
expect(row).toMatchObject({
131 +
userId: user.id,
132 +
kind: 'DAILY_REMINDER',
133 +
title: 'Como você está?',
134 +
body: 'Registre seu humor para acompanhar sua saúde.',
135 +
})
136 +
// 20:00 in São Paulo (UTC-3)
137 +
expect(row?.scheduledFor.getUTCHours()).toBe(23)
138 +
expect(row?.scheduledFor.getUTCMinutes()).toBe(0)
139 +
expect(row?.pushedAt).not.toBeNull()
86 140
87 141
expect(mockedSend).toHaveBeenCalledOnce()
88 142
expect(mockedSend).toHaveBeenCalledWith(
89 143
expect.objectContaining({
90 144
token: 'ExponentPushToken[daily123]',
92 146
data: expect.objectContaining({ screen: 'moods' }),
93 147
})
94 148
)
95 149
})
96 150
97 -
it('skips when notifications are disabled', async () => {
151 +
it('stores the notification without pushing when notifications are disabled', async () => {
98 152
const user = await createTestUser(db, {
99 153
pushToken: 'ExponentPushToken[disabled]',
100 154
notificationsEnabled: false,
101 155
})
102 156
103 -
await dailyReminderProcessor({ data: { userId: user.id } } as any)
157 +
await dailyReminderProcessor({
158 +
data: { userId: user.id, scheduledTime: '20:00' },
159 +
} as any)
104 160
105 161
expect(mockedSend).not.toHaveBeenCalled()
162 +
const row = await db.notification.findFirst()
163 +
expect(row?.kind).toBe('DAILY_REMINDER')
164 +
expect(row?.pushedAt).toBeNull()
106 165
})
107 166
108 -
it('skips when the user has no push token', async () => {
109 -
const user = await createTestUser(db, { notificationsEnabled: true })
110 -
111 -
await dailyReminderProcessor({ data: { userId: user.id } } as any)
112 -
113 -
expect(mockedSend).not.toHaveBeenCalled()
114 -
})
115 -
116 -
it('skips when the user does not exist', async () => {
117 -
await dailyReminderProcessor({ data: { userId: 999999 } } as any)
167 +
it('stores nothing when the user does not exist', async () => {
168 +
await dailyReminderProcessor({
169 +
data: { userId: 999999, scheduledTime: '20:00' },
170 +
} as any)
118 171
119 172
expect(mockedSend).not.toHaveBeenCalled()
173 +
expect(await db.notification.count()).toBe(0)
120 174
})
121 175
})
/dev/null → api/tests/notifications/create.test.ts
1 +
import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
2 +
import { NotificationKind } from '@prisma/client'
3 +
import { buildPrisma, cleanupTestDb, createTestUser } from '../test-helper'
4 +
5 +
vi.mock('@app/lib/sendPushNotification', () => ({
6 +
sendPushNotification: vi.fn().mockResolvedValue(undefined),
7 +
}))
8 +
9 +
import { sendPushNotification } from '@app/lib/sendPushNotification'
10 +
import { createNotification } from '@app/services/notification.service'
11 +
12 +
const mockedSend = vi.mocked(sendPushNotification)
13 +
14 +
const db = buildPrisma()
15 +
16 +
afterAll(() => db.$disconnect())
17 +
beforeEach(async () => {
18 +
await cleanupTestDb(db)
19 +
mockedSend.mockClear()
20 +
mockedSend.mockResolvedValue(undefined)
21 +
})
22 +
23 +
// São Paulo is UTC-3 year round, so this instant is midday in the app timezone
24 +
const midday = new Date('2026-03-10T12:00:00-03:00')
25 +
26 +
const dailyInput = (userId: number) => ({
27 +
userId,
28 +
kind: NotificationKind.DAILY_REMINDER,
29 +
title: 'Como você está?',
30 +
body: 'Registre seu humor para acompanhar sua saúde.',
31 +
scheduledFor: midday,
32 +
})
33 +
34 +
describe('createNotification', () => {
35 +
it('stores a notification whose window ends at the end of the app day', async () => {
36 +
const user = await createTestUser(db)
37 +
38 +
await createNotification(dailyInput(user.id))
39 +
40 +
const row = await db.notification.findFirst()
41 +
expect(row).toMatchObject({
42 +
userId: user.id,
43 +
kind: 'DAILY_REMINDER',
44 +
title: 'Como você está?',
45 +
regimenId: null,
46 +
})
47 +
expect(row?.scheduledFor.toISOString()).toBe(midday.toISOString())
48 +
expect(row?.expiresAt?.toISOString()).toBe('2026-03-11T02:59:59.999Z')
49 +
})
50 +
51 +
it('stores a timeless notification without a window', async () => {
52 +
const user = await createTestUser(db)
53 +
54 +
await createNotification({
55 +
userId: user.id,
56 +
kind: NotificationKind.INSIGHT_ALERT,
57 +
title: 'Trabalho aparece muito',
58 +
body: 'Trabalho tem aparecido bastante nos seus gatilhos.',
59 +
scheduledFor: midday,
60 +
})
61 +
62 +
const row = await db.notification.findFirst()
63 +
expect(row?.kind).toBe('INSIGHT_ALERT')
64 +
expect(row?.expiresAt).toBeNull()
65 +
})
66 +
67 +
it('records when a push was dispatched', async () => {
68 +
const user = await createTestUser(db, {
69 +
pushToken: 'ExponentPushToken[abc123]',
70 +
notificationsEnabled: true,
71 +
})
72 +
73 +
await createNotification(dailyInput(user.id))
74 +
75 +
expect(mockedSend).toHaveBeenCalledOnce()
76 +
const row = await db.notification.findFirst()
77 +
expect(row?.pushedAt).not.toBeNull()
78 +
})
79 +
80 +
it('stores the notification but does not push when notifications are disabled', async () => {
81 +
const user = await createTestUser(db, {
82 +
pushToken: 'ExponentPushToken[disabled]',
83 +
notificationsEnabled: false,
84 +
})
85 +
86 +
await createNotification(dailyInput(user.id))
87 +
88 +
expect(mockedSend).not.toHaveBeenCalled()
89 +
const row = await db.notification.findFirst()
90 +
expect(row).not.toBeNull()
91 +
expect(row?.pushedAt).toBeNull()
92 +
})
93 +
94 +
it('stores the notification but does not push without a push token', async () => {
95 +
const user = await createTestUser(db, { notificationsEnabled: true })
96 +
97 +
await createNotification(dailyInput(user.id))
98 +
99 +
expect(mockedSend).not.toHaveBeenCalled()
100 +
const row = await db.notification.findFirst()
101 +
expect(row).not.toBeNull()
102 +
expect(row?.pushedAt).toBeNull()
103 +
})
104 +
105 +
it('keeps an unpushed record when the push fails', async () => {
106 +
const user = await createTestUser(db, {
107 +
pushToken: 'ExponentPushToken[boom]',
108 +
notificationsEnabled: true,
109 +
})
110 +
mockedSend.mockRejectedValueOnce(new Error('Expo push API returned 500'))
111 +
112 +
await expect(createNotification(dailyInput(user.id))).resolves.toBeDefined()
113 +
114 +
const row = await db.notification.findFirst()
115 +
expect(row).not.toBeNull()
116 +
expect(row?.pushedAt).toBeNull()
117 +
})
118 +
119 +
it('stores nothing when the user does not exist', async () => {
120 +
const result = await createNotification(dailyInput(999999))
121 +
122 +
expect(result).toBeNull()
123 +
expect(await db.notification.count()).toBe(0)
124 +
expect(mockedSend).not.toHaveBeenCalled()
125 +
})
126 +
})
api/tests/users/preferences.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 3
import { createApp } from '@app/createApp'
4 4
import { DailyReminderJobName } from '@app/lib/queue/types'
5 5
import {
42 42
43 43
expect(recordedJobs).toContainEqual(
44 44
expect.objectContaining({
45 45
queue: 'daily-reminders',
46 46
name: DailyReminderJobName.Send,
47 -
data: { userId: user.id },
47 +
data: { userId: user.id, scheduledTime: '08:30' },
48 48
})
49 49
)
50 50
})
51 51
52 52
it('updates the weekly resume preference', async () => {
123 123
.send({ user: { notificationsEnabled: false } })
124 124
125 125
expect(res.status).toBe(401)
126 126
})
127 127
})