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 56e55a23..6e65c4f1 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[] Annotation Annotation[] @@index([ownerId]) @@ -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) @@ -199,6 +201,7 @@ model User { OrcidPutCodes OrcidPutCodes[] BookmarkedNode BookmarkedNode[] DeferredEmails DeferredEmails[] + UserNotifications UserNotifications[] @@index([orcid]) @@index([walletAddress]) @@ -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 @@ -1035,3 +1053,7 @@ enum DoiStatus { FAILED SUCCESS } + +enum NotificationType { + PUBLISH +} 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 { diff --git a/desci-server/src/controllers/notifications/create.ts b/desci-server/src/controllers/notifications/create.ts new file mode 100644 index 00000000..d8108f53 --- /dev/null +++ b/desci-server/src/controllers/notifications/create.ts @@ -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 }, + 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); + } + 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/index.ts b/desci-server/src/controllers/notifications/index.ts new file mode 100644 index 00000000..5cc3fa75 --- /dev/null +++ b/desci-server/src/controllers/notifications/index.ts @@ -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 { + 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 }, + 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' } 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); + } +}; diff --git a/desci-server/src/controllers/notifications/update.ts b/desci-server/src/controllers/notifications/update.ts new file mode 100644 index 00000000..ee793d8e --- /dev/null +++ b/desci-server/src/controllers/notifications/update.ts @@ -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 | z.infer; +}; + +export const updateNotification = async ( + req: UpdateNotificationRequest, + res: Response, +) => { + 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); + } +}; diff --git a/desci-server/src/controllers/notifications/updateSettings.ts b/desci-server/src/controllers/notifications/updateSettings.ts new file mode 100644 index 00000000..3400f310 --- /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 | 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); + } +}; 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..b9e1fa64 --- /dev/null +++ b/desci-server/src/routes/v1/notifications.ts @@ -0,0 +1,17 @@ +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); // Batch update route +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 new file mode 100644 index 00000000..ca39ab7e --- /dev/null +++ b/desci-server/src/services/NotificationService.ts @@ -0,0 +1,198 @@ +import { NotificationType, Prisma, User, 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 type NotificationSettings = Partial>; + +export type NotificationUpdateData = { + dismissed?: boolean; + // seen?: boolean; // future +}; + +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'); + + 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({ + 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; +}; + +export const updateUserNotification = async ( + notificationId: number, + userId: number, + updateData: NotificationUpdateData, +): Promise => { + logger.info({ notificationId, userId, updateData }, 'Updating user notification'); + + 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: 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'); + + 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 mergedSettings; +}; + +/* + ** 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; +}; 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({}); + }); + }); +});