Implement notification system

create notifications model
This commit is contained in:
timothycarambat
2023-10-16 15:38:52 -07:00
parent 2499f456c1
commit a5dc650afc
8 changed files with 444 additions and 50 deletions
@@ -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 };
+109
View File
@@ -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;
+27 -13
View File
@@ -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)
}
+164
View File
@@ -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>
-1
View File
@@ -298,7 +298,6 @@ const Organization = {
headers: baseHeaders(),
})
.then((res) => res.json())
.then((res) => res.notifications || [])
.catch((e) => {
console.error(e);
return { notifications: [] };
+8
View File
@@ -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();
}