Skip to content

Commit 912aab8

Browse files
authored
Merge pull request #226 from jonathanayubausara-a11y/fix/issue-19-notification-websocket
feat(backend): add real-time notification service with WebSocket push
2 parents c8ff76a + 89cd455 commit 912aab8

9 files changed

Lines changed: 656 additions & 87 deletions

File tree

backend/src/controllers/notificationController.ts

Lines changed: 166 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
1-
import { Request, Response } from "express";
1+
import { Response } from "express";
2+
import { AuthenticatedRequest } from "../middleware/auth";
23
import { notificationService } from "../services/notificationService";
4+
import { NotificationType } from "../models/Notification";
35
import logger from "../utils/logger";
46

57
export class NotificationController {
6-
public async getNotifications(req: Request, res: Response): Promise<void> {
8+
public async getNotifications(req: AuthenticatedRequest, res: Response): Promise<void> {
79
try {
8-
const { userId } = req.params;
9-
const { category, isRead, priority, limit, skip } = req.query;
10+
const userId = req.user.id;
11+
// Cast query params to plain strings to prevent NoSQL operator injection
12+
const category = typeof req.query.category === 'string' ? req.query.category : undefined;
13+
const type = typeof req.query.type === 'string' ? req.query.type : undefined;
14+
const isRead = req.query.isRead;
15+
const priority = typeof req.query.priority === 'string' ? req.query.priority : undefined;
16+
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20;
17+
const skip = req.query.skip ? parseInt(req.query.skip as string) : 0;
1018

1119
const result = await notificationService.getNotifications({
1220
userId,
1321
category: category as any,
22+
type: type as any,
1423
isRead:
1524
isRead === "true" ? true : isRead === "false" ? false : undefined,
1625
priority: priority as any,
17-
limit: limit ? parseInt(limit as string) : 20,
18-
skip: skip ? parseInt(skip as string) : 0,
26+
limit: isNaN(limit) ? 20 : limit,
27+
skip: isNaN(skip) ? 0 : skip,
1928
});
2029

2130
res.status(200).json({
@@ -30,10 +39,10 @@ export class NotificationController {
3039
}
3140
}
3241

33-
public async markAsRead(req: Request, res: Response): Promise<void> {
42+
public async markAsRead(req: AuthenticatedRequest, res: Response): Promise<void> {
3443
try {
3544
const { notificationId } = req.params;
36-
const { userId } = req.body; // In a real app, get from auth middleware
45+
const userId = req.user.id;
3746

3847
const success = await notificationService.markAsRead(
3948
notificationId,
@@ -48,9 +57,9 @@ export class NotificationController {
4857
}
4958
}
5059

51-
public async markAllAsRead(req: Request, res: Response): Promise<void> {
60+
public async markAllAsRead(req: AuthenticatedRequest, res: Response): Promise<void> {
5261
try {
53-
const { userId } = req.body;
62+
const userId = req.user.id;
5463
const count = await notificationService.markAllAsRead(userId);
5564
res.status(200).json({ success: true, count });
5665
} catch (error) {
@@ -61,9 +70,9 @@ export class NotificationController {
6170
}
6271
}
6372

64-
public async getPreferences(req: Request, res: Response): Promise<void> {
73+
public async getPreferences(req: AuthenticatedRequest, res: Response): Promise<void> {
6574
try {
66-
const { userId } = req.params;
75+
const userId = req.user.id;
6776
const preferences = await notificationService.getUserPreferences(userId);
6877
res.status(200).json({ success: true, data: preferences });
6978
} catch (error) {
@@ -74,10 +83,20 @@ export class NotificationController {
7483
}
7584
}
7685

77-
public async updatePreferences(req: Request, res: Response): Promise<void> {
86+
public async updatePreferences(req: AuthenticatedRequest, res: Response): Promise<void> {
7887
try {
79-
const { userId } = req.params;
80-
const preferences = req.body;
88+
const userId = req.user.id;
89+
// Sanitize input to only whitelisted preference fields
90+
const allowedFields = new Set([
91+
'emailNotifications', 'pushNotifications', 'inAppNotifications',
92+
'digestFrequency', 'quietHoursStart', 'quietHoursEnd',
93+
]);
94+
const preferences: Record<string, unknown> = {};
95+
for (const [key, value] of Object.entries(req.body)) {
96+
if (allowedFields.has(key)) {
97+
preferences[key] = value;
98+
}
99+
}
81100

82101
await notificationService.setNotificationPreferences(userId, preferences);
83102
res.status(200).json({ success: true, message: "Preferences updated" });
@@ -89,10 +108,10 @@ export class NotificationController {
89108
}
90109
}
91110

92-
public async deleteNotification(req: Request, res: Response): Promise<void> {
111+
public async deleteNotification(req: AuthenticatedRequest, res: Response): Promise<void> {
93112
try {
94113
const { notificationId } = req.params;
95-
const { userId } = req.query;
114+
const userId = req.user.id;
96115

97116
const success = await notificationService.deleteNotification(
98117
notificationId,
@@ -106,6 +125,136 @@ export class NotificationController {
106125
.json({ success: false, message: "Internal server error" });
107126
}
108127
}
128+
129+
/// Get unread notification count for a user
130+
public async getUnreadCount(req: AuthenticatedRequest, res: Response): Promise<void> {
131+
try {
132+
const userId = req.user.id;
133+
const unreadCount = await notificationService.getUnreadCount(userId);
134+
res.status(200).json({ success: true, data: { unreadCount } });
135+
} catch (error) {
136+
logger.error("Error in getUnreadCount controller:", error);
137+
res
138+
.status(500)
139+
.json({ success: false, message: "Internal server error" });
140+
}
141+
}
142+
143+
/// Push a real-time notification via WebSocket
144+
public async pushNotification(req: AuthenticatedRequest, res: Response): Promise<void> {
145+
try {
146+
const { userId, type, title, message, category, priority, actionUrl, metadata } = req.body;
147+
148+
// Enforce string types to prevent type confusion (e.g., array/object injection)
149+
if (!userId || !type || !title || !message) {
150+
res.status(400).json({
151+
success: false,
152+
message: "Missing required fields: userId, type, title, and message are required",
153+
});
154+
return;
155+
}
156+
if (typeof userId !== 'string' || typeof type !== 'string' || typeof title !== 'string' || typeof message !== 'string') {
157+
res.status(400).json({
158+
success: false,
159+
message: "userId, type, title and message must be strings",
160+
});
161+
return;
162+
}
163+
164+
// Validate and sanitize actionUrl and metadata before passing to service
165+
const safeActionUrl = typeof actionUrl === 'string' ? actionUrl : undefined;
166+
const safeMetadata = metadata && typeof metadata === 'object' && !Array.isArray(metadata)
167+
? metadata as Record<string, unknown>
168+
: undefined;
169+
170+
const notification = await notificationService.createAndPushNotification(
171+
userId,
172+
type as NotificationType,
173+
title,
174+
message,
175+
category || "system",
176+
{
177+
priority: priority || "medium",
178+
actionUrl: safeActionUrl,
179+
metadata: safeMetadata,
180+
deliveryMethods: ["websocket"],
181+
},
182+
);
183+
184+
res.status(200).json({ success: true, data: notification });
185+
} catch (error) {
186+
logger.error("Error in pushNotification controller:", error);
187+
res
188+
.status(500)
189+
.json({ success: false, message: "Internal server error" });
190+
}
191+
}
192+
193+
/// Admin announcement: push to all connected users or targeted roles
194+
public async sendAnnouncement(req: AuthenticatedRequest, res: Response): Promise<void> {
195+
try {
196+
const { title, message, targetRoles, priority, actionUrl } = req.body;
197+
198+
if (!title || !message) {
199+
res.status(400).json({
200+
success: false,
201+
message: "Title and message are required for announcements",
202+
});
203+
return;
204+
}
205+
if (typeof title !== 'string' || typeof message !== 'string') {
206+
res.status(400).json({
207+
success: false,
208+
message: "title and message must be strings",
209+
});
210+
return;
211+
}
212+
213+
// Validate that actionUrl is a string if provided
214+
const safeActionUrl = typeof actionUrl === 'string' ? actionUrl : undefined;
215+
216+
const result = await notificationService.sendAnnouncement(
217+
title,
218+
message,
219+
targetRoles || [],
220+
{
221+
priority: priority || "high",
222+
actionUrl: safeActionUrl,
223+
},
224+
);
225+
226+
res.status(200).json({ success: true, data: result });
227+
} catch (error) {
228+
logger.error("Error in sendAnnouncement controller:", error);
229+
res
230+
.status(500)
231+
.json({ success: false, message: "Internal server error" });
232+
}
233+
}
234+
235+
/// Deliver missed notifications to a user on reconnect
236+
public async deliverMissedNotifications(
237+
req: AuthenticatedRequest,
238+
res: Response,
239+
): Promise<void> {
240+
try {
241+
const userId = req.user.id;
242+
243+
const delivered = await notificationService.deliverMissedNotifications(
244+
userId,
245+
);
246+
247+
res.status(200).json({ success: true, data: { delivered } });
248+
} catch (error) {
249+
logger.error(
250+
"Error in deliverMissedNotifications controller:",
251+
error,
252+
);
253+
res
254+
.status(500)
255+
.json({ success: false, message: "Internal server error" });
256+
}
257+
}
109258
}
110259

111260
export const notificationController = new NotificationController();

backend/src/middleware/validation.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -908,41 +908,21 @@ export const markAsReadSchema: ValidationSchema = {
908908
};
909909

910910
export const markAllAsReadSchema: ValidationSchema = {
911-
body: Joi.object({
912-
userId: Joi.string()
913-
.trim()
914-
.min(1)
915-
.required()
916-
.messages({ "any.required": '"userId" is required' }),
917-
}),
911+
// userId is obtained from the authenticated request (req.user.id)
918912
};
919913

920914
export const updatePreferencesSchema: ValidationSchema = {
921-
params: Joi.object({
922-
userId: Joi.string().trim().min(1).required(),
923-
}),
924915
body: Joi.object({
925916
emailNotifications: Joi.boolean().optional(),
926917
pushNotifications: Joi.boolean().optional(),
927918
inAppNotifications: Joi.boolean().optional(),
928-
digestFrequency: Joi.string().valid("daily", "weekly", "never").optional(),
929-
quietHoursStart: Joi.string()
930-
.regex(/^\d{2}:\d{2}$/)
931-
.optional(),
932-
quietHoursEnd: Joi.string()
933-
.regex(/^\d{2}:\d{2}$/)
934-
.optional(),
935-
})
936-
.min(1)
937-
.messages({
938-
"object.min": "At least one preference field must be provided",
939-
}),
919+
digestFrequency: Joi.string().valid('daily', 'weekly', 'never').optional(),
920+
quietHoursStart: Joi.string().regex(/^\d{2}:\d{2}$/).optional(),
921+
quietHoursEnd: Joi.string().regex(/^\d{2}:\d{2}$/).optional(),
922+
}).min(1).unknown(false).messages({ 'object.min': 'At least one preference field must be provided' }),
940923
};
941924

942925
export const getNotificationsSchema: ValidationSchema = {
943-
params: Joi.object({
944-
userId: Joi.string().trim().min(1).required(),
945-
}),
946926
query: Joi.object({
947927
page: Joi.number().integer().min(1).optional(),
948928
limit: Joi.number().integer().min(1).max(100).optional(),

backend/src/models/Notification.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
import mongoose, { Document, Schema, Model } from "mongoose";
22

3+
export type NotificationType =
4+
| "credential_issued"
5+
| "enrollment_confirmed"
6+
| "announcement"
7+
| "course_update"
8+
| "achievement_earned"
9+
| "assignment_graded"
10+
| "payment_confirmed"
11+
| "system_alert";
12+
313
export interface INotification extends Document {
414
_id: string;
515
userId: string;
16+
type: NotificationType;
617
title: string;
718
message: string;
819
category: "course" | "message" | "system" | "achievement";
920
isRead: boolean;
1021
isDelivered: boolean;
22+
deliveredAt?: Date;
1123
priority: "low" | "medium" | "high";
1224
deliveryMethods: ("email" | "push" | "websocket")[];
1325
scheduledTime?: Date;
1426
sentTime?: Date;
1527
actionUrl?: string;
1628
metadata?: Record<string, any>;
29+
targetRoles?: string[];
1730
createdAt: Date;
1831
updatedAt: Date;
1932
}
@@ -25,6 +38,21 @@ const NotificationSchema: Schema = new Schema(
2538
required: true,
2639
index: true,
2740
},
41+
type: {
42+
type: String,
43+
enum: [
44+
"credential_issued",
45+
"enrollment_confirmed",
46+
"announcement",
47+
"course_update",
48+
"achievement_earned",
49+
"assignment_graded",
50+
"payment_confirmed",
51+
"system_alert",
52+
],
53+
required: true,
54+
index: true,
55+
},
2856
title: {
2957
type: String,
3058
required: true,
@@ -48,6 +76,9 @@ const NotificationSchema: Schema = new Schema(
4876
type: Boolean,
4977
default: false,
5078
},
79+
deliveredAt: {
80+
type: Date,
81+
},
5182
priority: {
5283
type: String,
5384
enum: ["low", "medium", "high"],
@@ -72,6 +103,11 @@ const NotificationSchema: Schema = new Schema(
72103
metadata: {
73104
type: Schema.Types.Mixed,
74105
},
106+
targetRoles: [
107+
{
108+
type: String,
109+
},
110+
],
75111
},
76112
{
77113
timestamps: true,
@@ -81,10 +117,16 @@ const NotificationSchema: Schema = new Schema(
81117
// Indexes for common queries
82118
NotificationSchema.index({ userId: 1, createdAt: -1 });
83119
NotificationSchema.index({ userId: 1, isRead: 1 });
120+
NotificationSchema.index({ userId: 1, type: 1 });
121+
NotificationSchema.index({ type: 1, createdAt: -1 });
84122
NotificationSchema.index(
85123
{ scheduledTime: 1 },
86124
{ partialFilterExpression: { isDelivered: false } },
87125
);
126+
NotificationSchema.index(
127+
{ userId: 1, isDelivered: 1 },
128+
{ partialFilterExpression: { isDelivered: false } },
129+
);
88130

89131
export const Notification: Model<INotification> = mongoose.model<INotification>(
90132
"Notification",

0 commit comments

Comments
 (0)