From 6875955c608bea1b2dbb77d8800398235e75c423 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 19 Nov 2024 11:47:30 -0500 Subject: [PATCH 01/22] #3000-set up hooks --- .../transformers/notifications.transformers.ts | 13 +++++++++++++ src/frontend/src/apis/users.api.ts | 18 ++++++++++++++++++ src/frontend/src/hooks/users.hooks.ts | 18 ++++++++++++++++-- src/frontend/src/utils/urls.ts | 4 ++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/apis/transformers/notifications.transformers.ts diff --git a/src/frontend/src/apis/transformers/notifications.transformers.ts b/src/frontend/src/apis/transformers/notifications.transformers.ts new file mode 100644 index 0000000000..b429b61efc --- /dev/null +++ b/src/frontend/src/apis/transformers/notifications.transformers.ts @@ -0,0 +1,13 @@ +import { Notification } from 'shared'; + +/** + * Transforms a notification + * + * @param notification Incoming task object supplied by the HTTP response. + * @returns Properly transformed notification object. + */ +export const notificationTransformer = (notification: Notification): Notification => { + return { + ...notification + }; +}; diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index afa5ea00f6..4ad76fe84c 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -5,6 +5,7 @@ import axios from '../utils/axios'; import { + Notification, Project, SetUserScheduleSettingsPayload, Task, @@ -23,6 +24,7 @@ import { import { AuthenticatedUser, UserSettings } from 'shared'; import { projectTransformer } from './transformers/projects.transformers'; import { taskTransformer } from './transformers/tasks.transformers'; +import notificationTransformer from '../../../backend/src/transformers/notification.transformer'; /** * Fetches all users. @@ -159,3 +161,19 @@ export const getManyUserTasks = (userIds: string[]) => { } ); }; + +/* + * Sends a notification to the user with the given id + */ +export const sendNotification = (id: string, notification: Notification) => { + return axios.post(apiUrls.userSendNotifications(id), notification); +}; + +/* + * Gets all unread notifications of the user with the given id + */ +export const getNotifications = (id: string) => { + return axios.get(apiUrls.userNotifications(id), { + transformResponse: (data) => notificationTransformer(JSON.parse(data)) + }); +}; diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index 96b659c1f1..e2278ee136 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -19,7 +19,8 @@ import { getUserScheduleSettings, updateUserScheduleSettings, getUserTasks, - getManyUserTasks + getManyUserTasks, + getNotifications } from '../apis/users.api'; import { User, @@ -31,7 +32,8 @@ import { UserScheduleSettings, UserWithScheduleSettings, SetUserScheduleSettingsPayload, - Task + Task, + Notification } from 'shared'; import { useAuth } from './auth.hooks'; import { useContext } from 'react'; @@ -260,3 +262,15 @@ 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; + }); +}; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 1b00a9e46b..cffa6050ac 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -26,6 +26,8 @@ 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 userSendNotifications = (id: string) => `${usersById(id)}/notifications/send`; +const userNotifications = (id: string) => `${usersById(id)}/notifications`; /**************** Projects Endpoints ****************/ const projects = () => `${API_URL}/projects`; @@ -211,6 +213,8 @@ export const apiUrls = { userScheduleSettingsSet, userTasks, manyUserTasks, + userSendNotifications, + userNotifications, projects, allProjects, From fc44a6c70879c1e646989e228aedfe2756676f6f Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 19 Nov 2024 20:53:47 -0500 Subject: [PATCH 02/22] #3000-created notification card --- src/backend/src/prisma/seed.ts | 3 + src/frontend/src/apis/users.api.ts | 2 +- .../src/components/NotificationCard.tsx | 59 +++++++++++++++++++ .../src/pages/HomePage/AdminHomePage.tsx | 16 ++++- 4 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 src/frontend/src/components/NotificationCard.tsx diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index a30e502baf..451cb37462 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -1893,6 +1893,9 @@ 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 UsersService.sendNotification(thomasEmrax.userId, 'testing notifications', 'star'); + await UsersService.sendNotification(thomasEmrax.userId, 'testing notifications #2', 'star'); }; performSeed() diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index 4ad76fe84c..3bcc945281 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -174,6 +174,6 @@ export const sendNotification = (id: string, notification: Notification) => { */ export const getNotifications = (id: string) => { return axios.get(apiUrls.userNotifications(id), { - transformResponse: (data) => notificationTransformer(JSON.parse(data)) + transformResponse: (data) => JSON.parse(data) }); }; diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/NotificationCard.tsx new file mode 100644 index 0000000000..407a17e250 --- /dev/null +++ b/src/frontend/src/components/NotificationCard.tsx @@ -0,0 +1,59 @@ +import { Box, Icon, IconButton, Typography, useTheme } from '@mui/material'; +import React from 'react'; +import { Notification } from 'shared'; +import CloseIcon from '@mui/icons-material/Close'; + +interface NotificationCardProps { + notification: Notification; +} + +const NotificationCard: React.FC = ({ notification }) => { + const theme = useTheme(); + return ( + + + + {notification.iconName} + + + + {notification.text} + + + + + + ); +}; + +export default NotificationCard; diff --git a/src/frontend/src/pages/HomePage/AdminHomePage.tsx b/src/frontend/src/pages/HomePage/AdminHomePage.tsx index 4c1bf340f9..5c06b32b11 100644 --- a/src/frontend/src/pages/HomePage/AdminHomePage.tsx +++ b/src/frontend/src/pages/HomePage/AdminHomePage.tsx @@ -4,11 +4,12 @@ */ import { Typography } from '@mui/material'; -import { useSingleUserSettings } from '../../hooks/users.hooks'; +import { useSingleUserSettings, useUserNotifications } from '../../hooks/users.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; import PageLayout from '../../components/PageLayout'; import { AuthenticatedUser } from 'shared'; +import NotificationCard from '../../components/NotificationCard'; interface AdminHomePageProps { user: AuthenticatedUser; @@ -16,12 +17,23 @@ interface AdminHomePageProps { const AdminHomePage = ({ user }: AdminHomePageProps) => { const { isLoading, isError, error, data: userSettingsData } = useSingleUserSettings(user.userId); + const { + data: notifications, + isLoading: notificationsIsLoading, + error: notificationsError, + isError: notificationsIsError + } = useUserNotifications(user.userId); - if (isLoading || !userSettingsData) return ; + if (isLoading || !userSettingsData || notificationsIsLoading || !notifications) return ; if (isError) return ; + if (notificationsIsError) return ; + + const currentNotification = notifications.length > 0 ? notifications[0] : undefined; + if (!currentNotification) return ; return ( + {currentNotification && } Welcome, {user.firstName}! From d0c8d8c161ce941e423faa30a1cd0c9e5c164257 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Thu, 21 Nov 2024 16:03:51 -0500 Subject: [PATCH 03/22] #3000-fixed styling --- src/frontend/src/components/NotificationCard.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/NotificationCard.tsx index 407a17e250..d0fe319a9c 100644 --- a/src/frontend/src/components/NotificationCard.tsx +++ b/src/frontend/src/components/NotificationCard.tsx @@ -43,7 +43,9 @@ const NotificationCard: React.FC = ({ notification }) => From bef883a60b82f164e9dfc0fdd2e9e7ec27d65d29 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Fri, 29 Nov 2024 21:32:12 -0500 Subject: [PATCH 04/22] #3000-animation is working --- src/backend/src/prisma/seed.ts | 5 +-- .../src/components/NotificationAlert.tsx | 31 +++++++++++++++++++ .../src/components/NotificationCard.tsx | 1 + .../src/pages/HomePage/AdminHomePage.tsx | 16 ++-------- src/frontend/src/pages/HomePage/Home.tsx | 26 ++++++++++------ 5 files changed, 53 insertions(+), 26 deletions(-) create mode 100644 src/frontend/src/components/NotificationAlert.tsx diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 451cb37462..19c94e6e2d 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -33,6 +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 { sendNotificationToUsers } from '../utils/homepage-notifications.utils'; const prisma = new PrismaClient(); @@ -1894,8 +1895,8 @@ const performSeed: () => Promise = async () => { await RecruitmentServices.createFaq(batman, 'How many developers are working on FinishLine?', '178 as of 2024', ner); - await UsersService.sendNotification(thomasEmrax.userId, 'testing notifications', 'star'); - await UsersService.sendNotification(thomasEmrax.userId, 'testing notifications #2', 'star'); + await sendNotificationToUsers([thomasEmrax.userId], 'test', 'star', ner.organizationId); + await sendNotificationToUsers([thomasEmrax.userId], 'test2', 'star', ner.organizationId); }; performSeed() diff --git a/src/frontend/src/components/NotificationAlert.tsx b/src/frontend/src/components/NotificationAlert.tsx new file mode 100644 index 0000000000..9b60b48760 --- /dev/null +++ b/src/frontend/src/components/NotificationAlert.tsx @@ -0,0 +1,31 @@ +import { Box } from '@mui/material'; +import React from 'react'; +import { User } from 'shared'; +import NotificationCard from './NotificationCard'; +import { useUserNotifications } from '../hooks/users.hooks'; + +interface NotificationAlertProps { + user: User; +} + +const NotificationAlert: React.FC = ({ user }) => { + const { data: notifications } = useUserNotifications(user.userId); + + const currentNotification = notifications && notifications.length > 0 ? notifications[0] : undefined; + + return ( + + {currentNotification && } + + ); +}; + +export default NotificationAlert; diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/NotificationCard.tsx index d0fe319a9c..432517fa98 100644 --- a/src/frontend/src/components/NotificationCard.tsx +++ b/src/frontend/src/components/NotificationCard.tsx @@ -9,6 +9,7 @@ interface NotificationCardProps { const NotificationCard: React.FC = ({ notification }) => { const theme = useTheme(); + return ( { const { isLoading, isError, error, data: userSettingsData } = useSingleUserSettings(user.userId); - const { - data: notifications, - isLoading: notificationsIsLoading, - error: notificationsError, - isError: notificationsIsError - } = useUserNotifications(user.userId); - if (isLoading || !userSettingsData || notificationsIsLoading || !notifications) return ; + if (isLoading || !userSettingsData) return ; if (isError) return ; - if (notificationsIsError) return ; - - const currentNotification = notifications.length > 0 ? notifications[0] : undefined; - if (!currentNotification) return ; return ( - {currentNotification && } Welcome, {user.firstName}! diff --git a/src/frontend/src/pages/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 961430d92e..76db11f05a 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -11,20 +11,26 @@ import { useState } from 'react'; import MemberHomePage from './MemberHomePage'; import LeadHomePage from './LeadHomePage'; import AdminHomePage from './AdminHomePage'; +import NotificationAlert from '../../components/NotificationAlert'; const Home = () => { const user = useCurrentUser(); const [onMemberHomePage, setOnMemberHomePage] = useState(false); - return isGuest(user.role) && !onMemberHomePage ? ( - - ) : isMember(user.role) ? ( - - ) : isLead(user.role) ? ( - - ) : isAdmin(user.role) ? ( - - ) : ( - + return ( + <> + {!onMemberHomePage && } + {isGuest(user.role) && !onMemberHomePage ? ( + + ) : isMember(user.role) ? ( + + ) : isLead(user.role) ? ( + + ) : isAdmin(user.role) ? ( + + ) : ( + + )} + ); }; From 5b9923627c736a31aeb4e7bca249824f87ab5af6 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Thu, 5 Dec 2024 15:33:34 -0500 Subject: [PATCH 05/22] #3000-updated controller --- .../src/controllers/users.controllers.ts | 4 +- .../migration.sql | 51 ------------------- 2 files changed, 2 insertions(+), 53 deletions(-) delete mode 100644 src/backend/src/prisma/migrations/20241122155216_updated_notifications/migration.sql diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index be57729561..e491d3f4f4 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -198,9 +198,9 @@ export default class UsersController { const { organization } = req; const unreadNotifications = await UsersService.getUserUnreadNotifications(userId, organization); - return res.status(200).json(unreadNotifications); + res.status(200).json(unreadNotifications); } catch (error: unknown) { - return next(error); + next(error); } } } diff --git a/src/backend/src/prisma/migrations/20241122155216_updated_notifications/migration.sql b/src/backend/src/prisma/migrations/20241122155216_updated_notifications/migration.sql deleted file mode 100644 index 192243b385..0000000000 --- a/src/backend/src/prisma/migrations/20241122155216_updated_notifications/migration.sql +++ /dev/null @@ -1,51 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `userId` on the `Notification` table. All the data in the column will be lost. - - Added the required column `text` to the `Announcement` table without a default value. This is not possible if the table is not empty. - -*/ --- DropForeignKey -ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey"; - --- AlterTable -ALTER TABLE "Announcement" ADD COLUMN "text" TEXT NOT NULL; - --- AlterTable -ALTER TABLE "Notification" DROP COLUMN "userId"; - --- CreateTable -CREATE TABLE "_ReceivedAnnouncements" ( - "A" TEXT NOT NULL, - "B" TEXT NOT NULL -); - --- CreateTable -CREATE TABLE "_NotificationToUser" ( - "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 "_NotificationToUser_AB_unique" ON "_NotificationToUser"("A", "B"); - --- CreateIndex -CREATE INDEX "_NotificationToUser_B_index" ON "_NotificationToUser"("B"); - --- 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 "_NotificationToUser" ADD CONSTRAINT "_NotificationToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Notification"("notificationId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_NotificationToUser" ADD CONSTRAINT "_NotificationToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; From 3369a6e153b317f8335624eac841dac835ff2957 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Sun, 15 Dec 2024 16:01:47 -0500 Subject: [PATCH 06/22] #2999-remove notification endpoint created --- src/backend/src/controllers/users.controllers.ts | 13 +++++++++++++ src/backend/src/routes/users.routes.ts | 5 +++++ src/backend/src/services/users.services.ts | 16 ++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index e491d3f4f4..bf965c8d8e 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -203,4 +203,17 @@ export default class UsersController { 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/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 34ae1a0136..622a6fb01c 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -55,5 +55,10 @@ userRouter.post( 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/users.services.ts b/src/backend/src/services/users.services.ts index d9d37b259e..3b5ecf3bdc 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -578,4 +578,20 @@ export default class UsersService { return requestedUser.unreadNotifications.map(notificationTransformer); } + + static async removeUserNotification(userId: string, notificationId: string, organization: Organization) { + const requestedUser = await prisma.user.update({ + where: { userId }, + data: { + unreadNotifications: { + disconnect: { + notificationId + } + } + }, + include: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) } + }); + + return requestedUser.unreadNotifications.map(notificationTransformer); + } } From 4ad0bef2530b71866d31a7cb5df8b03f47447856 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Sun, 15 Dec 2024 16:18:54 -0500 Subject: [PATCH 07/22] #2999-created tests --- src/backend/src/services/users.services.ts | 10 +++++-- src/backend/tests/unmocked/users.test.ts | 33 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 3b5ecf3bdc..fce1944a57 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -580,7 +580,13 @@ export default class UsersService { } static async removeUserNotification(userId: string, notificationId: string, organization: Organization) { - const requestedUser = await prisma.user.update({ + 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: { @@ -592,6 +598,6 @@ export default class UsersService { include: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) } }); - return requestedUser.unreadNotifications.map(notificationTransformer); + return updatedUser.unreadNotifications.map(notificationTransformer); } } diff --git a/src/backend/tests/unmocked/users.test.ts b/src/backend/tests/unmocked/users.test.ts index 14d8b0bffe..512a651b90 100644 --- a/src/backend/tests/unmocked/users.test.ts +++ b/src/backend/tests/unmocked/users.test.ts @@ -69,4 +69,37 @@ describe('User Tests', () => { 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'); + }); + }); }); From bc4200482fd5cd091cde97e9a3a3edd628becb0d Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Sun, 15 Dec 2024 18:47:18 -0500 Subject: [PATCH 08/22] #2999-javadocs --- src/backend/src/services/users.services.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index fce1944a57..1358ca5f6c 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -569,6 +569,12 @@ export default class UsersService { 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 }, @@ -579,6 +585,13 @@ export default class UsersService { 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 } From 46b80c73ebf71042a8f949731ac5783c19d9b451 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Sun, 15 Dec 2024 21:28:28 -0500 Subject: [PATCH 09/22] #3000-created remove hook --- .../src/services/design-reviews.services.ts | 3 +++ src/frontend/src/apis/users.api.ts | 15 ++++++------ .../src/components/NotificationAlert.tsx | 18 ++++++++++---- .../src/components/NotificationCard.tsx | 5 ++-- src/frontend/src/hooks/users.hooks.ts | 24 ++++++++++++++++++- src/frontend/src/utils/urls.ts | 4 ++-- 6 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/backend/src/services/design-reviews.services.ts b/src/backend/src/services/design-reviews.services.ts index 8f7511b73c..1804e9ffcc 100644 --- a/src/backend/src/services/design-reviews.services.ts +++ b/src/backend/src/services/design-reviews.services.ts @@ -39,6 +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 NotificationsService from './notifications.services'; export default class DesignReviewsService { /** @@ -211,6 +212,8 @@ export default class DesignReviewsService { await sendSlackDRNotifications(teams, designReview, submitter, wbsElement.name); } + NotificationsService.sendNotifcationToUsers('DR created!', 'star', [submitter.userId], organization.organizationId); + return designReviewTransformer(designReview); } diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index 3bcc945281..5a91bff5fd 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -24,7 +24,6 @@ import { import { AuthenticatedUser, UserSettings } from 'shared'; import { projectTransformer } from './transformers/projects.transformers'; import { taskTransformer } from './transformers/tasks.transformers'; -import notificationTransformer from '../../../backend/src/transformers/notification.transformer'; /** * Fetches all users. @@ -162,13 +161,6 @@ export const getManyUserTasks = (userIds: string[]) => { ); }; -/* - * Sends a notification to the user with the given id - */ -export const sendNotification = (id: string, notification: Notification) => { - return axios.post(apiUrls.userSendNotifications(id), notification); -}; - /* * Gets all unread notifications of the user with the given id */ @@ -177,3 +169,10 @@ export const getNotifications = (id: string) => { 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 index 9b60b48760..e7295c119a 100644 --- a/src/frontend/src/components/NotificationAlert.tsx +++ b/src/frontend/src/components/NotificationAlert.tsx @@ -1,17 +1,23 @@ import { Box } from '@mui/material'; import React from 'react'; -import { User } from 'shared'; +import { Notification, User } from 'shared'; import NotificationCard from './NotificationCard'; -import { useUserNotifications } from '../hooks/users.hooks'; +import { useRemoveUserNotification, useUserNotifications } from '../hooks/users.hooks'; interface NotificationAlertProps { user: User; } const NotificationAlert: React.FC = ({ user }) => { - const { data: notifications } = useUserNotifications(user.userId); + const { data: notifications, isLoading: notificationIsLoading } = useUserNotifications(user.userId); + const { mutateAsync: removeNotification } = useRemoveUserNotification(user.userId); - const currentNotification = notifications && notifications.length > 0 ? notifications[0] : undefined; + const currentNotification = + !notificationIsLoading && notifications && notifications.length > 0 ? notifications[0] : undefined; + + const removeNotificationWrapper = async (notification: Notification) => { + await removeNotification(notification); + }; return ( = ({ user }) => { transition: 'transform 0.5s ease-out' }} > - {currentNotification && } + {currentNotification && ( + + )} ); }; diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/NotificationCard.tsx index 432517fa98..5f2a213bc2 100644 --- a/src/frontend/src/components/NotificationCard.tsx +++ b/src/frontend/src/components/NotificationCard.tsx @@ -5,9 +5,10 @@ import CloseIcon from '@mui/icons-material/Close'; interface NotificationCardProps { notification: Notification; + removeNotification: (notificationId: Notification) => Promise; } -const NotificationCard: React.FC = ({ notification }) => { +const NotificationCard: React.FC = ({ notification, removeNotification }) => { const theme = useTheme(); return ( @@ -51,7 +52,7 @@ const NotificationCard: React.FC = ({ notification }) => }} > {notification.text} - + removeNotification(notification)}> diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index e2278ee136..32279217f9 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -20,7 +20,8 @@ import { updateUserScheduleSettings, getUserTasks, getManyUserTasks, - getNotifications + getNotifications, + removeNotification } from '../apis/users.api'; import { User, @@ -274,3 +275,24 @@ export const useUserNotifications = (userId: string) => { 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/utils/urls.ts b/src/frontend/src/utils/urls.ts index e5e5a207c0..d50675ed69 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -26,8 +26,8 @@ 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 userSendNotifications = (id: string) => `${usersById(id)}/notifications/send`; const userNotifications = (id: string) => `${usersById(id)}/notifications`; +const userRemoveNotifications = (id: string) => `${usersById(id)}/notifications/remove`; /**************** Projects Endpoints ****************/ const projects = () => `${API_URL}/projects`; @@ -214,8 +214,8 @@ export const apiUrls = { userScheduleSettingsSet, userTasks, manyUserTasks, - userSendNotifications, userNotifications, + userRemoveNotifications, projects, allProjects, From 6b4c7a9466b18f7f30a25c1d56a9ac8552a3c20c Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Mon, 16 Dec 2024 14:39:51 -0500 Subject: [PATCH 10/22] #3000-mulitple notifications showing --- .../src/services/design-reviews.services.ts | 3 --- .../src/components/NotificationAlert.tsx | 17 +++++++++++------ .../src/components/NotificationCard.tsx | 7 ++++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/backend/src/services/design-reviews.services.ts b/src/backend/src/services/design-reviews.services.ts index 1804e9ffcc..8f7511b73c 100644 --- a/src/backend/src/services/design-reviews.services.ts +++ b/src/backend/src/services/design-reviews.services.ts @@ -39,7 +39,6 @@ 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 NotificationsService from './notifications.services'; export default class DesignReviewsService { /** @@ -212,8 +211,6 @@ export default class DesignReviewsService { await sendSlackDRNotifications(teams, designReview, submitter, wbsElement.name); } - NotificationsService.sendNotifcationToUsers('DR created!', 'star', [submitter.userId], organization.organizationId); - return designReviewTransformer(designReview); } diff --git a/src/frontend/src/components/NotificationAlert.tsx b/src/frontend/src/components/NotificationAlert.tsx index e7295c119a..e053d6fcaf 100644 --- a/src/frontend/src/components/NotificationAlert.tsx +++ b/src/frontend/src/components/NotificationAlert.tsx @@ -1,5 +1,5 @@ import { Box } from '@mui/material'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Notification, User } from 'shared'; import NotificationCard from './NotificationCard'; import { useRemoveUserNotification, useUserNotifications } from '../hooks/users.hooks'; @@ -9,13 +9,18 @@ interface NotificationAlertProps { } const NotificationAlert: React.FC = ({ user }) => { - const { data: notifications, isLoading: notificationIsLoading } = useUserNotifications(user.userId); - const { mutateAsync: removeNotification } = useRemoveUserNotification(user.userId); + const { data: notifications, isLoading: notificationsIsLoading } = useUserNotifications(user.userId); + const { mutateAsync: removeNotification, isLoading: removeIsLoading } = useRemoveUserNotification(user.userId); + const [currentNotification, setCurrentNotification] = useState(); - const currentNotification = - !notificationIsLoading && notifications && notifications.length > 0 ? notifications[0] : undefined; + useEffect(() => { + if (notifications && notifications.length > 0) { + setCurrentNotification(notifications[0]); + } + }, [notifications]); const removeNotificationWrapper = async (notification: Notification) => { + setCurrentNotification(undefined); await removeNotification(notification); }; @@ -29,7 +34,7 @@ const NotificationAlert: React.FC = ({ user }) => { transition: 'transform 0.5s ease-out' }} > - {currentNotification && ( + {!removeIsLoading && !notificationsIsLoading && currentNotification && ( )} diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/NotificationCard.tsx index 5f2a213bc2..a25f1d240e 100644 --- a/src/frontend/src/components/NotificationCard.tsx +++ b/src/frontend/src/components/NotificationCard.tsx @@ -1,4 +1,4 @@ -import { Box, Icon, IconButton, Typography, useTheme } from '@mui/material'; +import { Box, Card, Icon, IconButton, Typography, useTheme } from '@mui/material'; import React from 'react'; import { Notification } from 'shared'; import CloseIcon from '@mui/icons-material/Close'; @@ -12,7 +12,8 @@ const NotificationCard: React.FC = ({ notification, remov const theme = useTheme(); return ( - = ({ notification, remov - + ); }; From 56e668c7aef56b3bcca4f3636090451c6628b36d Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Mon, 16 Dec 2024 15:21:52 -0500 Subject: [PATCH 11/22] #3000-added notifications to sercives --- .../src/services/change-requests.services.ts | 14 ++++++++++++++ .../src/services/design-reviews.services.ts | 8 ++++++++ src/frontend/src/components/NotificationCard.tsx | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 89cbaaa370..1cbd4a0611 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -46,6 +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 NotificationsService from './notifications.services'; export default class ChangeRequestsService { /** @@ -153,6 +154,12 @@ export default class ChangeRequestsService { // send a reply to a CR's notifications of its updated status await sendSlackCRStatusToThread(updated.notificationSlackThreads, foundCR.crId, foundCR.identifier, accepted); + await NotificationsService.sendNotifcationToUsers( + `CR #${updated.identifier} has been ${accepted ? 'approved!' : 'denied.'}`, + accepted ? 'check_circle' : 'cancel', + [updated.submitter.userId], + organization.organizationId + ); return updated.crId; } @@ -1078,5 +1085,12 @@ export default class ChangeRequestsService { // send slack message to CR reviewers await sendSlackRequestedReviewNotification(newReviewers, changeRequestTransformer(foundCR)); + + await NotificationsService.sendNotifcationToUsers( + `Your review has been requested on CR #${foundCR.identifier}`, + 'edit_note', + newReviewers.map((reviewer) => reviewer.userId), + organization.organizationId + ); } } diff --git a/src/backend/src/services/design-reviews.services.ts b/src/backend/src/services/design-reviews.services.ts index 8f7511b73c..83ceb74ceb 100644 --- a/src/backend/src/services/design-reviews.services.ts +++ b/src/backend/src/services/design-reviews.services.ts @@ -39,6 +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 NotificationsService from './notifications.services'; export default class DesignReviewsService { /** @@ -205,6 +206,13 @@ export default class DesignReviewsService { } } + await NotificationsService.sendNotifcationToUsers( + `You have been invited to the ${designReview.wbsElement.name} Design Review!`, + 'calendar_month', + members.map((member) => member.userId), + organization.organizationId + ); + const project = wbsElement.workPackage?.project; const teams = project?.teams; if (teams && teams.length > 0) { diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/NotificationCard.tsx index a25f1d240e..d31e576588 100644 --- a/src/frontend/src/components/NotificationCard.tsx +++ b/src/frontend/src/components/NotificationCard.tsx @@ -31,7 +31,7 @@ const NotificationCard: React.FC = ({ notification, remov justifyContent: 'center', alignItems: 'center', padding: 2, - background: 'red', + background: theme.palette.primary.main, width: '30%', borderRadius: 4 }} From e5f2a44fa4d90eb0524df89059134c34e559e0dc Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Mon, 16 Dec 2024 15:25:17 -0500 Subject: [PATCH 12/22] #3000-removed test notifications --- src/backend/src/prisma/seed.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 6fed93e2c2..fdb5ffefdb 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -1894,9 +1894,6 @@ 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 NotificationsService.sendNotifcationToUsers('test1', 'star', [thomasEmrax.userId], ner.organizationId); - await NotificationsService.sendNotifcationToUsers('test2', 'star', [thomasEmrax.userId], ner.organizationId); }; performSeed() From 8806ff1084eed9904c7cedd9dff62eb40e40a728 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 17 Dec 2024 20:30:41 -0500 Subject: [PATCH 13/22] #3000-goes to link on click --- .../migration.sql | 1 + src/backend/src/prisma/schema.prisma | 5 +- .../src/services/change-requests.services.ts | 17 ++---- .../src/services/design-reviews.services.ts | 9 +-- .../src/services/notifications.services.ts | 12 +++- .../transformers/notifications.transformer.ts | 3 +- src/backend/src/utils/notifications.utils.ts | 58 ++++++++++++++++++- .../notifications.transformers.ts | 13 ----- .../src/components/NotificationAlert.tsx | 16 ++++- .../src/components/NotificationCard.tsx | 51 +++++++++------- src/shared/src/types/notifications.types.ts | 1 + 11 files changed, 125 insertions(+), 61 deletions(-) rename src/backend/src/prisma/migrations/{20241211195435_announcements_and_notifications => 20241217232127_announcements_and_notifications}/migration.sql (98%) delete mode 100644 src/frontend/src/apis/transformers/notifications.transformers.ts diff --git a/src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql b/src/backend/src/prisma/migrations/20241217232127_announcements_and_notifications/migration.sql similarity index 98% rename from src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql rename to src/backend/src/prisma/migrations/20241217232127_announcements_and_notifications/migration.sql index c57afabd25..fac3e9f480 100644 --- a/src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql +++ b/src/backend/src/prisma/migrations/20241217232127_announcements_and_notifications/migration.sql @@ -13,6 +13,7 @@ CREATE TABLE "Notification" ( "notificationId" TEXT NOT NULL, "text" TEXT NOT NULL, "iconName" TEXT NOT NULL, + "eventLink" TEXT, CONSTRAINT "Notification_pkey" PRIMARY KEY ("notificationId") ); diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index c06ca3d45f..e0157c3353 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -941,8 +941,9 @@ model Announcement { } model Notification { - notificationId String @id @default(uuid()) + notificationId String @id @default(uuid()) text String iconName String - users User[] @relation("userNotifications") + users User[] @relation("userNotifications") + eventLink String? } diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 1cbd4a0611..85e0dc5579 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 NotificationsService from './notifications.services'; +import { sendHomeCrRequestReviewNotification, sendHomeCrReviewedNotification } from '../utils/notifications.utils'; export default class ChangeRequestsService { /** @@ -151,15 +151,11 @@ 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); + // send a reply to a CR's notifications of its updated status await sendSlackCRStatusToThread(updated.notificationSlackThreads, foundCR.crId, foundCR.identifier, accepted); - await NotificationsService.sendNotifcationToUsers( - `CR #${updated.identifier} has been ${accepted ? 'approved!' : 'denied.'}`, - accepted ? 'check_circle' : 'cancel', - [updated.submitter.userId], - organization.organizationId - ); return updated.crId; } @@ -1086,11 +1082,6 @@ export default class ChangeRequestsService { // send slack message to CR reviewers await sendSlackRequestedReviewNotification(newReviewers, changeRequestTransformer(foundCR)); - await NotificationsService.sendNotifcationToUsers( - `Your review has been requested on CR #${foundCR.identifier}`, - 'edit_note', - newReviewers.map((reviewer) => reviewer.userId), - organization.organizationId - ); + await sendHomeCrRequestReviewNotification(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 83ceb74ceb..644903fa43 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 NotificationsService from './notifications.services'; +import { sendHomeDrNotification } from '../utils/notifications.utils'; export default class DesignReviewsService { /** @@ -206,12 +206,7 @@ export default class DesignReviewsService { } } - await NotificationsService.sendNotifcationToUsers( - `You have been invited to the ${designReview.wbsElement.name} Design Review!`, - 'calendar_month', - members.map((member) => member.userId), - organization.organizationId - ); + await sendHomeDrNotification(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 483e6ed9d6..e0617301f5 100644 --- a/src/backend/src/services/notifications.services.ts +++ b/src/backend/src/services/notifications.services.ts @@ -202,13 +202,21 @@ export default class NotificationsService { * @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) { + static async sendNotifcationToUsers( + text: string, + iconName: string, + userIds: string[], + organizationId: string, + eventLink?: string + ) { const createdNotification = await prisma.notification.create({ data: { text, - iconName + iconName, + eventLink }, ...getNotificationQueryArgs(organizationId) }); diff --git a/src/backend/src/transformers/notifications.transformer.ts b/src/backend/src/transformers/notifications.transformer.ts index 32666b151a..45dd25dee9 100644 --- a/src/backend/src/transformers/notifications.transformer.ts +++ b/src/backend/src/transformers/notifications.transformer.ts @@ -6,7 +6,8 @@ const notificationTransformer = (notification: Prisma.NotificationGetPayload { endOfDay.setDate(startOfDay.getDate() + 1); return endOfDay; }; + +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 + ); +}; + +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 + ); +}; + +export const sendHomeCrRequestReviewNotification = async ( + changeRequest: Change_Request, + newReviewers: User[], + organizationId: string +) => { + const changeRequestLink = `/change-requests/${changeRequest.crId}`; + await NotificationsService.sendNotifcationToUsers( + `Your review has been requested on CR #${changeRequest.identifier}`, + 'edit_note', + newReviewers.map((reviewer) => reviewer.userId), + organizationId, + changeRequestLink + ); +}; diff --git a/src/frontend/src/apis/transformers/notifications.transformers.ts b/src/frontend/src/apis/transformers/notifications.transformers.ts deleted file mode 100644 index b429b61efc..0000000000 --- a/src/frontend/src/apis/transformers/notifications.transformers.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Notification } from 'shared'; - -/** - * Transforms a notification - * - * @param notification Incoming task object supplied by the HTTP response. - * @returns Properly transformed notification object. - */ -export const notificationTransformer = (notification: Notification): Notification => { - return { - ...notification - }; -}; diff --git a/src/frontend/src/components/NotificationAlert.tsx b/src/frontend/src/components/NotificationAlert.tsx index e053d6fcaf..f334e06064 100644 --- a/src/frontend/src/components/NotificationAlert.tsx +++ b/src/frontend/src/components/NotificationAlert.tsx @@ -3,6 +3,8 @@ 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'; +import { routes } from '../utils/routes'; interface NotificationAlertProps { user: User; @@ -12,6 +14,7 @@ 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) { @@ -24,6 +27,13 @@ const NotificationAlert: React.FC = ({ user }) => { await removeNotification(notification); }; + const onClick = async (notification: Notification) => { + if (!!notification.eventLink) { + await removeNotification(notification); + history.push(notification.eventLink); + } + }; + return ( = ({ user }) => { }} > {!removeIsLoading && !notificationsIsLoading && currentNotification && ( - + )} ); diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/NotificationCard.tsx index d31e576588..1e4cfb4c02 100644 --- a/src/frontend/src/components/NotificationCard.tsx +++ b/src/frontend/src/components/NotificationCard.tsx @@ -6,11 +6,11 @@ import CloseIcon from '@mui/icons-material/Close'; interface NotificationCardProps { notification: Notification; removeNotification: (notificationId: Notification) => Promise; + onClick: (notificationId: Notification) => Promise; } -const NotificationCard: React.FC = ({ notification, removeNotification }) => { +const NotificationCard: React.FC = ({ notification, removeNotification, onClick }) => { const theme = useTheme(); - return ( = ({ notification, remov > - await onClick(notification)} sx={{ - fontSize: 36 + display: 'flex', + gap: 1, + cursor: !!notification.eventLink ? 'pointer' : 'default' }} > - {notification.iconName} - - - - {notification.text} + + + {notification.iconName} + + + {notification.text} + removeNotification(notification)}> diff --git a/src/shared/src/types/notifications.types.ts b/src/shared/src/types/notifications.types.ts index e4419ef2ed..abd16fcd21 100644 --- a/src/shared/src/types/notifications.types.ts +++ b/src/shared/src/types/notifications.types.ts @@ -2,4 +2,5 @@ export interface Notification { notificationId: string; text: string; iconName: string; + eventLink?: string; } From 0ea40750d43045480cc67de897bbfd77a5bf4852 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 17 Dec 2024 21:07:15 -0500 Subject: [PATCH 14/22] #3000-javadocs --- src/backend/src/utils/notifications.utils.ts | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/backend/src/utils/notifications.utils.ts b/src/backend/src/utils/notifications.utils.ts index da3ef08f19..7b28ae5b5c 100644 --- a/src/backend/src/utils/notifications.utils.ts +++ b/src/backend/src/utils/notifications.utils.ts @@ -38,6 +38,14 @@ export const endOfDayTomorrow = () => { return endOfDay; }; +/** + * Sends a finishline notification that a design review was scheduled + * @param designReview + * @param members + * @param submitter + * @param workPackageName + * @param organizationId + */ export const sendHomeDrNotification = async ( designReview: Design_Review, members: User[], @@ -57,6 +65,13 @@ export const sendHomeDrNotification = async ( ); }; +/** + * Sends a finishline notification that a change request was reviewed + * @param changeRequest + * @param submitter + * @param accepted + * @param organizationId + */ export const sendHomeCrReviewedNotification = async ( changeRequest: Change_Request, submitter: User, @@ -77,6 +92,12 @@ export const sendHomeCrReviewedNotification = async ( ); }; +/** + * Sends a finishline notification to all requested reviewers of a change request + * @param changeRequest + * @param newReviewers + * @param organizationId + */ export const sendHomeCrRequestReviewNotification = async ( changeRequest: Change_Request, newReviewers: User[], From 382e65b017c2fa462d9d0e617f99261b369c282f Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 17 Dec 2024 21:09:30 -0500 Subject: [PATCH 15/22] #3000-linting --- src/frontend/src/components/NotificationAlert.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/frontend/src/components/NotificationAlert.tsx b/src/frontend/src/components/NotificationAlert.tsx index f334e06064..8fcc707855 100644 --- a/src/frontend/src/components/NotificationAlert.tsx +++ b/src/frontend/src/components/NotificationAlert.tsx @@ -4,7 +4,6 @@ import { Notification, User } from 'shared'; import NotificationCard from './NotificationCard'; import { useRemoveUserNotification, useUserNotifications } from '../hooks/users.hooks'; import { useHistory } from 'react-router-dom'; -import { routes } from '../utils/routes'; interface NotificationAlertProps { user: User; From c12b8ede495d402f01881000b2639357efa09b5f Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 17 Dec 2024 21:10:39 -0500 Subject: [PATCH 16/22] #3000-small fix --- src/frontend/src/components/NotificationAlert.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/components/NotificationAlert.tsx b/src/frontend/src/components/NotificationAlert.tsx index 8fcc707855..581d849ef0 100644 --- a/src/frontend/src/components/NotificationAlert.tsx +++ b/src/frontend/src/components/NotificationAlert.tsx @@ -28,7 +28,7 @@ const NotificationAlert: React.FC = ({ user }) => { const onClick = async (notification: Notification) => { if (!!notification.eventLink) { - await removeNotification(notification); + await removeNotificationWrapper(notification); history.push(notification.eventLink); } }; From 47da71e6c99b92392230f85e0939f688f9a7dc2e Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 17 Dec 2024 22:14:49 -0500 Subject: [PATCH 17/22] #3000-consolidated migraations --- .../migration.sql | 8 ------ .../migration.sql | 9 ------ .../migration.sql | 9 ++++++ src/backend/src/utils/notifications.utils.ts | 28 +++++++++---------- 4 files changed, 23 insertions(+), 31 deletions(-) delete mode 100644 src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql delete mode 100644 src/backend/src/prisma/migrations/20240911164338_changed_logo_image_name_to_logo_image_id/migration.sql rename src/backend/src/prisma/migrations/{20241217232127_announcements_and_notifications => 20241218031222_home_page_updates}/migration.sql (85%) diff --git a/src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql b/src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql deleted file mode 100644 index fe5771a6b1..0000000000 --- a/src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ --- AlterTable -ALTER TABLE "Organization" ADD COLUMN "logoImage" TEXT; - --- AlterTable -ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT; - --- AddForeignKey -ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/migrations/20240911164338_changed_logo_image_name_to_logo_image_id/migration.sql b/src/backend/src/prisma/migrations/20240911164338_changed_logo_image_name_to_logo_image_id/migration.sql deleted file mode 100644 index 2ddd9fa7b6..0000000000 --- a/src/backend/src/prisma/migrations/20240911164338_changed_logo_image_name_to_logo_image_id/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `logoImage` on the `Organization` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "Organization" DROP COLUMN "logoImage", -ADD COLUMN "logoImageId" TEXT; diff --git a/src/backend/src/prisma/migrations/20241217232127_announcements_and_notifications/migration.sql b/src/backend/src/prisma/migrations/20241218031222_home_page_updates/migration.sql similarity index 85% rename from src/backend/src/prisma/migrations/20241217232127_announcements_and_notifications/migration.sql rename to src/backend/src/prisma/migrations/20241218031222_home_page_updates/migration.sql index fac3e9f480..c7975f2e21 100644 --- a/src/backend/src/prisma/migrations/20241217232127_announcements_and_notifications/migration.sql +++ b/src/backend/src/prisma/migrations/20241218031222_home_page_updates/migration.sql @@ -1,3 +1,9 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "logoImageId" TEXT; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT; + -- CreateTable CREATE TABLE "Announcement" ( "announcementId" TEXT NOT NULL, @@ -42,6 +48,9 @@ CREATE UNIQUE INDEX "_userNotifications_AB_unique" ON "_userNotifications"("A", -- CreateIndex CREATE INDEX "_userNotifications_B_index" ON "_userNotifications"("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; diff --git a/src/backend/src/utils/notifications.utils.ts b/src/backend/src/utils/notifications.utils.ts index 7b28ae5b5c..c47ba9b7a0 100644 --- a/src/backend/src/utils/notifications.utils.ts +++ b/src/backend/src/utils/notifications.utils.ts @@ -40,11 +40,11 @@ export const endOfDayTomorrow = () => { /** * Sends a finishline notification that a design review was scheduled - * @param designReview - * @param members - * @param submitter - * @param workPackageName - * @param organizationId + * @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, @@ -67,10 +67,10 @@ export const sendHomeDrNotification = async ( /** * Sends a finishline notification that a change request was reviewed - * @param changeRequest - * @param submitter - * @param accepted - * @param organizationId + * @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, @@ -94,20 +94,20 @@ export const sendHomeCrReviewedNotification = async ( /** * Sends a finishline notification to all requested reviewers of a change request - * @param changeRequest - * @param newReviewers - * @param organizationId + * @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, - newReviewers: User[], + reviewers: User[], organizationId: string ) => { const changeRequestLink = `/change-requests/${changeRequest.crId}`; await NotificationsService.sendNotifcationToUsers( `Your review has been requested on CR #${changeRequest.identifier}`, 'edit_note', - newReviewers.map((reviewer) => reviewer.userId), + reviewers.map((reviewer) => reviewer.userId), organizationId, changeRequestLink ); From 2f337d9a9da3fd31fd980760e6a5bddb4734de22 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 17 Dec 2024 23:50:44 -0500 Subject: [PATCH 18/22] #3074-created get unread announcements endpoint --- .../src/controllers/users.controllers.ts | 12 ++++++++++++ .../announcements.query.args.ts | 12 ++++++++++++ .../migration.sql | 4 +++- src/backend/src/prisma/schema.prisma | 16 +++++++++------- src/backend/src/routes/users.routes.ts | 1 + src/backend/src/services/users.services.ts | 18 ++++++++++++++++++ .../transformers/announcements.transformer.ts | 17 +++++++++++++++++ src/shared/index.ts | 1 + src/shared/src/types/announcements.types.ts | 10 ++++++++++ 9 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 src/backend/src/prisma-query-args/announcements.query.args.ts rename src/backend/src/prisma/migrations/{20241218031222_home_page_updates => 20241218044032_homepage_updates}/migration.sql (95%) create mode 100644 src/backend/src/transformers/announcements.transformer.ts create mode 100644 src/shared/src/types/announcements.types.ts diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index bf965c8d8e..cc084c714e 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -216,4 +216,16 @@ export default class UsersController { next(error); } } + + static async getUserUnreadAnnouncements(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.params; + const { organization } = req; + + const unreadAnnouncements = await UsersService.getUserUnreadAnnouncements(userId, organization); + res.status(200).json(unreadAnnouncements); + } 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..2f0e1ba294 --- /dev/null +++ b/src/backend/src/prisma-query-args/announcements.query.args.ts @@ -0,0 +1,12 @@ +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), + userCreated: getUserQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma/migrations/20241218031222_home_page_updates/migration.sql b/src/backend/src/prisma/migrations/20241218044032_homepage_updates/migration.sql similarity index 95% rename from src/backend/src/prisma/migrations/20241218031222_home_page_updates/migration.sql rename to src/backend/src/prisma/migrations/20241218044032_homepage_updates/migration.sql index c7975f2e21..f35a709d3f 100644 --- a/src/backend/src/prisma/migrations/20241218031222_home_page_updates/migration.sql +++ b/src/backend/src/prisma/migrations/20241218044032_homepage_updates/migration.sql @@ -8,8 +8,10 @@ ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT; CREATE TABLE "Announcement" ( "announcementId" TEXT NOT NULL, "text" TEXT NOT NULL, - "dateCrated" TIMESTAMP(3) NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL, "userCreatedId" TEXT NOT NULL, + "slackEventId" TEXT NOT NULL, + "slackChannelName" TEXT NOT NULL, CONSTRAINT "Announcement_pkey" PRIMARY KEY ("announcementId") ); diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index e0157c3353..f70d812b2a 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -180,7 +180,7 @@ model User { deletedFrequentlyAskedQuestions FrequentlyAskedQuestion[] @relation(name: "frequentlyAskedQuestionDeleter") createdMilestones Milestone[] @relation(name: "milestoneCreator") deletedMilestones Milestone[] @relation(name: "milestoneDeleter") - receivedAnnouncements Announcement[] @relation(name: "receivedAnnouncements") + unreadAnnouncements Announcement[] @relation(name: "receivedAnnouncements") createdAnnouncements Announcement[] @relation(name: "createdAnnouncements") unreadNotifications Notification[] @relation(name: "userNotifications") } @@ -932,12 +932,14 @@ model Milestone { } model Announcement { - announcementId String @id @default(uuid()) - text String - usersReceived User[] @relation("receivedAnnouncements") - dateCrated DateTime - userCreatedId String - userCreated User @relation("createdAnnouncements", fields: [userCreatedId], references: [userId]) + announcementId String @id @default(uuid()) + text String + usersReceived User[] @relation("receivedAnnouncements") + dateCreated DateTime + userCreatedId String + userCreated User @relation("createdAnnouncements", fields: [userCreatedId], references: [userId]) + slackEventId String + slackChannelName String } model Notification { diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 622a6fb01c..802d586500 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -55,6 +55,7 @@ userRouter.post( UsersController.getManyUserTasks ); userRouter.get('/:userId/notifications', UsersController.getUserUnreadNotifications); +userRouter.get('/:userId/announcements', UsersController.getUserUnreadAnnouncements); userRouter.post( '/:userId/notifications/remove', nonEmptyString(body('notificationId')), diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 1358ca5f6c..fff25ef56b 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -40,6 +40,8 @@ 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'; +import { getAnnouncementQueryArgs } from '../prisma-query-args/announcements.query.args'; +import announcementTransformer from '../transformers/announcements.transformer'; export default class UsersService { /** @@ -585,6 +587,22 @@ export default class UsersService { return requestedUser.unreadNotifications.map(notificationTransformer); } + /** + * Gets all of a user's unread announcements + * @param userId id of user to get unread announcements from + * @param organization the user's orgainzation + * @returns the unread announcements of the user + */ + static async getUserUnreadAnnouncements(userId: string, organization: Organization) { + const requestedUser = await prisma.user.findUnique({ + where: { userId }, + include: { unreadAnnouncements: getAnnouncementQueryArgs(organization.organizationId) } + }); + if (!requestedUser) throw new NotFoundException('User', userId); + + return requestedUser.unreadAnnouncements.map(announcementTransformer); + } + /** * Removes a notification from the user's unread notifications * @param userId id of the user to remove notification from diff --git a/src/backend/src/transformers/announcements.transformer.ts b/src/backend/src/transformers/announcements.transformer.ts new file mode 100644 index 0000000000..2a8f77e6b0 --- /dev/null +++ b/src/backend/src/transformers/announcements.transformer.ts @@ -0,0 +1,17 @@ +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 { + announcementId: announcement.announcementId, + text: announcement.text, + dateCreated: announcement.dateCreated, + userCreated: userTransformer(announcement.userCreated), + slackEventId: announcement.slackEventId, + slackChannelName: announcement.slackChannelName + }; +}; + +export default announcementTransformer; diff --git a/src/shared/index.ts b/src/shared/index.ts index 409dae2e65..40246d0fe4 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -12,6 +12,7 @@ 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/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..9315535c86 --- /dev/null +++ b/src/shared/src/types/announcements.types.ts @@ -0,0 +1,10 @@ +import { User } from './user-types'; + +export interface Announcement { + announcementId: string; + text: string; + userCreated: User; + dateCreated: Date; + slackEventId: string; + slackChannelName: string; +} From cc6e64310744e8611fa00af32da8c49dcc4089ce Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Thu, 19 Dec 2024 07:58:15 -0500 Subject: [PATCH 19/22] #3074-updated announcements in schema --- .../src/prisma-query-args/announcements.query.args.ts | 3 +-- .../migration.sql | 9 +++++---- src/backend/src/prisma/schema.prisma | 11 +++++------ .../src/transformers/announcements.transformer.ts | 6 ++++-- src/shared/src/types/announcements.types.ts | 4 +++- 5 files changed, 18 insertions(+), 15 deletions(-) rename src/backend/src/prisma/migrations/{20241218044032_homepage_updates => 20241219125602_homepage_updates}/migration.sql (90%) diff --git a/src/backend/src/prisma-query-args/announcements.query.args.ts b/src/backend/src/prisma-query-args/announcements.query.args.ts index 2f0e1ba294..b88c9fbf1d 100644 --- a/src/backend/src/prisma-query-args/announcements.query.args.ts +++ b/src/backend/src/prisma-query-args/announcements.query.args.ts @@ -6,7 +6,6 @@ export type AnnouncementQueryArgs = ReturnType; export const getAnnouncementQueryArgs = (organizationId: string) => Prisma.validator()({ include: { - usersReceived: getUserQueryArgs(organizationId), - userCreated: getUserQueryArgs(organizationId) + usersReceived: getUserQueryArgs(organizationId) } }); diff --git a/src/backend/src/prisma/migrations/20241218044032_homepage_updates/migration.sql b/src/backend/src/prisma/migrations/20241219125602_homepage_updates/migration.sql similarity index 90% rename from src/backend/src/prisma/migrations/20241218044032_homepage_updates/migration.sql rename to src/backend/src/prisma/migrations/20241219125602_homepage_updates/migration.sql index f35a709d3f..37c468e58e 100644 --- a/src/backend/src/prisma/migrations/20241218044032_homepage_updates/migration.sql +++ b/src/backend/src/prisma/migrations/20241219125602_homepage_updates/migration.sql @@ -9,7 +9,8 @@ CREATE TABLE "Announcement" ( "announcementId" TEXT NOT NULL, "text" TEXT NOT NULL, "dateCreated" TIMESTAMP(3) NOT NULL, - "userCreatedId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "senderName" TEXT NOT NULL, "slackEventId" TEXT NOT NULL, "slackChannelName" TEXT NOT NULL, @@ -38,6 +39,9 @@ CREATE TABLE "_userNotifications" ( "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"); @@ -53,9 +57,6 @@ CREATE INDEX "_userNotifications_B_index" ON "_userNotifications"("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; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index f70d812b2a..bb8247266d 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -181,7 +181,6 @@ model User { createdMilestones Milestone[] @relation(name: "milestoneCreator") deletedMilestones Milestone[] @relation(name: "milestoneDeleter") unreadAnnouncements Announcement[] @relation(name: "receivedAnnouncements") - createdAnnouncements Announcement[] @relation(name: "createdAnnouncements") unreadNotifications Notification[] @relation(name: "userNotifications") } @@ -932,13 +931,13 @@ model Milestone { } model Announcement { - announcementId String @id @default(uuid()) + announcementId String @id @default(uuid()) text String - usersReceived User[] @relation("receivedAnnouncements") + usersReceived User[] @relation("receivedAnnouncements") dateCreated DateTime - userCreatedId String - userCreated User @relation("createdAnnouncements", fields: [userCreatedId], references: [userId]) - slackEventId String + dateDeleted DateTime? + senderName String + slackEventId String @unique slackChannelName String } diff --git a/src/backend/src/transformers/announcements.transformer.ts b/src/backend/src/transformers/announcements.transformer.ts index 2a8f77e6b0..4fee90eac2 100644 --- a/src/backend/src/transformers/announcements.transformer.ts +++ b/src/backend/src/transformers/announcements.transformer.ts @@ -7,10 +7,12 @@ const announcementTransformer = (announcement: Prisma.AnnouncementGetPayload Date: Thu, 19 Dec 2024 08:24:31 -0500 Subject: [PATCH 20/22] #3074-tests --- src/backend/src/prisma/seed.ts | 12 +++++- .../src/services/announcement.service.ts | 34 ++++++++++++++++ src/backend/tests/test-utils.ts | 1 + src/backend/tests/unmocked/users.test.ts | 39 ++++++++++++++++++- 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/backend/src/services/announcement.service.ts 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/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts new file mode 100644 index 0000000000..5b9fdab389 --- /dev/null +++ b/src/backend/src/services/announcement.service.ts @@ -0,0 +1,34 @@ +import { Announcement } from 'shared'; +import prisma from '../prisma/prisma'; +import { getAnnouncementQueryArgs } from '../prisma-query-args/announcements.query.args'; +import announcementTransformer from '../transformers/announcements.transformer'; + +export default class AnnouncementService { + static async createAnnouncement( + text: string, + usersReceivedIds: string[], + dateCreated: 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 + })) + }, + dateCreated, + senderName, + slackEventId, + slackChannelName + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + return announcementTransformer(announcement); + } +} 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/users.test.ts b/src/backend/tests/unmocked/users.test.ts index 512a651b90..789109a2ae 100644 --- a/src/backend/tests/unmocked/users.test.ts +++ b/src/backend/tests/unmocked/users.test.ts @@ -4,6 +4,7 @@ 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'; +import AnnouncementService from '../../src/services/announcement.service'; describe('User Tests', () => { let orgId: string; @@ -81,7 +82,7 @@ describe('User Tests', () => { ).rejects.toThrow(new NotFoundException('User', '1')); }); - it('Succeeds and gets user notifications', async () => { + it('Succeeds and removes user notification', async () => { const testBatman = await createTestUser(batmanAppAdmin, orgId); await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); await NotificationsService.sendNotifcationToUsers('test2', 'test2', [testBatman.userId], orgId); @@ -102,4 +103,40 @@ describe('User Tests', () => { expect(updatedNotifications[0].text).toBe('test2'); }); }); + + describe('Get Announcements', () => { + it('fails on invalid user id', async () => { + await expect(async () => await UsersService.getUserUnreadAnnouncements('1', organization)).rejects.toThrow( + new NotFoundException('User', '1') + ); + }); + + 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 UsersService.getUserUnreadAnnouncements(testBatman.userId, organization); + + expect(announcements).toHaveLength(2); + expect(announcements[0].text).toBe('test1'); + expect(announcements[1].text).toBe('test2'); + }); + }); }); From 0dfedd2c315c5d2a6cc5e94078555d1c44d74f43 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Thu, 19 Dec 2024 19:20:51 -0500 Subject: [PATCH 21/22] #2074-javadocs --- src/backend/src/services/announcement.service.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts index 5b9fdab389..eb0d9a5f1b 100644 --- a/src/backend/src/services/announcement.service.ts +++ b/src/backend/src/services/announcement.service.ts @@ -4,6 +4,18 @@ import { getAnnouncementQueryArgs } from '../prisma-query-args/announcements.que import announcementTransformer from '../transformers/announcements.transformer'; 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[], From 66f5837a16e4a267cfbdc8232d1cbe862efebfab Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Fri, 20 Dec 2024 08:48:20 -0500 Subject: [PATCH 22/22] #3074-using current user --- .../src/controllers/users.controllers.ts | 21 +++++----- src/backend/src/routes/users.routes.ts | 10 ++--- src/backend/src/services/users.services.ts | 42 +++++++++++-------- src/backend/tests/unmocked/users.test.ts | 22 ---------- 4 files changed, 38 insertions(+), 57 deletions(-) diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index cc084c714e..b3fee931c2 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -194,10 +194,9 @@ export default class UsersController { static async getUserUnreadNotifications(req: Request, res: Response, next: NextFunction) { try { - const { userId } = req.params; - const { organization } = req; + const { organization, currentUser } = req; - const unreadNotifications = await UsersService.getUserUnreadNotifications(userId, organization); + const unreadNotifications = await UsersService.getUserUnreadNotifications(currentUser.userId, organization); res.status(200).json(unreadNotifications); } catch (error: unknown) { next(error); @@ -206,11 +205,14 @@ export default class UsersController { static async removeUserNotification(req: Request, res: Response, next: NextFunction) { try { - const { userId } = req.params; - const { notificationId } = req.body; - const { organization } = req; + const { notificationId } = req.params; + const { organization, currentUser } = req; - const unreadNotifications = await UsersService.removeUserNotification(userId, notificationId, organization); + const unreadNotifications = await UsersService.removeUserNotification( + currentUser.userId, + notificationId, + organization + ); res.status(200).json(unreadNotifications); } catch (error: unknown) { next(error); @@ -219,10 +221,9 @@ export default class UsersController { static async getUserUnreadAnnouncements(req: Request, res: Response, next: NextFunction) { try { - const { userId } = req.params; - const { organization } = req; + const { organization, currentUser } = req; - const unreadAnnouncements = await UsersService.getUserUnreadAnnouncements(userId, organization); + const unreadAnnouncements = await UsersService.getUserUnreadAnnouncements(currentUser.userId, organization); res.status(200).json(unreadAnnouncements); } catch (error: unknown) { next(error); diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 802d586500..96be49828c 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -54,12 +54,8 @@ userRouter.post( validateInputs, UsersController.getManyUserTasks ); -userRouter.get('/:userId/notifications', UsersController.getUserUnreadNotifications); -userRouter.get('/:userId/announcements', UsersController.getUserUnreadAnnouncements); -userRouter.post( - '/:userId/notifications/remove', - nonEmptyString(body('notificationId')), - UsersController.removeUserNotification -); +userRouter.get('/notifications/current-user', UsersController.getUserUnreadNotifications); +userRouter.get('/announcements/current-user', UsersController.getUserUnreadAnnouncements); +userRouter.post('/notifications/:notificationId/remove', UsersController.removeUserNotification); export default userRouter; diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index fff25ef56b..b537d019d2 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -578,45 +578,49 @@ export default class UsersService { * @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) } + const unreadNotifications = await prisma.notification.findMany({ + where: { + users: { + some: { userId } + } + }, + ...getNotificationQueryArgs(organization.organizationId) }); - if (!requestedUser) throw new NotFoundException('User', userId); - return requestedUser.unreadNotifications.map(notificationTransformer); + if (!unreadNotifications) throw new HttpException(404, 'User Unread Notifications Not Found'); + + return unreadNotifications.map(notificationTransformer); } /** * Gets all of a user's unread announcements - * @param userId id of user to get unread announcements from + * @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, organization: Organization) { - const requestedUser = await prisma.user.findUnique({ - where: { userId }, - include: { unreadAnnouncements: getAnnouncementQueryArgs(organization.organizationId) } + const unreadAnnouncements = await prisma.announcement.findMany({ + where: { + usersReceived: { + some: { userId } + } + }, + ...getAnnouncementQueryArgs(organization.organizationId) }); - if (!requestedUser) throw new NotFoundException('User', userId); - return requestedUser.unreadAnnouncements.map(announcementTransformer); + if (!unreadAnnouncements) throw new HttpException(404, 'User Unread Announcements Not Found'); + + return unreadAnnouncements.map(announcementTransformer); } /** * Removes a notification from the user's unread notifications - * @param userId id of the user to remove notification from + * @param userId id of the current user * @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: { @@ -629,6 +633,8 @@ export default class UsersService { include: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) } }); + if (!updatedUser) throw new HttpException(404, `Failed to remove notication: ${notificationId}`); + return updatedUser.unreadNotifications.map(notificationTransformer); } } diff --git a/src/backend/tests/unmocked/users.test.ts b/src/backend/tests/unmocked/users.test.ts index 789109a2ae..bd0b934270 100644 --- a/src/backend/tests/unmocked/users.test.ts +++ b/src/backend/tests/unmocked/users.test.ts @@ -52,12 +52,6 @@ describe('User Tests', () => { }); 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); @@ -72,16 +66,6 @@ describe('User Tests', () => { }); 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 removes user notification', async () => { const testBatman = await createTestUser(batmanAppAdmin, orgId); await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); @@ -105,12 +89,6 @@ describe('User Tests', () => { }); describe('Get Announcements', () => { - it('fails on invalid user id', async () => { - await expect(async () => await UsersService.getUserUnreadAnnouncements('1', organization)).rejects.toThrow( - new NotFoundException('User', '1') - ); - }); - it('Succeeds and gets user announcements', async () => { const testBatman = await createTestUser(batmanAppAdmin, orgId); await AnnouncementService.createAnnouncement(