eletrotupi / tcc/ commit / ed13065

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 1 month ago ed13065ca7e9582c60b2bbc2ffd34c78abb4cc8a
Parents: 78897ec
7 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 + }