mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-07-27 02:04:39 +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>
|
|
||||||
@@ -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>
|
<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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -21,15 +21,39 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center">
|
<div class="inline-flex items-center">
|
||||||
<ol class="inline-flex gap-3">
|
<ol class="inline-flex gap-3">
|
||||||
<li v-for="(item, itemIdx) in quickActions">
|
<li>
|
||||||
<HeaderWidget
|
<UserHeaderWidget>
|
||||||
@click="item.action"
|
<UserGroupIcon class="h-5" />
|
||||||
:notifications="item.notifications"
|
</UserHeaderWidget>
|
||||||
>
|
|
||||||
<component class="h-5" :is="item.icon" />
|
|
||||||
</HeaderWidget>
|
|
||||||
</li>
|
</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>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +65,7 @@
|
|||||||
<div class="flex gap-x-4 lg:gap-x-6">
|
<div class="flex gap-x-4 lg:gap-x-6">
|
||||||
<div class="flex items-center gap-x-3">
|
<div class="flex items-center gap-x-3">
|
||||||
<!-- Profile dropdown -->
|
<!-- Profile dropdown -->
|
||||||
<HeaderUserWidget />
|
<UserHeaderUserWidget />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -111,7 +135,7 @@
|
|||||||
<Logo class="h-8 w-auto" />
|
<Logo class="h-8 w-auto" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<HeaderUserWidget />
|
<UserHeaderUserWidget />
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex flex-1 flex-col gap-y-8">
|
<nav class="flex flex-1 flex-col gap-y-8">
|
||||||
<ol class="flex flex-col gap-y-3">
|
<ol class="flex flex-col gap-y-3">
|
||||||
@@ -130,14 +154,18 @@
|
|||||||
</ol>
|
</ol>
|
||||||
<div class="h-px w-full bg-zinc-700" />
|
<div class="h-px w-full bg-zinc-700" />
|
||||||
<div class="flex flex-row gap-x-4 justify-stretch">
|
<div class="flex flex-row gap-x-4 justify-stretch">
|
||||||
<li class="w-full" v-for="(item, itemIdx) in quickActions">
|
<li class="w-full">
|
||||||
<HeaderWidget
|
<UserHeaderWidget class="w-full">
|
||||||
class="w-full"
|
<UserGroupIcon class="h-5" />
|
||||||
@click="item.action"
|
</UserHeaderWidget>
|
||||||
:notifications="item.notifications"
|
</li>
|
||||||
|
<li class="w-full">
|
||||||
|
<UserHeaderWidget
|
||||||
|
class="w-full"
|
||||||
|
:notifications="unreadNotifications.length"
|
||||||
>
|
>
|
||||||
<component class="h-5" :is="item.icon" />
|
<BellIcon class="h-5" />
|
||||||
</HeaderWidget>
|
</UserHeaderWidget>
|
||||||
</li>
|
</li>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -156,9 +184,11 @@ import {
|
|||||||
DialogPanel,
|
DialogPanel,
|
||||||
TransitionChild,
|
TransitionChild,
|
||||||
TransitionRoot,
|
TransitionRoot,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItems,
|
||||||
} from "@headlessui/vue";
|
} from "@headlessui/vue";
|
||||||
import type { NavigationItem, QuickActionNav } from "../composables/types";
|
import type { NavigationItem } from "../composables/types";
|
||||||
import HeaderWidget from "./HeaderWidget.vue";
|
|
||||||
import { Bars3Icon } from "@heroicons/vue/24/outline";
|
import { Bars3Icon } from "@heroicons/vue/24/outline";
|
||||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||||
|
|
||||||
@@ -189,16 +219,10 @@ const navigation: Array<NavigationItem> = [
|
|||||||
|
|
||||||
const currentPageIndex = useCurrentNavigationIndex(navigation);
|
const currentPageIndex = useCurrentNavigationIndex(navigation);
|
||||||
|
|
||||||
const quickActions: Array<QuickActionNav> = [
|
const notifications = useNotifications();
|
||||||
{
|
const unreadNotifications = computed(() =>
|
||||||
icon: UserGroupIcon,
|
notifications.value.filter((e) => !e.read)
|
||||||
action: async () => {},
|
);
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: BellIcon,
|
|
||||||
action: async () => {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
router.afterEach(() => (sidebarOpen.value = false));
|
router.afterEach(() => (sidebarOpen.value = false));
|
||||||
|
|||||||
@@ -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>
|
||||||
+3
-4
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Menu v-if="user" as="div" class="relative inline-block">
|
<Menu v-if="user" as="div" class="relative inline-block">
|
||||||
<MenuButton>
|
<MenuButton>
|
||||||
<HeaderWidget>
|
<UserHeaderWidget>
|
||||||
<div class="inline-flex items-center text-zinc-300 hover:text-white">
|
<div class="inline-flex items-center text-zinc-300 hover:text-white">
|
||||||
<img
|
<img
|
||||||
:src="useObject(user.profilePicture)"
|
:src="useObject(user.profilePicture)"
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
|
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
|
||||||
<ChevronDownIcon class="ml-3 h-4" />
|
<ChevronDownIcon class="ml-3 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</HeaderWidget>
|
</UserHeaderWidget>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
<transition
|
<transition
|
||||||
@@ -60,9 +60,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||||
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
|
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
|
||||||
import type { NavigationItem } from "../composables/types";
|
|
||||||
import HeaderWidget from "./HeaderWidget.vue";
|
|
||||||
import { useObject } from "~/composables/objects";
|
import { useObject } from "~/composables/objects";
|
||||||
|
import type { NavigationItem } from "~/composables/types";
|
||||||
|
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Vendored
+1
-1
@@ -8,6 +8,6 @@ export type NavigationItem = {
|
|||||||
|
|
||||||
export type QuickActionNav = {
|
export type QuickActionNav = {
|
||||||
icon: Component,
|
icon: Component,
|
||||||
notifications?: number,
|
notifications?: Ref<number>,
|
||||||
action: () => Promise<void>,
|
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;
|
||||||
+2
@@ -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[]
|
authMecs LinkedAuthMec[]
|
||||||
clients Client[]
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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 session from "~/server/internal/session";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import taskHandler, { TaskMessage } from "~/server/internal/tasks";
|
import taskHandler, { TaskMessage } from "~/server/internal/tasks";
|
||||||
import { parse as parseCookies } from "cookie-es";
|
import { parse as parseCookies } from "cookie-es";
|
||||||
|
|
||||||
@@ -24,7 +22,7 @@ export default defineWebSocketHandler({
|
|||||||
peer.send("unauthenticated");
|
peer.send("unauthenticated");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const admin = session.getAdminUser(token);
|
const admin = session.getAdminUser(token);
|
||||||
adminSocketSessions[peer.id] = admin !== undefined;
|
adminSocketSessions[peer.id] = admin !== undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -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