diff --git a/client/src/components/notifications/NotificationRow.tsx b/client/src/components/notifications/NotificationRow.tsx
index b8fd28bb2..7633d61eb 100644
--- a/client/src/components/notifications/NotificationRow.tsx
+++ b/client/src/components/notifications/NotificationRow.tsx
@@ -28,9 +28,14 @@ export default function NotificationRow({ notif }: { notif: NotificationDocument
function NotifToURL(notif: NotificationDocument) {
switch (notif.body.type) {
- case "QUEST_CHANGED":
- return "/idkyet";
+ case "QUEST_CHANGED": {
+ const { game, playtype, questID } = notif.body.content;
+
+ return `/dashboard/games/${game}/${playtype}/quests/${questID}`;
+ }
case "RIVALED_BY":
return `/dashboard/users/${notif.body.content.userID}/games/${notif.body.content.game}/${notif.body.content.playtype}`;
+ case "SITE_ANNOUNCEMENT":
+ return `#`;
}
}
diff --git a/client/src/context/NotificationsContext.tsx b/client/src/context/NotificationsContext.tsx
new file mode 100644
index 000000000..b4dba1ae2
--- /dev/null
+++ b/client/src/context/NotificationsContext.tsx
@@ -0,0 +1,49 @@
+import { APIFetchV1 } from "util/api";
+import React, { createContext, useContext, useEffect, useState } from "react";
+import { NotificationDocument } from "tachi-common";
+import { JustChildren } from "types/react";
+import { UserContext } from "./UserContext";
+
+export const NotificationsContext = createContext<{
+ notifications: Array
;
+ reload: () => Promise;
+}>({
+ notifications: [],
+ // eslint-disable-next-line require-await
+ reload: async () => void 0,
+});
+
+export function NotificationsContextProvider({ children }: JustChildren) {
+ const { user } = useContext(UserContext);
+
+ const [notifications, setNotifications] = useState>([]);
+
+ const reload = async () => {
+ if (!user) {
+ setNotifications([]);
+ return;
+ }
+
+ await APIFetchV1>(`/users/${user.id}/notifications`).then(
+ (r) => {
+ if (!r.success) {
+ setNotifications([]);
+ return;
+ }
+
+ setNotifications(r.body);
+ }
+ );
+ };
+
+ // fetch the target subscriptions from the api.
+ useEffect(() => {
+ reload();
+ }, [user]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/common/src/types.ts b/common/src/types.ts
index 90b3c8d4a..c9731343e 100644
--- a/common/src/types.ts
+++ b/common/src/types.ts
@@ -1571,6 +1571,8 @@ export type NotificationBody =
type: "QUEST_CHANGED"; // Emitted when a quest the user is subscribed to changed.
content: {
questID: string;
+ game: Game;
+ playtype: Playtype;
};
}
| {
@@ -1580,6 +1582,11 @@ export type NotificationBody =
game: Game;
playtype: Playtype;
};
+ }
+ | {
+ type: "SITE_ANNOUNCEMENT"; // Emitted as a site announcement
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ content: {};
};
export type NotificationDocument = BaseNotification & {
diff --git a/server/src/lib/notifications/notification-wrappers.ts b/server/src/lib/notifications/notification-wrappers.ts
index 05c3e872d..e3d1d736c 100644
--- a/server/src/lib/notifications/notification-wrappers.ts
+++ b/server/src/lib/notifications/notification-wrappers.ts
@@ -1,4 +1,4 @@
-import { SendNotification } from "./notifications";
+import { BulkSendNotification, SendNotification } from "./notifications";
import db from "external/mongo/db";
import { FormatGame } from "tachi-common";
import type { Game, integer, Playtype, UserDocument } from "tachi-common";
@@ -44,3 +44,27 @@ export async function SendSetRivalNotification(
}
);
}
+
+export async function SendSiteAnnouncementNotification(
+ title: string,
+ maybeGame?: Game,
+ maybePlaytype?: Playtype
+) {
+ let toUserIDs = [];
+
+ if (maybeGame && maybePlaytype) {
+ toUserIDs = (
+ await db["game-stats"].find(
+ { game: maybeGame, playtype: maybePlaytype },
+ { projection: { userID: 1 } }
+ )
+ ).map((e) => e.userID);
+ } else {
+ toUserIDs = (await db.users.find({}, { projection: { id: 1 } })).map((e) => e.id);
+ }
+
+ return BulkSendNotification(title, toUserIDs, {
+ type: "SITE_ANNOUNCEMENT",
+ content: {},
+ });
+}
diff --git a/server/src/lib/targets/quests.ts b/server/src/lib/targets/quests.ts
index 4b91d1598..76053d251 100644
--- a/server/src/lib/targets/quests.ts
+++ b/server/src/lib/targets/quests.ts
@@ -319,6 +319,8 @@ export async function UpdateQuestSubscriptions(questID: string) {
type: "QUEST_CHANGED",
content: {
questID,
+ game: maybeQuest.game,
+ playtype: maybeQuest.playtype,
},
}
);
diff --git a/server/src/server/router/api/v1/admin/router.ts b/server/src/server/router/api/v1/admin/router.ts
index 0c70414c3..ba2a9191d 100644
--- a/server/src/server/router/api/v1/admin/router.ts
+++ b/server/src/server/router/api/v1/admin/router.ts
@@ -4,6 +4,7 @@ import db from "external/mongo/db";
import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi";
import { ONE_MINUTE } from "lib/constants/time";
import CreateLogCtx, { ChangeRootLogLevel, GetLogLevel } from "lib/logger/logger";
+import { SendSiteAnnouncementNotification } from "lib/notifications/notification-wrappers";
import { DeleteMultipleScores, DeleteScore } from "lib/score-mutation/delete-scores";
import { ServerConfig, TachiConfig } from "lib/setup/config";
import p from "prudence";
@@ -300,4 +301,46 @@ router.post("/recalc", async (req, res) => {
});
});
+/**
+ * Send an announcement to the site.
+ *
+ * @name POST /api/v1/admin/announcement
+ */
+router.post(
+ "/announcement",
+ prValidate({
+ game: p.optional(p.isIn(TachiConfig.GAMES)),
+ playtype: "*string",
+ title: "string",
+ }),
+ async (req, res) => {
+ const { game, playtype, title } = req.safeBody as {
+ game?: Game;
+ playtype?: string;
+ title: string;
+ };
+
+ let maybePlaytype: Playtype | undefined;
+
+ if (game && playtype) {
+ if (!IsValidPlaytype(game, playtype)) {
+ return res.status(400).json({
+ success: false,
+ description: `Invalid playtype '${playtype}' for game '${game}'.`,
+ });
+ }
+
+ maybePlaytype = playtype;
+ }
+
+ await SendSiteAnnouncementNotification(title, game, maybePlaytype);
+
+ return res.status(200).json({
+ success: true,
+ description: `Sent notification '${title}'.`,
+ body: {},
+ });
+ }
+);
+
export default router;