Files
drop/server/internal/notifications/index.ts
luzpaz a435ead916 Fix various typos (#156)
Found via `codespell -q 3 -S "./yarn.lock" -L pris`
2025-08-01 21:53:31 +10:00

155 lines
4.2 KiB
TypeScript

/*
The notification system handles the receiving, creation and sending of notifications in Drop
Design goals:
1. Nonce-based notifications; notifications should only be created once
2. Real-time; use websocket listeners to keep clients up-to-date
*/
import type { NotificationModel } from "~/prisma/client/models";
import prisma from "../db/database";
import type { GlobalACL } from "../acls";
// type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
// TODO: document notification action format
export type NotificationCreateArgs = Pick<
NotificationModel,
"title" | "description" | "actions" | "nonce"
> & { acls: Array<GlobalACL> };
class NotificationSystem {
// userId to acl to listenerId
private listeners = new Map<
string,
Map<
string,
{ callback: (notification: NotificationModel) => void; acls: GlobalACL[] }
>
>();
listen(
userId: string,
acls: Array<GlobalACL>,
id: string,
callback: (notification: NotificationModel) => void,
) {
if (!this.listeners.has(userId)) this.listeners.set(userId, new Map());
// eslint-disable-next-line @typescript-eslint/no-extra-non-null-assertion
this.listeners.get(userId)!!.set(id, { callback, acls });
this.catchupListener(userId, id);
}
unlisten(userId: string, id: string) {
this.listeners.get(userId)?.delete(id);
}
private async catchupListener(userId: string, id: string) {
const listener = this.listeners.get(userId)?.get(id);
if (!listener)
throw new Error("Failed to catch-up listener: callback does not exist");
const notifications = await prisma.notification.findMany({
where: { userId: userId, acls: { hasSome: listener.acls } },
orderBy: {
created: "asc", // Oldest first, because they arrive in reverse order
},
});
for (const notification of notifications) {
await listener.callback(notification);
}
}
private async pushNotification(
userId: string,
notification: NotificationModel,
) {
for (const [_, listener] of this.listeners.get(userId) ?? []) {
const hasSome =
notification.acls.findIndex(
(e) => listener.acls.findIndex((v) => v === e) != -1,
) != -1;
if (hasSome) await listener.callback(notification);
}
}
async push(userId: string, notificationCreateArgs: NotificationCreateArgs) {
if (!notificationCreateArgs.nonce)
throw new Error("No nonce in notificationCreateArgs");
const notification = await prisma.notification.upsert({
where: {
userId_nonce: {
nonce: notificationCreateArgs.nonce,
userId,
},
},
update: {
// we don't need to update the userid right?
// userId: userId,
...notificationCreateArgs,
},
create: {
userId,
...notificationCreateArgs,
},
});
await this.pushNotification(userId, notification);
}
/**
* Internal call to batch push notifications to many users
* @param notificationCreateArgs
* @param users
*/
private async _pushMany(
notificationCreateArgs: NotificationCreateArgs,
users: { id: string }[],
) {
const res: Promise<void>[] = [];
for (const user of users) {
res.push(this.push(user.id, notificationCreateArgs));
}
// wait for all notifications to pass
await Promise.all(res);
}
/**
* Send a notification to all users
* @param notificationCreateArgs
*/
async pushAll(notificationCreateArgs: NotificationCreateArgs) {
const users = await prisma.user.findMany({
where: { id: { not: "system" } },
select: {
id: true,
},
});
await this._pushMany(notificationCreateArgs, users);
}
/**
* Send a notification to all system level users
* @param notificationCreateArgs
* @returns
*/
async systemPush(notificationCreateArgs: NotificationCreateArgs) {
const users = await prisma.user.findMany({
where: {
id: { not: "system" },
// no reason to send to any users other then admins rn
admin: true,
},
select: {
id: true,
},
});
await this._pushMany(notificationCreateArgs, users);
}
}
export const notificationSystem = new NotificationSystem();
export default notificationSystem;