Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notifications System #561

Draft
wants to merge 12 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- CreateEnum
CREATE TYPE "NotificationType" AS ENUM ('PUBLISH');

-- AlterTable
ALTER TABLE "User" ADD COLUMN "notificationSettings" JSONB;

-- CreateTable
CREATE TABLE "UserNotifications" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" INTEGER NOT NULL,
"nodeUuid" TEXT,
"type" "NotificationType" NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"payload" JSONB,
"dismissed" BOOLEAN NOT NULL DEFAULT false,

CONSTRAINT "UserNotifications_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "UserNotifications" ADD CONSTRAINT "UserNotifications_nodeUuid_fkey" FOREIGN KEY ("nodeUuid") REFERENCES "Node"("uuid") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "UserNotifications" ADD CONSTRAINT "UserNotifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
22 changes: 22 additions & 0 deletions desci-server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ model Node {
DoiSubmissionQueue DoiSubmissionQueue[]
BookmarkedNode BookmarkedNode[]
DeferredEmails DeferredEmails[]
UserNotifications UserNotifications[]
Annotation Annotation[]

@@index([ownerId])
Expand Down Expand Up @@ -154,6 +155,7 @@ model User {
isKeeper Boolean @default(false)
pseudonym String? @unique
orcid String? @unique
notificationSettings Json?
// rorPid String[] @default([])
// organization String[] @default([])
isAdmin Boolean @default(false)
Expand Down Expand Up @@ -199,6 +201,7 @@ model User {
OrcidPutCodes OrcidPutCodes[]
BookmarkedNode BookmarkedNode[]
DeferredEmails DeferredEmails[]
UserNotifications UserNotifications[]

@@index([orcid])
@@index([walletAddress])
Expand Down Expand Up @@ -911,6 +914,21 @@ model DoiSubmissionQueue {
updatedAt DateTime @updatedAt
}

model UserNotifications {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId Int
nodeUuid String?
type NotificationType
title String
message String
payload Json? // E.g. hyperlinks, DPID, DOI, frontend handles.
dismissed Boolean @default(false)
node Node? @relation(fields: [nodeUuid], references: [uuid])
user User @relation(fields: [userId], references: [id])
}

enum ORCIDRecord {
WORK
QUALIFICATION
Expand Down Expand Up @@ -1035,3 +1053,7 @@ enum DoiStatus {
FAILED
SUCCESS
}

enum NotificationType {
PUBLISH
}
1 change: 1 addition & 0 deletions desci-server/src/controllers/auth/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const profile = async (req: Request, res: Response, next: NextFunction) =
orcid: user.orcid,
userOrganization: organization.map((org) => org.organization),
consent: !!(await getUserConsent(user.id)),
notificationSettings: user.notificationSettings || {},
},
};
try {
Expand Down
72 changes: 72 additions & 0 deletions desci-server/src/controllers/notifications/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NotificationType, User, UserNotifications } from '@prisma/client';
import { Request, Response } from 'express';
import { z } from 'zod';

import { logger as parentLogger } from '../../logger.js';
import { createUserNotification } from '../../services/NotificationService.js';

export const CreateNotificationSchema = z.object({
userId: z.number(),
nodeUuid: z.string().optional(),
type: z.nativeEnum(NotificationType),
title: z.string(),
message: z.string(),
payload: z.record(z.unknown()).optional(),
});

interface AuthenticatedRequest extends Request {
user: User;
}

export interface ErrorResponse {
error: string;
details?: z.ZodIssue[] | string;
}

export const createNotification = async (
req: AuthenticatedRequest & { body: z.infer<typeof CreateNotificationSchema> },
res: Response<UserNotifications | ErrorResponse>,
) => {
const logger = parentLogger.child({
module: 'UserNotifications::CreateNotification',
userId: req.user?.id,
});

logger.info('Creating user notification');

try {
if (!req.user) {
logger.warn('Unauthorized, check middleware');
return res.status(401).json({ error: 'Unauthorized' } as ErrorResponse);
}

const { id: userId } = req.user;
const notificationData = CreateNotificationSchema.parse({ ...req.body, userId });

const notification = await createUserNotification(notificationData);

logger.info({ notificationId: notification.id }, 'Successfully created user notification');
return res.status(201).json(notification);
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn({ error: error.errors }, 'Invalid request parameters');
return res.status(400).json({ error: 'Invalid request parameters', details: error.errors } as ErrorResponse);
}
if (error instanceof Error) {
if (error.message === 'Node not found') {
logger.warn({ error }, 'Node not found');
return res.status(404).json({ error: 'Node not found' } as ErrorResponse);
}
if (error.message === 'Node does not belong to the user') {
logger.warn({ error }, 'Node does not belong to the user');
return res.status(403).json({ error: 'Node does not belong to the user' } as ErrorResponse);
}
if (error.message === 'Notification type is disabled for this user') {
logger.warn({ error }, 'Notification type is disabled for this user');
return res.status(403).json({ error: 'Notification type is disabled for this user' } as ErrorResponse);
}
}
logger.error({ error }, 'Error creating user notification');
return res.status(500).json({ error: 'Internal server error' } as ErrorResponse);
}
};
76 changes: 76 additions & 0 deletions desci-server/src/controllers/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { User, UserNotifications } from '@prisma/client';
import { Request, Response } from 'express';
import { z } from 'zod';

import { prisma } from '../../client.js';
import { logger as parentLogger } from '../../logger.js';
import { getUserNotifications } from '../../services/NotificationService.js';

export const GetNotificationsQuerySchema = z.object({
page: z.string().regex(/^\d+$/).transform(Number).optional().default('1'),
perPage: z.string().regex(/^\d+$/).transform(Number).optional().default('25'),
dismissed: z
.enum(['true', 'false'])
.optional()
.transform((value) => value === 'true'),
});

interface AuthenticatedRequest extends Request {
user: User;
}

export interface PaginatedResponse<T> {
data: T[];
pagination: {
currentPage: number;
totalPages: number;
totalItems: number;
};
}

export interface ErrorResponse {
error: string;
details?: z.ZodIssue[] | string;
}

export const listUserNotifications = async (
req: AuthenticatedRequest & { query: z.infer<typeof GetNotificationsQuerySchema> },
res: Response<PaginatedResponse<UserNotifications> | ErrorResponse>,
) => {
const logger = parentLogger.child({
module: 'UserNotifications::GetUserNotifications',
userId: req.user?.id,
query: req.query,
});
logger.info('Fetching user notifications');

try {
if (!req.user) {
logger.warn('Unauthorized, check middleware');
return res.status(401).json({ error: 'Unauthorized' } as ErrorResponse);
}

const { id: userId } = req.user;
const query = GetNotificationsQuerySchema.parse(req.query);

const notifs = await getUserNotifications(userId, query);

logger.info(
{
totalItems: notifs.pagination.totalItems,
page: notifs.pagination.currentPage,
totalPages: notifs.pagination.totalPages,
},
'Successfully fetched user notifications',
);

return res.status(200).json(notifs);
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn({ error: error.errors }, 'Invalid request parameters');
return res.status(400).json({ error: 'Invalid request parameters', details: error.errors } as ErrorResponse);
}
logger.error({ error }, 'Error fetching user notifications');
return res.status(500).json({ error: 'Internal server error' } as ErrorResponse);
}
};
82 changes: 82 additions & 0 deletions desci-server/src/controllers/notifications/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { User, UserNotifications } from '@prisma/client';
import { Request, Response } from 'express';
import { z } from 'zod';

import { logger as parentLogger } from '../../logger.js';
import { updateUserNotification, batchUpdateUserNotifications } from '../../services/NotificationService.js';

const UpdateDataSchema = z.object({
dismissed: z.boolean().optional(),
});

const BatchUpdateSchema = z.object({
notificationIds: z.array(z.number()),
updateData: UpdateDataSchema,
});

interface AuthenticatedRequest extends Request {
user: User;
}

export interface ErrorResponse {
error: string;
details?: z.ZodIssue[] | string;
}

type UpdateNotificationRequest = AuthenticatedRequest & {
params: { notificationId?: string };
body: z.infer<typeof UpdateDataSchema> | z.infer<typeof BatchUpdateSchema>;
};

export const updateNotification = async (
req: UpdateNotificationRequest,
res: Response<UserNotifications | { count: number } | ErrorResponse>,
) => {
const logger = parentLogger.child({
module: 'UserNotifications::UpdateNotification',
userId: req.user?.id,
});

logger.info({ params: req.params, body: req.body }, 'Updating user notification(s)');

try {
if (!req.user) {
logger.warn('Unauthorized, check middleware');
return res.status(401).json({ error: 'Unauthorized' } as ErrorResponse);
}

const { id: userId } = req.user;

if (req.params.notificationId) {
// Single update
const notificationId = parseInt(req.params.notificationId);
const updateData = UpdateDataSchema.parse(req.body);
const updatedNotification = await updateUserNotification(notificationId, userId, updateData);
logger.info({ notificationId: updatedNotification.id }, 'Successfully updated user notification');
return res.status(200).json(updatedNotification);
} else {
// Batch update
const { notificationIds, updateData } = BatchUpdateSchema.parse(req.body);
const count = await batchUpdateUserNotifications(notificationIds, userId, updateData);
logger.info({ count }, 'Successfully batch updated user notifications');
return res.status(200).json({ count });
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn({ error: error.errors }, 'Invalid request parameters');
return res.status(400).json({ error: 'Invalid request parameters', details: error.errors } as ErrorResponse);
}
if (error instanceof Error) {
if (error.message === 'Notification not found') {
logger.warn({ error }, 'Notification not found');
return res.status(404).json({ error: 'Notification not found' } as ErrorResponse);
}
if (error.message === 'Notification does not belong to the user') {
logger.warn({ error }, 'Notification does not belong to the user');
return res.status(403).json({ error: 'Notification does not belong to the user' } as ErrorResponse);
}
}
logger.error({ error }, 'Error updating user notification(s)');
return res.status(500).json({ error: 'Internal server error' } as ErrorResponse);
}
};
48 changes: 48 additions & 0 deletions desci-server/src/controllers/notifications/updateSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { User, NotificationType } from '@prisma/client';
import { Request, Response } from 'express';
import { z } from 'zod';

import { logger as parentLogger } from '../../logger.js';
import { updateNotificationSettings } from '../../services/NotificationService.js';

const NotificationSettingsSchema = z.record(z.nativeEnum(NotificationType), z.boolean());

interface AuthenticatedRequest extends Request {
user: User;
}

export interface ErrorResponse {
error: string;
details?: z.ZodIssue[] | string;
}

export const updateSettings = async (
req: AuthenticatedRequest & { body: z.infer<typeof NotificationSettingsSchema> },
res: Response<Record<NotificationType, boolean> | ErrorResponse>,
) => {
const logger = parentLogger.child({
module: 'UserNotifications::UpdateSettings',
userId: req.user?.id,
});

try {
if (!req.user) {
logger.warn('Unauthorized, check middleware');
return res.status(401).json({ error: 'Unauthorized' } as ErrorResponse);
}

const { id: userId } = req.user;
const settings = NotificationSettingsSchema.parse(req.body);

const newSettings = await updateNotificationSettings(userId, settings);

return res.status(200).json(newSettings);
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn({ error: error.errors }, 'Invalid request parameters');
return res.status(400).json({ error: 'Invalid request parameters', details: error.errors } as ErrorResponse);
}
logger.error({ error }, 'Error updating user notification settings');
return res.status(500).json({ error: 'Internal server error' } as ErrorResponse);
}
};
2 changes: 2 additions & 0 deletions desci-server/src/routes/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import data from './data.js';
import doi from './doi.js';
import log from './log.js';
import nodes from './nodes.js';
import notifications from './notifications.js';
import pub from './pub.js';
import referral from './referral.js';
import search from './search.js';
Expand Down Expand Up @@ -60,6 +61,7 @@ router.use('/communities', communities);
router.use('/attestations', attestations);
router.use('/doi', doi);
router.use('/search', search);
router.use('/notifications', notifications);

router.get('/nft/:id', nft);
router.use('/referral', referral);
Expand Down
Loading
Loading