Merge pull request #710 from TNG-dev:zkldi/issue-674

feat: notification engine
This commit is contained in:
zkldi
2022-04-24 23:47:34 +01:00
committed by GitHub
16 changed files with 547 additions and 10 deletions
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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
+4 -2
View File
@@ -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<RecentlyViewedFolderDocument>("recent-folder-views"),
"milestone-sets": monkDB.get<MilestoneSetDocument>("milestone-sets"),
migrations: monkDB.get<MigrationDocument>("migrations"),
notifications: monkDB.get<NotificationDocument>("notifications"),
};
export type StaticDatabases = Exclude<keyof typeof db, "songs" | "charts">;
+1
View File
@@ -119,6 +119,7 @@ const staticIndexes: Partial<Record<Databases, Index[]>> = {
"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<Record<Databases, Index[]>> = staticIndexes;
@@ -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,
},
}
);
}
@@ -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();
});
@@ -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);
}
+36 -1
View File
@@ -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,
+12 -1
View File
@@ -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;
@@ -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();
});
@@ -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;
@@ -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;
+6
View File
@@ -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<ScoreDocument<"sdvx:Single">>
export function mkFakePBIIDXSP(modifant: Partial<PBScoreDocument<"iidx:SP">> = {}) {
return dmf(TestingIIDXSPScorePB, modifant);
}
export function mkFakeNotification(modifant: Partial<NotificationDocument> = {}) {
return dmf(FakeNotification, modifant);
}
@@ -0,0 +1 @@
[]
+15
View File
@@ -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",
},
},
};
+11 -1
View File
@@ -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<T>(left: T[], right: T[]) {
return right.filter((e) => !left.includes(e));
}