api: add push helper, medicine-reminders and daily-reminders queues
Extract Expo push fetch into lib/sendPushNotification.ts and update moods controller to use it. Add MedicineReminderJobName/DailyReminderJobName enums and payload types. Register medicine-reminders and daily-reminders queues + processors in QueueRegistry/WorkerRegistry.
Parents:
78897ec7 file(s) changed
- api/src/controllers/moods.ts +14 -21
- api/src/lib/queue/QueueRegistry.ts +1 -1
- api/src/lib/queue/WorkerRegistry.ts +6 -0
- api/src/lib/queue/processors/dailyReminders.ts +44 -0
- api/src/lib/queue/processors/medicineReminders.ts +34 -0
- api/src/lib/queue/types.ts +22 -0
- api/src/lib/sendPushNotification.ts +30 -0
api/src/controllers/moods.ts
1 1
import { Request, Response, NextFunction } from 'express';
2 2
import { AuthenticatedRequest } from '@app/middleware/auth';
3 3
import { User } from '@prisma/client';
4 4
import {
5 5
createMood,
6 6
getMoodsByUserId,
7 7
getMoodById,
8 8
destroyMoodById
9 9
} from '@app/services/mood.service';
10 10
11 -
import {
12 -
findUsersElligibleForPush
13 -
} from '@app/services/user.service';
11 +
import { findUsersElligibleForPush } from '@app/services/user.service';
12 +
import { sendPushNotification } from '@app/lib/sendPushNotification';
14 13
15 14
import {
16 15
CreateMoodSchema
17 16
} from '@app/schemas'
18 17
▸ 62 unchanged lines
81 80
sendNotification: async (req: Request, res: Response, next: NextFunction) => {
82 81
try {
83 82
const users = await findUsersElligibleForPush();
84 83
85 84
if (users) {
86 -
users.forEach(async (user: User) => {
87 -
const response = await fetch('https://exp.host/--/api/v2/push/send', {
88 -
method: 'POST', headers: {
89 -
'Content-Type': 'application/json'
90 -
},
91 -
body: JSON.stringify({
92 -
to: user.pushToken,
93 -
title: 'Como você está?',
94 -
body: 'Adicione mais registros de humor.',
95 -
data: { screen: 'notifications' }, // Deep link payload
96 -
})
97 -
});
98 -
99 -
const data = await response.json();
100 -
101 -
req.log.info("Resposta %s", data)
102 -
console.log("Resposta ", data)
103 -
})
85 +
await Promise.all(
86 +
users
87 +
.filter((user: User) => user.pushToken)
88 +
.map((user: User) =>
89 +
sendPushNotification({
90 +
token: user.pushToken!,
91 +
title: 'Como você está?',
92 +
body: 'Adicione mais registros de humor.',
93 +
data: { screen: 'notifications' },
94 +
})
95 +
)
96 +
);
104 97
}
105 98
106 99
return res.status(200).json(users)
107 100
} catch (err) {
108 101
next(err);
109 102
}
110 103
}
111 104
};
api/src/lib/queue/QueueRegistry.ts
1 1
import { Queue, QueueOptions } from "bullmq";
2 2
3 -
export type QueueName = "mail" | "insights";
3 +
export type QueueName = "mail" | "insights" | "medicine-reminders" | "daily-reminders";
4 4
5 5
const queues = new Map<QueueName, Queue>();
6 6
7 7
const defaultOpts: QueueOptions = {
8 8
connection: {
▸ 17 unchanged lines
26 26
}
27 27
28 28
export async function closeAllQueues(): Promise<void> {
29 29
await Promise.all([...queues.values()].map((q) => q.close()));
30 30
}
api/src/lib/queue/WorkerRegistry.ts
1 1
import { Worker, WorkerOptions, Processor } from "bullmq";
2 2
import { getQueue, QueueName } from "@app/lib/queue/QueueRegistry";
3 3
import { mailProcessor } from "@app/lib/queue/processors/mail";
4 4
import { insightProcessor } from "@app/lib/queue/processors/insights";
5 +
import { medicineReminderProcessor } from "@app/lib/queue/processors/medicineReminders";
6 +
import { dailyReminderProcessor } from "@app/lib/queue/processors/dailyReminders";
5 7
import { InsightJobName } from "@app/lib/queue/types";
6 8
7 9
type ProcessorMap = Record<QueueName, Processor>;
8 10
9 11
const processorMap: ProcessorMap = {
10 12
mail: mailProcessor,
11 13
insights: insightProcessor,
14 +
"medicine-reminders": medicineReminderProcessor,
15 +
"daily-reminders": dailyReminderProcessor,
12 16
};
13 17
14 18
// Per-queue concurrency
15 19
// TODO: When we process images, we need to bump for that queue
16 20
const concurrencyMap: Record<QueueName, number> = {
17 21
mail: 10,
18 22
insights: 5,
23 +
"medicine-reminders": 5,
24 +
"daily-reminders": 5,
19 25
};
20 26
21 27
const workers: Worker[] = [];
22 28
23 29
export function bootWorkers(): void {
▸ 31 unchanged lines
55 61
}
56 62
57 63
export async function closeAllWorkers(): Promise<void> {
58 64
await Promise.all(workers.map((w) => w.close()));
59 65
}
api/src/lib/queue/processors/dailyReminders.ts
@@ -0,0 +1,44 @@
1 + import { Job } from "bullmq";
2 + import { prisma } from "@app/lib/prisma";
3 + import { sendPushNotification } from "@app/lib/sendPushNotification";
4 + import { DailyReminderPayload } from "@app/lib/queue/types";
5 +
6 + export async function dailyReminderProcessor(job: Job): Promise<void> {
7 + const { userId } = job.data as DailyReminderPayload;
8 +
9 + console.log(`[daily-reminder] job ${job.id}: user=${userId}`);
10 +
11 + const user = await prisma.user.findUnique({
12 + where: { id: userId },
13 + select: { pushToken: true, notificationsEnabled: true },
14 + });
15 +
16 + if (!user) {
17 + console.warn(`[daily-reminder] user ${userId} not found, skipping`);
18 +
19 + return;
20 + }
21 +
22 + if (!user.notificationsEnabled) {
23 + console.warn(`[daily-reminder] user ${userId} has notifications disabled, skipping`);
24 +
25 + return;
26 + }
27 +
28 + if (!user.pushToken) {
29 + console.warn(`[daily-reminder] user ${userId} has no pushToken, skipping`);
30 +
31 + return;
32 + }
33 +
34 + // TODO: These should be a side-effect from a new entry on the notifications
35 + // table
36 + await sendPushNotification({
37 + token: user.pushToken,
38 + title: "Como você está?",
39 + body: "Registre seu humor para acompanhar sua saúde.",
40 + data: { screen: "moods" },
41 + });
42 +
43 + console.log(`[daily-reminder] sent OK to user ${userId}`);
44 + }
api/src/lib/queue/processors/medicineReminders.ts
@@ -0,0 +1,34 @@
1 + import { Job } from "bullmq";
2 + import { prisma } from "@app/lib/prisma";
3 + import { sendPushNotification } from "@app/lib/sendPushNotification";
4 + import { MedicineReminderPayload } from "@app/lib/queue/types";
5 +
6 + export async function medicineReminderProcessor(job: Job): Promise<void> {
7 + const { userId, regimenId, medicineName, dosage } =
8 + job.data as MedicineReminderPayload;
9 +
10 + console.log(`[medicine-reminder] job ${job.id}: user=${userId} regimen=${regimenId} medicine=${medicineName}`);
11 +
12 + const user = await prisma.user.findUnique({
13 + where: { id: userId },
14 + select: { pushToken: true },
15 + });
16 +
17 + if (!user) {
18 + console.warn(`[medicine-reminder] user ${userId} not found, skipping`);
19 + return;
20 + }
21 + if (!user.pushToken) {
22 + console.warn(`[medicine-reminder] user ${userId} has no pushToken, skipping`);
23 + return;
24 + }
25 +
26 + await sendPushNotification({
27 + token: user.pushToken,
28 + title: `Hora de tomar ${medicineName}`,
29 + body: dosage,
30 + data: { screen: "medicine-regimens", regimenId },
31 + });
32 +
33 + console.log(`[medicine-reminder] sent OK to user ${userId}`);
34 + }
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",
▸ 34 unchanged lines
40 40
| { name: InsightJobName.MoodTrend; data: InsightPeriodPayload }
41 41
| { name: InsightJobName.EnergySleep; data: InsightPeriodPayload }
42 42
| { name: InsightJobName.TriggerPattern; data: InsightPeriodPayload }
43 43
| { name: InsightJobName.DailyEnergy; data: InsightPeriodPayload }
44 44
| { name: InsightJobName.DailySleep; data: InsightPeriodPayload };
45 +
46 +
// Medicine reminder queue
47 +
export enum MedicineReminderJobName {
48 +
Send = "medicine-reminder:send",
49 +
}
50 +
51 +
export type MedicineReminderPayload = {
52 +
userId: number;
53 +
regimenId: number;
54 +
medicineName: string;
55 +
dosage: string;
56 +
scheduledTime: string;
57 +
};
58 +
59 +
// Daily reminder queue
60 +
export enum DailyReminderJobName {
61 +
Send = "daily-reminder:send",
62 +
}
63 +
64 +
export type DailyReminderPayload = {
65 +
userId: number;
66 +
};
api/src/lib/sendPushNotification.ts
@@ -0,0 +1,30 @@
1 + const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
2 +
3 + type PushPayload = {
4 + token: string;
5 + title: string;
6 + body: string;
7 + data?: Record<string, unknown>;
8 + };
9 +
10 + export async function sendPushNotification({
11 + token,
12 + title,
13 + body,
14 + data,
15 + }: PushPayload): Promise<void> {
16 + console.log(`[push] => token ${token.slice(0, 24)}... title="${title}"`);
17 +
18 + const response = await fetch(EXPO_PUSH_URL, {
19 + method: "POST",
20 + headers: { "Content-Type": "application/json" },
21 + body: JSON.stringify({ to: token, title, body, data }),
22 + });
23 +
24 + if (!response.ok) {
25 + const errBody = await response.text();
26 +
27 + console.error(`[push] Expo API ${response.status}: ${errBody}`);
28 + throw new Error(`Expo push API returned ${response.status}`);
29 + }
30 + }