mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-09 20:12:10 +10:00
feat(notifications): added notification system w/ interwoven refactoring
This commit is contained in:
@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<div class="transition inline-flex items-center justify-center cursor-pointer rounded-sm px-4 py-2 bg-zinc-900 text-zinc-600 hover:bg-zinc-800 hover:text-zinc-300 relative">
|
||||
<slot />
|
||||
<div v-if="props.notifications !== undefined"
|
||||
class="text-zinc-900 absolute top-0 right-0 translate-x-[30%] translate-y-[-30%] text-xs bg-blue-300 rounded-full w-3.5 h-3.5 text-center">
|
||||
{{ props.notifications }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
notifications?: number
|
||||
}>();
|
||||
|
||||
|
||||
</script>
|
||||
57
components/Notification.vue
Normal file
57
components/Notification.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="pointer-events-auto w-full bg-zinc-950/40 rounded">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="w-0 flex-1 pt-0.5">
|
||||
<p class="text-sm font-medium text-zinc-100">
|
||||
{{ notification.title }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-zinc-600 line-clamp-3">
|
||||
{{ notification.description }}
|
||||
</p>
|
||||
<div
|
||||
v-if="notification.actions.length > 0"
|
||||
class="mt-3 flex space-x-7"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-white text-sm font-medium text-blue-600 hover:text-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
<!-- todo -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex shrink-0">
|
||||
<button
|
||||
@click="() => deleteMe()"
|
||||
type="button"
|
||||
class="inline-flex rounded-md text-zinc-400 hover:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<XMarkIcon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import type { Notification } from "@prisma/client";
|
||||
|
||||
const props = defineProps<{ notification: Notification }>();
|
||||
|
||||
async function deleteMe() {
|
||||
await $fetch(`/api/v1/notifications/${props.notification.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const notifications = useNotifications();
|
||||
const indexOfMe = notifications.value.findIndex(
|
||||
(e) => e.id === props.notification.id
|
||||
);
|
||||
// Delete me
|
||||
notifications.value.splice(indexOfMe, 1);
|
||||
}
|
||||
</script>
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex rounded-sm px-2 py-2 bg-zinc-900 text-zinc-600">
|
||||
<div class="flex rounded px-2 py-2 bg-zinc-900 text-zinc-600">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@ -21,15 +21,39 @@
|
||||
</div>
|
||||
<div class="inline-flex items-center">
|
||||
<ol class="inline-flex gap-3">
|
||||
<li v-for="(item, itemIdx) in quickActions">
|
||||
<HeaderWidget
|
||||
@click="item.action"
|
||||
:notifications="item.notifications"
|
||||
>
|
||||
<component class="h-5" :is="item.icon" />
|
||||
</HeaderWidget>
|
||||
<li>
|
||||
<UserHeaderWidget>
|
||||
<UserGroupIcon class="h-5" />
|
||||
</UserHeaderWidget>
|
||||
</li>
|
||||
<HeaderUserWidget />
|
||||
|
||||
<li>
|
||||
<Menu as="div" class="relative inline-block">
|
||||
<MenuButton>
|
||||
<UserHeaderWidget :notifications="unreadNotifications.length">
|
||||
<BellIcon class="h-5" />
|
||||
</UserHeaderWidget>
|
||||
</MenuButton>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute inset-x-0 -translate-x-1/2 top-10 z-50 w-96 focus:outline-none shadow-md"
|
||||
>
|
||||
<UserHeaderNotificationWidgetPanel
|
||||
:notifications="unreadNotifications"
|
||||
/>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</Menu>
|
||||
</li>
|
||||
<UserHeaderUserWidget />
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@ -41,7 +65,7 @@
|
||||
<div class="flex gap-x-4 lg:gap-x-6">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<!-- Profile dropdown -->
|
||||
<HeaderUserWidget />
|
||||
<UserHeaderUserWidget />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@ -111,7 +135,7 @@
|
||||
<Logo class="h-8 w-auto" />
|
||||
</NuxtLink>
|
||||
|
||||
<HeaderUserWidget />
|
||||
<UserHeaderUserWidget />
|
||||
</div>
|
||||
<nav class="flex flex-1 flex-col gap-y-8">
|
||||
<ol class="flex flex-col gap-y-3">
|
||||
@ -130,14 +154,18 @@
|
||||
</ol>
|
||||
<div class="h-px w-full bg-zinc-700" />
|
||||
<div class="flex flex-row gap-x-4 justify-stretch">
|
||||
<li class="w-full" v-for="(item, itemIdx) in quickActions">
|
||||
<HeaderWidget
|
||||
class="w-full"
|
||||
@click="item.action"
|
||||
:notifications="item.notifications"
|
||||
<li class="w-full">
|
||||
<UserHeaderWidget class="w-full">
|
||||
<UserGroupIcon class="h-5" />
|
||||
</UserHeaderWidget>
|
||||
</li>
|
||||
<li class="w-full">
|
||||
<UserHeaderWidget
|
||||
class="w-full"
|
||||
:notifications="unreadNotifications.length"
|
||||
>
|
||||
<component class="h-5" :is="item.icon" />
|
||||
</HeaderWidget>
|
||||
<BellIcon class="h-5" />
|
||||
</UserHeaderWidget>
|
||||
</li>
|
||||
</div>
|
||||
</nav>
|
||||
@ -156,9 +184,11 @@ import {
|
||||
DialogPanel,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItems,
|
||||
} from "@headlessui/vue";
|
||||
import type { NavigationItem, QuickActionNav } from "../composables/types";
|
||||
import HeaderWidget from "./HeaderWidget.vue";
|
||||
import type { NavigationItem } from "../composables/types";
|
||||
import { Bars3Icon } from "@heroicons/vue/24/outline";
|
||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
@ -189,16 +219,10 @@ const navigation: Array<NavigationItem> = [
|
||||
|
||||
const currentPageIndex = useCurrentNavigationIndex(navigation);
|
||||
|
||||
const quickActions: Array<QuickActionNav> = [
|
||||
{
|
||||
icon: UserGroupIcon,
|
||||
action: async () => {},
|
||||
},
|
||||
{
|
||||
icon: BellIcon,
|
||||
action: async () => {},
|
||||
},
|
||||
];
|
||||
const notifications = useNotifications();
|
||||
const unreadNotifications = computed(() =>
|
||||
notifications.value.filter((e) => !e.read)
|
||||
);
|
||||
|
||||
const sidebarOpen = ref(false);
|
||||
router.afterEach(() => (sidebarOpen.value = false));
|
||||
|
||||
43
components/UserHeader/NotificationWidgetPanel.vue
Normal file
43
components/UserHeader/NotificationWidgetPanel.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<PanelWidget class="flex-col gap-y-2">
|
||||
<div class="border-b border-zinc-700 pb-3 p-2">
|
||||
<div
|
||||
class="-ml-4 -mt-2 flex flex-wrap items-center justify-between sm:flex-nowrap"
|
||||
>
|
||||
<div class="ml-4 mt-2">
|
||||
<h3 class="text-base font-semibold text-zinc-100 text-sm">
|
||||
Unread notifications
|
||||
</h3>
|
||||
</div>
|
||||
<div class="ml-4 mt-2 shrink-0">
|
||||
<NuxtLink
|
||||
to="/account/notifications"
|
||||
type="button"
|
||||
class="text-sm text-zinc-500"
|
||||
>
|
||||
View all →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-y-2 max-h-[300px] overflow-y-scroll">
|
||||
<Notification
|
||||
v-for="notification in props.notifications"
|
||||
:notification="notification"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.notifications.length == 0"
|
||||
class="text-sm text-zinc-600 p-3 text-center w-full"
|
||||
>
|
||||
No notifications
|
||||
</div>
|
||||
</PanelWidget>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Notification } from "@prisma/client";
|
||||
|
||||
const props = defineProps<{ notifications: Array<Notification> }>();
|
||||
</script>
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Menu v-if="user" as="div" class="relative inline-block">
|
||||
<MenuButton>
|
||||
<HeaderWidget>
|
||||
<UserHeaderWidget>
|
||||
<div class="inline-flex items-center text-zinc-300 hover:text-white">
|
||||
<img
|
||||
:src="useObject(user.profilePicture)"
|
||||
@ -10,7 +10,7 @@
|
||||
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
|
||||
<ChevronDownIcon class="ml-3 h-4" />
|
||||
</div>
|
||||
</HeaderWidget>
|
||||
</UserHeaderWidget>
|
||||
</MenuButton>
|
||||
|
||||
<transition
|
||||
@ -60,9 +60,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
|
||||
import type { NavigationItem } from "../composables/types";
|
||||
import HeaderWidget from "./HeaderWidget.vue";
|
||||
import { useObject } from "~/composables/objects";
|
||||
import type { NavigationItem } from "~/composables/types";
|
||||
|
||||
const user = useUser();
|
||||
|
||||
37
components/UserHeader/Widget.vue
Normal file
37
components/UserHeader/Widget.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'transition inline-flex items-center justify-center cursor-pointer rounded-sm px-4 py-2 relative',
|
||||
showNotifications
|
||||
? 'bg-blue-300 text-zinc-900 hover:bg-blue-200 hover:text-zinc-950'
|
||||
: 'bg-zinc-900 text-zinc-600 hover:bg-zinc-800 hover:text-zinc-300',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
<TransitionRoot
|
||||
:show="showNotifications"
|
||||
enter="ease-out duration-150"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 absolute top-0 right-0 translate-x-[30%] translate-y-[-30%] rounded-full bg-blue-300 inline-flex items-center justify-center text-xs text-zinc-900 font-bold"
|
||||
>
|
||||
{{ props.notifications }}
|
||||
</div>
|
||||
</TransitionRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TransitionRoot } from "@headlessui/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
notifications?: number;
|
||||
}>();
|
||||
|
||||
const showNotifications = computed(() => !!props.notifications);
|
||||
</script>
|
||||
12
composables/notifications.ts
Normal file
12
composables/notifications.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { Notification } from "@prisma/client";
|
||||
|
||||
const ws = new WebSocketHandler("/api/v1/notifications/ws");
|
||||
|
||||
export const useNotifications = () =>
|
||||
useState<Array<Notification>>("notifications", () => []);
|
||||
|
||||
ws.listen((e) => {
|
||||
const notification = JSON.parse(e) as Notification;
|
||||
const notifications = useNotifications();
|
||||
notifications.value.push(notification);
|
||||
});
|
||||
2
composables/types.d.ts
vendored
2
composables/types.d.ts
vendored
@ -8,6 +8,6 @@ export type NavigationItem = {
|
||||
|
||||
export type QuickActionNav = {
|
||||
icon: Component,
|
||||
notifications?: number,
|
||||
notifications?: Ref<number>,
|
||||
action: () => Promise<void>,
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Notification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nonce" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"actions" TEXT[],
|
||||
"read" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Notification_nonce_key" ON "Notification"("nonce");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Notification" ADD COLUMN "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
@ -9,4 +9,22 @@ model User {
|
||||
|
||||
authMecs LinkedAuthMec[]
|
||||
clients Client[]
|
||||
}
|
||||
|
||||
notifications Notification[]
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(uuid())
|
||||
|
||||
nonce String? @unique
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
created DateTime @default(now())
|
||||
title String
|
||||
description String
|
||||
actions String[]
|
||||
|
||||
read Boolean @default(false)
|
||||
}
|
||||
|
||||
28
server/api/v1/notifications/[id]/index.delete.ts
Normal file
28
server/api/v1/notifications/[id]/index.delete.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const userId = await h3.context.session.getUserId(h3);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const notificationId = getRouterParam(h3, "id");
|
||||
if (!notificationId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing notification ID",
|
||||
});
|
||||
|
||||
const notification = await prisma.notification.delete({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!notification)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid notification ID",
|
||||
});
|
||||
|
||||
return {};
|
||||
});
|
||||
28
server/api/v1/notifications/[id]/index.get.ts
Normal file
28
server/api/v1/notifications/[id]/index.get.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const userId = await h3.context.session.getUserId(h3);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const notificationId = getRouterParam(h3, "id");
|
||||
if (!notificationId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing notification ID",
|
||||
});
|
||||
|
||||
const notification = await prisma.notification.findFirst({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!notification)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid notification ID",
|
||||
});
|
||||
|
||||
return notification;
|
||||
});
|
||||
31
server/api/v1/notifications/[id]/read.post.ts
Normal file
31
server/api/v1/notifications/[id]/read.post.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const userId = await h3.context.session.getUserId(h3);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const notificationId = getRouterParam(h3, "id");
|
||||
if (!notificationId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing notification ID",
|
||||
});
|
||||
|
||||
const notification = await prisma.notification.update({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId,
|
||||
},
|
||||
data: {
|
||||
read: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!notification)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid notification ID",
|
||||
});
|
||||
|
||||
return notification;
|
||||
});
|
||||
17
server/api/v1/notifications/index.get.ts
Normal file
17
server/api/v1/notifications/index.get.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const userId = await h3.context.session.getUserId(h3);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: {
|
||||
created: "desc", // Newest first
|
||||
},
|
||||
});
|
||||
|
||||
return notifications;
|
||||
});
|
||||
17
server/api/v1/notifications/readall.post.ts
Normal file
17
server/api/v1/notifications/readall.post.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const userId = await h3.context.session.getUserId(h3);
|
||||
if (!userId) throw createError({ statusCode: 403 });
|
||||
|
||||
await prisma.notification.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
data: {
|
||||
read: true,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
});
|
||||
42
server/api/v1/notifications/ws.get.ts
Normal file
42
server/api/v1/notifications/ws.get.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import notificationSystem from "~/server/internal/notifications";
|
||||
import session from "~/server/internal/session";
|
||||
import { parse as parseCookies } from "cookie-es";
|
||||
|
||||
// TODO add web socket sessions for horizontal scaling
|
||||
// Peer ID to user ID
|
||||
const socketSessions: { [key: string]: string } = {};
|
||||
|
||||
export default defineWebSocketHandler({
|
||||
async open(peer) {
|
||||
const cookies = peer.request?.headers?.get("Cookie");
|
||||
if (!cookies) {
|
||||
peer.send("unauthenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedCookies = parseCookies(cookies);
|
||||
const token = parsedCookies[session.getDropTokenCookie()];
|
||||
|
||||
const userId = await session.getUserIdRaw(token);
|
||||
if (!userId) {
|
||||
peer.send("unauthenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
socketSessions[peer.id] = userId;
|
||||
|
||||
notificationSystem.listen(userId, peer.id, (notification) => {
|
||||
peer.send(JSON.stringify(notification));
|
||||
});
|
||||
},
|
||||
async close(peer, details) {
|
||||
const userId = socketSessions[peer.id];
|
||||
if (!userId) {
|
||||
console.log(`skipping websocket close for ${peer.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
notificationSystem.unlisten(userId, peer.id);
|
||||
delete socketSessions[peer.id];
|
||||
},
|
||||
});
|
||||
@ -1,6 +1,4 @@
|
||||
import { H3Event } from "h3";
|
||||
import session from "~/server/internal/session";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import taskHandler, { TaskMessage } from "~/server/internal/tasks";
|
||||
import { parse as parseCookies } from "cookie-es";
|
||||
|
||||
@ -24,7 +22,7 @@ export default defineWebSocketHandler({
|
||||
peer.send("unauthenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const admin = session.getAdminUser(token);
|
||||
adminSocketSessions[peer.id] = admin !== undefined;
|
||||
|
||||
|
||||
88
server/internal/notifications/index.ts
Normal file
88
server/internal/notifications/index.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/*
|
||||
The notification system handles the recieving, 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 { Notification } from "@prisma/client";
|
||||
import prisma from "../db/database";
|
||||
|
||||
export type NotificationCreateArgs = Pick<
|
||||
Notification,
|
||||
"title" | "description" | "actions" | "nonce"
|
||||
>;
|
||||
|
||||
class NotificationSystem {
|
||||
private listeners: {
|
||||
[key: string]: Map<string, (notification: Notification) => any>;
|
||||
} = {};
|
||||
|
||||
listen(
|
||||
userId: string,
|
||||
id: string,
|
||||
callback: (notification: Notification) => any
|
||||
) {
|
||||
this.listeners[userId] ??= new Map();
|
||||
this.listeners[userId].set(id, callback);
|
||||
|
||||
this.catchupListener(userId, id);
|
||||
}
|
||||
|
||||
unlisten(userId: string, id: string) {
|
||||
this.listeners[userId].delete(id);
|
||||
}
|
||||
|
||||
private async catchupListener(userId: string, id: string) {
|
||||
const callback = this.listeners[userId].get(id);
|
||||
if (!callback)
|
||||
throw new Error("Failed to catch-up listener: callback does not exist");
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { userId: userId },
|
||||
orderBy: {
|
||||
created: "asc", // Oldest first, because they arrive in reverse order
|
||||
},
|
||||
});
|
||||
for (const notification of notifications) {
|
||||
await callback(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private async pushNotification(userId: string, notification: Notification) {
|
||||
for (const listener of this.listeners[userId] ?? []) {
|
||||
await listener[1](notification);
|
||||
}
|
||||
}
|
||||
|
||||
async push(userId: string, notificationCreateArgs: NotificationCreateArgs) {
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: userId,
|
||||
...notificationCreateArgs,
|
||||
},
|
||||
});
|
||||
|
||||
await this.pushNotification(userId, notification);
|
||||
}
|
||||
|
||||
async pushAll(notificationCreateArgs: NotificationCreateArgs) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { not: "system" } },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
await this.push(user.id, notificationCreateArgs);
|
||||
}
|
||||
}
|
||||
|
||||
async systemPush(notificationCreateArgs: NotificationCreateArgs) {
|
||||
return await this.push("system", notificationCreateArgs);
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationSystem = new NotificationSystem();
|
||||
export default notificationSystem;
|
||||
Reference in New Issue
Block a user