mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-11 08:48:15 +03:00
Merge pull request #710 from TNG-dev:zkldi/issue-674
feat: notification engine
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
Generated
+4
-4
@@ -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
|
||||
|
||||
Vendored
+4
-2
@@ -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">;
|
||||
|
||||
Vendored
+1
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
[]
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user