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.
Pedro Lucas Porcellis porcellis@eletrotupi.com · 4 months ago
ed13065ca7e9582c60b2bbc2ffd34c78abb4cc8a Browse filesParents
78897ecChanged files
7
Additions
+151
Deletions
-22
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
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: {
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 {
55 61
}
56 62
57 63
export async function closeAllWorkers(): Promise<void> {
58 64
await Promise.all(workers.map((w) => w.close()));
59 65
}
/dev/null → api/src/lib/queue/processors/dailyReminders.ts
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 +
}
/dev/null → api/src/lib/queue/processors/medicineReminders.ts
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",
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 +
};
/dev/null → api/src/lib/sendPushNotification.ts
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 +
}