From ea28adcc02be35ac2882c2f3a583e430063fb7d5 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 19:54:36 +0100 Subject: [PATCH 01/17] feat: barebones notif system --- server/package.json | 2 +- server/pnpm-lock.yaml | 8 ++-- server/src/external/mongo/db.ts | 2 + server/src/lib/notifications/notifications.ts | 40 +++++++++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 server/src/lib/notifications/notifications.ts diff --git a/server/package.json b/server/package.json index 195fac104..d7c2d149c 100644 --- a/server/package.json +++ b/server/package.json @@ -85,7 +85,7 @@ "rimraf": "3.0.2", "safe-json-stringify": "1.2.0", "seq-logging": "1.1.1", - "tachi-common": "0.10.2", + "tachi-common": "0.11.1", "ts-node": "10.4.0", "tsconfig-paths": "3.12.0", "typescript": "4.5.5", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 69996cbde..7e60d7acd 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -65,7 +65,7 @@ specifiers: safe-json-stringify: 1.2.0 seq-logging: 1.1.1 supertest: 6.2.2 - tachi-common: 0.10.2 + tachi-common: 0.11.1 tap: 15.1.6 ts-node: 10.4.0 tsconfig-paths: 3.12.0 @@ -112,7 +112,7 @@ dependencies: rimraf: 3.0.2 safe-json-stringify: 1.2.0 seq-logging: 1.1.1 - tachi-common: 0.10.2 + tachi-common: 0.11.1 ts-node: 10.4.0_2615db9039ce432b4abf2fc39ef336ec tsconfig-paths: 3.12.0 typescript: 4.5.5 @@ -5364,8 +5364,8 @@ packages: engines: {node: '>= 0.4'} dev: true - /tachi-common/0.10.2: - resolution: {integrity: sha512-hU3qQUacbL9rbiFibePzKeP+WuDXyKgcOlGNEXgZZIWZ3cpeEwFm6tk8PtVAoQPa5nPfXobmEmWYN4eGOQoKjA==} + /tachi-common/0.11.1: + resolution: {integrity: sha512-gLhFJG84YfzrHdoHXlVqxnBOjiKsI2xaivscmHVdZr9lqKaPUzBYmkMKZ5IsWdFxYXo8Ry+JrkZ83WPuSuVWEg==} dependencies: '@types/express-serve-static-core': 4.17.28 '@types/mongodb': 3.6.18 diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index ccb60cf6f..c7bfe3eba 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -38,6 +38,7 @@ import { GoalSubscriptionDocument, MilestoneSubscriptionDocument, UserSettings, + NotificationDocument, } from "tachi-common"; import { GetMillisecondsSince } from "utils/misc"; import { MigrationDocument, PrivateUserInfoDocument } from "utils/types"; @@ -193,6 +194,7 @@ const db = { "recent-folder-views": monkDB.get("recent-folder-views"), "milestone-sets": monkDB.get("milestone-sets"), migrations: monkDB.get("migrations"), + notifications: monkDB.get("notifications"), }; export type StaticDatabases = Exclude; diff --git a/server/src/lib/notifications/notifications.ts b/server/src/lib/notifications/notifications.ts new file mode 100644 index 000000000..eb645dd20 --- /dev/null +++ b/server/src/lib/notifications/notifications.ts @@ -0,0 +1,40 @@ +import db from "external/mongo/db"; +import { integer, NotificationBody, NotificationDocument } from "tachi-common"; +import { Random20Hex } from "utils/misc"; + +/** + * Send a notification to a user. + * + * @param title - A human friendly title for this notification. + * @param toUserID - The user to send the notification to. + * @param body - The body of the notification. + */ +export async function SendNotification(title: string, toUserID: integer, body: NotificationBody) { + const notification: NotificationDocument = { + title, + sentTo: toUserID, + read: false, + sentAt: Date.now(), + notifID: `N${Random20Hex()}`, + body, + }; + + await db.notifications.insert(notification); +} + +/** + * Mark a notification as read. This is notably different to deleting a notification, + * and is typically done when the user acknowledges the existence of the notification. + */ +export function ReadNotification(notifID: string) { + return db.notifications.update( + { notifID }, + { + $set: { read: true }, + } + ); +} + +export function DeleteNotification(notifID: string) { + return db.notifications.remove({ notifID }); +} From c1f5da3cce78168a41826615dab285340b9775c3 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 20:12:35 +0100 Subject: [PATCH 02/17] fix: update tachi-common to fix schema miscompilation --- server/package.json | 2 +- server/pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/package.json b/server/package.json index d7c2d149c..87a71ee0f 100644 --- a/server/package.json +++ b/server/package.json @@ -85,7 +85,7 @@ "rimraf": "3.0.2", "safe-json-stringify": "1.2.0", "seq-logging": "1.1.1", - "tachi-common": "0.11.1", + "tachi-common": "0.11.2", "ts-node": "10.4.0", "tsconfig-paths": "3.12.0", "typescript": "4.5.5", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 7e60d7acd..aa2cdd9ee 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -65,7 +65,7 @@ specifiers: safe-json-stringify: 1.2.0 seq-logging: 1.1.1 supertest: 6.2.2 - tachi-common: 0.11.1 + tachi-common: 0.11.2 tap: 15.1.6 ts-node: 10.4.0 tsconfig-paths: 3.12.0 @@ -112,7 +112,7 @@ dependencies: rimraf: 3.0.2 safe-json-stringify: 1.2.0 seq-logging: 1.1.1 - tachi-common: 0.11.1 + tachi-common: 0.11.2 ts-node: 10.4.0_2615db9039ce432b4abf2fc39ef336ec tsconfig-paths: 3.12.0 typescript: 4.5.5 @@ -5364,8 +5364,8 @@ packages: engines: {node: '>= 0.4'} dev: true - /tachi-common/0.11.1: - resolution: {integrity: sha512-gLhFJG84YfzrHdoHXlVqxnBOjiKsI2xaivscmHVdZr9lqKaPUzBYmkMKZ5IsWdFxYXo8Ry+JrkZ83WPuSuVWEg==} + /tachi-common/0.11.2: + resolution: {integrity: sha512-t/isJQH3QHD25zTc8b6n+6G3utYlpNp+Y7MGffb0iTSQkCZvOmBt4GFrWDU36c/mRB2v2G5dAf4/HlyhX3tJBg==} dependencies: '@types/express-serve-static-core': 4.17.28 '@types/mongodb': 3.6.18 From 2fbd56472d08a55d247d8e45fa411f8ee21e895b Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 21:12:29 +0100 Subject: [PATCH 03/17] feat: add ReadUsersNotifications fn --- server/src/lib/notifications/notifications.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/src/lib/notifications/notifications.ts b/server/src/lib/notifications/notifications.ts index eb645dd20..74aaa4aac 100644 --- a/server/src/lib/notifications/notifications.ts +++ b/server/src/lib/notifications/notifications.ts @@ -35,6 +35,19 @@ export function ReadNotification(notifID: string) { ); } +/** + * Mark all of a user's notifications as read. + */ +export function ReadUsersNotifications(userID: integer) { + return db.notifications.update( + { sentTo: userID }, + { + $set: { read: true }, + }, + { multi: true } + ); +} + export function DeleteNotification(notifID: string) { return db.notifications.remove({ notifID }); } From 5991a0be9635368e4d4634ab8ffffde7e14fe382 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 21:19:39 +0100 Subject: [PATCH 04/17] style: organise imports --- server/src/external/mongo/db.ts | 6 +++--- server/src/lib/targets/milestones.ts | 1 - server/src/utils/misc.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index c7bfe3eba..5f066a374 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -15,6 +15,7 @@ import { FolderDocument, Game, GoalDocument, + GoalSubscriptionDocument, ImportDocument, ImportTimingsDocument, integer, @@ -22,6 +23,8 @@ import { KaiAuthDocument, MilestoneDocument, MilestoneSetDocument, + MilestoneSubscriptionDocument, + NotificationDocument, OrphanChart, PBScoreDocument, PublicUserDocument, @@ -35,10 +38,7 @@ import { UGPTSettings, UserGameStats, UserGameStatsSnapshot, - GoalSubscriptionDocument, - MilestoneSubscriptionDocument, UserSettings, - NotificationDocument, } from "tachi-common"; import { GetMillisecondsSince } from "utils/misc"; import { MigrationDocument, PrivateUserInfoDocument } from "utils/types"; diff --git a/server/src/lib/targets/milestones.ts b/server/src/lib/targets/milestones.ts index 10a31c98a..e87306b0e 100644 --- a/server/src/lib/targets/milestones.ts +++ b/server/src/lib/targets/milestones.ts @@ -8,7 +8,6 @@ import { MilestoneDocument, MilestoneSubscriptionDocument, } from "tachi-common"; -import { GetMilestoneForIDGuaranteed } from "utils/db"; import { EvaluatedGoalReturn, EvaluateGoalForUser, SubscribeToGoal } from "./goals"; const logger = CreateLogCtx(__filename); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 921e13d1d..8644fd9fe 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -2,7 +2,7 @@ import { exec } from "child_process"; import crypto from "crypto"; import { ONE_HOUR } from "lib/constants/time"; import { TachiConfig } from "lib/setup/config"; -import { Game, GamePTConfig, GetGameConfig, integer, Playtype, Playtypes } from "tachi-common"; +import { Game, GamePTConfig, GetGameConfig, integer, Playtype } from "tachi-common"; import { URL } from "url"; // https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js From 027797930d0803c270e7c1293e52a092a463e541 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 21:58:33 +0100 Subject: [PATCH 05/17] feat: notify users when their milestones change --- server/src/lib/notifications/notifications.ts | 38 ++++++++++++++----- server/src/lib/targets/milestones.ts | 12 ++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/server/src/lib/notifications/notifications.ts b/server/src/lib/notifications/notifications.ts index 74aaa4aac..5e5925f64 100644 --- a/server/src/lib/notifications/notifications.ts +++ b/server/src/lib/notifications/notifications.ts @@ -2,6 +2,21 @@ import db from "external/mongo/db"; import { integer, NotificationBody, NotificationDocument } from "tachi-common"; import { Random20Hex } from "utils/misc"; +function ConstructNotificationDoc( + title: string, + toUserID: integer, + body: NotificationBody +): NotificationDocument { + return { + title, + sentTo: toUserID, + read: false, + sentAt: Date.now(), + notifID: `N${Random20Hex()}`, + body, + }; +} + /** * Send a notification to a user. * @@ -9,17 +24,20 @@ import { Random20Hex } from "utils/misc"; * @param toUserID - The user to send the notification to. * @param body - The body of the notification. */ -export async function SendNotification(title: string, toUserID: integer, body: NotificationBody) { - const notification: NotificationDocument = { - title, - sentTo: toUserID, - read: false, - sentAt: Date.now(), - notifID: `N${Random20Hex()}`, - body, - }; +export function SendNotification(title: string, toUserID: integer, body: NotificationBody) { + const notification = ConstructNotificationDoc(title, toUserID, body); - await db.notifications.insert(notification); + return db.notifications.insert(notification); +} + +/** + * Send notifications to multiple users at once. This is more efficient than calling + * send notification in parallel. + */ +export function BulkSendNotification(title: string, toUserIDs: integer[], body: NotificationBody) { + const notifications = toUserIDs.map((e) => ConstructNotificationDoc(title, e, body)); + + return db.notifications.insert(notifications); } /** diff --git a/server/src/lib/targets/milestones.ts b/server/src/lib/targets/milestones.ts index e87306b0e..d2578a885 100644 --- a/server/src/lib/targets/milestones.ts +++ b/server/src/lib/targets/milestones.ts @@ -1,6 +1,7 @@ import db from "external/mongo/db"; import { SubscribeFailReasons } from "lib/constants/err-codes"; import CreateLogCtx from "lib/logger/logger"; +import { BulkSendNotification } from "lib/notifications/notifications"; import { GoalDocument, GoalSubscriptionDocument, @@ -299,6 +300,17 @@ export async function UpdateMilestoneSubscriptions(milestoneID: string) { logger.info( `Updating subscriptions for '${maybeMilestone.name}' resulted in ${newStuff} updates.` ); + + await BulkSendNotification( + `The milestone '${maybeMilestone.name}' has changed, You have been automatically subscribed to some new goals.`, + subscriptions.map((e) => e.userID), + { + type: "MILESTONE_CHANGED", + content: { + milestoneID, + }, + } + ); } return subscriptionResults; From e24d7d170533c52c124f7ac60dd572801d7500d6 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 22:09:50 +0100 Subject: [PATCH 06/17] feat: notify users when they have been set as a rival --- server/src/lib/rivals/rivals.ts | 64 ++++++++++++++++++++++++++++++++- server/src/utils/misc.ts | 10 ++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/server/src/lib/rivals/rivals.ts b/server/src/lib/rivals/rivals.ts index a770fcef7..a6a023eff 100644 --- a/server/src/lib/rivals/rivals.ts +++ b/server/src/lib/rivals/rivals.ts @@ -1,7 +1,12 @@ import db from "external/mongo/db"; import { SetRivalsFailReasons } from "lib/constants/err-codes"; +import CreateLogCtx from "lib/logger/logger"; +import { SendNotification } from "lib/notifications/notifications"; import { FormatGame, Game, integer, Playtype } from "tachi-common"; -import { GetUsersWithIDs } from "utils/user"; +import { ArrayDiff } from "utils/misc"; +import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "utils/user"; + +const logger = CreateLogCtx(__filename); /** * Retrieve all of a user's set rival IDs. @@ -72,6 +77,63 @@ export async function SetRivals( return SetRivalsFailReasons.RIVALS_HAVENT_PLAYED_GPT; } + const currentGameSettings = await db["game-settings"].findOne({ + userID, + game, + playtype, + }); + + if (!currentGameSettings) { + logger.severe( + `User ${userID} attempted to set rivals for ${FormatGame( + game, + playtype + )}, but doesn't have game settings. Was their account deleted in midair?` + ); + + throw new Error( + `User ${userID} attempted to set rivals for ${FormatGame( + game, + playtype + )}, but doesn't have game settings. Was their account deleted in midair?` + ); + } + + const newSubs = ArrayDiff(currentGameSettings.rivals, newRivals); + + const user = await GetUserWithIDGuaranteed(userID); + + await Promise.all( + newSubs.map(async (e) => { + const alreadyBeenPinged = await db.notifications.findOne({ + sentTo: e, + "body.type": "RIVALED_BY", + "body.content": { + userID, + game, + playtype, + }, + }); + + if (alreadyBeenPinged) { + return; + } + + return SendNotification( + `${user.username} just added you as a rival for ${FormatGame(game, playtype)}`, + e, + { + type: "RIVALED_BY", + content: { + userID, + game, + playtype, + }, + } + ); + }) + ); + return db["game-settings"].update( { userID, diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 8644fd9fe..bf5669231 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -206,3 +206,13 @@ export function FormatMaxDP(num: number, points = 2) { export function IsSupported(game: Game) { return TachiConfig.GAMES.includes(game); } + +/** + * Given two arrays, return all elements in arr2 that are not in arr1. + * @param left - The array of original elements. + * @param right - The right side of elements; everything in here not in left will be + * returned. + */ +export function ArrayDiff(left: T[], right: T[]) { + return right.filter((e) => !left.includes(e)); +} From 4b3074e48cce2c3e1c0b320ecf15d98229625c9a Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 22:14:57 +0100 Subject: [PATCH 07/17] refactor: create wrapper scripts for SetRivalNotification --- .../notifications/notification-wrappers.ts | 45 +++++++++++++++++++ server/src/lib/rivals/rivals.ts | 31 +------------ 2 files changed, 47 insertions(+), 29 deletions(-) create mode 100644 server/src/lib/notifications/notification-wrappers.ts diff --git a/server/src/lib/notifications/notification-wrappers.ts b/server/src/lib/notifications/notification-wrappers.ts new file mode 100644 index 000000000..eefa62127 --- /dev/null +++ b/server/src/lib/notifications/notification-wrappers.ts @@ -0,0 +1,45 @@ +import db from "external/mongo/db"; +import { FormatGame, Game, integer, Playtype, PublicUserDocument } from "tachi-common"; +import { SendNotification } from "./notifications"; + +/** + * A utility wrapper for sending the SET_RIVAL notification. + * + * @param toUserID - The user to send this notification to. + * @param fromUser - The user who rivalled them. + * @param game - The game they rivalled them on. + * @param playtype - The playtype they rivalled them on. + */ +export async function SendSetRivalNotification( + toUserID: integer, + fromUser: PublicUserDocument, + game: Game, + playtype: Playtype +) { + const alreadyBeenPinged = await db.notifications.findOne({ + sentTo: toUserID, + "body.type": "RIVALED_BY", + "body.content": { + userID: fromUser.id, + game, + playtype, + }, + }); + + if (alreadyBeenPinged) { + return; + } + + return SendNotification( + `${fromUser.username} just added you as a rival for ${FormatGame(game, playtype)}`, + toUserID, + { + type: "RIVALED_BY", + content: { + userID: fromUser.id, + game, + playtype, + }, + } + ); +} diff --git a/server/src/lib/rivals/rivals.ts b/server/src/lib/rivals/rivals.ts index a6a023eff..09de27a93 100644 --- a/server/src/lib/rivals/rivals.ts +++ b/server/src/lib/rivals/rivals.ts @@ -1,7 +1,7 @@ import db from "external/mongo/db"; import { SetRivalsFailReasons } from "lib/constants/err-codes"; import CreateLogCtx from "lib/logger/logger"; -import { SendNotification } from "lib/notifications/notifications"; +import { SendSetRivalNotification } from "lib/notifications/notification-wrappers"; import { FormatGame, Game, integer, Playtype } from "tachi-common"; import { ArrayDiff } from "utils/misc"; import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "utils/user"; @@ -104,34 +104,7 @@ export async function SetRivals( const user = await GetUserWithIDGuaranteed(userID); await Promise.all( - newSubs.map(async (e) => { - const alreadyBeenPinged = await db.notifications.findOne({ - sentTo: e, - "body.type": "RIVALED_BY", - "body.content": { - userID, - game, - playtype, - }, - }); - - if (alreadyBeenPinged) { - return; - } - - return SendNotification( - `${user.username} just added you as a rival for ${FormatGame(game, playtype)}`, - e, - { - type: "RIVALED_BY", - content: { - userID, - game, - playtype, - }, - } - ); - }) + newSubs.map((toUserID) => SendSetRivalNotification(toUserID, user, game, playtype)) ); return db["game-settings"].update( From b65bdcb8f3712ebeceb936a0ee205163debd6893 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 22:18:51 +0100 Subject: [PATCH 08/17] perf: add indexes to notifications db --- server/src/external/mongo/indexes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/external/mongo/indexes.ts b/server/src/external/mongo/indexes.ts index ad1b12b22..ccf13fc3b 100644 --- a/server/src/external/mongo/indexes.ts +++ b/server/src/external/mongo/indexes.ts @@ -119,6 +119,7 @@ const staticIndexes: Partial> = { "arc-saved-profiles": [index({ userID: 1, forImportType: 1 }, UNIQUE)], "score-blacklist": [index({ scoreID: 1 }, UNIQUE)], migrations: [index({ migrationID: 1 }, UNIQUE)], + notifications: [index({ notifID: 1 }, UNIQUE), index({ sentTo: 1, sentAt: 1 })], }; const indexes: Partial> = staticIndexes; From fb944b6d0da292f235b6343b3a7ad8d423d1b427 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 22:39:19 +0100 Subject: [PATCH 09/17] feat: add notification endpoints --- .../v1/users/_userID/notifications/router.ts | 83 +++++++++++++++++++ .../router/api/v1/users/_userID/router.ts | 2 + 2 files changed, 85 insertions(+) create mode 100644 server/src/server/router/api/v1/users/_userID/notifications/router.ts diff --git a/server/src/server/router/api/v1/users/_userID/notifications/router.ts b/server/src/server/router/api/v1/users/_userID/notifications/router.ts new file mode 100644 index 000000000..bb10ed655 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/notifications/router.ts @@ -0,0 +1,83 @@ +import { Router } from "express"; +import db from "external/mongo/db"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; +import { RequireSelfRequestFromUser } from "../middleware"; + +const router: Router = Router({ mergeParams: true }); + +// Notifications aren't really for anyone else to interact with. Only the requesting user +// should be able to see their notifications. +router.use(RequireSelfRequestFromUser); + +/** + * Return all of this user's notifications, read. + */ +router.get("/", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + const notifs = await db.notifications.find({ + sentTo: user.id, + }); + + return res.status(200).json({ + success: true, + description: `Found ${notifs.length} notifications.`, + body: notifs, + }); +}); + +/** + * Mark all notifications in this user's inbox as read. + * + * @name POST /api/v1/users/:userID/notifications/mark-all-read + */ +router.post("/mark-all-read", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + const updateRes = await db.notifications.update( + { + sentTo: user.id, + }, + { + $set: { read: true }, + }, + { + multi: true, + } + ); + + return res.status(200).json({ + success: true, + description: `Marked ${updateRes.n} notifications as read.`, + body: {}, + }); +}); + +/** + * Delete a notification from your inbox. + * + * @name DELETE /api/v1/users/:userID/notifications/:notifID + */ +router.delete("/:notifID", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + const isTheirNotif = await db.notifications.findOne({ + sentTo: user.id, + notifID: req.params.notifID, + }); + + if (!isTheirNotif) { + return res.status(404).json({ + success: false, + description: `This notification does not exist, is not yours, or was already deleted.`, + }); + } + + return res.status(200).json({ + success: true, + description: `Deleted notification.`, + body: isTheirNotif, + }); +}); + +export default router; diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index 3c40a8e3a..6721ab7b4 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -25,6 +25,7 @@ import { GetUserFromParam, RequireSelfRequestFromUser } from "./middleware"; import pfpRouter from "./pfp/router"; import settingsRouter from "./settings/router"; import importsRouter from "./imports/router"; +import notifsRouter from "./notifications/router"; const logger = CreateLogCtx(__filename); @@ -394,5 +395,6 @@ router.use("/settings", settingsRouter); router.use("/api-tokens", apiTokensRouter); router.use("/invites", invitesRouter); router.use("/imports", importsRouter); +router.use("/notifications", notifsRouter); export default router; From 880d1f44f917d74ef5ab3ef42587827e41d6f225 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 22:42:29 +0100 Subject: [PATCH 10/17] feat: make userID/notifications return in most-recent order --- .../v1/users/_userID/notifications/router.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/server/src/server/router/api/v1/users/_userID/notifications/router.ts b/server/src/server/router/api/v1/users/_userID/notifications/router.ts index bb10ed655..646ddc85f 100644 --- a/server/src/server/router/api/v1/users/_userID/notifications/router.ts +++ b/server/src/server/router/api/v1/users/_userID/notifications/router.ts @@ -10,14 +10,23 @@ const router: Router = Router({ mergeParams: true }); router.use(RequireSelfRequestFromUser); /** - * Return all of this user's notifications, read. + * Return all of this user's notifications, this is sorted on most recently sent first. + * + * @name GET /api/v1/users/:userID/notifications */ router.get("/", async (req, res) => { const user = req[SYMBOL_TachiData]!.requestedUser!; - const notifs = await db.notifications.find({ - sentTo: user.id, - }); + const notifs = await db.notifications.find( + { + sentTo: user.id, + }, + { + sort: { + sentAt: -1, + }, + } + ); return res.status(200).json({ success: true, From 0b84416c299c7c048f1a2521cb7a4d2e0727a85c Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 23:09:24 +0100 Subject: [PATCH 11/17] test: add tests for userID/Notifications --- .../_userID/notifications/router.test.ts | 65 +++++++++++++++++++ server/src/test-utils/misc.ts | 6 ++ .../src/test-utils/mock-db/notifications.json | 1 + server/src/test-utils/test-data.ts | 15 +++++ 4 files changed, 87 insertions(+) create mode 100644 server/src/server/router/api/v1/users/_userID/notifications/router.test.ts create mode 100644 server/src/test-utils/mock-db/notifications.json diff --git a/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts b/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts new file mode 100644 index 000000000..9058faa82 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts @@ -0,0 +1,65 @@ +import db from "external/mongo/db"; +import { NotificationDocument } from "tachi-common"; +import t from "tap"; +import { CreateFakeAuthCookie } from "test-utils/fake-auth"; +import { mkFakeNotification, mkFakeUser } from "test-utils/misc"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; + +t.test("GET /api/v1/users/:userID/notifications", async (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(() => + Promise.all([ + db.notifications.insert([ + mkFakeNotification({ notifID: "read", read: true, sentAt: 2 }), + mkFakeNotification({ notifID: "unread", read: false, sentAt: 3 }), + mkFakeNotification({ notifID: "not_ours", sentTo: 2, sentAt: 4 }), + ]), + db.users.insert(mkFakeUser(2)), + ]) + ); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should return all of this user's notifications, read or unread.", async (t) => { + const res = await mockApi.get("/api/v1/users/1/notifications").set("Cookie", cookie); + + t.equal(res.statusCode, 200, "Should return 200"); + + t.strictSame( + res.body.body.map((e: NotificationDocument) => e.notifID), + ["unread", "read"], + "Should return the exact notifications we expected (in most-recent order), and no more." + ); + + t.end(); + }); + + t.test("Should return 401 if not authenticated.", async (t) => { + const res = await mockApi.get("/api/v1/users/1/notifications"); + + t.equal(res.statusCode, 401); + + t.end(); + }); + + t.test("Should return 403 if authed as someone else.", async (t) => { + const res = await mockApi.get("/api/v1/users/2/notifications").set("Cookie", cookie); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Should return 403 if authed as right user without self-key", async (t) => { + const res = await mockApi + .get("/api/v1/users/1/notifications") + .set("Authorization", "Bearer fake_api_token"); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/test-utils/misc.ts b/server/src/test-utils/misc.ts index cf65483a2..e487dc982 100644 --- a/server/src/test-utils/misc.ts +++ b/server/src/test-utils/misc.ts @@ -3,6 +3,7 @@ import { Game, ImportDocument, integer, + NotificationDocument, PBScoreDocument, Playtype, PublicUserDocument, @@ -12,6 +13,7 @@ import { import { FakeGameSettings, FakeImport, + FakeNotification, FakeOtherUser, TestingIIDXSPScore, TestingIIDXSPScorePB, @@ -94,3 +96,7 @@ export function mkFakeScoreSDVX(modifant: Partial> export function mkFakePBIIDXSP(modifant: Partial> = {}) { return dmf(TestingIIDXSPScorePB, modifant); } + +export function mkFakeNotification(modifant: Partial = {}) { + return dmf(FakeNotification, modifant); +} diff --git a/server/src/test-utils/mock-db/notifications.json b/server/src/test-utils/mock-db/notifications.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/server/src/test-utils/mock-db/notifications.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index fa584d27b..f4810cbc0 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -22,6 +22,7 @@ import { ImportDocument, MilestoneDocument, MilestoneSubscriptionDocument, + NotificationDocument, PBScoreDocument, PublicUserDocument, ScoreDocument, @@ -685,3 +686,17 @@ export const FakeImport: ImportDocument = { userID: 1, userIntent: false, }; + +export const FakeNotification: NotificationDocument = { + title: "fake notif", + notifID: "fake_notif", + read: false, + sentAt: 1000, + sentTo: 1, + body: { + type: "MILESTONE_CHANGED", + content: { + milestoneID: "a", + }, + }, +}; From db990940acdeaf6c8e55e508368dd106db7b1f9a Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 23:13:08 +0100 Subject: [PATCH 12/17] test: add tests for POST userID/notif/mark-all-read --- .../_userID/notifications/router.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts b/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts index 9058faa82..f89d629ee 100644 --- a/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts @@ -63,3 +63,69 @@ t.test("GET /api/v1/users/:userID/notifications", async (t) => { t.end(); }); + +t.test("POST /api/v1/users/:userID/notifications/mark-all-read", async (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(() => + Promise.all([ + db.notifications.insert([ + mkFakeNotification({ notifID: "read", read: true, sentAt: 2 }), + mkFakeNotification({ notifID: "unread", read: false, sentAt: 3 }), + mkFakeNotification({ notifID: "unread_2", read: false, sentAt: 4 }), + mkFakeNotification({ notifID: "not_ours", sentTo: 2, sentAt: 5 }), + ]), + db.users.insert(mkFakeUser(2)), + ]) + ); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should mark all of a user's notifications as read.", async (t) => { + const res = await mockApi + .post("/api/v1/users/1/notifications/mark-all-read") + .set("Cookie", cookie); + + t.equal(res.statusCode, 200, "Should return 200."); + + t.strictSame(res.body.body, {}, "Should have no response body."); + + const dbRes = await db.notifications.findOne({ + sentTo: 1, + read: false, + }); + + t.equal(dbRes, null, "Should not find any unread messages in the users inbox."); + + t.end(); + }); + + t.test("Should return 401 if not authenticated.", async (t) => { + const res = await mockApi.post("/api/v1/users/1/notifications/mark-all-read"); + + t.equal(res.statusCode, 401); + + t.end(); + }); + + t.test("Should return 403 if authed as someone else.", async (t) => { + const res = await mockApi + .post("/api/v1/users/2/notifications/mark-all-read") + .set("Cookie", cookie); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Should return 403 if authed as right user without self-key", async (t) => { + const res = await mockApi + .post("/api/v1/users/1/notifications/mark-all-read") + .set("Authorization", "Bearer fake_api_token"); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.end(); +}); From 5ccd69e6051c433baa91e65bfbf42236648da61b Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 23:20:22 +0100 Subject: [PATCH 13/17] feat: replace delete notif with empty inbox endpoint --- .../v1/users/_userID/notifications/router.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/server/src/server/router/api/v1/users/_userID/notifications/router.ts b/server/src/server/router/api/v1/users/_userID/notifications/router.ts index 646ddc85f..bbab0de19 100644 --- a/server/src/server/router/api/v1/users/_userID/notifications/router.ts +++ b/server/src/server/router/api/v1/users/_userID/notifications/router.ts @@ -63,29 +63,21 @@ router.post("/mark-all-read", async (req, res) => { }); /** - * Delete a notification from your inbox. + * Clear all notifications from your inbox. * - * @name DELETE /api/v1/users/:userID/notifications/:notifID + * @name POST /api/v1/users/:userID/notifications/delete-all */ -router.delete("/:notifID", async (req, res) => { +router.post("/delete-all", async (req, res) => { const user = req[SYMBOL_TachiData]!.requestedUser!; - const isTheirNotif = await db.notifications.findOne({ + const deleted = await db.notifications.remove({ sentTo: user.id, - notifID: req.params.notifID, }); - if (!isTheirNotif) { - return res.status(404).json({ - success: false, - description: `This notification does not exist, is not yours, or was already deleted.`, - }); - } - return res.status(200).json({ success: true, - description: `Deleted notification.`, - body: isTheirNotif, + description: `Deleted ${deleted.deletedCount ?? 0} notification(s).`, + body: {}, }); }); From cfa0d9d5e100d5b9a54156f10542a280044b81e7 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 23:20:42 +0100 Subject: [PATCH 14/17] test: add tests for empty inbox endpoint --- .../_userID/notifications/router.test.ts | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts b/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts index f89d629ee..d6439dbf0 100644 --- a/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts @@ -64,7 +64,7 @@ t.test("GET /api/v1/users/:userID/notifications", async (t) => { t.end(); }); -t.test("POST /api/v1/users/:userID/notifications/mark-all-read", async (t) => { +t.test("POST /api/v1/users/:userID/notifications/delete-all", async (t) => { t.beforeEach(ResetDBState); t.beforeEach(() => Promise.all([ @@ -129,3 +129,68 @@ t.test("POST /api/v1/users/:userID/notifications/mark-all-read", async (t) => { t.end(); }); + +t.test("POST /api/v1/users/:userID/notifications/delete-all", async (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(() => + Promise.all([ + db.notifications.insert([ + mkFakeNotification({ notifID: "read", read: true, sentAt: 2 }), + mkFakeNotification({ notifID: "unread", read: false, sentAt: 3 }), + mkFakeNotification({ notifID: "unread_2", read: false, sentAt: 4 }), + mkFakeNotification({ notifID: "not_ours", sentTo: 2, sentAt: 5 }), + ]), + db.users.insert(mkFakeUser(2)), + ]) + ); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should empty a user's notification inbox.", async (t) => { + const res = await mockApi + .post("/api/v1/users/1/notifications/delete-all") + .set("Cookie", cookie); + + t.equal(res.statusCode, 200, "Should return 200."); + + t.strictSame(res.body.body, {}, "Should have no response body."); + + const dbRes = await db.notifications.findOne({ + sentTo: 1, + }); + + t.equal(dbRes, null, "Should not find any messages in the users inbox."); + + t.end(); + }); + + t.test("Should return 401 if not authenticated.", async (t) => { + const res = await mockApi.post("/api/v1/users/1/notifications/delete-all"); + + t.equal(res.statusCode, 401); + + t.end(); + }); + + t.test("Should return 403 if authed as someone else.", async (t) => { + const res = await mockApi + .post("/api/v1/users/2/notifications/delete-all") + .set("Cookie", cookie); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Should return 403 if authed as right user without self-key", async (t) => { + const res = await mockApi + .post("/api/v1/users/1/notifications/delete-all") + .set("Authorization", "Bearer fake_api_token"); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.end(); +}); From f05da38163693666c1980f0cb802ab74c350785e Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 23:22:53 +0100 Subject: [PATCH 15/17] feat: add protection against accidental instant-notif-disregarding --- .../api/v1/users/_userID/notifications/router.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/src/server/router/api/v1/users/_userID/notifications/router.ts b/server/src/server/router/api/v1/users/_userID/notifications/router.ts index bbab0de19..45a017184 100644 --- a/server/src/server/router/api/v1/users/_userID/notifications/router.ts +++ b/server/src/server/router/api/v1/users/_userID/notifications/router.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import db from "external/mongo/db"; import { SYMBOL_TachiData } from "lib/constants/tachi"; +import { ONE_SECOND } from "lib/constants/time"; import { RequireSelfRequestFromUser } from "../middleware"; const router: Router = Router({ mergeParams: true }); @@ -46,6 +47,13 @@ router.post("/mark-all-read", async (req, res) => { const updateRes = await db.notifications.update( { sentTo: user.id, + // insanely rare edge case, but if someone submits an empty-my-inbox + // request, and then gets a notif at the same time, they run the risk of + // emptying something so immediately they don't actually ever see it. + // This hack mitigates that, slightly. + sentAt: { + $lt: Date.now() - ONE_SECOND * 2, + }, }, { $set: { read: true }, @@ -72,6 +80,10 @@ router.post("/delete-all", async (req, res) => { const deleted = await db.notifications.remove({ sentTo: user.id, + // See mark-all-read for an explanation of this behaviour. + sentAt: { + $lt: Date.now() - ONE_SECOND * 2, + }, }); return res.status(200).json({ From 9f4277781f5413599df3fa8a6341929ddaa26125 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 23:39:14 +0100 Subject: [PATCH 16/17] style: remove unused functions --- server/src/lib/notifications/notifications.ts | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/server/src/lib/notifications/notifications.ts b/server/src/lib/notifications/notifications.ts index 5e5925f64..d655cd6bd 100644 --- a/server/src/lib/notifications/notifications.ts +++ b/server/src/lib/notifications/notifications.ts @@ -39,33 +39,3 @@ export function BulkSendNotification(title: string, toUserIDs: integer[], body: return db.notifications.insert(notifications); } - -/** - * Mark a notification as read. This is notably different to deleting a notification, - * and is typically done when the user acknowledges the existence of the notification. - */ -export function ReadNotification(notifID: string) { - return db.notifications.update( - { notifID }, - { - $set: { read: true }, - } - ); -} - -/** - * Mark all of a user's notifications as read. - */ -export function ReadUsersNotifications(userID: integer) { - return db.notifications.update( - { sentTo: userID }, - { - $set: { read: true }, - }, - { multi: true } - ); -} - -export function DeleteNotification(notifID: string) { - return db.notifications.remove({ notifID }); -} From ab2ef791817433f741246ee65cc0b49f43aa00c1 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Sun, 24 Apr 2022 23:41:22 +0100 Subject: [PATCH 17/17] test: add tests for lib/notifications --- .../lib/notifications/notifications.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 server/src/lib/notifications/notifications.test.ts diff --git a/server/src/lib/notifications/notifications.test.ts b/server/src/lib/notifications/notifications.test.ts new file mode 100644 index 000000000..37d57beca --- /dev/null +++ b/server/src/lib/notifications/notifications.test.ts @@ -0,0 +1,76 @@ +import db from "external/mongo/db"; +import t from "tap"; +import ResetDBState from "test-utils/resets"; +import { BulkSendNotification, SendNotification } from "./notifications"; + +t.test("#SendNotification", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should send a notification.", async (t) => { + await SendNotification("title", 1, { + type: "MILESTONE_CHANGED", + content: { milestoneID: "foo" }, + }); + + const dbRes = await db.notifications.findOne({ + sentTo: 1, + }); + + t.hasStrict(dbRes, { + sentTo: 1, + title: "title", + read: false, + body: { + type: "MILESTONE_CHANGED", + content: { milestoneID: "foo" }, + }, + }); + + t.end(); + }); + + t.end(); +}); + +t.test("#BulkSendNotification", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should send multiple notifications.", async (t) => { + await BulkSendNotification("title", [1, 2], { + type: "MILESTONE_CHANGED", + content: { milestoneID: "foo" }, + }); + + const dbRes = await db.notifications.findOne({ + sentTo: 1, + }); + + t.hasStrict(dbRes, { + sentTo: 1, + title: "title", + read: false, + body: { + type: "MILESTONE_CHANGED", + content: { milestoneID: "foo" }, + }, + }); + + const dbRes2 = await db.notifications.findOne({ + sentTo: 2, + }); + + t.hasStrict(dbRes2, { + sentTo: 2, + title: "title", + read: false, + body: { + type: "MILESTONE_CHANGED", + content: { milestoneID: "foo" }, + }, + }); + + t.end(); + }); + + t.end(); +});