feat(notifications): added notification system w/ interwoven refactoring

This commit is contained in:
DecDuck
2024-11-16 19:41:19 +11:00
parent 62ea9a116b
commit 6e6f09dba0
22 changed files with 498 additions and 56 deletions

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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));

View 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 &rarr;
</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>

View File

@ -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();

View 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>

View 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);
});

View File

@ -8,6 +8,6 @@ export type NavigationItem = {
export type QuickActionNav = {
icon: Component,
notifications?: number,
notifications?: Ref<number>,
action: () => Promise<void>,
}

View File

@ -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;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@ -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)
}

View 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 {};
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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];
},
});

View File

@ -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;

View 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;