+
\ No newline at end of file
diff --git a/components/UserHeader.vue b/components/UserHeader.vue
index fb13efb..a8afc0d 100644
--- a/components/UserHeader.vue
+++ b/components/UserHeader.vue
@@ -21,15 +21,39 @@
-
+
-
+
@@ -130,14 +154,18 @@
-
-
+
+
+
+
+
+
-
-
+
+
@@ -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
= [
const currentPageIndex = useCurrentNavigationIndex(navigation);
-const quickActions: Array = [
- {
- 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));
diff --git a/components/UserHeader/NotificationWidgetPanel.vue b/components/UserHeader/NotificationWidgetPanel.vue
new file mode 100644
index 0000000..b0addf4
--- /dev/null
+++ b/components/UserHeader/NotificationWidgetPanel.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ Unread notifications
+
+
+
+
+ View all →
+
+
+
+
+
+
+
+
+
+ No notifications
+
+
+
+
+
diff --git a/components/HeaderUserWidget.vue b/components/UserHeader/UserWidget.vue
similarity index 94%
rename from components/HeaderUserWidget.vue
rename to components/UserHeader/UserWidget.vue
index 54cd21e..de99c97 100644
--- a/components/HeaderUserWidget.vue
+++ b/components/UserHeader/UserWidget.vue
@@ -1,7 +1,7 @@
-
+
{{ user.displayName }}
-
+
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();
diff --git a/components/UserHeader/Widget.vue b/components/UserHeader/Widget.vue
new file mode 100644
index 0000000..dddec3a
--- /dev/null
+++ b/components/UserHeader/Widget.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+ {{ props.notifications }}
+
+
+
+
+
+
diff --git a/composables/notifications.ts b/composables/notifications.ts
new file mode 100644
index 0000000..304a7cd
--- /dev/null
+++ b/composables/notifications.ts
@@ -0,0 +1,12 @@
+import type { Notification } from "@prisma/client";
+
+const ws = new WebSocketHandler("/api/v1/notifications/ws");
+
+export const useNotifications = () =>
+ useState>("notifications", () => []);
+
+ws.listen((e) => {
+ const notification = JSON.parse(e) as Notification;
+ const notifications = useNotifications();
+ notifications.value.push(notification);
+});
diff --git a/composables/types.d.ts b/composables/types.d.ts
index e1102f1..9b5af13 100644
--- a/composables/types.d.ts
+++ b/composables/types.d.ts
@@ -8,6 +8,6 @@ export type NavigationItem = {
export type QuickActionNav = {
icon: Component,
- notifications?: number,
+ notifications?: Ref,
action: () => Promise,
}
\ No newline at end of file
diff --git a/prisma/migrations/20241116053120_add_notifications/migration.sql b/prisma/migrations/20241116053120_add_notifications/migration.sql
new file mode 100644
index 0000000..230e67c
--- /dev/null
+++ b/prisma/migrations/20241116053120_add_notifications/migration.sql
@@ -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;
diff --git a/prisma/migrations/20241116054212_add_created_time_stamp_to_notifications/migration.sql b/prisma/migrations/20241116054212_add_created_time_stamp_to_notifications/migration.sql
new file mode 100644
index 0000000..ec5756c
--- /dev/null
+++ b/prisma/migrations/20241116054212_add_created_time_stamp_to_notifications/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Notification" ADD COLUMN "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma
index d39cb10..518297b 100644
--- a/prisma/schema/user.prisma
+++ b/prisma/schema/user.prisma
@@ -9,4 +9,22 @@ model User {
authMecs LinkedAuthMec[]
clients Client[]
-}
\ No newline at end of file
+
+ 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)
+}
diff --git a/server/api/v1/notifications/[id]/index.delete.ts b/server/api/v1/notifications/[id]/index.delete.ts
new file mode 100644
index 0000000..c89a147
--- /dev/null
+++ b/server/api/v1/notifications/[id]/index.delete.ts
@@ -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 {};
+});
diff --git a/server/api/v1/notifications/[id]/index.get.ts b/server/api/v1/notifications/[id]/index.get.ts
new file mode 100644
index 0000000..0d9b2c2
--- /dev/null
+++ b/server/api/v1/notifications/[id]/index.get.ts
@@ -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;
+});
diff --git a/server/api/v1/notifications/[id]/read.post.ts b/server/api/v1/notifications/[id]/read.post.ts
new file mode 100644
index 0000000..ef180c4
--- /dev/null
+++ b/server/api/v1/notifications/[id]/read.post.ts
@@ -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;
+});
diff --git a/server/api/v1/notifications/index.get.ts b/server/api/v1/notifications/index.get.ts
new file mode 100644
index 0000000..65a6cba
--- /dev/null
+++ b/server/api/v1/notifications/index.get.ts
@@ -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;
+});
diff --git a/server/api/v1/notifications/readall.post.ts b/server/api/v1/notifications/readall.post.ts
new file mode 100644
index 0000000..9f29bba
--- /dev/null
+++ b/server/api/v1/notifications/readall.post.ts
@@ -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;
+});
diff --git a/server/api/v1/notifications/ws.get.ts b/server/api/v1/notifications/ws.get.ts
new file mode 100644
index 0000000..1309128
--- /dev/null
+++ b/server/api/v1/notifications/ws.get.ts
@@ -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];
+ },
+});
diff --git a/server/api/v1/task/index.get.ts b/server/api/v1/task/index.get.ts
index 5716c6e..6cc0295 100644
--- a/server/api/v1/task/index.get.ts
+++ b/server/api/v1/task/index.get.ts
@@ -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;
diff --git a/server/internal/notifications/index.ts b/server/internal/notifications/index.ts
new file mode 100644
index 0000000..e2ec1fe
--- /dev/null
+++ b/server/internal/notifications/index.ts
@@ -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 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;