diff --git a/src/backend/index.ts b/src/backend/index.ts index 680ead6886..86470eb0a0 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -17,7 +17,9 @@ import workPackageTemplatesRouter from './src/routes/work-package-templates.rout import carsRouter from './src/routes/cars.routes'; import organizationRouter from './src/routes/organizations.routes'; import recruitmentRouter from './src/routes/recruitment.routes'; +import announcementsRouter from './src/routes/announcements.routes'; import onboardingRouter from './src/routes/onboarding.routes'; +import popUpsRouter from './src/routes/pop-up.routes'; const app = express(); @@ -69,6 +71,8 @@ app.use('/templates', workPackageTemplatesRouter); app.use('/cars', carsRouter); app.use('/organizations', organizationRouter); app.use('/recruitment', recruitmentRouter); +app.use('/pop-ups', popUpsRouter); +app.use('/announcements', announcementsRouter); app.use('/onboarding', onboardingRouter); app.use('/', (_req, res) => { res.status(200).json('Welcome to FinishLine'); diff --git a/src/backend/src/controllers/announcements.controllers.ts b/src/backend/src/controllers/announcements.controllers.ts new file mode 100644 index 0000000000..e5ccafbe06 --- /dev/null +++ b/src/backend/src/controllers/announcements.controllers.ts @@ -0,0 +1,18 @@ +import { NextFunction, Request, Response } from 'express'; +import AnnouncementService from '../services/announcement.service'; + +export default class AnnouncementController { + static async getUserUnreadAnnouncements(req: Request, res: Response, next: NextFunction) { + try { + const { organization, currentUser } = req; + + const unreadAnnouncements = await AnnouncementService.getUserUnreadAnnouncements( + currentUser.userId, + organization.organizationId + ); + res.status(200).json(unreadAnnouncements); + } catch (error: unknown) { + next(error); + } + } +} diff --git a/src/backend/src/controllers/popUps.controllers.ts b/src/backend/src/controllers/popUps.controllers.ts new file mode 100644 index 0000000000..6247a03fbd --- /dev/null +++ b/src/backend/src/controllers/popUps.controllers.ts @@ -0,0 +1,27 @@ +import { NextFunction, Request, Response } from 'express'; +import { PopUpService } from '../services/pop-up.services'; + +export default class PopUpsController { + static async getUserUnreadPopUps(req: Request, res: Response, next: NextFunction) { + try { + const { organization, currentUser } = req; + + const unreadPopUps = await PopUpService.getUserUnreadPopUps(currentUser.userId, organization.organizationId); + res.status(200).json(unreadPopUps); + } catch (error: unknown) { + next(error); + } + } + + static async removeUserPopUps(req: Request, res: Response, next: NextFunction) { + try { + const { popUpId } = req.params; + const { organization, currentUser } = req; + + const unreadPopUps = await PopUpService.removeUserPopUp(currentUser.userId, popUpId, organization.organizationId); + res.status(200).json(unreadPopUps); + } catch (error: unknown) { + next(error); + } + } +} diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index bf965c8d8e..8076e225d7 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -191,29 +191,4 @@ export default class UsersController { next(error); } } - - static async getUserUnreadNotifications(req: Request, res: Response, next: NextFunction) { - try { - const { userId } = req.params; - const { organization } = req; - - const unreadNotifications = await UsersService.getUserUnreadNotifications(userId, organization); - res.status(200).json(unreadNotifications); - } catch (error: unknown) { - next(error); - } - } - - static async removeUserNotification(req: Request, res: Response, next: NextFunction) { - try { - const { userId } = req.params; - const { notificationId } = req.body; - const { organization } = req; - - const unreadNotifications = await UsersService.removeUserNotification(userId, notificationId, organization); - res.status(200).json(unreadNotifications); - } catch (error: unknown) { - next(error); - } - } } diff --git a/src/backend/src/prisma-query-args/announcements.query.args.ts b/src/backend/src/prisma-query-args/announcements.query.args.ts new file mode 100644 index 0000000000..b88c9fbf1d --- /dev/null +++ b/src/backend/src/prisma-query-args/announcements.query.args.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args'; + +export type AnnouncementQueryArgs = ReturnType; + +export const getAnnouncementQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + usersReceived: getUserQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma-query-args/notifications.query-args.ts b/src/backend/src/prisma-query-args/notifications.query-args.ts deleted file mode 100644 index 4cf877ac5c..0000000000 --- a/src/backend/src/prisma-query-args/notifications.query-args.ts +++ /dev/null @@ -1,11 +0,0 @@ -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-query-args/pop-up.query-args.ts b/src/backend/src/prisma-query-args/pop-up.query-args.ts new file mode 100644 index 0000000000..0862956d22 --- /dev/null +++ b/src/backend/src/prisma-query-args/pop-up.query-args.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args'; + +export type PopUpQueryArgs = ReturnType; + +export const getPopUpQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + users: getUserQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma/migrations/20241221002428_homepage_redesign/migration.sql b/src/backend/src/prisma/migrations/20241222150147_homepage_redesign/migration.sql similarity index 61% rename from src/backend/src/prisma/migrations/20241221002428_homepage_redesign/migration.sql rename to src/backend/src/prisma/migrations/20241222150147_homepage_redesign/migration.sql index 166c2bd8ae..17a36eb2d3 100644 --- a/src/backend/src/prisma/migrations/20241221002428_homepage_redesign/migration.sql +++ b/src/backend/src/prisma/migrations/20241222150147_homepage_redesign/migration.sql @@ -9,20 +9,23 @@ ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT; CREATE TABLE "Announcement" ( "announcementId" TEXT NOT NULL, "text" TEXT NOT NULL, - "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "userCreatedId" TEXT NOT NULL, + "dateMessageSent" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dateDeleted" TIMESTAMP(3), + "senderName" TEXT NOT NULL, + "slackEventId" TEXT NOT NULL, + "slackChannelName" TEXT NOT NULL, CONSTRAINT "Announcement_pkey" PRIMARY KEY ("announcementId") ); -- CreateTable -CREATE TABLE "Notification" ( - "notificationId" TEXT NOT NULL, +CREATE TABLE "PopUp" ( + "popUpId" TEXT NOT NULL, "text" TEXT NOT NULL, "iconName" TEXT NOT NULL, "eventLink" TEXT, - CONSTRAINT "Notification_pkey" PRIMARY KEY ("notificationId") + CONSTRAINT "PopUp_pkey" PRIMARY KEY ("popUpId") ); -- CreateTable @@ -32,11 +35,14 @@ CREATE TABLE "_receivedAnnouncements" ( ); -- CreateTable -CREATE TABLE "_userNotifications" ( +CREATE TABLE "_userPopUps" ( "A" TEXT NOT NULL, "B" TEXT NOT NULL ); +-- CreateIndex +CREATE UNIQUE INDEX "Announcement_slackEventId_key" ON "Announcement"("slackEventId"); + -- CreateIndex CREATE UNIQUE INDEX "_receivedAnnouncements_AB_unique" ON "_receivedAnnouncements"("A", "B"); @@ -44,17 +50,14 @@ CREATE UNIQUE INDEX "_receivedAnnouncements_AB_unique" ON "_receivedAnnouncement CREATE INDEX "_receivedAnnouncements_B_index" ON "_receivedAnnouncements"("B"); -- CreateIndex -CREATE UNIQUE INDEX "_userNotifications_AB_unique" ON "_userNotifications"("A", "B"); +CREATE UNIQUE INDEX "_userPopUps_AB_unique" ON "_userPopUps"("A", "B"); -- CreateIndex -CREATE INDEX "_userNotifications_B_index" ON "_userNotifications"("B"); +CREATE INDEX "_userPopUps_B_index" ON "_userPopUps"("B"); -- AddForeignKey ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE SET NULL ON UPDATE CASCADE; --- 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; @@ -62,7 +65,7 @@ ALTER TABLE "_receivedAnnouncements" ADD CONSTRAINT "_receivedAnnouncements_A_fk 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; +ALTER TABLE "_userPopUps" ADD CONSTRAINT "_userPopUps_A_fkey" FOREIGN KEY ("A") REFERENCES "PopUp"("popUpId") 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; +ALTER TABLE "_userPopUps" ADD CONSTRAINT "_userPopUps_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 b865f65f2c..c62827da19 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -180,9 +180,8 @@ 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[] @relation(name: "userNotifications") + unreadAnnouncements Announcement[] @relation(name: "receivedAnnouncements") + unreadPopUps PopUp[] @relation(name: "userPopUps") } model Role { @@ -933,18 +932,20 @@ model Milestone { } model Announcement { - announcementId String @id @default(uuid()) - text String - usersReceived User[] @relation("receivedAnnouncements") - dateCreated DateTime @default(now()) - userCreatedId String - userCreated User @relation("createdAnnouncements", fields: [userCreatedId], references: [userId]) -} - -model Notification { - notificationId String @id @default(uuid()) - text String - iconName String - users User[] @relation("userNotifications") - eventLink String? + announcementId String @id @default(uuid()) + text String + usersReceived User[] @relation("receivedAnnouncements") + dateMessageSent DateTime @default(now()) + dateDeleted DateTime? + senderName String + slackEventId String @unique + slackChannelName String +} + +model PopUp { + popUpId String @id @default(uuid()) + text String + iconName String + users User[] @relation("userPopUps") + eventLink String? } diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index fdb5ffefdb..ddd4e190cb 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -33,7 +33,7 @@ import { writeFileSync } from 'fs'; import WorkPackageTemplatesService from '../services/work-package-template.services'; import RecruitmentServices from '../services/recruitment.services'; import OrganizationsService from '../services/organizations.services'; -import NotificationsService from '../services/notifications.services'; +import AnnouncementService from '../services/announcement.service'; const prisma = new PrismaClient(); @@ -1894,6 +1894,16 @@ const performSeed: () => Promise = async () => { await RecruitmentServices.createFaq(batman, 'When was FinishLine created?', 'FinishLine was created in 2019', ner); await RecruitmentServices.createFaq(batman, 'How many developers are working on FinishLine?', '178 as of 2024', ner); + + await AnnouncementService.createAnnouncement( + 'Welcome to Finishline!', + [regina.userId], + new Date(), + 'Thomas Emrax', + '1', + 'software', + ner.organizationId + ); }; performSeed() diff --git a/src/backend/src/routes/announcements.routes.ts b/src/backend/src/routes/announcements.routes.ts new file mode 100644 index 0000000000..b772f09fbb --- /dev/null +++ b/src/backend/src/routes/announcements.routes.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import AnnouncementController from '../controllers/announcements.controllers'; + +const announcementsRouter = express.Router(); + +announcementsRouter.get('/current-user', AnnouncementController.getUserUnreadAnnouncements); + +export default announcementsRouter; diff --git a/src/backend/src/routes/pop-up.routes.ts b/src/backend/src/routes/pop-up.routes.ts new file mode 100644 index 0000000000..5ecaeff01f --- /dev/null +++ b/src/backend/src/routes/pop-up.routes.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import PopUpsController from '../controllers/popUps.controllers'; + +const popUpsRouter = express.Router(); + +popUpsRouter.get('/current-user', PopUpsController.getUserUnreadPopUps); +popUpsRouter.post('/:popUpId/remove', PopUpsController.removeUserPopUps); + +export default popUpsRouter; diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 622a6fb01c..2f95201f6f 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -54,11 +54,5 @@ userRouter.post( validateInputs, UsersController.getManyUserTasks ); -userRouter.get('/:userId/notifications', UsersController.getUserUnreadNotifications); -userRouter.post( - '/:userId/notifications/remove', - nonEmptyString(body('notificationId')), - UsersController.removeUserNotification -); export default userRouter; diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts new file mode 100644 index 0000000000..8d35cb879a --- /dev/null +++ b/src/backend/src/services/announcement.service.ts @@ -0,0 +1,68 @@ +import { Announcement } from 'shared'; +import prisma from '../prisma/prisma'; +import { getAnnouncementQueryArgs } from '../prisma-query-args/announcements.query.args'; +import announcementTransformer from '../transformers/announcements.transformer'; +import { HttpException } from '../utils/errors.utils'; + +export default class AnnouncementService { + /** + * Creates an announcement that is sent to users + * this data is populated from slack events + * @param text slack message text + * @param usersReceivedIds users to send announcements to + * @param dateCreated date created of slack message + * @param senderName name of user who sent slack message + * @param slackEventId id of slack event (provided by slack api) + * @param slackChannelName name of channel message was sent in + * @param organizationId id of organization of users + * @returns the created announcement + */ + static async createAnnouncement( + text: string, + usersReceivedIds: string[], + dateMessageSent: Date, + senderName: string, + slackEventId: string, + slackChannelName: string, + organizationId: string + ): Promise { + const announcement = await prisma.announcement.create({ + data: { + text, + usersReceived: { + connect: usersReceivedIds.map((id) => ({ + userId: id + })) + }, + dateMessageSent, + senderName, + slackEventId, + slackChannelName + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + return announcementTransformer(announcement); + } + + /** + * Gets all of a user's unread announcements + * @param userId id of the current user + * @param organization the user's orgainzation + * @returns the unread announcements of the user + */ + static async getUserUnreadAnnouncements(userId: string, organizationId: string) { + const unreadAnnouncements = await prisma.announcement.findMany({ + where: { + usersReceived: { + some: { userId } + } + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + if (!unreadAnnouncements) throw new HttpException(404, 'User Unread Announcements Not Found'); + + return unreadAnnouncements.map(announcementTransformer); + } +} diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 85e0dc5579..709f4b2e91 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -46,7 +46,7 @@ import { import { ChangeRequestQueryArgs, getChangeRequestQueryArgs } from '../prisma-query-args/change-requests.query-args'; import proposedSolutionTransformer from '../transformers/proposed-solutions.transformer'; import { getProposedSolutionQueryArgs } from '../prisma-query-args/proposed-solutions.query-args'; -import { sendHomeCrRequestReviewNotification, sendHomeCrReviewedNotification } from '../utils/notifications.utils'; +import { sendCrRequestReviewPopUp, sendCrReviewedPopUp } from '../utils/pop-up.utils'; export default class ChangeRequestsService { /** @@ -151,7 +151,7 @@ export default class ChangeRequestsService { // send a notification to the submitter that their change request has been reviewed await sendCRSubmitterReviewedNotification(updated); - await sendHomeCrReviewedNotification(foundCR, updated.submitter, accepted, organization.organizationId); + await sendCrReviewedPopUp(foundCR, updated.submitter, accepted, organization.organizationId); // send a reply to a CR's notifications of its updated status await sendSlackCRStatusToThread(updated.notificationSlackThreads, foundCR.crId, foundCR.identifier, accepted); @@ -1082,6 +1082,6 @@ export default class ChangeRequestsService { // send slack message to CR reviewers await sendSlackRequestedReviewNotification(newReviewers, changeRequestTransformer(foundCR)); - await sendHomeCrRequestReviewNotification(foundCR, newReviewers, organization.organizationId); + await sendCrRequestReviewPopUp(foundCR, newReviewers, organization.organizationId); } } diff --git a/src/backend/src/services/design-reviews.services.ts b/src/backend/src/services/design-reviews.services.ts index 644903fa43..64363b0c61 100644 --- a/src/backend/src/services/design-reviews.services.ts +++ b/src/backend/src/services/design-reviews.services.ts @@ -39,7 +39,7 @@ import { getWorkPackageQueryArgs } from '../prisma-query-args/work-packages.quer import { UserWithSettings } from '../utils/auth.utils'; import { getUserScheduleSettingsQueryArgs } from '../prisma-query-args/user.query-args'; import { createCalendarEvent, deleteCalendarEvent, updateCalendarEvent } from '../utils/google-integration.utils'; -import { sendHomeDrNotification } from '../utils/notifications.utils'; +import { sendDrPopUp } from '../utils/pop-up.utils'; export default class DesignReviewsService { /** @@ -206,7 +206,7 @@ export default class DesignReviewsService { } } - await sendHomeDrNotification(designReview, members, submitter, wbsElement.name, organization.organizationId); + await sendDrPopUp(designReview, members, submitter, wbsElement.name, organization.organizationId); const project = wbsElement.workPackage?.project; const teams = project?.teams; diff --git a/src/backend/src/services/notifications.services.ts b/src/backend/src/services/notifications.services.ts index e0617301f5..a443d93588 100644 --- a/src/backend/src/services/notifications.services.ts +++ b/src/backend/src/services/notifications.services.ts @@ -11,10 +11,8 @@ import { daysBetween, startOfDay, wbsPipe } from 'shared'; import { buildDueString } from '../utils/slack.utils'; import WorkPackagesService from './work-packages.services'; import { addWeeksToDate } from 'shared'; -import { HttpException, NotFoundException } from '../utils/errors.utils'; +import { HttpException } 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() { @@ -195,52 +193,4 @@ 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 - * @param eventLink link the notification will go to when clicked - * @returns the created notification - */ - static async sendNotifcationToUsers( - text: string, - iconName: string, - userIds: string[], - organizationId: string, - eventLink?: string - ) { - const createdNotification = await prisma.notification.create({ - data: { - text, - iconName, - eventLink - }, - ...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/services/pop-up.services.ts b/src/backend/src/services/pop-up.services.ts new file mode 100644 index 0000000000..267c72761f --- /dev/null +++ b/src/backend/src/services/pop-up.services.ts @@ -0,0 +1,100 @@ +import { getPopUpQueryArgs } from '../prisma-query-args/pop-up.query-args'; +import prisma from '../prisma/prisma'; +import popUpTransformer from '../transformers/pop-up.transformer'; +import { HttpException, NotFoundException } from '../utils/errors.utils'; + +export class PopUpService { + /** + * Gets all of a user's unread pop up + * @param userId id of user to get unread pop up from + * @param organization the user's orgainzation + * @returns the unread pop up of the user + */ + static async getUserUnreadPopUps(userId: string, organizationId: string) { + const unreadPopUps = await prisma.popUp.findMany({ + where: { + users: { + some: { userId } + } + }, + ...getPopUpQueryArgs(organizationId) + }); + + if (!unreadPopUps) throw new HttpException(404, 'User Unread Notifications Not Found'); + + return unreadPopUps.map(popUpTransformer); + } + + /** + * Removes a pop up from the user's unread pop up + * @param userId id of the current user + * @param popUpId id of the pop up to remove + * @param organization the user's organization + * @returns the user's updated unread pop up + */ + static async removeUserPopUp(userId: string, popUpId: string, organizationId: string) { + const updatedUser = await prisma.user.update({ + where: { userId }, + data: { + unreadPopUps: { + disconnect: { + popUpId + } + } + }, + include: { unreadPopUps: getPopUpQueryArgs(organizationId) } + }); + + if (!updatedUser) throw new HttpException(404, `Failed to remove notication: ${popUpId}`); + + return updatedUser.unreadPopUps.map(popUpTransformer); + } + + /** + * Creates and sends a pop up to all users with the given userIds + * @param text writing in the pop up + * @param iconName icon that appears in the pop up + * @param userIds ids of users to send the pop up to + * @param organizationId + * @param eventLink link the pop up will go to when clicked + * @returns the created notification + */ + static async sendPopUpToUsers( + text: string, + iconName: string, + userIds: string[], + organizationId: string, + eventLink?: string + ) { + const createdPopUp = await prisma.popUp.create({ + data: { + text, + iconName, + eventLink + }, + ...getPopUpQueryArgs(organizationId) + }); + + if (!createdPopUp) throw new HttpException(500, 'Failed to create notification'); + + const popUpPromises = 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: { + unreadPopUps: { + connect: { popUpId: createdPopUp.popUpId } + } + } + }); + }); + + await Promise.all(popUpPromises); + return popUpTransformer(createdPopUp); + } +} diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 1358ca5f6c..d786c04137 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -38,8 +38,6 @@ import { getAuthUserQueryArgs } from '../prisma-query-args/auth-user.query-args' import authenticatedUserTransformer from '../transformers/auth-user.transformer'; import { getTaskQueryArgs } from '../prisma-query-args/tasks.query-args'; import taskTransformer from '../transformers/tasks.transformer'; -import { getNotificationQueryArgs } from '../prisma-query-args/notifications.query-args'; -import notificationTransformer from '../transformers/notifications.transformer'; export default class UsersService { /** @@ -568,49 +566,4 @@ export default class UsersService { const resolvedTasks = await Promise.all(tasksPromises); return resolvedTasks.flat(); } - - /** - * Gets all of a user's unread notifications - * @param userId id of user to get unread notifications from - * @param organization the user's orgainzation - * @returns the unread notifications of the user - */ - static async getUserUnreadNotifications(userId: string, organization: Organization) { - const requestedUser = await prisma.user.findUnique({ - where: { userId }, - include: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) } - }); - if (!requestedUser) throw new NotFoundException('User', userId); - - return requestedUser.unreadNotifications.map(notificationTransformer); - } - - /** - * Removes a notification from the user's unread notifications - * @param userId id of the user to remove notification from - * @param notificationId id of the notification to remove - * @param organization the user's organization - * @returns the user's updated unread notifications - */ - static async removeUserNotification(userId: string, notificationId: string, organization: Organization) { - const requestedUser = await prisma.user.findUnique({ - where: { userId } - }); - - if (!requestedUser) throw new NotFoundException('User', userId); - - const updatedUser = await prisma.user.update({ - where: { userId }, - data: { - unreadNotifications: { - disconnect: { - notificationId - } - } - }, - include: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) } - }); - - return updatedUser.unreadNotifications.map(notificationTransformer); - } } diff --git a/src/backend/src/transformers/announcements.transformer.ts b/src/backend/src/transformers/announcements.transformer.ts new file mode 100644 index 0000000000..8b43031390 --- /dev/null +++ b/src/backend/src/transformers/announcements.transformer.ts @@ -0,0 +1,14 @@ +import { Prisma } from '@prisma/client'; +import { AnnouncementQueryArgs } from '../prisma-query-args/announcements.query.args'; +import { Announcement } from 'shared'; +import { userTransformer } from './user.transformer'; + +const announcementTransformer = (announcement: Prisma.AnnouncementGetPayload): Announcement => { + return { + ...announcement, + usersReceived: announcement.usersReceived.map(userTransformer), + dateDeleted: announcement.dateDeleted ?? undefined + }; +}; + +export default announcementTransformer; diff --git a/src/backend/src/transformers/notifications.transformer.ts b/src/backend/src/transformers/notifications.transformer.ts deleted file mode 100644 index 45dd25dee9..0000000000 --- a/src/backend/src/transformers/notifications.transformer.ts +++ /dev/null @@ -1,14 +0,0 @@ -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, - eventLink: notification.eventLink ?? undefined - }; -}; - -export default notificationTransformer; diff --git a/src/backend/src/transformers/pop-up.transformer.ts b/src/backend/src/transformers/pop-up.transformer.ts new file mode 100644 index 0000000000..1be7e4ec68 --- /dev/null +++ b/src/backend/src/transformers/pop-up.transformer.ts @@ -0,0 +1,12 @@ +import { Prisma } from '@prisma/client'; +import { PopUpQueryArgs } from '../prisma-query-args/pop-up.query-args'; +import { PopUp } from 'shared'; + +const popUpTransformer = (popUp: Prisma.PopUpGetPayload): PopUp => { + return { + ...popUp, + eventLink: popUp.eventLink ?? undefined + }; +}; + +export default popUpTransformer; diff --git a/src/backend/src/utils/notifications.utils.ts b/src/backend/src/utils/notifications.utils.ts index c47ba9b7a0..8fb6046318 100644 --- a/src/backend/src/utils/notifications.utils.ts +++ b/src/backend/src/utils/notifications.utils.ts @@ -1,7 +1,5 @@ -import { Task as Prisma_Task, WBS_Element, Design_Review, Change_Request } from '@prisma/client'; +import { Task as Prisma_Task, WBS_Element, Design_Review } from '@prisma/client'; import { UserWithSettings } from './auth.utils'; -import NotificationsService from '../services/notifications.services'; -import { User } from '@prisma/client'; export type TaskWithAssignees = Prisma_Task & { assignees: UserWithSettings[] | null; @@ -37,78 +35,3 @@ export const endOfDayTomorrow = () => { endOfDay.setDate(startOfDay.getDate() + 1); return endOfDay; }; - -/** - * Sends a finishline notification that a design review was scheduled - * @param designReview dr that was created - * @param members optional and required members of the dr - * @param submitter the user who created the dr - * @param workPackageName the name of the work package associated witht the dr - * @param organizationId id of the organization of the dr - */ -export const sendHomeDrNotification = async ( - designReview: Design_Review, - members: User[], - submitter: User, - workPackageName: string, - organizationId: string -) => { - const designReviewLink = `/settings/preferences?drId=${designReview.designReviewId}`; - - const msg = `Design Review for ${workPackageName} is being scheduled by ${submitter.firstName} ${submitter.lastName}`; - await NotificationsService.sendNotifcationToUsers( - msg, - 'calendar_month', - members.map((member) => member.userId), - organizationId, - designReviewLink - ); -}; - -/** - * Sends a finishline notification that a change request was reviewed - * @param changeRequest cr that was requested review - * @param submitter the user who submitted the cr - * @param accepted true if the cr changes were accepted, false if denied - * @param organizationId id of the organization of the cr - */ -export const sendHomeCrReviewedNotification = async ( - changeRequest: Change_Request, - submitter: User, - accepted: boolean, - organizationId: string -) => { - const isProd = process.env.NODE_ENV === 'production'; - - const changeRequestLink = isProd - ? `https://finishlinebyner.com/change-requests/${changeRequest.crId}` - : `http://localhost:3000/change-requests/${changeRequest.crId}`; - await NotificationsService.sendNotifcationToUsers( - `CR #${changeRequest.identifier} has been ${accepted ? 'approved!' : 'denied.'}`, - accepted ? 'check_circle' : 'cancel', - [submitter.userId], - organizationId, - changeRequestLink - ); -}; - -/** - * Sends a finishline notification to all requested reviewers of a change request - * @param changeRequest cr that was requested review - * @param reviewers user's reviewing the cr - * @param organizationId id of the organization of the cr - */ -export const sendHomeCrRequestReviewNotification = async ( - changeRequest: Change_Request, - reviewers: User[], - organizationId: string -) => { - const changeRequestLink = `/change-requests/${changeRequest.crId}`; - await NotificationsService.sendNotifcationToUsers( - `Your review has been requested on CR #${changeRequest.identifier}`, - 'edit_note', - reviewers.map((reviewer) => reviewer.userId), - organizationId, - changeRequestLink - ); -}; diff --git a/src/backend/src/utils/pop-up.utils.ts b/src/backend/src/utils/pop-up.utils.ts new file mode 100644 index 0000000000..30c0fecdf0 --- /dev/null +++ b/src/backend/src/utils/pop-up.utils.ts @@ -0,0 +1,69 @@ +import { Change_Request, Design_Review, User } from '@prisma/client'; +import { PopUpService } from '../services/pop-up.services'; + +/** + * Sends a pop up that a design review was scheduled + * @param designReview dr that was created + * @param members optional and required members of the dr + * @param submitter the user who created the dr + * @param workPackageName the name of the work package associated witht the dr + * @param organizationId id of the organization of the dr + */ +export const sendDrPopUp = async ( + designReview: Design_Review, + members: User[], + submitter: User, + workPackageName: string, + organizationId: string +) => { + const designReviewLink = `/settings/preferences?drId=${designReview.designReviewId}`; + + const msg = `Design Review for ${workPackageName} is being scheduled by ${submitter.firstName} ${submitter.lastName}`; + await PopUpService.sendPopUpToUsers( + msg, + 'calendar_month', + members.map((member) => member.userId), + organizationId, + designReviewLink + ); +}; + +/** + * Sends a pop up that a change request was reviewed + * @param changeRequest cr that was requested review + * @param submitter the user who submitted the cr + * @param accepted true if the cr changes were accepted, false if denied + * @param organizationId id of the organization of the cr + */ +export const sendCrReviewedPopUp = async ( + changeRequest: Change_Request, + submitter: User, + accepted: boolean, + organizationId: string +) => { + const changeRequestLink = `/change-requests/${changeRequest.crId}`; + await PopUpService.sendPopUpToUsers( + `CR #${changeRequest.identifier} has been ${accepted ? 'approved!' : 'denied.'}`, + accepted ? 'check_circle' : 'cancel', + [submitter.userId], + organizationId, + changeRequestLink + ); +}; + +/** + * Sends a finishline pop up to all requested reviewers of a change request + * @param changeRequest cr that was requested review + * @param reviewers user's reviewing the cr + * @param organizationId id of the organization of the cr + */ +export const sendCrRequestReviewPopUp = async (changeRequest: Change_Request, reviewers: User[], organizationId: string) => { + const changeRequestLink = `/change-requests/${changeRequest.crId}`; + await PopUpService.sendPopUpToUsers( + `Your review has been requested on CR #${changeRequest.identifier}`, + 'edit_note', + reviewers.map((reviewer) => reviewer.userId), + organizationId, + changeRequestLink + ); +}; diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 99f2a010e2..b399e44fe8 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -120,6 +120,7 @@ export const resetUsers = async () => { await prisma.frequentlyAskedQuestion.deleteMany(); await prisma.organization.deleteMany(); await prisma.user.deleteMany(); + await prisma.announcement.deleteMany(); }; export const createFinanceTeamAndLead = async (organization?: Organization) => { diff --git a/src/backend/tests/unmocked/announcements.test.ts b/src/backend/tests/unmocked/announcements.test.ts new file mode 100644 index 0000000000..97ab4baeef --- /dev/null +++ b/src/backend/tests/unmocked/announcements.test.ts @@ -0,0 +1,49 @@ +import { Organization } from '@prisma/client'; +import { batmanAppAdmin } from '../test-data/users.test-data'; +import { createTestOrganization, createTestUser, resetUsers } from '../test-utils'; +import AnnouncementService from '../../src/services/announcement.service'; + +describe('Announcemnts Tests', () => { + let orgId: string; + let organization: Organization; + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + }); + + afterEach(async () => { + await resetUsers(); + }); + describe('Get Announcements', () => { + it('Succeeds and gets user announcements', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await AnnouncementService.createAnnouncement( + 'test1', + [testBatman.userId], + new Date(), + 'Thomas Emrax', + '1', + 'software', + organization.organizationId + ); + await AnnouncementService.createAnnouncement( + 'test2', + [testBatman.userId], + new Date(), + 'Superman', + '50', + 'mechanical', + organization.organizationId + ); + + const announcements = await AnnouncementService.getUserUnreadAnnouncements( + testBatman.userId, + organization.organizationId + ); + + expect(announcements).toHaveLength(2); + expect(announcements[0].text).toBe('test1'); + expect(announcements[1].text).toBe('test2'); + }); + }); +}); diff --git a/src/backend/tests/unmocked/notifications.test.ts b/src/backend/tests/unmocked/notifications.test.ts deleted file mode 100644 index d3cce68361..0000000000 --- a/src/backend/tests/unmocked/notifications.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -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/backend/tests/unmocked/pop-up.test.ts b/src/backend/tests/unmocked/pop-up.test.ts new file mode 100644 index 0000000000..4421aea4f8 --- /dev/null +++ b/src/backend/tests/unmocked/pop-up.test.ts @@ -0,0 +1,90 @@ +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 { PopUpService } from '../../src/services/pop-up.services'; + +describe('Pop Ups Tests', () => { + let orgId: string; + let organization: Organization; + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Send Pop Up', () => { + it('fails on invalid user id', async () => { + await expect( + async () => await PopUpService.sendPopUpToUsers('test pop up', 'star', ['1', '2'], organization.organizationId) + ).rejects.toThrow(new NotFoundException('User', '1')); + }); + + it('Succeeds and sends pop up to user', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + const testSuperman = await createTestUser(supermanAdmin, orgId); + await PopUpService.sendPopUpToUsers( + 'test pop up', + 'star', + [testBatman.userId, testSuperman.userId], + organization.organizationId + ); + + const batmanWithPopUps = await prisma.user.findUnique({ + where: { userId: testBatman.userId }, + include: { unreadPopUps: true } + }); + + const supermanWithPopUps = await prisma.user.findUnique({ + where: { userId: testBatman.userId }, + include: { unreadPopUps: true } + }); + + expect(batmanWithPopUps?.unreadPopUps).toHaveLength(1); + expect(batmanWithPopUps?.unreadPopUps[0].text).toBe('test pop up'); + expect(supermanWithPopUps?.unreadPopUps).toHaveLength(1); + expect(supermanWithPopUps?.unreadPopUps[0].text).toBe('test pop up'); + }); + }); + + describe('Get Notifications', () => { + it('Succeeds and gets user pop ups', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await PopUpService.sendPopUpToUsers('test1', 'test1', [testBatman.userId], orgId); + await PopUpService.sendPopUpToUsers('test2', 'test2', [testBatman.userId], orgId); + + const popUps = await PopUpService.getUserUnreadPopUps(testBatman.userId, organization.organizationId); + + expect(popUps).toHaveLength(2); + expect(popUps[0].text).toBe('test1'); + expect(popUps[1].text).toBe('test2'); + }); + }); + + describe('Remove Pop Ups', () => { + it('Succeeds and removes user pop up', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await PopUpService.sendPopUpToUsers('test1', 'test1', [testBatman.userId], orgId); + await PopUpService.sendPopUpToUsers('test2', 'test2', [testBatman.userId], orgId); + + const popUps = await PopUpService.getUserUnreadPopUps(testBatman.userId, organization.organizationId); + + expect(popUps).toHaveLength(2); + expect(popUps[0].text).toBe('test1'); + expect(popUps[1].text).toBe('test2'); + + const updatedPopUps = await PopUpService.removeUserPopUp( + testBatman.userId, + popUps[0].popUpId, + organization.organizationId + ); + + expect(updatedPopUps).toHaveLength(1); + expect(updatedPopUps[0].text).toBe('test2'); + }); + }); +}); diff --git a/src/backend/tests/unmocked/users.test.ts b/src/backend/tests/unmocked/users.test.ts index 512a651b90..c13a0c857f 100644 --- a/src/backend/tests/unmocked/users.test.ts +++ b/src/backend/tests/unmocked/users.test.ts @@ -3,7 +3,6 @@ import { createTestOrganization, createTestTask, createTestUser, resetUsers } fr import { batmanAppAdmin } from '../test-data/users.test-data'; import UsersService from '../../src/services/users.services'; import { NotFoundException } from '../../src/utils/errors.utils'; -import NotificationsService from '../../src/services/notifications.services'; describe('User Tests', () => { let orgId: string; @@ -49,57 +48,4 @@ describe('User Tests', () => { expect(userTasks).toStrictEqual([batmanTask, batmanTask]); }); }); - - describe('Get Notifications', () => { - it('fails on invalid user id', async () => { - await expect(async () => await UsersService.getUserUnreadNotifications('1', organization)).rejects.toThrow( - new NotFoundException('User', '1') - ); - }); - - it('Succeeds and gets user notifications', async () => { - const testBatman = await createTestUser(batmanAppAdmin, orgId); - await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); - await NotificationsService.sendNotifcationToUsers('test2', 'test2', [testBatman.userId], orgId); - - const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization); - - expect(notifications).toHaveLength(2); - expect(notifications[0].text).toBe('test1'); - expect(notifications[1].text).toBe('test2'); - }); - }); - - describe('Remove Notifications', () => { - it('Fails with invalid user', async () => { - const testBatman = await createTestUser(batmanAppAdmin, orgId); - await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); - const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization); - - await expect( - async () => await UsersService.removeUserNotification('1', notifications[0].notificationId, organization) - ).rejects.toThrow(new NotFoundException('User', '1')); - }); - - it('Succeeds and gets user notifications', async () => { - const testBatman = await createTestUser(batmanAppAdmin, orgId); - await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); - await NotificationsService.sendNotifcationToUsers('test2', 'test2', [testBatman.userId], orgId); - - const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization); - - expect(notifications).toHaveLength(2); - expect(notifications[0].text).toBe('test1'); - expect(notifications[1].text).toBe('test2'); - - const updatedNotifications = await UsersService.removeUserNotification( - testBatman.userId, - notifications[0].notificationId, - organization - ); - - expect(updatedNotifications).toHaveLength(1); - expect(updatedNotifications[0].text).toBe('test2'); - }); - }); }); diff --git a/src/frontend/src/apis/pop-ups.api.ts b/src/frontend/src/apis/pop-ups.api.ts new file mode 100644 index 0000000000..a0674b9527 --- /dev/null +++ b/src/frontend/src/apis/pop-ups.api.ts @@ -0,0 +1,19 @@ +import axios from '../utils/axios'; +import { apiUrls } from '../utils/urls'; +import { PopUp } from 'shared'; + +/* + * Gets all unread notifications of the user with the given id + */ +export const getPopUps = () => { + return axios.get(apiUrls.popUpsCurrentUser(), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +/* + * Removes a notification from the user with the given id + */ +export const removePopUps = (notificationId: string) => { + return axios.post(apiUrls.popUpsRemove(notificationId)); +}; diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index 5a91bff5fd..afa5ea00f6 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -5,7 +5,6 @@ import axios from '../utils/axios'; import { - Notification, Project, SetUserScheduleSettingsPayload, Task, @@ -160,19 +159,3 @@ export const getManyUserTasks = (userIds: string[]) => { } ); }; - -/* - * Gets all unread notifications of the user with the given id - */ -export const getNotifications = (id: string) => { - return axios.get(apiUrls.userNotifications(id), { - transformResponse: (data) => JSON.parse(data) - }); -}; - -/* - * Removes a notification from the user with the given id - */ -export const removeNotification = (userId: string, notificationId: string) => { - return axios.post(apiUrls.userRemoveNotifications(userId), { notificationId }); -}; diff --git a/src/frontend/src/components/NotificationAlert.tsx b/src/frontend/src/components/NotificationAlert.tsx deleted file mode 100644 index 581d849ef0..0000000000 --- a/src/frontend/src/components/NotificationAlert.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Box } from '@mui/material'; -import React, { useEffect, useState } from 'react'; -import { Notification, User } from 'shared'; -import NotificationCard from './NotificationCard'; -import { useRemoveUserNotification, useUserNotifications } from '../hooks/users.hooks'; -import { useHistory } from 'react-router-dom'; - -interface NotificationAlertProps { - user: User; -} - -const NotificationAlert: React.FC = ({ user }) => { - const { data: notifications, isLoading: notificationsIsLoading } = useUserNotifications(user.userId); - const { mutateAsync: removeNotification, isLoading: removeIsLoading } = useRemoveUserNotification(user.userId); - const [currentNotification, setCurrentNotification] = useState(); - const history = useHistory(); - - useEffect(() => { - if (notifications && notifications.length > 0) { - setCurrentNotification(notifications[0]); - } - }, [notifications]); - - const removeNotificationWrapper = async (notification: Notification) => { - setCurrentNotification(undefined); - await removeNotification(notification); - }; - - const onClick = async (notification: Notification) => { - if (!!notification.eventLink) { - await removeNotificationWrapper(notification); - history.push(notification.eventLink); - } - }; - - return ( - - {!removeIsLoading && !notificationsIsLoading && currentNotification && ( - - )} - - ); -}; - -export default NotificationAlert; diff --git a/src/frontend/src/components/PopUpAlert.tsx b/src/frontend/src/components/PopUpAlert.tsx new file mode 100644 index 0000000000..0a0b271b66 --- /dev/null +++ b/src/frontend/src/components/PopUpAlert.tsx @@ -0,0 +1,49 @@ +import { Box } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { PopUp } from 'shared'; +import PopUpCard from './PopUpCard'; +import { useHistory } from 'react-router-dom'; +import { useCurrentUserPopUps, useRemoveUserPopUp } from '../hooks/pop-ups.hooks'; + +const PopUpAlert: React.FC = () => { + const { data: popUps, isLoading: popUpsIsLoading } = useCurrentUserPopUps(); + const { mutateAsync: removePopUp, isLoading: removeIsLoading } = useRemoveUserPopUp(); + const [currentPopUp, setCurrentPopUp] = useState(); + const history = useHistory(); + + useEffect(() => { + if (popUps && popUps.length > 0) { + setCurrentPopUp(popUps[0]); + } + }, [popUps]); + + const removePopUpWrapper = async (popUp: PopUp) => { + setCurrentPopUp(undefined); + await removePopUp(popUp); + }; + + const onClick = async (popUp: PopUp) => { + if (!!popUp.eventLink) { + await removePopUpWrapper(popUp); + history.push(popUp.eventLink); + } + }; + + return ( + + {!removeIsLoading && !popUpsIsLoading && currentPopUp && ( + + )} + + ); +}; + +export default PopUpAlert; diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/PopUpCard.tsx similarity index 65% rename from src/frontend/src/components/NotificationCard.tsx rename to src/frontend/src/components/PopUpCard.tsx index 1e4cfb4c02..e905ca2ad6 100644 --- a/src/frontend/src/components/NotificationCard.tsx +++ b/src/frontend/src/components/PopUpCard.tsx @@ -1,15 +1,15 @@ import { Box, Card, Icon, IconButton, Typography, useTheme } from '@mui/material'; import React from 'react'; -import { Notification } from 'shared'; +import { PopUp } from 'shared'; import CloseIcon from '@mui/icons-material/Close'; -interface NotificationCardProps { - notification: Notification; - removeNotification: (notificationId: Notification) => Promise; - onClick: (notificationId: Notification) => Promise; +interface PopUpCardProps { + popUp: PopUp; + removePopUp: (popUp: PopUp) => Promise; + onClick: (popUp: PopUp) => Promise; } -const NotificationCard: React.FC = ({ notification, removeNotification, onClick }) => { +const PopUpCard: React.FC = ({ popUp, removePopUp, onClick }) => { const theme = useTheme(); return ( = ({ notification, remov }} > await onClick(notification)} + onClick={async () => await onClick(popUp)} sx={{ display: 'flex', + alignItems: 'center', gap: 1, - cursor: !!notification.eventLink ? 'pointer' : 'default' + cursor: !!popUp.eventLink ? 'pointer' : 'default' }} > = ({ notification, remov fontSize: 36 }} > - {notification.iconName} + {popUp.iconName} - {notification.text} + {popUp.text} - removeNotification(notification)}> + removePopUp(popUp)}> @@ -70,4 +71,4 @@ const NotificationCard: React.FC = ({ notification, remov ); }; -export default NotificationCard; +export default PopUpCard; diff --git a/src/frontend/src/hooks/pop-ups.hooks.ts b/src/frontend/src/hooks/pop-ups.hooks.ts new file mode 100644 index 0000000000..7816102dd6 --- /dev/null +++ b/src/frontend/src/hooks/pop-ups.hooks.ts @@ -0,0 +1,36 @@ +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { PopUp } from 'shared'; +import { getPopUps, removePopUps } from '../apis/pop-ups.api'; + +/** + * Curstom react hook to get all unread notifications from a user + * @param userId id of user to get unread notifications from + * @returns + */ +export const useCurrentUserPopUps = () => { + return useQuery(['pop-ups', 'current-user'], async () => { + const { data } = await getPopUps(); + return data; + }); +}; + +/** + * Curstom react hook to remove a notification from a user's unread notifications + * @param userId id of user to get unread notifications from + * @returns + */ +export const useRemoveUserPopUp = () => { + const queryClient = useQueryClient(); + return useMutation( + ['pop-ups', 'current-user', 'remove'], + async (popUp: PopUp) => { + const { data } = await removePopUps(popUp.popUpId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['pop-ups', 'current-user']); + } + } + ); +}; diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index 32279217f9..96b659c1f1 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -19,9 +19,7 @@ import { getUserScheduleSettings, updateUserScheduleSettings, getUserTasks, - getManyUserTasks, - getNotifications, - removeNotification + getManyUserTasks } from '../apis/users.api'; import { User, @@ -33,8 +31,7 @@ import { UserScheduleSettings, UserWithScheduleSettings, SetUserScheduleSettingsPayload, - Task, - Notification + Task } from 'shared'; import { useAuth } from './auth.hooks'; import { useContext } from 'react'; @@ -263,36 +260,3 @@ export const useManyUserTasks = (userIds: string[]) => { return data; }); }; - -/** - * Curstom react hook to get all unread notifications from a user - * @param userId id of user to get unread notifications from - * @returns - */ -export const useUserNotifications = (userId: string) => { - return useQuery(['users', userId, 'notifications'], async () => { - const { data } = await getNotifications(userId); - return data; - }); -}; - -/** - * Curstom react hook to remove a notification from a user's unread notifications - * @param userId id of user to get unread notifications from - * @returns - */ -export const useRemoveUserNotification = (userId: string) => { - const queryClient = useQueryClient(); - return useMutation( - ['users', userId, 'notifications', 'remove'], - async (notification: Notification) => { - const { data } = await removeNotification(userId, notification.notificationId); - return data; - }, - { - onSuccess: () => { - queryClient.invalidateQueries(['users', userId, 'notifications']); - } - } - ); -}; diff --git a/src/frontend/src/pages/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 76db11f05a..1ce8f7c7f6 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -11,14 +11,14 @@ import { useState } from 'react'; import MemberHomePage from './MemberHomePage'; import LeadHomePage from './LeadHomePage'; import AdminHomePage from './AdminHomePage'; -import NotificationAlert from '../../components/NotificationAlert'; +import PopUpAlert from '../../components/PopUpAlert'; const Home = () => { const user = useCurrentUser(); const [onMemberHomePage, setOnMemberHomePage] = useState(false); return ( <> - {!onMemberHomePage && } + {!onMemberHomePage && } {isGuest(user.role) && !onMemberHomePage ? ( ) : isMember(user.role) ? ( diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index ba62d213d6..15a25d3026 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -26,8 +26,6 @@ const userScheduleSettings = (id: string) => `${usersById(id)}/schedule-settings const userScheduleSettingsSet = () => `${users()}/schedule-settings/set`; const userTasks = (id: string) => `${usersById(id)}/tasks`; const manyUserTasks = () => `${users()}/tasks/get-many`; -const userNotifications = (id: string) => `${usersById(id)}/notifications`; -const userRemoveNotifications = (id: string) => `${usersById(id)}/notifications/remove`; /**************** Projects Endpoints ****************/ const projects = () => `${API_URL}/projects`; @@ -199,6 +197,11 @@ const faqCreate = () => `${recruitment()}/faq/create`; const faqEdit = (id: string) => `${recruitment()}/faq/${id}/edit`; const faqDelete = (id: string) => `${recruitment()}/faq/${id}/delete`; +/************** Pop Up Endpoints ***************/ +const popUps = () => `${API_URL}/pop-ups`; +const popUpsCurrentUser = () => `${popUps()}/current-user`; +const popUpsRemove = (id: string) => `${popUps()}/${id}/remove`; + /************** Onboarding Endpoints ***************/ const onboarding = () => `${API_URL}/onboarding`; const imageById = (imageId: string) => `${onboarding()}/image/${imageId}`; @@ -221,8 +224,6 @@ export const apiUrls = { userScheduleSettingsSet, userTasks, manyUserTasks, - userNotifications, - userRemoveNotifications, projects, allProjects, @@ -366,5 +367,9 @@ export const apiUrls = { faqDelete, imageById, + popUps, + popUpsCurrentUser, + popUpsRemove, + version }; diff --git a/src/shared/index.ts b/src/shared/index.ts index 409dae2e65..93e9c8899b 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -11,7 +11,8 @@ 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/types/pop-up-types'; +export * from './src/types/announcements.types'; export * from './src/validate-wbs'; export * from './src/date-utils'; diff --git a/src/shared/src/types/announcements.types.ts b/src/shared/src/types/announcements.types.ts new file mode 100644 index 0000000000..ac31f72062 --- /dev/null +++ b/src/shared/src/types/announcements.types.ts @@ -0,0 +1,12 @@ +import { User } from './user-types'; + +export interface Announcement { + announcementId: string; + text: string; + usersReceived: User[]; + senderName: string; + dateMessageSent: Date; + slackEventId: string; + slackChannelName: string; + dateDeleted?: Date; +} diff --git a/src/shared/src/types/notifications.types.ts b/src/shared/src/types/pop-up-types.ts similarity index 50% rename from src/shared/src/types/notifications.types.ts rename to src/shared/src/types/pop-up-types.ts index abd16fcd21..2b517a3900 100644 --- a/src/shared/src/types/notifications.types.ts +++ b/src/shared/src/types/pop-up-types.ts @@ -1,5 +1,5 @@ -export interface Notification { - notificationId: string; +export interface PopUp { + popUpId: string; text: string; iconName: string; eventLink?: string;