From a5dc650afcae02fffde49bf0f66360b42465f89a Mon Sep 17 00:00:00 2001 From: timothycarambat Date: Mon, 16 Oct 2023 15:38:52 -0700 Subject: [PATCH] Implement notification system create notifications model --- backend/endpoints/v1/organizations/index.js | 84 +++++++++ backend/models/notification.js | 109 ++++++++++++ .../20231016215213_init/migration.sql | 17 ++ backend/prisma/schema.prisma | 40 +++-- backend/storage/settings.json | 164 ++++++++++++++++++ .../components/Header/Notifications/index.tsx | 71 ++++---- frontend/src/models/organization.ts | 1 - frontend/src/utils/data.ts | 8 + 8 files changed, 444 insertions(+), 50 deletions(-) create mode 100644 backend/models/notification.js create mode 100644 backend/prisma/migrations/20231016215213_init/migration.sql create mode 100644 frontend/src/utils/data.ts diff --git a/backend/endpoints/v1/organizations/index.js b/backend/endpoints/v1/organizations/index.js index 1330fa2..d69cc1e 100644 --- a/backend/endpoints/v1/organizations/index.js +++ b/backend/endpoints/v1/organizations/index.js @@ -1,3 +1,4 @@ +const { Notification } = require("../../../models/notification"); const { Organization } = require("../../../models/organization"); const { OrganizationApiKey } = require("../../../models/organizationApiKey"); const { @@ -710,6 +711,89 @@ function organizationEndpoints(app) { } } ); + + app.get( + "/v1/org/:orgSlug/notifications", + [validSessionForUser], + async function (request, response) { + try { + const { orgSlug } = request.params; + const user = await userFromSession(request); + if (!user) { + response.sendStatus(403).end(); + return; + } + + const organization = await Organization.getWithOwner(user.id, { + slug: orgSlug, + }); + if (!organization) { + response + .status(200) + .json({ notifications: [], error: "No orgs by that slug." }); + return; + } + + const unseenNotifications = await Notification.where( + { + organization_id: Number(organization.id), + seen: false, + }, + 10, + null, + { createdAt: "desc" } + ); + const recentNotifications = await Notification.where( + { + organization_id: Number(organization.id), + id: { + notIn: unseenNotifications.map((notif) => notif.id), + }, + }, + 10, + null, + { createdAt: "desc" } + ); + + const notifications = [...unseenNotifications, ...recentNotifications]; + + response.status(200).json({ notifications, error: null }); + } catch (e) { + console.log(e.message, e); + response.status(500).json({ notifications: [], error: e.message }); + } + } + ); + + app.post( + "/v1/org/:orgSlug/notifications/mark-seen", + [validSessionForUser], + async function (request, response) { + try { + const { orgSlug } = request.params; + const user = await userFromSession(request); + if (!user) { + response.sendStatus(403).end(); + return; + } + + const organization = await Organization.getWithOwner(user.id, { + slug: orgSlug, + }); + if (!organization) { + response + .status(200) + .json({ notifications: [], error: "No orgs by that slug." }); + return; + } + await Notification.markSeenForOrg(organization.id); + response.sendStatus(200).end(); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); } module.exports = { organizationEndpoints }; diff --git a/backend/models/notification.js b/backend/models/notification.js new file mode 100644 index 0000000..b27c1a0 --- /dev/null +++ b/backend/models/notification.js @@ -0,0 +1,109 @@ +const prisma = require("../utils/prisma"); + +const Notification = { + create: async function (organizationId = 0, notificationData = {}) { + try { + const { + textContent = "", + symbol = null, + link = null, + target = null, + } = notificationData; + const notification = await prisma.organization_notifications.create({ + data: { + organization_id: Number(organizationId), + textContent, + symbol, + link, + target, + }, + }); + + if (!notification) { + await db.close(); + console.error("FAILED TO CREATE NOTIFICATION."); + return { notification: null, message: "Could not create notification" }; + } + + return { notification, message: null }; + } catch (e) { + console.error(e.message); + return null; + } + }, + + get: async function (clause = {}) { + try { + const notification = await prisma.organization_notifications.findFirst({ + where: clause, + }); + return notification ? { ...notification } : null; + } catch (e) { + console.error(e.message); + return null; + } + }, + + where: async function ( + clause = {}, + limit = null, + offset = null, + orderBy = null + ) { + try { + const workspaces = await prisma.organization_notifications.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(offset !== null ? { skip: offset } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return workspaces; + } catch (e) { + console.error(e.message); + return 0; + } + }, + + count: async function (clause = {}) { + try { + const count = await prisma.organization_notifications.count({ + where: clause, + }); + return count; + } catch (e) { + console.error(e.message); + return 0; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.organization_notifications.deleteMany({ where: clause }); + return true; + } catch (e) { + console.error(e.message); + return false; + } + }, + + markSeenForOrg: async function (organizationId = 0) { + const unseenNotifications = await this.where({ + organization_id: Number(organizationId), + seen: false, + }); + const notificationIds = unseenNotifications.map((notif) => notif.id); + await prisma.organization_notifications.updateMany({ + where: { + id: { + in: notificationIds, + }, + }, + data: { + seen: true, + lastUpdatedAt: new Date(), + }, + }); + }, +}; + +module.exports.Notification = Notification; diff --git a/backend/prisma/migrations/20231016215213_init/migration.sql b/backend/prisma/migrations/20231016215213_init/migration.sql new file mode 100644 index 0000000..a1d2546 --- /dev/null +++ b/backend/prisma/migrations/20231016215213_init/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "organization_notifications" ( + "id" SERIAL NOT NULL, + "organization_id" INTEGER NOT NULL, + "seen" BOOLEAN NOT NULL DEFAULT false, + "textContent" TEXT NOT NULL, + "symbol" TEXT, + "link" TEXT, + "target" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "organization_notifications_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "organization_notifications" ADD CONSTRAINT "organization_notifications_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 52f2a78..2b194d7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -21,19 +21,20 @@ model users { } model organizations { - id Int @id @default(autoincrement()) - name String - slug String @unique - uuid String @unique - createdAt DateTime @default(now()) - lastUpdatedAt DateTime @default(now()) - organization_users organization_users[] - organization_api_keys organization_api_keys[] - organization_connections organization_connections[] - organization_workspaces organization_workspaces[] - workspace_documents workspace_documents[] - document_vectors document_vectors[] - jobs jobs[] + id Int @id @default(autoincrement()) + name String + slug String @unique + uuid String @unique + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + organization_users organization_users[] + organization_api_keys organization_api_keys[] + organization_connections organization_connections[] + organization_workspaces organization_workspaces[] + workspace_documents workspace_documents[] + document_vectors document_vectors[] + jobs jobs[] + organization_notifications organization_notifications[] } model organization_users { @@ -128,3 +129,16 @@ model system_settings { createdAt DateTime @default(now()) lastUpdatedAt DateTime @default(now()) } + +model organization_notifications { + id Int @id @default(autoincrement()) + organization_id Int + seen Boolean @default(false) + textContent String + symbol String? + link String? + target String? + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + organization organizations @relation(fields: [organization_id], references: [id], onDelete: Cascade) +} diff --git a/backend/storage/settings.json b/backend/storage/settings.json index a54e7bb..e30446c 100644 --- a/backend/storage/settings.json +++ b/backend/storage/settings.json @@ -1386,5 +1386,169 @@ "editview": { "readonly": false } + }, + "organization_notifications": { + "slug": "organization_notifications", + "table": { + "name": "organization_notifications", + "pk": "id", + "verbose": "organization_notifications" + }, + "columns": [ + { + "name": "id", + "verbose": "id", + "control": { + "text": true + }, + "type": "integer", + "allowNull": false, + "defaultValue": null, + "listview": { + "show": true + }, + "editview": { + "show": true + } + }, + { + "name": "organization_id", + "verbose": "organization_id", + "control": { + "text": true + }, + "type": "integer", + "allowNull": false, + "defaultValue": null, + "listview": { + "show": true + }, + "editview": { + "show": true + } + }, + { + "name": "seen", + "verbose": "seen", + "control": { + "text": true + }, + "type": "char", + "allowNull": false, + "defaultValue": "false", + "listview": { + "show": true + }, + "editview": { + "show": true + } + }, + { + "name": "textContent", + "verbose": "textContent", + "control": { + "text": true + }, + "type": "text", + "allowNull": false, + "defaultValue": null, + "listview": { + "show": true + }, + "editview": { + "show": true + } + }, + { + "name": "symbol", + "verbose": "symbol", + "control": { + "text": true + }, + "type": "text", + "allowNull": true, + "defaultValue": null, + "listview": { + "show": true + }, + "editview": { + "show": true + } + }, + { + "name": "link", + "verbose": "link", + "control": { + "text": true + }, + "type": "text", + "allowNull": true, + "defaultValue": null, + "listview": { + "show": true + }, + "editview": { + "show": true + } + }, + { + "name": "target", + "verbose": "target", + "control": { + "text": true + }, + "type": "text", + "allowNull": true, + "defaultValue": null, + "listview": { + "show": true + }, + "editview": { + "show": true + } + }, + { + "name": "createdAt", + "verbose": "createdAt", + "control": { + "text": true + }, + "type": "timestamp", + "allowNull": false, + "defaultValue": "CURRENT_TIMESTAMP", + "listview": { + "show": true + }, + "editview": { + "show": true + } + }, + { + "name": "lastUpdatedAt", + "verbose": "lastUpdatedAt", + "control": { + "text": true + }, + "type": "timestamp", + "allowNull": false, + "defaultValue": "CURRENT_TIMESTAMP", + "listview": { + "show": true + }, + "editview": { + "show": true + } + } + ], + "mainview": { + "show": true + }, + "listview": { + "order": {}, + "page": 25 + }, + "editview": { + "readonly": false + } } } \ No newline at end of file diff --git a/frontend/src/components/Header/Notifications/index.tsx b/frontend/src/components/Header/Notifications/index.tsx index f7ad74e..db495ff 100644 --- a/frontend/src/components/Header/Notifications/index.tsx +++ b/frontend/src/components/Header/Notifications/index.tsx @@ -1,31 +1,14 @@ import { useEffect, useState } from 'react'; -import { Bell, Info } from 'react-feather'; +import { AlertOctagon, AlertTriangle, Bell, Info } from 'react-feather'; import { useParams } from 'react-router-dom'; import Organization from '../../../models/organization'; -import moment from 'moment'; +import { databaseTimestampFromNow } from '../../../utils/data'; +import ChromaLogo from '../../../images/vectordbs/chroma.png'; +import PineconeLogo from '../../../images/vectordbs/pinecone.png'; +import qDrantLogo from '../../../images/vectordbs/qdrant.png'; +import WeaviateLogo from '../../../images/vectordbs/weaviate.png'; + const POLLING_INTERVAL = 30_000; -const SAMPLE_NOTIFS = [ - { - id: 1, - organization_id: 12, - seen: false, - textContent: 'Your Notification is here!', - symbol: 'info', - link: '/chroma/background-jobs', - target: '_blank', - createdAt: Math.floor(Number(new Date()) / 1_000) - 3600, - }, - { - id: 1, - organization_id: 12, - seen: true, - textContent: 'Your Notification is here!', - symbol: 'info', - link: '/chroma/background-jobs', - target: '_blank', - createdAt: Math.floor(Number(new Date()) / 1_000) - 3600, - }, -] as INotification[]; export type INotification = { id: number; @@ -35,7 +18,8 @@ export type INotification = { symbol: string; link?: string; target?: '_blank' | 'self'; - createdAt: number; + createdAt: string; + lastUpdatedAt: string; }; export default function Notifications() { @@ -61,7 +45,9 @@ export default function Notifications() { return; } - const { notifications } = await Organization.notifications(slug); + const { notifications: _notifications } = await Organization.notifications( + slug + ); setNotifications(_notifications); setHasUnseen(_notifications.some((notif) => notif.seen === false)); setLoading(false); @@ -70,13 +56,12 @@ export default function Notifications() { useEffect(() => { if (!slug) return; fetchNotifications(); - // TODO: Enable before merge. - // setTimeout(() => { - // fetchNotifications(); - // }, POLLING_INTERVAL); + setTimeout(() => { + fetchNotifications(); + }, POLLING_INTERVAL); }, [slug]); - // if (loading || notifications.length === 0) return null; + if (loading || notifications.length === 0) return null; return (
@@ -141,19 +126,33 @@ export default function Notifications() { function NotificationImage({ notification }: { notification: INotification }) { switch (notification.symbol) { case 'info': - return ; + return ; + case 'warning': + return ; + case 'error': + return ; + case 'chroma': + return ; + case 'pinecone': + return ; + case 'qdrant': + return ; + case 'weaviate': + return ; default: - return ; + return ; } } function Notification({ notification }: { notification: INotification }) { return (
@@ -163,7 +162,7 @@ function Notification({ notification }: { notification: INotification }) { {notification.textContent}
- {moment.unix(notification.createdAt).fromNow()} + {databaseTimestampFromNow(notification.createdAt)}
diff --git a/frontend/src/models/organization.ts b/frontend/src/models/organization.ts index fdf7c01..7dc383d 100644 --- a/frontend/src/models/organization.ts +++ b/frontend/src/models/organization.ts @@ -298,7 +298,6 @@ const Organization = { headers: baseHeaders(), }) .then((res) => res.json()) - .then((res) => res.notifications || []) .catch((e) => { console.error(e); return { notifications: [] }; diff --git a/frontend/src/utils/data.ts b/frontend/src/utils/data.ts new file mode 100644 index 0000000..f9892ac --- /dev/null +++ b/frontend/src/utils/data.ts @@ -0,0 +1,8 @@ +import moment from 'moment'; + +export function databaseTimestampFromNow(timeDateString: string) { + if (!timeDateString) + return moment.unix(Math.floor(Number(new Date()) / 1000) - 30).fromNow(); + const utcString = moment.utc(timeDateString).format('YYYY-MM-DDTHH:mm:ss'); + return moment(utcString).fromNow(); +}