mirror of
https://github.com/Mintplex-Labs/vector-admin.git
synced 2026-07-18 18:14:31 -04:00
Implement notification system
create notifications model
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative">
|
||||
@@ -141,19 +126,33 @@ export default function Notifications() {
|
||||
function NotificationImage({ notification }: { notification: INotification }) {
|
||||
switch (notification.symbol) {
|
||||
case 'info':
|
||||
return <Info className="rounded-full text-blue-500" size={20} />;
|
||||
return <Info className="text-blue-500" size={20} />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="text-orange-500" size={20} />;
|
||||
case 'error':
|
||||
return <AlertOctagon className="text-red-600" size={20} />;
|
||||
case 'chroma':
|
||||
return <img className="h-10 w-10 rounded-full" src={ChromaLogo} />;
|
||||
case 'pinecone':
|
||||
return <img className="h-8 w-8 rounded-full" src={PineconeLogo} />;
|
||||
case 'qdrant':
|
||||
return <img className="h-8 w-8 rounded-full" src={qDrantLogo} />;
|
||||
case 'weaviate':
|
||||
return <img className="h-8 w-8 rounded-full" src={WeaviateLogo} />;
|
||||
default:
|
||||
return <Info className="rounded-full text-blue-500" size={20} />;
|
||||
return <Info className="text-blue-500" size={20} />;
|
||||
}
|
||||
}
|
||||
|
||||
function Notification({ notification }: { notification: INotification }) {
|
||||
return (
|
||||
<a
|
||||
key={notification.id}
|
||||
href={notification?.link || '#'}
|
||||
target={notification?.target || 'self'}
|
||||
className={`flex px-4 py-3 hover:bg-gray-100 ${!notification.seen ? 'border-l-4 border-blue-600' : ''
|
||||
}`}
|
||||
className={`flex px-4 py-3 hover:bg-gray-100 ${
|
||||
!notification.seen ? 'border-l-4 !border-l-blue-600' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-shrink-0 items-center justify-center">
|
||||
<NotificationImage notification={notification} />
|
||||
@@ -163,7 +162,7 @@ function Notification({ notification }: { notification: INotification }) {
|
||||
{notification.textContent}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600">
|
||||
{moment.unix(notification.createdAt).fromNow()}
|
||||
{databaseTimestampFromNow(notification.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -298,7 +298,6 @@ const Organization = {
|
||||
headers: baseHeaders(),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => res.notifications || [])
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
return { notifications: [] };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user