diff --git a/src/backend/src/prisma-query-args/notifications.query-args.ts b/src/backend/src/prisma-query-args/notifications.query-args.ts new file mode 100644 index 0000000000..4cf877ac5c --- /dev/null +++ b/src/backend/src/prisma-query-args/notifications.query-args.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args'; + +export type NotificationQueryArgs = ReturnType; + +export const getNotificationQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + users: getUserQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma/migrations/20241112180715_announcements_and_notifications/migration.sql b/src/backend/src/prisma/migrations/20241112180715_announcements_and_notifications/migration.sql deleted file mode 100644 index 91b483cea3..0000000000 --- a/src/backend/src/prisma/migrations/20241112180715_announcements_and_notifications/migration.sql +++ /dev/null @@ -1,24 +0,0 @@ --- CreateTable -CREATE TABLE "Announcement" ( - "announcementId" TEXT NOT NULL, - "userCreatedId" TEXT NOT NULL, - "dateCrated" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Announcement_pkey" PRIMARY KEY ("announcementId") -); - --- CreateTable -CREATE TABLE "Notification" ( - "notificationId" TEXT NOT NULL, - "text" TEXT NOT NULL, - "iconName" TEXT NOT NULL, - "userId" TEXT, - - CONSTRAINT "Notification_pkey" PRIMARY KEY ("notificationId") -); - --- AddForeignKey -ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql b/src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql new file mode 100644 index 0000000000..c57afabd25 --- /dev/null +++ b/src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql @@ -0,0 +1,57 @@ +-- CreateTable +CREATE TABLE "Announcement" ( + "announcementId" TEXT NOT NULL, + "text" TEXT NOT NULL, + "dateCrated" TIMESTAMP(3) NOT NULL, + "userCreatedId" TEXT NOT NULL, + + CONSTRAINT "Announcement_pkey" PRIMARY KEY ("announcementId") +); + +-- CreateTable +CREATE TABLE "Notification" ( + "notificationId" TEXT NOT NULL, + "text" TEXT NOT NULL, + "iconName" TEXT NOT NULL, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("notificationId") +); + +-- CreateTable +CREATE TABLE "_receivedAnnouncements" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "_userNotifications" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_receivedAnnouncements_AB_unique" ON "_receivedAnnouncements"("A", "B"); + +-- CreateIndex +CREATE INDEX "_receivedAnnouncements_B_index" ON "_receivedAnnouncements"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_userNotifications_AB_unique" ON "_userNotifications"("A", "B"); + +-- CreateIndex +CREATE INDEX "_userNotifications_B_index" ON "_userNotifications"("B"); + +-- AddForeignKey +ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_receivedAnnouncements" ADD CONSTRAINT "_receivedAnnouncements_A_fkey" FOREIGN KEY ("A") REFERENCES "Announcement"("announcementId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_receivedAnnouncements" ADD CONSTRAINT "_receivedAnnouncements_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_userNotifications" ADD CONSTRAINT "_userNotifications_A_fkey" FOREIGN KEY ("A") REFERENCES "Notification"("notificationId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_userNotifications" ADD CONSTRAINT "_userNotifications_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 112f7a6936..c06ca3d45f 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -180,9 +180,9 @@ model User { deletedFrequentlyAskedQuestions FrequentlyAskedQuestion[] @relation(name: "frequentlyAskedQuestionDeleter") createdMilestones Milestone[] @relation(name: "milestoneCreator") deletedMilestones Milestone[] @relation(name: "milestoneDeleter") - receivedAnnouncements Announcement[] @relation(name: "ReceivedAnnouncements") - createdAnnouncements Announcement[] @relation(name: "CreatedAnnouncements") - unreadNotifications Notification[] + receivedAnnouncements Announcement[] @relation(name: "receivedAnnouncements") + createdAnnouncements Announcement[] @relation(name: "createdAnnouncements") + unreadNotifications Notification[] @relation(name: "userNotifications") } model Role { @@ -934,15 +934,15 @@ model Milestone { model Announcement { announcementId String @id @default(uuid()) text String - usersReceived User[] @relation("ReceivedAnnouncements") + usersReceived User[] @relation("receivedAnnouncements") dateCrated DateTime userCreatedId String - userCreated User @relation("CreatedAnnouncements", fields: [userCreatedId], references: [userId]) + userCreated User @relation("createdAnnouncements", fields: [userCreatedId], references: [userId]) } model Notification { notificationId String @id @default(uuid()) text String iconName String - users User[] + users User[] @relation("userNotifications") } diff --git a/src/backend/src/services/notifications.services.ts b/src/backend/src/services/notifications.services.ts index a443d93588..483e6ed9d6 100644 --- a/src/backend/src/services/notifications.services.ts +++ b/src/backend/src/services/notifications.services.ts @@ -11,8 +11,10 @@ import { daysBetween, startOfDay, wbsPipe } from 'shared'; import { buildDueString } from '../utils/slack.utils'; import WorkPackagesService from './work-packages.services'; import { addWeeksToDate } from 'shared'; -import { HttpException } from '../utils/errors.utils'; +import { HttpException, NotFoundException } from '../utils/errors.utils'; import { meetingStartTimePipe } from '../utils/design-reviews.utils'; +import { getNotificationQueryArgs } from '../prisma-query-args/notifications.query-args'; +import notificationTransformer from '../transformers/notifications.transformer'; export default class NotificationsService { static async sendDailySlackNotifications() { @@ -193,4 +195,44 @@ export default class NotificationsService { await Promise.all(promises); } + + /** + * Creates and sends a notification to all users with the given userIds + * @param text writing in the notification + * @param iconName icon that appears in the notification + * @param userIds ids of users to send the notification to + * @param organizationId + * @returns the created notification + */ + static async sendNotifcationToUsers(text: string, iconName: string, userIds: string[], organizationId: string) { + const createdNotification = await prisma.notification.create({ + data: { + text, + iconName + }, + ...getNotificationQueryArgs(organizationId) + }); + + if (!createdNotification) throw new HttpException(500, 'Failed to create notification'); + + const notificationsPromises = userIds.map(async (userId) => { + const requestedUser = await prisma.user.findUnique({ + where: { userId } + }); + + if (!requestedUser) throw new NotFoundException('User', userId); + + return await prisma.user.update({ + where: { userId: requestedUser.userId }, + data: { + unreadNotifications: { + connect: { notificationId: createdNotification.notificationId } + } + } + }); + }); + + await Promise.all(notificationsPromises); + return notificationTransformer(createdNotification); + } } diff --git a/src/backend/src/transformers/notifications.transformer.ts b/src/backend/src/transformers/notifications.transformer.ts new file mode 100644 index 0000000000..32666b151a --- /dev/null +++ b/src/backend/src/transformers/notifications.transformer.ts @@ -0,0 +1,13 @@ +import { Prisma } from '@prisma/client'; +import { NotificationQueryArgs } from '../prisma-query-args/notifications.query-args'; +import { Notification } from 'shared'; + +const notificationTransformer = (notification: Prisma.NotificationGetPayload): Notification => { + return { + notificationId: notification.notificationId, + text: notification.text, + iconName: notification.iconName + }; +}; + +export default notificationTransformer; diff --git a/src/backend/tests/unmocked/notifications.test.ts b/src/backend/tests/unmocked/notifications.test.ts new file mode 100644 index 0000000000..d3cce68361 --- /dev/null +++ b/src/backend/tests/unmocked/notifications.test.ts @@ -0,0 +1,59 @@ +import { Organization } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers } from '../test-utils'; +import { batmanAppAdmin, supermanAdmin } from '../test-data/users.test-data'; +import { NotFoundException } from '../../src/utils/errors.utils'; +import prisma from '../../src/prisma/prisma'; +import NotificationService from '../../src/services/notifications.services'; + +describe('Notifications Tests', () => { + let orgId: string; + let organization: Organization; + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Send Notification', () => { + it('fails on invalid user id', async () => { + await expect( + async () => + await NotificationService.sendNotifcationToUsers( + 'test notification', + 'star', + ['1', '2'], + organization.organizationId + ) + ).rejects.toThrow(new NotFoundException('User', '1')); + }); + + it('Succeeds and sends notification to user', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + const testSuperman = await createTestUser(supermanAdmin, orgId); + await NotificationService.sendNotifcationToUsers( + 'test notification', + 'star', + [testBatman.userId, testSuperman.userId], + organization.organizationId + ); + + const batmanWithNotifications = await prisma.user.findUnique({ + where: { userId: testBatman.userId }, + include: { unreadNotifications: true } + }); + + const supermanWithNotifications = await prisma.user.findUnique({ + where: { userId: testBatman.userId }, + include: { unreadNotifications: true } + }); + + expect(batmanWithNotifications?.unreadNotifications).toHaveLength(1); + expect(batmanWithNotifications?.unreadNotifications[0].text).toBe('test notification'); + expect(supermanWithNotifications?.unreadNotifications).toHaveLength(1); + expect(supermanWithNotifications?.unreadNotifications[0].text).toBe('test notification'); + }); + }); +}); diff --git a/src/shared/index.ts b/src/shared/index.ts index 1d3f6c399f..409dae2e65 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -11,7 +11,7 @@ export * from './src/types/team-types'; export * from './src/types/task-types'; export * from './src/types/reimbursement-requests-types'; export * from './src/types/design-review-types'; - +export * from './src/types/notifications.types'; export * from './src/validate-wbs'; export * from './src/date-utils'; diff --git a/src/shared/src/types/notifications.types.ts b/src/shared/src/types/notifications.types.ts new file mode 100644 index 0000000000..e4419ef2ed --- /dev/null +++ b/src/shared/src/types/notifications.types.ts @@ -0,0 +1,5 @@ +export interface Notification { + notificationId: string; + text: string; + iconName: string; +}