From ddad1865e8dfbf3e642c4e8a2cb2f0dc71701e71 Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Tue, 8 Oct 2024 19:40:39 +0000 Subject: [PATCH 01/11] add userNotifs table, add notifSettings to users --- .../migration.sql | 27 +++++++++++++++++++ desci-server/prisma/schema.prisma | 22 +++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 desci-server/prisma/migrations/20241008193920_add_notifications_table/migration.sql diff --git a/desci-server/prisma/migrations/20241008193920_add_notifications_table/migration.sql b/desci-server/prisma/migrations/20241008193920_add_notifications_table/migration.sql new file mode 100644 index 00000000..aa69954d --- /dev/null +++ b/desci-server/prisma/migrations/20241008193920_add_notifications_table/migration.sql @@ -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; diff --git a/desci-server/prisma/schema.prisma b/desci-server/prisma/schema.prisma index d44babc7..639ed004 100755 --- a/desci-server/prisma/schema.prisma +++ b/desci-server/prisma/schema.prisma @@ -51,6 +51,7 @@ model Node { DoiSubmissionQueue DoiSubmissionQueue[] BookmarkedNode BookmarkedNode[] DeferredEmails DeferredEmails[] + UserNotifications UserNotifications[] @@index([ownerId]) @@index([uuid]) @@ -153,6 +154,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) @@ -198,6 +200,7 @@ model User { OrcidPutCodes OrcidPutCodes[] BookmarkedNode BookmarkedNode[] DeferredEmails DeferredEmails[] + UserNotifications UserNotifications[] @@index([orcid]) @@index([walletAddress]) @@ -907,6 +910,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 @@ -1031,3 +1049,7 @@ enum DoiStatus { FAILED SUCCESS } + +enum NotificationType { + PUBLISH +} From 2763871938965477b408929c9526c26193f55443 Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Tue, 8 Oct 2024 19:41:03 +0000 Subject: [PATCH 02/11] add userNotifs index controller --- .../src/controllers/notifications/index.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 desci-server/src/controllers/notifications/index.ts diff --git a/desci-server/src/controllers/notifications/index.ts b/desci-server/src/controllers/notifications/index.ts new file mode 100644 index 00000000..3d8810ef --- /dev/null +++ b/desci-server/src/controllers/notifications/index.ts @@ -0,0 +1,94 @@ +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'; + +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 { + data: T[]; + pagination: { + currentPage: number; + totalPages: number; + totalItems: number; + }; +} + +export interface ErrorResponse { + error: string; + details?: z.ZodIssue[] | string; +} + +export const getUserNotifications = async ( + req: AuthenticatedRequest & { query: z.infer }, + res: Response | 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' }); + } + + const { id: userId } = req.user; + const { page, perPage, dismissed = false } = GetNotificationsQuerySchema.parse(req.query); + + const skip = (page - 1) * perPage; + + const whereClause = { + userId, + dismissed, + }; + + const [notifications, totalItems] = await Promise.all([ + prisma.userNotifications.findMany({ + where: whereClause, + skip, + take: perPage, + orderBy: { createdAt: 'desc' }, + }), + prisma.userNotifications.count({ where: whereClause }), + ]); + + const totalPages = Math.ceil(totalItems / perPage); + + const response: PaginatedResponse = { + data: notifications, + pagination: { + currentPage: page, + totalPages, + totalItems, + }, + }; + + logger.info({ totalItems, page, totalPages }, 'Successfully fetched user notifications'); + return res.status(200).json(response); + } 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 }); + } + logger.error({ error }, 'Error fetching user notifications'); + return res.status(500).json({ error: 'Internal server error' }); + } +}; From 77c1e90a20fd0990bef4e8c7ccbc95f4e87767fc Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Tue, 8 Oct 2024 19:45:04 +0000 Subject: [PATCH 03/11] add notification routing --- desci-server/src/routes/v1/index.ts | 2 ++ desci-server/src/routes/v1/notifications.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 desci-server/src/routes/v1/notifications.ts diff --git a/desci-server/src/routes/v1/index.ts b/desci-server/src/routes/v1/index.ts index 616777fc..4ef92d65 100755 --- a/desci-server/src/routes/v1/index.ts +++ b/desci-server/src/routes/v1/index.ts @@ -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'; @@ -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); diff --git a/desci-server/src/routes/v1/notifications.ts b/desci-server/src/routes/v1/notifications.ts new file mode 100644 index 00000000..a73d4e47 --- /dev/null +++ b/desci-server/src/routes/v1/notifications.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; + +import { getUserNotifications } from '../../controllers/notifications/index.js'; +import { ensureUser } from '../../internal.js'; + +const router = Router(); + +router.get('/', [ensureUser], getUserNotifications); +// router.post('/',[ensureUser], createUserNotification); +// router.patch('/',[ensureUser], updateUserNotification); + +export default router; From c41c529102c7f0b385f5773c8ea6f3f326d817aa Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Wed, 9 Oct 2024 11:15:48 +0000 Subject: [PATCH 04/11] added create controller/svc --- .../src/controllers/notifications/create.ts | 68 ++++++++++++++ .../src/services/NotificationService.ts | 93 +++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 desci-server/src/controllers/notifications/create.ts create mode 100644 desci-server/src/services/NotificationService.ts diff --git a/desci-server/src/controllers/notifications/create.ts b/desci-server/src/controllers/notifications/create.ts new file mode 100644 index 00000000..f3c80440 --- /dev/null +++ b/desci-server/src/controllers/notifications/create.ts @@ -0,0 +1,68 @@ +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 }, + res: Response, +) => { + 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); + } + } + logger.error({ error }, 'Error creating user notification'); + return res.status(500).json({ error: 'Internal server error' } as ErrorResponse); + } +}; diff --git a/desci-server/src/services/NotificationService.ts b/desci-server/src/services/NotificationService.ts new file mode 100644 index 00000000..952e9949 --- /dev/null +++ b/desci-server/src/services/NotificationService.ts @@ -0,0 +1,93 @@ +import { NotificationType, Prisma, UserNotifications } from '@prisma/client'; +import { z } from 'zod'; + +import { prisma } from '../client.js'; +import { CreateNotificationSchema } from '../controllers/notifications/create.js'; +import { GetNotificationsQuerySchema, PaginatedResponse } from '../controllers/notifications/index.js'; +import { logger as parentLogger } from '../logger.js'; + +type GetNotificationsQuery = z.infer; +export type CreateNotificationData = z.infer; + +const logger = parentLogger.child({ + module: 'UserNotifications::NotificationService', +}); + +export const getUserNotifications = async ( + userId: number, + query: GetNotificationsQuery, +): Promise> => { + const { page, perPage, dismissed = false } = query; + const skip = (page - 1) * perPage; + const whereClause = { + userId, + dismissed, + }; + + const [notifications, totalItems] = await Promise.all([ + prisma.userNotifications.findMany({ + where: whereClause, + skip, + take: perPage, + orderBy: { createdAt: 'desc' }, + }), + prisma.userNotifications.count({ where: whereClause }), + ]); + + const totalPages = Math.ceil(totalItems / perPage); + + return { + data: notifications, + pagination: { + currentPage: page, + totalPages, + totalItems, + }, + }; +}; + +export const createUserNotification = async (data: CreateNotificationData): Promise => { + logger.info({ data }, 'Creating user notification'); + + if (data.nodeUuid) { + // Validate node belongs to user + const node = await prisma.node.findUnique({ + where: { uuid: data.nodeUuid }, + select: { ownerId: true }, + }); + + if (!node) { + logger.warn({ nodeUuid: data.nodeUuid }, 'Node not found'); + throw new Error('Node not found'); + } + + if (node.ownerId !== data.userId) { + logger.warn({ nodeUuid: data.nodeUuid, userId: data.userId }, 'Node does not belong to the user'); + throw new Error('Node does not belong to the user'); + } + } + + if (!Object.values(NotificationType).includes(data.type as NotificationType)) { + // Validates valid notification type + logger.warn({ type: data.type }, 'Invalid notification type'); + throw new Error('Invalid notification type'); + } + + const notificationData: Prisma.UserNotificationsCreateInput = { + user: { connect: { id: data.userId } }, + type: data.type as NotificationType, + title: data.title, + message: data.message, + dismissed: false, + node: data.nodeUuid ? { connect: { uuid: data.nodeUuid } } : undefined, + payload: data.payload ? (data.payload as Prisma.JsonObject) : undefined, + }; + + const notification = await prisma.userNotifications.create({ + data: notificationData, + }); + + logger.info({ notificationId: notification.id }, 'User notification created successfully'); + + return notification; +}; From bec63ac3574bd9becbe0e0d14de6cb162e6712b3 Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Wed, 9 Oct 2024 11:16:10 +0000 Subject: [PATCH 05/11] extracted index controller business logic out into its own service --- .../src/controllers/notifications/index.ts | 50 ++++++------------- desci-server/src/routes/v1/notifications.ts | 4 +- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/desci-server/src/controllers/notifications/index.ts b/desci-server/src/controllers/notifications/index.ts index 3d8810ef..2465335f 100644 --- a/desci-server/src/controllers/notifications/index.ts +++ b/desci-server/src/controllers/notifications/index.ts @@ -4,8 +4,9 @@ import { z } from 'zod'; import { prisma } from '../../client.js'; import { logger as parentLogger } from '../../logger.js'; +import { getUserNotifications } from '../../services/NotificationService.js'; -const GetNotificationsQuerySchema = z.object({ +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 @@ -32,7 +33,7 @@ export interface ErrorResponse { details?: z.ZodIssue[] | string; } -export const getUserNotifications = async ( +export const list = async ( req: AuthenticatedRequest & { query: z.infer }, res: Response | ErrorResponse>, ) => { @@ -41,54 +42,35 @@ export const getUserNotifications = async ( 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' }); + return res.status(401).json({ error: 'Unauthorized' } as ErrorResponse); } const { id: userId } = req.user; - const { page, perPage, dismissed = false } = GetNotificationsQuerySchema.parse(req.query); - - const skip = (page - 1) * perPage; - - const whereClause = { - userId, - dismissed, - }; - - const [notifications, totalItems] = await Promise.all([ - prisma.userNotifications.findMany({ - where: whereClause, - skip, - take: perPage, - orderBy: { createdAt: 'desc' }, - }), - prisma.userNotifications.count({ where: whereClause }), - ]); + const query = GetNotificationsQuerySchema.parse(req.query); - const totalPages = Math.ceil(totalItems / perPage); + const notifs = await getUserNotifications(userId, query); - const response: PaginatedResponse = { - data: notifications, - pagination: { - currentPage: page, - totalPages, - totalItems, + logger.info( + { + totalItems: notifs.pagination.totalItems, + page: notifs.pagination.currentPage, + totalPages: notifs.pagination.totalPages, }, - }; + 'Successfully fetched user notifications', + ); - logger.info({ totalItems, page, totalPages }, 'Successfully fetched user notifications'); - return res.status(200).json(response); + 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 }); + 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' }); + return res.status(500).json({ error: 'Internal server error' } as ErrorResponse); } }; diff --git a/desci-server/src/routes/v1/notifications.ts b/desci-server/src/routes/v1/notifications.ts index a73d4e47..3d12924b 100644 --- a/desci-server/src/routes/v1/notifications.ts +++ b/desci-server/src/routes/v1/notifications.ts @@ -1,11 +1,11 @@ import { Router } from 'express'; -import { getUserNotifications } from '../../controllers/notifications/index.js'; +import { listUserNotifications } from '../../controllers/notifications/index.js'; import { ensureUser } from '../../internal.js'; const router = Router(); -router.get('/', [ensureUser], getUserNotifications); +router.get('/', [ensureUser], listUserNotifications); // router.post('/',[ensureUser], createUserNotification); // router.patch('/',[ensureUser], updateUserNotification); From 51e8d325b2c302655dcc9e53588038c0d6b1a3e4 Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Wed, 9 Oct 2024 11:27:04 +0000 Subject: [PATCH 06/11] added update/dismiss notification controller/svc --- .../src/controllers/notifications/update.ts | 64 +++++++++++++++++++ desci-server/src/routes/v1/notifications.ts | 6 +- .../src/services/NotificationService.ts | 28 ++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 desci-server/src/controllers/notifications/update.ts diff --git a/desci-server/src/controllers/notifications/update.ts b/desci-server/src/controllers/notifications/update.ts new file mode 100644 index 00000000..2ff3fbf3 --- /dev/null +++ b/desci-server/src/controllers/notifications/update.ts @@ -0,0 +1,64 @@ +import { User, UserNotifications } from '@prisma/client'; +import { Request, Response } from 'express'; +import { z } from 'zod'; + +import { logger as parentLogger } from '../../logger.js'; +import { updateUserNotification } from '../../services/NotificationService.js'; + +export const UpdateNotificationSchema = z.object({ + notificationId: z.number(), + dismissed: z.boolean(), +}); + +interface AuthenticatedRequest extends Request { + user: User; +} + +export interface ErrorResponse { + error: string; + details?: z.ZodIssue[] | string; +} + +export const updateNotification = async ( + req: AuthenticatedRequest & { body: z.infer }, + res: Response, +) => { + const logger = parentLogger.child({ + module: 'UserNotifications::UpdateNotification', + userId: req.user?.id, + }); + + logger.info({ body: req.body }, 'Updating 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 { notificationId, dismissed } = UpdateNotificationSchema.parse(req.body); + + const updatedNotification = await updateUserNotification(notificationId, userId, dismissed); + + logger.info({ notificationId: updatedNotification.id }, 'Successfully updated user notification'); + return res.status(200).json(updatedNotification); + } 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'); + return res.status(500).json({ error: 'Internal server error' } as ErrorResponse); + } +}; diff --git a/desci-server/src/routes/v1/notifications.ts b/desci-server/src/routes/v1/notifications.ts index 3d12924b..66510a87 100644 --- a/desci-server/src/routes/v1/notifications.ts +++ b/desci-server/src/routes/v1/notifications.ts @@ -1,12 +1,14 @@ import { Router } from 'express'; +import { createNotification } from '../../controllers/notifications/create.js'; import { listUserNotifications } from '../../controllers/notifications/index.js'; +import { updateNotification } from '../../controllers/notifications/update.js'; import { ensureUser } from '../../internal.js'; const router = Router(); router.get('/', [ensureUser], listUserNotifications); -// router.post('/',[ensureUser], createUserNotification); -// router.patch('/',[ensureUser], updateUserNotification); +router.post('/', [ensureUser], createNotification); +router.patch('/', [ensureUser], updateNotification); export default router; diff --git a/desci-server/src/services/NotificationService.ts b/desci-server/src/services/NotificationService.ts index 952e9949..2516d409 100644 --- a/desci-server/src/services/NotificationService.ts +++ b/desci-server/src/services/NotificationService.ts @@ -91,3 +91,31 @@ export const createUserNotification = async (data: CreateNotificationData): Prom return notification; }; + +export const updateUserNotification = async ( + notificationId: number, + userId: number, + dismissed: boolean, +): Promise => { + const notification = await prisma.userNotifications.findUnique({ + where: { id: notificationId }, + }); + + if (!notification) { + logger.warn({ notificationId }, 'Notification not found'); + throw new Error('Notification not found'); + } + + if (notification.userId !== userId) { + logger.warn({ notificationId, userId }, 'Notification does not belong to the user'); + throw new Error('Notification does not belong to the user'); + } + + const updatedNotification = await prisma.userNotifications.update({ + where: { id: notificationId }, + data: { dismissed }, + }); + + logger.info({ notificationId: updatedNotification.id }, 'User notification updated successfully'); + return updatedNotification; +}; From 1fc0d57832d6482932356c4c08e229ecf533bcff Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Wed, 9 Oct 2024 18:21:59 +0000 Subject: [PATCH 07/11] add notification settings logic, update settings controller, check on create --- .../src/controllers/notifications/create.ts | 4 ++ .../notifications/updateSettings.ts | 48 ++++++++++++++++++ desci-server/src/routes/v1/notifications.ts | 4 +- .../src/services/NotificationService.ts | 50 ++++++++++++++++++- 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 desci-server/src/controllers/notifications/updateSettings.ts diff --git a/desci-server/src/controllers/notifications/create.ts b/desci-server/src/controllers/notifications/create.ts index f3c80440..d8108f53 100644 --- a/desci-server/src/controllers/notifications/create.ts +++ b/desci-server/src/controllers/notifications/create.ts @@ -61,6 +61,10 @@ export const createNotification = async ( 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); diff --git a/desci-server/src/controllers/notifications/updateSettings.ts b/desci-server/src/controllers/notifications/updateSettings.ts new file mode 100644 index 00000000..a5f9f9a7 --- /dev/null +++ b/desci-server/src/controllers/notifications/updateSettings.ts @@ -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 }, + res: Response, +) => { + 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 updatedUser = await updateNotificationSettings(userId, settings); + + return res.status(200).json(updatedUser); + } 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); + } +}; diff --git a/desci-server/src/routes/v1/notifications.ts b/desci-server/src/routes/v1/notifications.ts index 66510a87..849d22b3 100644 --- a/desci-server/src/routes/v1/notifications.ts +++ b/desci-server/src/routes/v1/notifications.ts @@ -3,12 +3,14 @@ import { Router } from 'express'; import { createNotification } from '../../controllers/notifications/create.js'; import { listUserNotifications } from '../../controllers/notifications/index.js'; import { updateNotification } from '../../controllers/notifications/update.js'; +import { updateSettings } from '../../controllers/notifications/updateSettings.js'; import { ensureUser } from '../../internal.js'; const router = Router(); router.get('/', [ensureUser], listUserNotifications); router.post('/', [ensureUser], createNotification); -router.patch('/', [ensureUser], updateNotification); +router.patch('/:notificationId', [ensureUser], updateNotification); +router.patch('/settings', [ensureUser], updateSettings); export default router; diff --git a/desci-server/src/services/NotificationService.ts b/desci-server/src/services/NotificationService.ts index 2516d409..defed00e 100644 --- a/desci-server/src/services/NotificationService.ts +++ b/desci-server/src/services/NotificationService.ts @@ -1,4 +1,4 @@ -import { NotificationType, Prisma, UserNotifications } from '@prisma/client'; +import { NotificationType, Prisma, User, UserNotifications } from '@prisma/client'; import { z } from 'zod'; import { prisma } from '../client.js'; @@ -13,6 +13,8 @@ const logger = parentLogger.child({ module: 'UserNotifications::NotificationService', }); +export type NotificationSettings = Partial>; + export const getUserNotifications = async ( userId: number, query: GetNotificationsQuery, @@ -49,6 +51,13 @@ export const getUserNotifications = async ( export const createUserNotification = async (data: CreateNotificationData): Promise => { logger.info({ data }, 'Creating user notification'); + const settings = await getNotificationSettings(data.userId); + + if (!shouldSendNotification(settings, data.type)) { + logger.warn({ userId: data.userId, type: data.type }, 'Notification creation blocked by user settings'); + throw new Error('Notification type is disabled for this user'); + } + if (data.nodeUuid) { // Validate node belongs to user const node = await prisma.node.findUnique({ @@ -119,3 +128,42 @@ export const updateUserNotification = async ( logger.info({ notificationId: updatedNotification.id }, 'User notification updated successfully'); return updatedNotification; }; + +export const updateNotificationSettings = async (userId: number, newSettings: NotificationSettings): Promise => { + logger.info({ userId, newSettings }, 'Updating user notification settings'); + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { notificationSettings: true }, + }); + + const currentSettings = (user?.notificationSettings as NotificationSettings) || {}; + const mergedSettings = { ...currentSettings, ...newSettings }; + + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { + notificationSettings: mergedSettings as Prisma.JsonObject, + }, + }); + + logger.info({ userId, mergedSettings }, 'User notification settings updated successfully'); + return updatedUser; +}; + +/* + ** A JSON object stored on the User model, if is set to false, the user will not receive notifications of that type, + ** otherwise, they will receive notifications of that type. Note: Undefined types will default to true. + */ +export const getNotificationSettings = async (userId: number): Promise => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { notificationSettings: true }, + }); + + return (user?.notificationSettings as NotificationSettings) || {}; +}; + +export const shouldSendNotification = (settings: NotificationSettings, type: NotificationType): boolean => { + return settings[type] !== false; +}; From 9c0969f5ae586aa17df65ddb2f2e71cca0c1c042 Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Wed, 9 Oct 2024 18:50:45 +0000 Subject: [PATCH 08/11] added batch updating/dismissing notifs --- .../src/controllers/notifications/update.ts | 44 +++++++++++++------ desci-server/src/routes/v1/notifications.ts | 1 + .../src/services/NotificationService.ts | 30 ++++++++++++- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/desci-server/src/controllers/notifications/update.ts b/desci-server/src/controllers/notifications/update.ts index 2ff3fbf3..ee793d8e 100644 --- a/desci-server/src/controllers/notifications/update.ts +++ b/desci-server/src/controllers/notifications/update.ts @@ -3,11 +3,15 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import { logger as parentLogger } from '../../logger.js'; -import { updateUserNotification } from '../../services/NotificationService.js'; +import { updateUserNotification, batchUpdateUserNotifications } from '../../services/NotificationService.js'; -export const UpdateNotificationSchema = z.object({ - notificationId: z.number(), - dismissed: z.boolean(), +const UpdateDataSchema = z.object({ + dismissed: z.boolean().optional(), +}); + +const BatchUpdateSchema = z.object({ + notificationIds: z.array(z.number()), + updateData: UpdateDataSchema, }); interface AuthenticatedRequest extends Request { @@ -19,16 +23,21 @@ export interface ErrorResponse { details?: z.ZodIssue[] | string; } +type UpdateNotificationRequest = AuthenticatedRequest & { + params: { notificationId?: string }; + body: z.infer | z.infer; +}; + export const updateNotification = async ( - req: AuthenticatedRequest & { body: z.infer }, - res: Response, + req: UpdateNotificationRequest, + res: Response, ) => { const logger = parentLogger.child({ module: 'UserNotifications::UpdateNotification', userId: req.user?.id, }); - logger.info({ body: req.body }, 'Updating user notification'); + logger.info({ params: req.params, body: req.body }, 'Updating user notification(s)'); try { if (!req.user) { @@ -37,12 +46,21 @@ export const updateNotification = async ( } const { id: userId } = req.user; - const { notificationId, dismissed } = UpdateNotificationSchema.parse(req.body); - - const updatedNotification = await updateUserNotification(notificationId, userId, dismissed); - logger.info({ notificationId: updatedNotification.id }, 'Successfully updated user notification'); - return res.status(200).json(updatedNotification); + 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'); @@ -58,7 +76,7 @@ export const updateNotification = async ( return res.status(403).json({ error: 'Notification does not belong to the user' } as ErrorResponse); } } - logger.error({ error }, 'Error updating user notification'); + logger.error({ error }, 'Error updating user notification(s)'); return res.status(500).json({ error: 'Internal server error' } as ErrorResponse); } }; diff --git a/desci-server/src/routes/v1/notifications.ts b/desci-server/src/routes/v1/notifications.ts index 849d22b3..b9e1fa64 100644 --- a/desci-server/src/routes/v1/notifications.ts +++ b/desci-server/src/routes/v1/notifications.ts @@ -10,6 +10,7 @@ const router = Router(); router.get('/', [ensureUser], listUserNotifications); router.post('/', [ensureUser], createNotification); +router.patch('/', [ensureUser], updateNotification); // Batch update route router.patch('/:notificationId', [ensureUser], updateNotification); router.patch('/settings', [ensureUser], updateSettings); diff --git a/desci-server/src/services/NotificationService.ts b/desci-server/src/services/NotificationService.ts index defed00e..65909ec0 100644 --- a/desci-server/src/services/NotificationService.ts +++ b/desci-server/src/services/NotificationService.ts @@ -15,6 +15,11 @@ const logger = parentLogger.child({ export type NotificationSettings = Partial>; +export type NotificationUpdateData = { + dismissed?: boolean; + // seen?: boolean; // future +}; + export const getUserNotifications = async ( userId: number, query: GetNotificationsQuery, @@ -104,8 +109,10 @@ export const createUserNotification = async (data: CreateNotificationData): Prom export const updateUserNotification = async ( notificationId: number, userId: number, - dismissed: boolean, + updateData: NotificationUpdateData, ): Promise => { + logger.info({ notificationId, userId, updateData }, 'Updating user notification'); + const notification = await prisma.userNotifications.findUnique({ where: { id: notificationId }, }); @@ -122,13 +129,32 @@ export const updateUserNotification = async ( const updatedNotification = await prisma.userNotifications.update({ where: { id: notificationId }, - data: { dismissed }, + data: updateData, }); logger.info({ notificationId: updatedNotification.id }, 'User notification updated successfully'); return updatedNotification; }; +export const batchUpdateUserNotifications = async ( + notificationIds: number[], + userId: number, + updateData: NotificationUpdateData, +): Promise => { + logger.info({ notificationIds, userId, updateData }, 'Batch updating user notifications'); + + const result = await prisma.userNotifications.updateMany({ + where: { + id: { in: notificationIds }, + userId: userId, + }, + data: updateData, + }); + + logger.info({ userId, count: result.count }, 'User notifications batch updated successfully'); + return result.count; +}; + export const updateNotificationSettings = async (userId: number, newSettings: NotificationSettings): Promise => { logger.info({ userId, newSettings }, 'Updating user notification settings'); From 383c0d752ea764e0238c531fa7aad1650e899a41 Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Wed, 9 Oct 2024 19:22:23 +0000 Subject: [PATCH 09/11] add tests for notification services --- .../src/controllers/notifications/index.ts | 2 +- .../test/integration/notifications.test.ts | 163 ++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 desci-server/test/integration/notifications.test.ts diff --git a/desci-server/src/controllers/notifications/index.ts b/desci-server/src/controllers/notifications/index.ts index 2465335f..5cc3fa75 100644 --- a/desci-server/src/controllers/notifications/index.ts +++ b/desci-server/src/controllers/notifications/index.ts @@ -33,7 +33,7 @@ export interface ErrorResponse { details?: z.ZodIssue[] | string; } -export const list = async ( +export const listUserNotifications = async ( req: AuthenticatedRequest & { query: z.infer }, res: Response | ErrorResponse>, ) => { diff --git a/desci-server/test/integration/notifications.test.ts b/desci-server/test/integration/notifications.test.ts new file mode 100644 index 00000000..6dd9ef23 --- /dev/null +++ b/desci-server/test/integration/notifications.test.ts @@ -0,0 +1,163 @@ +import 'mocha'; +import { NotificationType, User, UserNotifications } from '@prisma/client'; +import { expect } from 'chai'; + +import { prisma } from '../../src/client.js'; +import { + createUserNotification, + getUserNotifications, + updateUserNotification, + batchUpdateUserNotifications, + updateNotificationSettings, + getNotificationSettings, +} from '../../src/services/NotificationService.js'; +import { expectThrowsAsync } from '../util.js'; + +describe('Notification Service', () => { + let user: User; + + beforeEach(async () => { + await prisma.$queryRaw`TRUNCATE TABLE "User" CASCADE;`; + await prisma.$queryRaw`TRUNCATE TABLE "UserNotifications" CASCADE;`; + user = await prisma.user.create({ + data: { + email: 'test@example.com', + }, + }); + }); + + describe('createUserNotification', () => { + it('should create a notification for a user', async () => { + const notification = await createUserNotification({ + userId: user.id, + type: NotificationType.PUBLISH, + title: 'Test Notification', + message: 'This is a test notification', + }); + + expect(notification.userId).to.equal(user.id); + expect(notification.type).to.equal(NotificationType.PUBLISH); + expect(notification.title).to.equal('Test Notification'); + expect(notification.message).to.equal('This is a test notification'); + }); + + it('should throw an error when creating a notification for a disabled type', async () => { + await updateNotificationSettings(user.id, { [NotificationType.PUBLISH]: false }); + + await expectThrowsAsync( + () => + createUserNotification({ + userId: user.id, + type: NotificationType.PUBLISH, + title: 'Test Notification', + message: 'This is a test notification', + }), + 'Notification type is disabled for this user', + ); + }); + }); + + describe('getUserNotifications', () => { + it('should retrieve user notifications with pagination', async () => { + for (let i = 0; i < 30; i++) { + await createUserNotification({ + userId: user.id, + type: NotificationType.PUBLISH, + title: `Notification ${i}`, + message: `This is notification ${i}`, + }); + } + + const result = await getUserNotifications(user.id, { page: 1, perPage: 10 }); + + expect(result.data.length).to.equal(10); + expect(result.pagination.currentPage).to.equal(1); + expect(result.pagination.totalPages).to.equal(2); + expect(result.pagination.totalItems).to.equal(30); + }); + }); + + describe('updateUserNotification', () => { + it('should update a single notification', async () => { + const notification = await createUserNotification({ + userId: user.id, + type: NotificationType.PUBLISH, + title: 'Test Notification', + message: 'This is a test notification', + }); + + const updatedNotification = await updateUserNotification(notification.id, user.id, { dismissed: true }); + + expect(updatedNotification.dismissed).to.be.true; + }); + + it('should throw an error when updating a non-existent notification', async () => { + await expectThrowsAsync( + () => updateUserNotification(999, user.id, { dismissed: true }), + 'Notification not found', + ); + }); + }); + + describe('batchUpdateUserNotifications', () => { + it('should update multiple notifications', async () => { + const notifications = await Promise.all([ + createUserNotification({ + userId: user.id, + type: NotificationType.PUBLISH, + title: 'Notification 1', + message: 'This is notification 1', + }), + createUserNotification({ + userId: user.id, + type: NotificationType.PUBLISH, + title: 'Notification 2', + message: 'This is notification 2', + }), + ]); + + const updatedCount = await batchUpdateUserNotifications( + notifications.map((n) => n.id), + user.id, + { dismissed: true }, + ); + + expect(updatedCount).to.equal(2); + + const updatedNotifications = await prisma.userNotifications.findMany({ + where: { id: { in: notifications.map((n) => n.id) } }, + }); + + expect(updatedNotifications.every((n) => n.dismissed)).to.be.true; + }); + }); + + describe('updateNotificationSettings', () => { + it('should update user notification settings', async () => { + const updatedUser = await updateNotificationSettings(user.id, { + [NotificationType.PUBLISH]: false, + }); + + const settings = updatedUser.notificationSettings as Record; + expect(settings[NotificationType.PUBLISH]).to.be.false; + }); + }); + + describe('getNotificationSettings', () => { + it('should retrieve user notification settings', async () => { + await updateNotificationSettings(user.id, { + [NotificationType.PUBLISH]: false, + }); + + const settings = await getNotificationSettings(user.id); + + expect(settings[NotificationType.PUBLISH]).to.be.false; + }); + + it('should return an empty object for users without settings', async () => { + const settings = await getNotificationSettings(user.id); + + expect(settings).to.deep.equal({}); + }); + }); +}); From f065663dbbe00ac5dadd0c7c1e4096cb7c47aa7a Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Thu, 10 Oct 2024 08:35:07 +0000 Subject: [PATCH 10/11] return notif settings --- desci-server/src/controllers/auth/profile.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/desci-server/src/controllers/auth/profile.ts b/desci-server/src/controllers/auth/profile.ts index 537b9120..01508ee4 100755 --- a/desci-server/src/controllers/auth/profile.ts +++ b/desci-server/src/controllers/auth/profile.ts @@ -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 { From 36923f04a32755de4ed75f518a9ea780aa112503 Mon Sep 17 00:00:00 2001 From: kadami <86646883+kadamidev@users.noreply.github.com> Date: Thu, 10 Oct 2024 08:56:06 +0000 Subject: [PATCH 11/11] update notif settings, return new settings --- .../src/controllers/notifications/updateSettings.ts | 6 +++--- desci-server/src/services/NotificationService.ts | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/desci-server/src/controllers/notifications/updateSettings.ts b/desci-server/src/controllers/notifications/updateSettings.ts index a5f9f9a7..3400f310 100644 --- a/desci-server/src/controllers/notifications/updateSettings.ts +++ b/desci-server/src/controllers/notifications/updateSettings.ts @@ -18,7 +18,7 @@ export interface ErrorResponse { export const updateSettings = async ( req: AuthenticatedRequest & { body: z.infer }, - res: Response, + res: Response | ErrorResponse>, ) => { const logger = parentLogger.child({ module: 'UserNotifications::UpdateSettings', @@ -34,9 +34,9 @@ export const updateSettings = async ( const { id: userId } = req.user; const settings = NotificationSettingsSchema.parse(req.body); - const updatedUser = await updateNotificationSettings(userId, settings); + const newSettings = await updateNotificationSettings(userId, settings); - return res.status(200).json(updatedUser); + return res.status(200).json(newSettings); } catch (error) { if (error instanceof z.ZodError) { logger.warn({ error: error.errors }, 'Invalid request parameters'); diff --git a/desci-server/src/services/NotificationService.ts b/desci-server/src/services/NotificationService.ts index 65909ec0..ca39ab7e 100644 --- a/desci-server/src/services/NotificationService.ts +++ b/desci-server/src/services/NotificationService.ts @@ -155,7 +155,10 @@ export const batchUpdateUserNotifications = async ( return result.count; }; -export const updateNotificationSettings = async (userId: number, newSettings: NotificationSettings): Promise => { +export const updateNotificationSettings = async ( + userId: number, + newSettings: NotificationSettings, +): Promise> => { logger.info({ userId, newSettings }, 'Updating user notification settings'); const user = await prisma.user.findUnique({ @@ -174,7 +177,7 @@ export const updateNotificationSettings = async (userId: number, newSettings: No }); logger.info({ userId, mergedSettings }, 'User notification settings updated successfully'); - return updatedUser; + return mergedSettings; }; /*