From 7671e9eae5fc1d71dd1ddf2ba13eb00698e28011 Mon Sep 17 00:00:00 2001 From: Timothy-Gonzalez <105177619+Timothy-Gonzalez@users.noreply.github.com> Date: Tue, 15 Oct 2024 21:59:07 -0500 Subject: [PATCH] Remove notification batching & add specification Instead of /notification/batch -> /notification/send just do /notification/send since we are moving from serverless Also changes `GET /notifications/`` response type from `[{..notif1}]` to `{ notifications: [{..notif1}] }` to be consistent --- src/common/config.ts | 1 - src/database/models.ts | 2 +- src/database/notification-db.ts | 31 -- .../notification/notification-formats.ts | 46 --- .../notification/notification-router.ts | 297 +++++++++--------- .../notification/notification-schemas.ts | 77 +++++ 6 files changed, 223 insertions(+), 231 deletions(-) delete mode 100644 src/database/notification-db.ts delete mode 100644 src/services/notification/notification-formats.ts create mode 100644 src/services/notification/notification-schemas.ts diff --git a/src/common/config.ts b/src/common/config.ts index eb1cfbd..b73ae17 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -138,7 +138,6 @@ const Config = { /* Limits */ LEADERBOARD_QUERY_LIMIT: 25, MAX_RESUME_SIZE_BYTES: 2 * 1024 * 1024, - NOTIFICATION_BATCH_SIZE: 50, /* Misc */ SHOP_BYTES_GEN: 2, diff --git a/src/database/models.ts b/src/database/models.ts index b796668..0a8d865 100644 --- a/src/database/models.ts +++ b/src/database/models.ts @@ -12,7 +12,7 @@ import { ShopItem } from "./shop-db"; import { UserAttendance, UserInfo } from "../services/user/user-schemas"; import { AnyParamConstructor, IModelOptions } from "@typegoose/typegoose/lib/types"; import { StaffShift } from "../services/staff/staff-schemas"; -import { NotificationMappings, NotificationMessages } from "./notification-db"; +import { NotificationMappings, NotificationMessages } from "../services/notification/notification-schemas"; import { PuzzleItem } from "./puzzle-db"; // Groups for collections diff --git a/src/database/notification-db.ts b/src/database/notification-db.ts deleted file mode 100644 index fe5bf28..0000000 --- a/src/database/notification-db.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { prop } from "@typegoose/typegoose"; - -export class NotificationMappings { - @prop({ required: true }) - public userId: string; - - @prop({ required: true }) - public deviceToken: string; -} - -class NotificationMessageBatch { - @prop({ required: true, type: () => [String] }) - public sent!: string[]; - - @prop({ required: true, type: () => [String] }) - public failed!: string[]; -} - -export class NotificationMessages { - @prop({ required: true }) - public sender: string; - - @prop({ required: true }) - public title: string; - - @prop({ required: true }) - public body: string; - - @prop({ required: true, type: () => [NotificationMessageBatch] }) - public batches!: NotificationMessageBatch[]; -} diff --git a/src/services/notification/notification-formats.ts b/src/services/notification/notification-formats.ts deleted file mode 100644 index c179c2f..0000000 --- a/src/services/notification/notification-formats.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { isEnumOfType, isNumber, isString } from "../../common/formatTools"; -import { Role } from "../auth/auth-schemas"; - -export interface NotificationSendFormat { - role?: string; - eventId?: string; - staffShift?: string; - foodWave?: number; - - title: string; - body: string; -} - -/* eslint-disable no-magic-numbers */ -export function isValidNotificationSendFormat(obj: NotificationSendFormat): boolean { - const validCt = (obj.role ? 1 : 0) + (obj.eventId ? 1 : 0) + (obj.foodWave ? 1 : 0) + (obj.staffShift ? 1 : 0); - if (validCt != 1) { - return false; - } - - if (obj.role && !isEnumOfType(obj.role, Role)) { - return false; - } - - if (obj.eventId && !isString(obj.eventId)) { - return false; - } - - if (obj.foodWave && !isNumber(obj.foodWave)) { - return false; - } - - if (obj.staffShift && !isString(obj.staffShift)) { - return false; - } - - if (!isString(obj.title)) { - return false; - } - - if (!isString(obj.body)) { - return false; - } - - return true; -} diff --git a/src/services/notification/notification-router.ts b/src/services/notification/notification-router.ts index 347436b..c8d9e62 100644 --- a/src/services/notification/notification-router.ts +++ b/src/services/notification/notification-router.ts @@ -1,165 +1,158 @@ -import { NextFunction, Request, Response, Router } from "express"; -import { strongJwtVerification } from "../../middleware/verify-jwt"; -import { RouterError } from "../../middleware/error-handler"; +import { Router } from "express"; import { StatusCode } from "status-code-enum"; import Models from "../../database/models"; import { StaffShift } from "../staff/staff-schemas"; -import { JwtPayload } from "../auth/auth-schemas"; -import { hasAdminPerms, hasStaffPerms } from "../../common/auth"; -import { NotificationSendFormat, isValidNotificationSendFormat } from "./notification-formats"; -import Config from "../../common/config"; +import { Role } from "../auth/auth-schemas"; +import { getAuthenticatedUser } from "../../common/auth"; +import { NotificationSendRequestSchema, NotificationSendSchema, NotificationsSchema } from "./notification-schemas"; import { sendNotification } from "./notification-service"; +import specification, { Tag } from "../../middleware/specification"; +import { SuccessResponseSchema } from "../../common/schemas"; const notificationsRouter = Router(); -notificationsRouter.get("/", strongJwtVerification, async (_: Request, res: Response, next: NextFunction) => { - const payload = res.locals.payload as JwtPayload; - - if (!hasStaffPerms(payload)) { - return next(new RouterError(StatusCode.ClientErrorForbidden, "Forbidden")); - } - - const notifs = await Models.NotificationMessages.find(); - - return res.status(StatusCode.SuccessOK).send(notifs ?? []); -}); - -// register your current device token as associated with your userId -notificationsRouter.post("/", strongJwtVerification, async (req: Request, res: Response, next: NextFunction) => { - const payload = res.locals.payload as JwtPayload; - - const deviceToken: string | undefined = req.body.deviceToken; - const userId = payload.id; - - if (!deviceToken) { - return next(new RouterError(StatusCode.ClientErrorBadRequest, "NoDeviceToken")); - } - - await Models.NotificationMappings.updateOne({ userId: userId }, { deviceToken: deviceToken }, { upsert: true }); - return res.status(StatusCode.SuccessOK).send({ status: "Success" }); -}); - -// ADMIN ONLY ENDPOINT -// Gets batches that can be used to send notifications -// Call this first, then call /send for each batchId you get -notificationsRouter.post("/batch/", strongJwtVerification, async (req: Request, res: Response, next: NextFunction) => { - const payload = res.locals.payload as JwtPayload; - - if (!hasAdminPerms(payload)) { - return next(new RouterError(StatusCode.ClientErrorForbidden, "Forbidden")); - } - - const sendRequest = req.body as NotificationSendFormat; - sendRequest.role = sendRequest.role?.toUpperCase(); - - if (!isValidNotificationSendFormat(sendRequest)) { - return next(new RouterError(StatusCode.ClientErrorBadRequest, "BadSendRequest")); - } - - let targetUserIds: string[] = []; - - if (sendRequest.eventId) { - const eventFollowers = await Models.EventFollowers.findOne({ eventId: sendRequest.eventId }); - const eventUserIds = eventFollowers?.followers ?? []; - targetUserIds = targetUserIds.concat(eventUserIds); - } - - if (sendRequest.role) { - const roles = await Models.AuthInfo.find({ roles: { $in: [sendRequest.role] } }, "userId"); - const roleUserIds = roles.map((x) => x.userId); - targetUserIds = targetUserIds.concat(roleUserIds); - } - - if (sendRequest.staffShift) { - const staffShifts: StaffShift[] = await Models.StaffShift.find({ shifts: { $in: [sendRequest.staffShift] } }); - const staffUserIds: string[] = staffShifts.map((x) => x.userId); - targetUserIds = targetUserIds.concat(staffUserIds); - } - - if (sendRequest.foodWave) { - const foodwaves = await Models.AttendeeProfile.find({ foodWave: sendRequest.foodWave }); - const foodUserIds = foodwaves.map((x) => x.userId); - targetUserIds = targetUserIds.concat(foodUserIds); - } - - const message = await Models.NotificationMessages.create({ - sender: payload.id, - title: sendRequest.title, - body: sendRequest.body, - batches: [], - }); - - const batchIds: string[] = []; - - for (let i = 0; i < targetUserIds.length; i += Config.NOTIFICATION_BATCH_SIZE) { - const thisUserIds = targetUserIds.slice(i, i + Config.NOTIFICATION_BATCH_SIZE); - const batchId = JSON.stringify([message.id, thisUserIds]); - batchIds.push(Buffer.from(batchId).toString("base64url")); - } - - return res.status(StatusCode.SuccessOK).send({ status: "Success", batches: batchIds }); -}); - -// Sends notifications to a batch of users, gotten from /notification/batch -// Only accepts Config.NOTIFICATION_BATCH_SIZE users. -notificationsRouter.post("/send", strongJwtVerification, async (req: Request, res: Response, next: NextFunction) => { - const payload = res.locals.payload as JwtPayload; - const sendRequest = req.body as { batchId: string }; - - if (!hasAdminPerms(payload)) { - return next(new RouterError(StatusCode.ClientErrorForbidden, "Forbidden")); - } - if (!sendRequest.batchId) { - return next(new RouterError(StatusCode.ClientErrorBadRequest, "NoBatchId")); - } - - const decodedBatchId = Buffer.from(sendRequest.batchId, "base64url").toString("utf-8"); - const [messageId, targetUserIds] = JSON.parse(decodedBatchId) as [string, string[]]; - - const message = await Models.NotificationMessages.findById(messageId); - - if (!message || !targetUserIds || targetUserIds.length > Config.NOTIFICATION_BATCH_SIZE) { - return next(new RouterError(StatusCode.ClientErrorBadRequest, "InvalidBatch")); - } - const messageTemplate = { - notification: { - title: message.title, - body: message.body, +notificationsRouter.get( + "/", + specification({ + method: "get", + path: "/notification/", + tag: Tag.NOTIFICATION, + role: Role.STAFF, + summary: "Gets all notifications that have been sent", + responses: { + [StatusCode.SuccessOK]: { + description: "All the notifications", + schema: NotificationsSchema, + }, }, - }; - const startTime = new Date(); - let notifMappings = await Models.NotificationMappings.find({ userId: { $in: targetUserIds } }).exec(); - notifMappings = notifMappings.filter((x) => x?.deviceToken != undefined); - - const sent: string[] = []; - const failed: string[] = []; - - const messages = notifMappings.map((mapping) => - sendNotification({ token: mapping.deviceToken, ...messageTemplate }) - .then(() => { - sent.push(mapping.userId); - }) - .catch(() => { - failed.push(mapping.userId); - }), - ); - await Promise.all(messages); - await Models.NotificationMessages.findOneAndUpdate( - { - _id: messageId, + }), + async (_req, res) => { + const notifications = (await Models.NotificationMessages.find()) || []; + + return res.status(StatusCode.SuccessOK).send({ notifications }); + }, +); + +notificationsRouter.post( + "/", + specification({ + method: "post", + path: "/notification/", + tag: Tag.NOTIFICATION, + role: Role.USER, + summary: "Registers a device token to be associate with the currently authenticated user", + responses: { + [StatusCode.SuccessOK]: { + description: "Successfully registered", + schema: SuccessResponseSchema, + }, }, - { - $push: { - batches: { - sent, - failed, - }, + }), + async (req, res) => { + const { id: userId } = getAuthenticatedUser(req); + const { deviceToken } = req.body; + + await Models.NotificationMappings.updateOne({ userId: userId }, { deviceToken: deviceToken }, { upsert: true }); + return res.status(StatusCode.SuccessOK).send({ success: true }); + }, +); + +notificationsRouter.post( + "/send/", + specification({ + method: "post", + path: "/notification/send/", + tag: Tag.NOTIFICATION, + role: Role.ADMIN, + summary: "Sends a notification to a specified group of users", + description: + "Can filter by: \n" + + "- `eventId`: users following a event\n\n" + + "- `role`: users that have a role\n\n" + + "- `staffShift`: staff in a staff shift\n\n" + + "- `foodWave`: users in a food wave \n\n" + + "- `userIds`: some set of users\n\n" + + "Filters are intersecting, so `eventId = 123` and `foodWave = 1` would get users following event 123 AND in food wave 1.", + body: NotificationSendRequestSchema, + responses: { + [StatusCode.SuccessOK]: { + description: "The result of the sent batch", + schema: NotificationSendSchema, }, }, - ); - const endTime = new Date(); - const timeElapsed = endTime.getTime() - startTime.getTime(); - return res.status(StatusCode.SuccessOK).send({ status: "Success", sent, failed, time_ms: timeElapsed }); -}); + }), + async (req, res) => { + const { id: userId } = getAuthenticatedUser(req); + + const sendRequest = req.body; + + const usersForEachFilter: string[][] = []; + + if (sendRequest.eventId) { + const eventFollowers = await Models.EventFollowers.findOne({ eventId: sendRequest.eventId }); + usersForEachFilter.push(eventFollowers?.followers ?? []); + } + + if (sendRequest.role) { + const roles = await Models.AuthInfo.find({ roles: { $in: [sendRequest.role] } }, "userId"); + usersForEachFilter.push(roles.map((x) => x.userId)); + } + + if (sendRequest.staffShift) { + const staffShifts: StaffShift[] = await Models.StaffShift.find({ shifts: { $in: [sendRequest.staffShift] } }); + usersForEachFilter.push(staffShifts.map((x) => x.userId)); + } + + if (sendRequest.foodWave) { + const foodwaves = await Models.AttendeeProfile.find({ foodWave: sendRequest.foodWave }); + usersForEachFilter.push(foodwaves.map((x) => x.userId)); + } + + if (sendRequest.userIds) { + usersForEachFilter.push(sendRequest.userIds); + } + + // Get users which match every filter + const targetUserIds = usersForEachFilter.reduce((acc, array) => acc.filter((element) => array.includes(element))); + + const messageTemplate = { + notification: { + title: sendRequest.title, + body: sendRequest.body, + }, + }; + const startTime = new Date(); + + const sent: string[] = []; + const failed: string[] = []; + + const notificationMappings = await Models.NotificationMappings.find({ userId: { $in: targetUserIds } }).exec(); + const messages = notificationMappings + .filter((x) => x?.deviceToken != undefined) + .map((mapping) => + sendNotification({ token: mapping.deviceToken, ...messageTemplate }) + .then(() => { + sent.push(mapping.userId); + }) + .catch((e) => { + console.log(e); + failed.push(mapping.userId); + }), + ); + await Promise.allSettled(messages); + + await Models.NotificationMessages.create({ + sender: userId, + title: sendRequest.title, + body: sendRequest.body, + sent, + failed, + }); + + const endTime = new Date(); + const timeElapsed = endTime.getTime() - startTime.getTime(); + return res.status(StatusCode.SuccessOK).send({ sent, failed, time_ms: timeElapsed }); + }, +); export default notificationsRouter; diff --git a/src/services/notification/notification-schemas.ts b/src/services/notification/notification-schemas.ts new file mode 100644 index 0000000..3d16248 --- /dev/null +++ b/src/services/notification/notification-schemas.ts @@ -0,0 +1,77 @@ +import { prop } from "@typegoose/typegoose"; +import { z } from "zod"; +import { UserIdSchema } from "../user/user-schemas"; +import { Role, RoleSchema } from "../auth/auth-schemas"; + +export class NotificationMappings { + @prop({ required: true }) + public userId: string; + + @prop({ required: true }) + public deviceToken: string; +} + +export class NotificationMessages { + @prop({ required: true }) + public sender: string; + + @prop({ required: true }) + public title: string; + + @prop({ required: true }) + public body: string; + + @prop({ required: true, type: () => [String] }) + public sent!: string[]; + + @prop({ required: true, type: () => [String] }) + public failed!: string[]; +} + +export const RegisterDeviceTokenSchema = z + .object({ + deviceToken: z.string().openapi({ + example: "abcd", + }), + }) + .openapi("RegisterDeviceToken"); + +export const NotificationMessageSchema = z.object({ + sender: UserIdSchema, + title: z.string(), + body: z.string(), + sent: z.array(UserIdSchema), + failed: z.array(UserIdSchema), +}); + +export const NotificationsSchema = z + .object({ + notifications: z.array(NotificationMessageSchema).openapi("Notifications"), + }) + .openapi("Notifications"); + +export const NotificationSendRequestSchema = z + .object({ + title: z.string(), + body: z.string(), + role: z.optional(RoleSchema), + eventId: z.string().optional().openapi({ example: "event1" }), + staffShift: z.string().optional().openapi({ example: "event1" }), + foodWave: z.number().optional(), + userIds: z.array(UserIdSchema).optional(), + }) + .openapi("NotificationSendRequest", { + example: { + title: "This is a test notification", + body: "blame aydan", + role: Role.STAFF, + }, + }); + +export const NotificationSendSchema = z + .object({ + sent: z.array(UserIdSchema), + failed: z.array(UserIdSchema), + time_ms: z.number().openapi({ example: 532 }), + }) + .openapi("NotificationSend");