diff --git a/server/package.json b/server/package.json index 195fac104..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.10.2", + "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 69996cbde..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.10.2 + 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.10.2 + 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.10.2: - resolution: {integrity: sha512-hU3qQUacbL9rbiFibePzKeP+WuDXyKgcOlGNEXgZZIWZ3cpeEwFm6tk8PtVAoQPa5nPfXobmEmWYN4eGOQoKjA==} + /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 diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index ccb60cf6f..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,8 +38,6 @@ import { UGPTSettings, UserGameStats, UserGameStatsSnapshot, - GoalSubscriptionDocument, - MilestoneSubscriptionDocument, UserSettings, } from "tachi-common"; import { GetMillisecondsSince } from "utils/misc"; @@ -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/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; 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/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(); +}); diff --git a/server/src/lib/notifications/notifications.ts b/server/src/lib/notifications/notifications.ts new file mode 100644 index 000000000..d655cd6bd --- /dev/null +++ b/server/src/lib/notifications/notifications.ts @@ -0,0 +1,41 @@ +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. + * + * @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 function SendNotification(title: string, toUserID: integer, body: NotificationBody) { + const notification = ConstructNotificationDoc(title, toUserID, body); + + 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/rivals/rivals.ts b/server/src/lib/rivals/rivals.ts index a770fcef7..09de27a93 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 { SendSetRivalNotification } from "lib/notifications/notification-wrappers"; 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,36 @@ 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((toUserID) => SendSetRivalNotification(toUserID, user, game, playtype)) + ); + return db["game-settings"].update( { userID, diff --git a/server/src/lib/targets/milestones.ts b/server/src/lib/targets/milestones.ts index 10a31c98a..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, @@ -8,7 +9,6 @@ import { MilestoneDocument, MilestoneSubscriptionDocument, } from "tachi-common"; -import { GetMilestoneForIDGuaranteed } from "utils/db"; import { EvaluatedGoalReturn, EvaluateGoalForUser, SubscribeToGoal } from "./goals"; const logger = CreateLogCtx(__filename); @@ -300,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; 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..d6439dbf0 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/notifications/router.test.ts @@ -0,0 +1,196 @@ +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(); +}); + +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 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(); +}); + +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(); +}); 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..45a017184 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/notifications/router.ts @@ -0,0 +1,96 @@ +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 }); + +// 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, 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, + }, + { + sort: { + sentAt: -1, + }, + } + ); + + 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, + // 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 }, + }, + { + multi: true, + } + ); + + return res.status(200).json({ + success: true, + description: `Marked ${updateRes.n} notifications as read.`, + body: {}, + }); +}); + +/** + * Clear all notifications from your inbox. + * + * @name POST /api/v1/users/:userID/notifications/delete-all + */ +router.post("/delete-all", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + 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({ + success: true, + description: `Deleted ${deleted.deletedCount ?? 0} notification(s).`, + body: {}, + }); +}); + +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; 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", + }, + }, +}; diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 921e13d1d..bf5669231 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 @@ -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)); +}