MaternAlert / src /notifications /notifications.service.ts
Auspicious14's picture
feat: add staff onboarding, password setup and welcome emails
131aa2a
Raw
History Blame Contribute Delete
22.7 kB
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Cron, CronExpression } from "@nestjs/schedule";
import { PrismaService } from "../database/prisma.service";
import { EmailService } from "../email/email.service";
import { CarePriority } from "../care-priority/types/care-priority.types";
import { NotificationType, NotificationPriority } from "@prisma/client";
import type { ExpoPushMessage } from "expo-server-sdk";
import {
CARE_PRIORITY_TEMPLATES,
BP_ALERT_TEMPLATES,
SYMPTOM_ALERT_TEMPLATES,
AUTH_TEMPLATES,
MONITORING_TEMPLATES,
TREND_ALERT_TEMPLATES,
} from "./templates/notification.templates";
import { generateEmailHtml } from "./templates/email.template";
/**
* Notification Service
*
* RESPONSIBILITIES:
* - Send care priority escalation notifications
* - Send BP alert notifications
* - Send symptom alert notifications
* - Log all notifications for audit trail
* - Persist notifications to database
* - Send push notifications via Expo
* - Background monitoring (Inactivity, Daily reminders, Trends, Follow-ups)
*/
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private expoInstance: any = null;
constructor(
private readonly prisma: PrismaService,
private readonly emailService: EmailService,
private readonly configService: ConfigService,
) {}
/**
* Helper to get Expo instance dynamically (fixes ERR_REQUIRE_ESM)
*/
private async getExpo() {
if (!this.expoInstance) {
const { Expo } = await import("expo-server-sdk");
this.expoInstance = new Expo();
}
return this.expoInstance;
}
private async getLatestBPDate(userId: string): Promise<Date | null> {
const latest = await this.prisma.bloodPressureReading.findFirst({
where: { userId },
orderBy: { recordedAt: "desc" },
select: { recordedAt: true },
});
return latest?.recordedAt || null;
}
private async hasLoggedBPSince(userId: string, cutoff: Date): Promise<boolean> {
const latest = await this.getLatestBPDate(userId);
return Boolean(latest && latest >= cutoff);
}
/**
* Helper to get user's email
*/
private async getUserEmail(userId: string): Promise<string | null> {
const user = await this.prisma.userAuth.findUnique({
where: { id: userId },
select: { email: true },
});
return user?.email || null;
}
/**
* Internal helper to send push notifications
*/
public async sendPushNotification(
userId: string,
title: string,
body: string,
data?: any,
): Promise<void> {
const user = await this.prisma.userAuth.findUnique({
where: { id: userId },
select: { pushToken: true },
});
if (!user?.pushToken) {
this.logger.debug(
`No push token found for user ${userId}, skipping push.`,
);
return;
}
const { Expo } = await import("expo-server-sdk");
if (!Expo.isExpoPushToken(user.pushToken)) {
this.logger.error(
`Push token ${user.pushToken} is not a valid Expo push token`,
);
return;
}
const messages: ExpoPushMessage[] = [
{
to: user.pushToken,
sound: "default",
title,
body,
data,
},
];
try {
const expo = await this.getExpo();
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
const tickets = await expo.sendPushNotificationsAsync(chunk);
this.logger.log(
`Push notification tickets for user ${userId}: ${JSON.stringify(tickets)}`,
);
}
this.logger.log(`Push notification sent to user ${userId}`);
} catch (error) {
this.logger.error(
`Error sending push notification to user ${userId}:`,
error,
);
}
}
/**
* Daily BP Reminder Monitor
* Runs every minute to check which users should be reminded
*/
@Cron(CronExpression.EVERY_MINUTE)
async checkDailyReminders() {
const now = new Date();
const hours = now.getHours().toString().padStart(2, "0");
const minutes = now.getMinutes().toString().padStart(2, "0");
const currentHHmm = `${hours}:${minutes}`;
const today = new Date();
today.setHours(0, 0, 0, 0);
// Find users who:
// 1. Have reminders enabled
// 2. Their reminderTime matches current time
// 3. Haven't logged BP today
const usersToRemind = await this.prisma.userAuth.findMany({
where: {
isActive: true,
profile: {
notifyReminders: true,
reminderTime: currentHHmm,
},
bloodPressureReadings: {
none: {
recordedAt: { gte: today },
},
},
},
select: { id: true, email: true },
});
if (usersToRemind.length > 0) {
this.logger.log(
`[REMINDER] Sending daily BP reminders to ${usersToRemind.length} users for time ${currentHHmm}`,
);
}
for (const user of usersToRemind) {
try {
if (await this.hasLoggedBPSince(user.id, today)) continue;
const template = MONITORING_TEMPLATES.DAILY_REMINDER;
await this.prisma.notification.create({
data: {
userId: user.id,
type: NotificationType.REMINDER,
title: template.subject,
message: template.body,
},
});
await this.sendPushNotification(
user.id,
template.subject,
template.body,
{ type: "BP_REMINDER" },
);
} catch (error) {
this.logger.error(
`Failed to send daily reminder to user ${user.id}:`,
error,
);
}
}
}
/**
* Inactivity Monitor - Checks for users who haven't logged BP in 3 days or more
* Runs daily at midnight
*/
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async checkInactivity() {
this.logger.log("Running inactivity check...");
const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
// Find users whose last reading was > 3 days ago or who never logged
const users = await this.prisma.userAuth.findMany({
where: {
isActive: true,
bloodPressureReadings: {
none: {
recordedAt: { gte: threeDaysAgo },
},
},
},
select: {
id: true,
email: true,
bloodPressureReadings: {
orderBy: { recordedAt: "desc" },
take: 1,
select: { recordedAt: true }
},
profile: {
select: { createdAt: true }
}
},
});
for (const user of users) {
try {
if (await this.hasLoggedBPSince(user.id, threeDaysAgo)) continue;
// Calculate days inactive
const lastReading = user.bloodPressureReadings[0];
let daysInactive: number;
if (lastReading) {
const now = new Date();
const diffInMs = now.getTime() - lastReading.recordedAt.getTime();
daysInactive = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
} else if (user.profile?.createdAt) {
// Never logged, use days since profile creation
const now = new Date();
const diffInMs = now.getTime() - user.profile.createdAt.getTime();
daysInactive = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
} else {
daysInactive = 3; // Fallback
}
// Ensure minimum 3 days
if (daysInactive < 3) continue;
const template = MONITORING_TEMPLATES.INACTIVITY_REMINDER;
const body = typeof template.body === 'function'
? template.body(daysInactive)
: template.body;
await this.prisma.notification.create({
data: {
userId: user.id,
type: NotificationType.REMINDER,
title: template.subject,
message: body,
},
});
// Send Push
await this.sendPushNotification(
user.id,
template.subject,
body,
{ type: "INACTIVITY_REMINDER" },
);
// Send Email
if (user.email) {
const html = generateEmailHtml(
template.subject,
body,
"Log BP Reading",
`${this.configService.get("FRONTEND_URL")}/bridge?type=inactivity-reminder`,
"#2DE474",
);
await this.emailService.sendEmail(
user.email,
template.subject,
body,
html,
);
}
} catch (error) {
this.logger.error(
`Failed to process inactivity reminder for user ${user.id}:`,
error,
);
}
}
this.logger.log(
`Inactivity check finished. ${users.length} users notified.`,
);
}
/**
* 4-Hour Follow-Up Monitor
* Runs every 30 minutes
*/
@Cron(CronExpression.EVERY_30_MINUTES)
async checkFollowUps() {
this.logger.log("Running follow-up recheck monitor...");
const now = new Date();
const fourHoursAgo = new Date(now.getTime() - 4 * 60 * 60 * 1000);
const fiveHoursAgo = new Date(now.getTime() - 5 * 60 * 60 * 1000);
// Find users who had an elevated reading (130/80 - 159/109) 4-5 hours ago
// AND haven't logged since then
const users = await this.prisma.userAuth.findMany({
where: {
isActive: true,
bloodPressureReadings: {
some: {
recordedAt: { gte: fiveHoursAgo, lte: fourHoursAgo },
OR: [{ systolic: { gte: 130 } }, { diastolic: { gte: 80 } }],
},
none: {
recordedAt: { gt: fourHoursAgo },
},
},
},
select: { id: true, email: true },
});
for (const user of users) {
try {
if (await this.hasLoggedBPSince(user.id, fourHoursAgo)) continue;
const template = MONITORING_TEMPLATES.FOLLOW_UP_REMINDER;
await this.prisma.notification.create({
data: {
userId: user.id,
type: NotificationType.REMINDER,
title: template.subject,
message: template.body,
},
});
// Send Push
await this.sendPushNotification(
user.id,
template.subject,
template.body,
{ type: "FOLLOW_UP" },
);
// Send Email
if (user.email) {
const html = generateEmailHtml(
template.subject,
template.body,
"Recheck BP Now",
`${this.configService.get("FRONTEND_URL")}/bridge?type=follow-up-recheck`,
"#FF9B3E",
);
await this.emailService.sendEmail(
user.email,
template.subject,
template.body,
html,
);
}
} catch (error) {
this.logger.error(
`Failed to process follow-up reminder for user ${user.id}:`,
error,
);
}
}
}
/**
* Trend Analysis Monitor - Analyzes 7 days of readings for patterns
* Runs every day at 1 AM
*/
@Cron(CronExpression.EVERY_DAY_AT_1AM)
async analyzeTrends() {
this.logger.log("Running trend analysis...");
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const users = await this.prisma.userAuth.findMany({
where: { isActive: true },
select: { id: true, email: true },
});
for (const user of users) {
try {
const readings = await this.prisma.bloodPressureReading.findMany({
where: {
userId: user.id,
recordedAt: { gte: sevenDaysAgo },
},
orderBy: { recordedAt: "asc" },
});
if (readings.length < 3) continue;
// 1. Creeping Rise Detection (Consistent rise over 3+ readings)
let riseCount = 0;
for (let i = 1; i < readings.length; i++) {
if (readings[i].systolic > readings[i - 1].systolic) riseCount++;
else riseCount = 0;
}
if (riseCount >= 3) {
await this.sendTrendAlert(
user.id,
TREND_ALERT_TEMPLATES.CREEPING_RISE,
"CREEPING_RISE",
user.email,
);
continue; // Don't spam multiple trend alerts
}
// 2. Repeated High Readings (3+ elevated readings in a week)
const highReadings = readings.filter(
(r) => r.systolic >= 135 || r.diastolic >= 85,
);
if (highReadings.length >= 3) {
await this.sendTrendAlert(
user.id,
TREND_ALERT_TEMPLATES.REPEATED_HIGH,
"REPEATED_HIGH",
user.email,
);
continue;
}
// 3. Sudden Spike Detection (Latest reading is 20% > personal average)
const avgSystolic =
readings.slice(0, -1).reduce((acc, curr) => acc + curr.systolic, 0) /
(readings.length - 1);
const latest = readings[readings.length - 1];
if (latest.systolic > avgSystolic * 1.2) {
await this.sendTrendAlert(
user.id,
TREND_ALERT_TEMPLATES.SUDDEN_SPIKE,
"SUDDEN_SPIKE",
user.email,
);
}
} catch (error) {
this.logger.error(
`Failed to analyze trends for user ${user.id}:`,
error,
);
}
}
}
private async sendTrendAlert(
userId: string,
template: any,
trendType: string,
email?: string | null,
) {
await this.prisma.notification.create({
data: {
userId,
type: NotificationType.BP_ALERT,
title: template.subject,
message: template.body,
},
});
await this.sendPushNotification(userId, template.subject, template.body, {
type: "TREND_ALERT",
trendType,
});
if (email) {
await this.emailService.sendTrendAlert(
email,
trendType,
template.body,
"Mama" // Default for now
);
}
}
/**
* Send care priority escalation notification
*/
async sendCarePriorityNotification(
userId: string,
priority: CarePriority,
): Promise<void> {
const template = CARE_PRIORITY_TEMPLATES[priority];
const email = await this.getUserEmail(userId);
// Log notification
this.logger.log(`[NOTIFICATION] User: ${userId}, Priority: ${priority}`);
// Persist to database
await this.prisma.notification.create({
data: {
userId,
type: NotificationType.CARE_PRIORITY,
title: template.subject,
message: `${template.body}\n\n${template.callToAction}`,
},
});
// Send email
if (email) {
const html = generateEmailHtml(
template.subject,
`${template.body}\n\n${template.callToAction}`,
"View Details",
`${this.configService.get("FRONTEND_URL")}/bridge?type=tracking`,
priority === CarePriority.EMERGENCY
? "#FF4B4B"
: priority === CarePriority.URGENT_REVIEW
? "#FF9B3E"
: "#2DE474",
);
await this.emailService.sendEmail(
email,
template.subject,
`${template.body}\n\n${template.callToAction}`,
html,
);
}
// Send Push
await this.sendPushNotification(userId, template.subject, template.body, {
type: "ESCALATION_ALERT",
priority,
});
this.logger.log(`✅ Notification handled for user ${userId}`);
}
/**
* Send severe BP alert
*/
async sendSevereBPAlert(
userId: string,
systolic: number,
diastolic: number,
): Promise<void> {
const template = BP_ALERT_TEMPLATES.SEVERE_HYPERTENSION;
const email = await this.getUserEmail(userId);
this.logger.warn(
`[ALERT] Severe BP for user ${userId}: ${systolic}/${diastolic}`,
);
const message = `${template.body}\nReading: ${systolic}/${diastolic}\n\n${template.callToAction}`;
await this.prisma.notification.create({
data: {
userId,
type: NotificationType.BP_ALERT,
title: template.subject,
message,
},
});
// Send email
if (email) {
const html = generateEmailHtml(
template.subject,
message,
"Log Now",
`${this.configService.get("FRONTEND_URL")}/bridge?type=bp-entry`,
"#FF4B4B",
);
await this.emailService.sendEmail(email, template.subject, message, html);
}
// Send Push
await this.sendPushNotification(userId, template.subject, template.body, {
type: "TREND_ALERT",
systolic,
diastolic,
});
this.logger.log(`✅ Severe BP alert handled for user ${userId}`);
}
/**
* Send elevated BP notification
*/
async sendElevatedBPNotification(
userId: string,
systolic: number,
diastolic: number,
): Promise<void> {
const template = BP_ALERT_TEMPLATES.ELEVATED_BP;
const email = await this.getUserEmail(userId);
this.logger.log(
`[NOTIFICATION] Elevated BP for user ${userId}: ${systolic}/${diastolic}`,
);
const message = `${template.body}\nReading: ${systolic}/${diastolic}\n\n${template.callToAction}`;
await this.prisma.notification.create({
data: {
userId,
type: NotificationType.BP_ALERT,
title: template.subject,
message,
},
});
// Send email
if (email) {
const html = generateEmailHtml(
template.subject,
message,
"Log Now",
`${this.configService.get("FRONTEND_URL")}/bridge?type=bp-entry`,
"#FF9B3E",
);
await this.emailService.sendEmail(email, template.subject, message, html);
}
// Send Push
await this.sendPushNotification(userId, template.subject, template.body, {
type: "TREND_ALERT",
systolic,
diastolic,
});
this.logger.log(`✅ Elevated BP notification handled for user ${userId}`);
}
/**
* Send dangerous symptom combination alert
*/
async sendDangerousSymptomAlert(
userId: string,
symptoms: string[],
): Promise<void> {
const template = SYMPTOM_ALERT_TEMPLATES.DANGEROUS_COMBINATION;
const email = await this.getUserEmail(userId);
this.logger.warn(
`[ALERT] Dangerous symptoms for user ${userId}: ${symptoms.join(", ")}`,
);
const message = `${template.body}\nSymptoms reported: ${symptoms.join(", ")}\n\n${template.callToAction}`;
await this.prisma.notification.create({
data: {
userId,
type: NotificationType.SYMPTOM_ALERT,
title: template.subject,
message,
},
});
// Send email
if (email) {
const html = generateEmailHtml(
template.subject,
message,
"Open App",
`${this.configService.get("FRONTEND_URL")}/bridge?type=tracking`,
"#FF4B4B",
);
await this.emailService.sendEmail(email, template.subject, message, html);
}
// Send Push
await this.sendPushNotification(userId, template.subject, template.body, {
type: "TREND_ALERT",
symptoms,
});
this.logger.log(`✅ Dangerous symptom alert handled for user ${userId}`);
}
/**
* Send warning symptom notification
*/
async sendWarningSymptomNotification(
userId: string,
symptom: string,
): Promise<void> {
const template = SYMPTOM_ALERT_TEMPLATES.SINGLE_WARNING_SYMPTOM;
const email = await this.getUserEmail(userId);
this.logger.log(
`[NOTIFICATION] Warning symptom for user ${userId}: ${symptom}`,
);
const message = `${template.body}\nSymptom reported: ${symptom}\n\n${template.callToAction}`;
await this.prisma.notification.create({
data: {
userId,
type: NotificationType.SYMPTOM_ALERT,
title: template.subject,
message,
},
});
// Send email
if (email) {
const html = generateEmailHtml(
template.subject,
message,
"Open App",
`${this.configService.get("FRONTEND_URL")}/bridge?type=tracking`,
"#FF9B3E",
);
await this.emailService.sendEmail(email, template.subject, message, html);
}
// Send Push
await this.sendPushNotification(userId, template.subject, template.body, {
type: "TREND_ALERT",
symptom,
});
this.logger.log(
`✅ Warning symptom notification handled for user ${userId}`,
);
}
/**
* Send reset password notification
*
*/
async sendResetPasswordNotification(
userId: string,
token: string,
): Promise<void> {
const email = await this.getUserEmail(userId);
this.logger.log(
`[AUTH] Reset password request for user ${userId}. Email: ${email || 'N/A'}`,
);
// Store in database
await this.prisma.notification.create({
data: {
userId,
type: NotificationType.REMINDER,
title: 'Reset your MaternAlert password',
message: 'A password reset link has been sent to your email.',
},
});
// Send email if user has one using our new template
if (email) {
await this.emailService.sendPasswordReset(email, token);
}
// Push notification — tapping it opens the login screen
await this.sendPushNotification(
userId,
'Reset your MaternAlert password',
'Tap to open MaternAlert and follow the link in your email.',
{ type: 'SESSION_EXPIRY' },
);
this.logger.log(
`✅ Reset password notification handled for user ${userId}`,
);
}
/**
* Register push token for user
*/
async registerPushToken(userId: string, pushToken: string) {
this.logger.log(`Registering push token for user ${userId}`);
return this.prisma.userAuth.update({
where: { id: userId },
data: { pushToken },
});
}
/**
* Get notification history for user
*/
async getNotificationHistory(userId: string) {
this.logger.log(`Fetching notification history for user ${userId}`);
return this.prisma.notification.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
}
/**
* Mark notification as read
*/
async markAsRead(notificationId: string, userId: string) {
const notification = await this.prisma.notification.findUnique({
where: { id: notificationId },
});
if (!notification || notification.userId !== userId) {
throw new Error("Notification not found or access denied");
}
return this.prisma.notification.update({
where: { id: notificationId },
data: { read: true },
});
}
}