diff --git a/package.json b/package.json index bc13987bec..6530b87e3e 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@types/react-dom": "17.0.1" }, "dependencies": { + "@slack/events-api": "^3.0.1", "mitt": "^3.0.1", "react-hook-form-persist": "^3.0.0", "typescript": "^4.1.5" diff --git a/src/backend/index.ts b/src/backend/index.ts index 135b741765..6caa2f24ec 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -17,6 +17,10 @@ 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 { slackEvents } from './src/routes/slack.routes'; +import announcementsRouter from './src/routes/announcements.routes'; +import onboardingRouter from './src/routes/onboarding.routes'; +import popUpsRouter from './src/routes/pop-up.routes'; import statisticsRouter from './src/routes/statistics.routes'; const app = express(); @@ -41,6 +45,10 @@ const options: cors.CorsOptions = { allowedHeaders }; +// so we can listen to slack messages +// NOTE: must be done before using json +app.use('/slack', slackEvents.requestListener()); + // so that we can use cookies and json app.use(cookieParser()); app.use(express.json()); @@ -69,6 +77,9 @@ 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('/statistics', statisticsRouter); 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..ec18d65538 --- /dev/null +++ b/src/backend/src/controllers/announcements.controllers.ts @@ -0,0 +1,34 @@ +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); + } + } + + static async removeUserAnnouncement(req: Request, res: Response, next: NextFunction) { + try { + const { announcementId } = req.params; + const { organization, currentUser } = req; + + const unreadAnnouncements = await AnnouncementService.removeUserAnnouncement( + currentUser.userId, + announcementId, + organization.organizationId + ); + res.status(200).json(unreadAnnouncements); + } catch (error: unknown) { + next(error); + } + } +} diff --git a/src/backend/src/controllers/onboarding.controller.ts b/src/backend/src/controllers/onboarding.controller.ts new file mode 100644 index 0000000000..2bd8660a35 --- /dev/null +++ b/src/backend/src/controllers/onboarding.controller.ts @@ -0,0 +1,21 @@ +import { NextFunction, Request, Response } from 'express'; +import OnboardingServices from '../services/onboarding.services'; + +export default class OnboardingController { + static async downloadImage(req: Request, res: Response, next: NextFunction) { + try { + const { fileId } = req.params; + + const imageData = await OnboardingServices.downloadImage(fileId); + + // Set the appropriate headers for the HTTP response + res.setHeader('content-type', String(imageData.type)); + res.setHeader('content-length', imageData.buffer.length); + + // Send the Buffer as the response body + res.send(imageData.buffer); + } catch (error: unknown) { + return next(error); + } + } +} diff --git a/src/backend/src/controllers/organizations.controllers.ts b/src/backend/src/controllers/organizations.controllers.ts index 1649776c2f..64a59bb0ee 100644 --- a/src/backend/src/controllers/organizations.controllers.ts +++ b/src/backend/src/controllers/organizations.controllers.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import OrganizationsService from '../services/organizations.services'; +import { HttpException } from '../utils/errors.utils'; export default class OrganizationsController { static async getCurrentOrganization(req: Request, res: Response, next: NextFunction) { @@ -60,4 +61,78 @@ export default class OrganizationsController { next(error); } } + + static async setOrganizationFeaturedProjects(req: Request, res: Response, next: NextFunction) { + try { + const { projectIds } = req.body; + const featuredProjects = await OrganizationsService.setFeaturedProjects(projectIds, req.organization, req.currentUser); + + res.status(200).json(featuredProjects); + } catch (error: unknown) { + next(error); + } + } + + static async setLogoImage(req: Request, res: Response, next: NextFunction) { + try { + if (!req.file) { + throw new HttpException(400, 'Invalid or undefined image data'); + } + + const updatedOrg = await OrganizationsService.setLogoImage(req.file, req.currentUser, req.organization); + + res.status(200).json(updatedOrg); + } catch (error: unknown) { + next(error); + } + } + + static async getOrganizationLogoImage(req: Request, res: Response, next: NextFunction) { + try { + const { organization } = req; + + const logoImageId = await OrganizationsService.getLogoImage(organization.organizationId); + res.status(200).json(logoImageId); + } catch (error: unknown) { + next(error); + } + } + + static async setOrganizationDescription(req: Request, res: Response, next: NextFunction) { + try { + const updatedOrg = await OrganizationsService.setOrganizationDescription( + req.body.description, + req.currentUser, + req.organization + ); + + res.status(200).json(updatedOrg); + } catch (error: unknown) { + next(error); + } + } + + static async getOrganizationFeaturedProjects(req: Request, res: Response, next: NextFunction) { + try { + const featuredProjects = await OrganizationsService.getOrganizationFeaturedProjects(req.organization.organizationId); + res.status(200).json(featuredProjects); + } catch (error: unknown) { + next(error); + } + } + + static async setSlackWorkspaceId(req: Request, res: Response, next: NextFunction) { + try { + const { workspaceId } = req.body; + + const updatedOrg = await OrganizationsService.setSlackWorkspaceId( + workspaceId, + req.currentUser, + req.organization.organizationId + ); + res.status(200).json(updatedOrg); + } 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/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts new file mode 100644 index 0000000000..e7336711d2 --- /dev/null +++ b/src/backend/src/controllers/slack.controllers.ts @@ -0,0 +1,18 @@ +import { getWorkspaceId } from '../integrations/slack'; +import OrganizationsService from '../services/organizations.services'; +import SlackServices from '../services/slack.services'; + +export default class SlackController { + static async processMessageEvent(event: any) { + try { + const organizations = await OrganizationsService.getAllOrganizations(); + const nerSlackWorkspaceId = await getWorkspaceId(); + const relatedOrganization = organizations.find((org) => org.slackWorkspaceId === nerSlackWorkspaceId); + if (relatedOrganization) { + SlackServices.processMessageSent(event, relatedOrganization.organizationId); + } + } catch (error: unknown) { + console.log(error); + } + } +} diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index b7cfff4078..8076e225d7 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from 'express'; import UsersService from '../services/users.services'; import { AccessDeniedException } from '../utils/errors.utils'; +import { Task } from 'shared'; export default class UsersController { static async getAllUsers(_req: Request, res: Response, next: NextFunction) { @@ -167,4 +168,27 @@ export default class UsersController { next(error); } } + + static async getUserTasks(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.params; + const { organization } = req; + + const userTasks = await UsersService.getUserTasks(userId, organization); + res.status(200).json(userTasks); + } catch (error: unknown) { + next(error); + } + } + + static async getManyUserTasks(req: Request, res: Response, next: NextFunction) { + try { + const { userIds } = req.body; + + const tasks: Task[] = await UsersService.getManyUserTasks(userIds, req.organization); + res.status(200).json(tasks); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index a4c3f175ea..6e855acae7 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -155,4 +155,79 @@ const generateSlackTextBlock = (message: string, link?: string, linkButtonText?: }; }; +/** + * Given an id of a channel, produces the slack ids of all the users in that channel. + * @param channelId the id of the channel + * @returns an array of strings of all the slack ids of the users in the given channel + */ +export const getUsersInChannel = async (channelId: string) => { + let members: string[] = []; + let cursor: string | undefined; + + try { + do { + const response = await slack.conversations.members({ + channel: channelId, + cursor, + limit: 200 + }); + + if (response.ok && response.members) { + members = members.concat(response.members); + cursor = response.response_metadata?.next_cursor; + } else { + throw new Error(`Failed to fetch members: ${response.error}`); + } + } while (cursor); + + return members; + } catch (error) { + return members; + } +}; + +/** + * Given a slack channel id, produces the name of the channel + * @param channelId the id of the slack channel + * @returns the name of the channel or undefined if it cannot be found + */ +export const getChannelName = async (channelId: string) => { + try { + const channelRes = await slack.conversations.info({ channel: channelId }); + return channelRes.channel?.name; + } catch (error) { + return undefined; + } +}; + +/** + * Given a slack user id, prood.uces the name of the channel + * @param userId the id of the slack user + * @returns the name of the user (real name if no display name), undefined if cannot be found + */ +export const getUserName = async (userId: string) => { + try { + const userRes = await slack.users.info({ user: userId }); + return userRes.user?.profile?.display_name || userRes.user?.real_name; + } catch (error) { + return undefined; + } +}; + +/** + * Get the workspace id of the workspace this slack api is registered with + * @returns the id of the workspace + */ +export const getWorkspaceId = async () => { + try { + const response = await slack.auth.test(); + if (response.ok) { + return response.team_id; + } + throw new Error(response.error); + } catch (error) { + throw new HttpException(500, 'Error getting slack workspace id: ' + (error as any).data.error); + } +}; + export default slack; 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/auth-user.query-args.ts b/src/backend/src/prisma-query-args/auth-user.query-args.ts index f99144f234..0c6db7bd94 100644 --- a/src/backend/src/prisma-query-args/auth-user.query-args.ts +++ b/src/backend/src/prisma-query-args/auth-user.query-args.ts @@ -1,4 +1,5 @@ import { Prisma } from '@prisma/client'; +import { getTeamQueryArgs } from './teams.query-args'; export type AuthUserQueryArgs = ReturnType; @@ -9,13 +10,15 @@ export const getAuthUserQueryArgs = (organizationId: string) => teamsAsHead: { where: { organizationId - } + }, + ...getTeamQueryArgs(organizationId) }, organizations: true, teamsAsLead: { where: { organizationId - } + }, + ...getTeamQueryArgs(organizationId) }, teamsAsMember: { where: { 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..52f989099f --- /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: { + usersReceived: getUserQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma/migrations/20241113231854_optional_deadline_assignees_task/migration.sql b/src/backend/src/prisma/migrations/20241113231854_optional_deadline_assignees_task/migration.sql deleted file mode 100644 index 28f390d560..0000000000 --- a/src/backend/src/prisma/migrations/20241113231854_optional_deadline_assignees_task/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Task" ALTER COLUMN "deadline" DROP NOT NULL; diff --git a/src/backend/src/prisma/migrations/20241227031743_stats_page/migration.sql b/src/backend/src/prisma/migrations/20250109215552_stats_home_page_optional_task_assignees_deadlines/migration.sql similarity index 58% rename from src/backend/src/prisma/migrations/20241227031743_stats_page/migration.sql rename to src/backend/src/prisma/migrations/20250109215552_stats_home_page_optional_task_assignees_deadlines/migration.sql index 0d12c002e3..90d73bcdaa 100644 --- a/src/backend/src/prisma/migrations/20241227031743_stats_page/migration.sql +++ b/src/backend/src/prisma/migrations/20250109215552_stats_home_page_optional_task_assignees_deadlines/migration.sql @@ -16,6 +16,16 @@ CREATE TYPE "Graph_Collection_Permission" AS ENUM ('EDIT_GRAPH_COLLECTION', 'CRE -- CreateEnum CREATE TYPE "Special_Permission" AS ENUM ('FINANCE_ONLY'); +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "logoImageId" TEXT, +ADD COLUMN "slackWorkspaceId" TEXT; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "featuredByOrganizationId" TEXT; + +-- AlterTable +ALTER TABLE "Task" ALTER COLUMN "deadline" DROP NOT NULL; + -- AlterTable ALTER TABLE "Team_Type" ADD COLUMN "dateDeleted" TIMESTAMP(3), ADD COLUMN "deletedById" TEXT; @@ -57,18 +67,73 @@ CREATE TABLE "Graph_Collection" ( CONSTRAINT "Graph_Collection_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "Announcement" ( + "announcementId" TEXT NOT NULL, + "text" 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, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "Announcement_pkey" PRIMARY KEY ("announcementId") +); + +-- CreateTable +CREATE TABLE "PopUp" ( + "popUpId" TEXT NOT NULL, + "text" TEXT NOT NULL, + "iconName" TEXT NOT NULL, + "eventLink" TEXT, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "PopUp_pkey" PRIMARY KEY ("popUpId") +); + -- CreateTable CREATE TABLE "_graphCars" ( "A" TEXT NOT NULL, "B" TEXT NOT NULL ); +-- CreateTable +CREATE TABLE "_receivedAnnouncements" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateTable +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 "_graphCars_AB_unique" ON "_graphCars"("A", "B"); -- CreateIndex CREATE INDEX "_graphCars_B_index" ON "_graphCars"("B"); +-- CreateIndex +CREATE UNIQUE INDEX "_receivedAnnouncements_AB_unique" ON "_receivedAnnouncements"("A", "B"); + +-- CreateIndex +CREATE INDEX "_receivedAnnouncements_B_index" ON "_receivedAnnouncements"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_userPopUps_AB_unique" ON "_userPopUps"("A", "B"); + +-- CreateIndex +CREATE INDEX "_userPopUps_B_index" ON "_userPopUps"("B"); + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_featuredByOrganizationId_fkey" FOREIGN KEY ("featuredByOrganizationId") REFERENCES "Organization"("organizationId") ON DELETE SET NULL ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "Team_Type" ADD CONSTRAINT "Team_Type_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; @@ -93,8 +158,26 @@ ALTER TABLE "Graph_Collection" ADD CONSTRAINT "Graph_Collection_userDeletedId_fk -- AddForeignKey ALTER TABLE "Graph_Collection" ADD CONSTRAINT "Graph_Collection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PopUp" ADD CONSTRAINT "PopUp_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "_graphCars" ADD CONSTRAINT "_graphCars_A_fkey" FOREIGN KEY ("A") REFERENCES "Car"("carId") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "_graphCars" ADD CONSTRAINT "_graphCars_B_fkey" FOREIGN KEY ("B") REFERENCES "Graph"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_receivedAnnouncements" ADD CONSTRAINT "_receivedAnnouncements_A_fkey" FOREIGN KEY ("A") REFERENCES "Announcement"("announcementId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_receivedAnnouncements" ADD CONSTRAINT "_receivedAnnouncements_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_userPopUps" ADD CONSTRAINT "_userPopUps_A_fkey" FOREIGN KEY ("A") REFERENCES "PopUp"("popUpId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +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 f68c60b7e9..12416a4a91 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -228,6 +228,8 @@ model User { createdGraphs Graph[] @relation(name: "graphCreator") deletedGraphs Graph[] @relation(name: "graphDeleter") deletedTeamTypes Team_Type[] @relation(name: "teamTypeDeleter") + unreadAnnouncements Announcement[] @relation(name: "receivedAnnouncements") + unreadPopUps PopUp[] @relation(name: "userPopUps") } model Role { @@ -430,16 +432,18 @@ model WBS_Element { } model Project { - projectId String @id @default(uuid()) - wbsElementId String @unique - wbsElement WBS_Element @relation(fields: [wbsElementId], references: [wbsElementId]) - budget Int @default(0) - summary String - workPackages Work_Package[] - carId String - car Car @relation(fields: [carId], references: [carId]) - teams Team[] @relation(name: "assignedBy") - favoritedBy User[] @relation(name: "favoritedBy") + projectId String @id @default(uuid()) + wbsElementId String @unique + wbsElement WBS_Element @relation(fields: [wbsElementId], references: [wbsElementId]) + budget Int @default(0) + summary String + workPackages Work_Package[] + carId String + car Car @relation(fields: [carId], references: [carId]) + teams Team[] @relation(name: "assignedBy") + favoritedBy User[] @relation(name: "favoritedBy") + featuredByOrganizationId String? + featuredByOrganization Organization? @relation(fields: [featuredByOrganizationId], references: [organizationId]) } model Work_Package { @@ -927,6 +931,8 @@ model Organization { description String applyInterestImageId String? @unique exploreAsGuestImageId String? @unique + logoImageId String? + slackWorkspaceId String? // Relation references wbsElements WBS_Element[] @@ -950,6 +956,9 @@ model Organization { milestones Milestone[] graphCollections Graph_Collection[] graphs Graph[] + featuredProjects Project[] + popUps PopUp[] + announcements Announcement[] } model FrequentlyAskedQuestion { @@ -1020,3 +1029,26 @@ model Graph_Collection { organizationId String organization Organization @relation(fields: [organizationId], references: [organizationId]) } + +model Announcement { + announcementId String @id @default(uuid()) + text String + usersReceived User[] @relation("receivedAnnouncements") + dateMessageSent DateTime @default(now()) + dateDeleted DateTime? + senderName String + slackEventId String @unique + slackChannelName String + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) +} + +model PopUp { + popUpId String @id @default(uuid()) + text String + iconName String + usersReceived User[] @relation("userPopUps") + eventLink String? + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) +} diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index f8155da887..ef0916a162 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -49,6 +49,7 @@ import OrganizationsService from '../services/organizations.services'; import StatisticsService from '../services/statistics.services'; import { seedGraph } from './seed-data/statistics.seed'; import { graphCollectionTransformer } from '../transformers/statistics-graphCollection.transformer'; +import AnnouncementService from '../services/announcement.service'; const prisma = new PrismaClient(); @@ -192,11 +193,11 @@ const performSeed: () => Promise = async () => { const glen = await createUser(dbSeedAllUsers.glen, RoleEnum.LEADERSHIP, organizationId); const shane = await createUser(dbSeedAllUsers.shane, RoleEnum.LEADERSHIP, organizationId); const june = await createUser(dbSeedAllUsers.june, RoleEnum.LEADERSHIP, organizationId); - const kevin = await createUser(dbSeedAllUsers.kevin, RoleEnum.LEADERSHIP, organizationId); - const norbury = await createUser(dbSeedAllUsers.norbury, RoleEnum.LEADERSHIP, organizationId); - const carr = await createUser(dbSeedAllUsers.carr, RoleEnum.LEADERSHIP, organizationId); - const trang = await createUser(dbSeedAllUsers.trang, RoleEnum.LEADERSHIP, organizationId); - const regina = await createUser(dbSeedAllUsers.regina, RoleEnum.LEADERSHIP, organizationId); + const kevin = await createUser(dbSeedAllUsers.kevin, RoleEnum.MEMBER, organizationId); + const norbury = await createUser(dbSeedAllUsers.norbury, RoleEnum.MEMBER, organizationId); + const carr = await createUser(dbSeedAllUsers.carr, RoleEnum.MEMBER, organizationId); + const trang = await createUser(dbSeedAllUsers.trang, RoleEnum.MEMBER, organizationId); + const regina = await createUser(dbSeedAllUsers.regina, RoleEnum.MEMBER, organizationId); await createUser(dbSeedAllUsers.spongebob, RoleEnum.GUEST, organizationId); await UsersService.updateUserRole(cyborg.userId, thomasEmrax, 'APP_ADMIN', ner); @@ -444,7 +445,7 @@ const performSeed: () => Promise = async () => { */ /** Project 1 */ - const { projectWbsNumber: project1WbsNumber } = await seedProject( + const { projectWbsNumber: project1WbsNumber, projectId: project1Id } = await seedProject( thomasEmrax, changeRequest1.crId, fergus.wbsElement.carNumber, @@ -472,7 +473,7 @@ const performSeed: () => Promise = async () => { ); /** Project 2 */ - const { projectWbsNumber: project2WbsNumber } = await seedProject( + const { projectWbsNumber: project2WbsNumber, projectId: project2Id } = await seedProject( thomasEmrax, changeRequest1.crId, fergus.wbsElement.carNumber, @@ -500,7 +501,7 @@ const performSeed: () => Promise = async () => { ); /** Project 3 */ - const { projectWbsNumber: project3WbsNumber } = await seedProject( + const { projectWbsNumber: project3WbsNumber, projectId: project3Id } = await seedProject( thomasEmrax, changeRequest1.crId, fergus.wbsElement.carNumber, @@ -528,7 +529,7 @@ const performSeed: () => Promise = async () => { ); /** Project 4 */ - const { projectWbsNumber: project4WbsNumber } = await seedProject( + const { projectWbsNumber: project4WbsNumber, projectId: project4Id } = await seedProject( thomasEmrax, changeRequest1.crId, fergus.wbsElement.carNumber, @@ -1927,6 +1928,8 @@ const performSeed: () => Promise = async () => { ner ); + await OrganizationsService.setFeaturedProjects([project1Id, project2Id, project3Id, project4Id], ner, thomasEmrax); + await OrganizationsService.setUsefulLinks(batman, organizationId, [ { linkId: '1', @@ -1949,6 +1952,36 @@ 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 + ); + + await AnnouncementService.createAnnouncement( + 'Welcome to Finishline!', + [regina.userId], + new Date(), + 'Damian', + '2', + 'mechanical', + ner.organizationId + ); + + await AnnouncementService.createAnnouncement( + 'Welcome to Finishline!', + [regina.userId], + new Date(), + 'Batman', + '3', + 'powertrain', + 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..9a5cabc913 --- /dev/null +++ b/src/backend/src/routes/announcements.routes.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import AnnouncementController from '../controllers/announcements.controllers'; + +const announcementsRouter = express.Router(); + +announcementsRouter.get('/current-user', AnnouncementController.getUserUnreadAnnouncements); +announcementsRouter.post('/:announcementId/remove', AnnouncementController.removeUserAnnouncement); + +export default announcementsRouter; diff --git a/src/backend/src/routes/onboarding.routes.ts b/src/backend/src/routes/onboarding.routes.ts new file mode 100644 index 0000000000..fabce68796 --- /dev/null +++ b/src/backend/src/routes/onboarding.routes.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import OnboardingController from '../controllers/onboarding.controller'; + +const onboardingRouter = express.Router(); + +onboardingRouter.get('/image/:fileId', OnboardingController.downloadImage); + +export default onboardingRouter; diff --git a/src/backend/src/routes/organizations.routes.ts b/src/backend/src/routes/organizations.routes.ts index 6bdf267bea..991eb8f95f 100644 --- a/src/backend/src/routes/organizations.routes.ts +++ b/src/backend/src/routes/organizations.routes.ts @@ -1,7 +1,8 @@ import express from 'express'; -import { linkValidators, validateInputs } from '../utils/validation.utils'; +import { linkValidators, nonEmptyString, validateInputs } from '../utils/validation.utils'; import OrganizationsController from '../controllers/organizations.controllers'; import multer, { memoryStorage } from 'multer'; +import { body } from 'express-validator'; const organizationRouter = express.Router(); const upload = multer({ limits: { fileSize: 30000000 }, storage: memoryStorage() }); @@ -19,4 +20,25 @@ organizationRouter.post( ); organizationRouter.get('/images', OrganizationsController.getOrganizationImages); +organizationRouter.post( + '/featured-projects/set', + body('projectIds').isArray(), + nonEmptyString(body('projectIds.*')), + validateInputs, + OrganizationsController.setOrganizationFeaturedProjects +); +organizationRouter.post('/logo/update', upload.single('logo'), OrganizationsController.setLogoImage); +organizationRouter.get('/logo', OrganizationsController.getOrganizationLogoImage); +organizationRouter.post( + '/description/set', + body('description').isString(), + validateInputs, + OrganizationsController.setOrganizationDescription +); +organizationRouter.get('/featured-projects', OrganizationsController.getOrganizationFeaturedProjects); +organizationRouter.post( + '/workspaceId/set', + nonEmptyString(body('workspaceId')), + OrganizationsController.setSlackWorkspaceId +); export default organizationRouter; 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/slack.routes.ts b/src/backend/src/routes/slack.routes.ts new file mode 100644 index 0000000000..6878b176b1 --- /dev/null +++ b/src/backend/src/routes/slack.routes.ts @@ -0,0 +1,8 @@ +import { createEventAdapter } from '@slack/events-api'; +import SlackController from '../controllers/slack.controllers'; + +export const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET || ''); + +slackEvents.on('message', SlackController.processMessageEvent); + +slackEvents.on('error', console.log); diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index c9430121a1..2f95201f6f 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -46,5 +46,13 @@ userRouter.post( userRouter.get('/:userId/secure-settings', UsersController.getUserSecureSettings); userRouter.get('/:userId/schedule-settings', UsersController.getUserScheduleSettings); +userRouter.get('/:userId/tasks', UsersController.getUserTasks); +userRouter.post( + '/tasks/get-many', + body('userIds').isArray(), + nonEmptyString(body('userIds.*')), + validateInputs, + UsersController.getManyUserTasks +); 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..e109cd1984 --- /dev/null +++ b/src/backend/src/services/announcement.service.ts @@ -0,0 +1,172 @@ +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 { DeletedException, HttpException, NotFoundException } from '../utils/errors.utils'; +import { getUsers } from '../utils/users.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 dateMessageSent 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 { + // throws if a user id is invalid + const usersToSend = await getUsers(usersReceivedIds); + + const announcement = await prisma.announcement.create({ + data: { + text, + usersReceived: { + connect: usersToSend.map((user) => ({ + userId: user.userId + })) + }, + dateMessageSent, + senderName, + slackEventId, + slackChannelName, + organizationId + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + return announcementTransformer(announcement); + } + + static async updateAnnouncement( + text: string, + usersReceivedIds: string[], + senderName: string, + slackEventId: string, + slackChannelName: string, + organizationId: string + ): Promise { + const originalAnnouncement = await prisma.announcement.findUnique({ + where: { + slackEventId + } + }); + + if (!originalAnnouncement) throw new NotFoundException('Announcement', slackEventId); + + if (originalAnnouncement.dateDeleted) throw new DeletedException('Announcement', slackEventId); + + if (originalAnnouncement.organizationId !== organizationId) + throw new HttpException(400, `Announcement is not apart of the current organization`); + + const announcement = await prisma.announcement.update({ + where: { announcementId: originalAnnouncement.announcementId }, + data: { + text, + usersReceived: { + set: usersReceivedIds.map((id) => ({ + userId: id + })) + }, + slackEventId, + senderName, + slackChannelName + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + return announcementTransformer(announcement); + } + + static async deleteAnnouncement(slackEventId: string, organizationId: string): Promise { + const originalAnnouncement = await prisma.announcement.findUnique({ + where: { + slackEventId + } + }); + + if (!originalAnnouncement) throw new NotFoundException('Announcement', slackEventId); + + if (originalAnnouncement.dateDeleted) throw new DeletedException('Announcement', slackEventId); + + if (originalAnnouncement.organizationId !== organizationId) + throw new HttpException(400, `Announcement is not apart of the current organization`); + + const announcement = await prisma.announcement.update({ + where: { slackEventId }, + data: { + dateDeleted: new Date(), + usersReceived: { + set: [] + } + }, + ...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: { + dateDeleted: null, + usersReceived: { + some: { userId } + }, + organizationId + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + if (!unreadAnnouncements) throw new HttpException(404, 'User Unread Announcements Not Found'); + + return unreadAnnouncements.map(announcementTransformer); + } + + /** + * Removes a announcement from the user's unread announcement + * @param userId id of the user to remove announcement from + * @param announcementId id of the announcement to remove + * @param organization the user's organization + * @returns the user's updated unread announcement + */ + static async removeUserAnnouncement(userId: string, announcementId: string, organizationId: string) { + const requestedUser = await prisma.user.findUnique({ + where: { userId } + }); + + if (!requestedUser) throw new NotFoundException('User', userId); + + const updatedUser = await prisma.user.update({ + where: { userId }, + data: { + unreadAnnouncements: { + disconnect: { + announcementId + } + } + }, + include: { unreadAnnouncements: getAnnouncementQueryArgs(organizationId) } + }); + + return updatedUser.unreadAnnouncements.map(announcementTransformer); + } +} diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 89cbaaa370..709f4b2e91 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 { sendCrRequestReviewPopUp, sendCrReviewedPopUp } from '../utils/pop-up.utils'; export default class ChangeRequestsService { /** @@ -150,6 +151,8 @@ export default class ChangeRequestsService { // send a notification to the submitter that their change request has been reviewed await sendCRSubmitterReviewedNotification(updated); + 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); @@ -1078,5 +1081,7 @@ export default class ChangeRequestsService { // send slack message to CR reviewers await sendSlackRequestedReviewNotification(newReviewers, changeRequestTransformer(foundCR)); + + 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 8f7511b73c..64363b0c61 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 { sendDrPopUp } from '../utils/pop-up.utils'; export default class DesignReviewsService { /** @@ -205,6 +206,8 @@ export default class DesignReviewsService { } } + await sendDrPopUp(designReview, members, submitter, wbsElement.name, organization.organizationId); + const project = wbsElement.workPackage?.project; const teams = project?.teams; if (teams && teams.length > 0) { diff --git a/src/backend/src/services/notifications.services.ts b/src/backend/src/services/notifications.services.ts index 176d4bc73e..8dd4a2d879 100644 --- a/src/backend/src/services/notifications.services.ts +++ b/src/backend/src/services/notifications.services.ts @@ -7,12 +7,11 @@ import { usersToSlackPings } from '../utils/notifications.utils'; import { sendMessage } from '../integrations/slack'; -import { daysBetween, startOfDay, wbsPipe } from 'shared'; +import { daysBetween, meetingStartTimePipe, startOfDay, wbsPipe } from 'shared'; import { buildDueString } from '../utils/slack.utils'; import WorkPackagesService from './work-packages.services'; import { addWeeksToDate } from 'shared'; import { HttpException } from '../utils/errors.utils'; -import { meetingStartTimePipe } from '../utils/design-reviews.utils'; export default class NotificationsService { static async sendDailySlackNotifications() { diff --git a/src/backend/src/services/onboarding.services.ts b/src/backend/src/services/onboarding.services.ts new file mode 100644 index 0000000000..b351952bf9 --- /dev/null +++ b/src/backend/src/services/onboarding.services.ts @@ -0,0 +1,11 @@ +import { NotFoundException } from '../utils/errors.utils'; +import { downloadImageFile } from '../utils/google-integration.utils'; + +export default class OnboardingServices { + static async downloadImage(fileId: string) { + const fileData = await downloadImageFile(fileId); + + if (!fileData) throw new NotFoundException('Image File', fileId); + return fileData; + } +} diff --git a/src/backend/src/services/organizations.services.ts b/src/backend/src/services/organizations.services.ts index f918b40163..31a26eadfb 100644 --- a/src/backend/src/services/organizations.services.ts +++ b/src/backend/src/services/organizations.services.ts @@ -1,14 +1,25 @@ import { Organization, User } from '@prisma/client'; import { LinkCreateArgs, isAdmin } from 'shared'; import prisma from '../prisma/prisma'; -import { AccessDeniedAdminOnlyException, DeletedException, NotFoundException } from '../utils/errors.utils'; +import { AccessDeniedAdminOnlyException, DeletedException, HttpException, NotFoundException } from '../utils/errors.utils'; import { userHasPermission } from '../utils/users.utils'; import { createUsefulLinks } from '../utils/organizations.utils'; import { linkTransformer } from '../transformers/links.transformer'; import { getLinkQueryArgs } from '../prisma-query-args/links.query-args'; import { uploadFile } from '../utils/google-integration.utils'; +import { getProjects } from '../utils/projects.utils'; +import { getProjectQueryArgs } from '../prisma-query-args/projects.query-args'; +import projectTransformer from '../transformers/projects.transformer'; export default class OrganizationsService { + /** + * Retrieve all the organizations + * @returns an array of every organization + */ + static async getAllOrganizations(): Promise { + return prisma.organization.findMany(); + } + /** * Gets the current organization * @param organizationId the organizationId to be fetched @@ -151,4 +162,144 @@ export default class OrganizationsService { exploreAsGuestImage: organization.exploreAsGuestImageId }; } + + /** + * Updates the featured projects of an organization + * @param projectIds project ids of featured projects + * @param organization user's organization + * @param submitter user submitting featured projects + * @returns updated organization with featured projects + */ + static async setFeaturedProjects(projectIds: string[], organization: Organization, submitter: User) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) + throw new AccessDeniedAdminOnlyException('update featured projects'); + + //throws if all projects are not found + const featuredProjects = await getProjects(projectIds, organization.organizationId); + + const updatedOrg = await prisma.organization.update({ + where: { organizationId: organization.organizationId }, + data: { + featuredProjects: { + set: featuredProjects.map((project) => ({ projectId: project.projectId })) + } + }, + include: { featuredProjects: true } + }); + + return updatedOrg; + } + + /** + * Sets the logo for an organization, User must be admin + * @param logoImage the image which will be uploaded and have its id stored in the org + * @param submitter the user submitting the logo + * @param organization the organization who's logo is being set + * @returns the updated organization + * @throws if the user is not an admin + */ + static async setLogoImage( + logoImage: Express.Multer.File, + submitter: User, + organization: Organization + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('update logo'); + } + + const logoImageData = await uploadFile(logoImage); + + if (!logoImageData?.name) { + throw new HttpException(500, 'Image Name not found'); + } + + const updatedOrg = await prisma.organization.update({ + where: { organizationId: organization.organizationId }, + data: { + logoImageId: logoImageData.id + } + }); + + return updatedOrg; + } + + /** + * Gets the logo image of the organization + * @param organizationId the id of the organization + * @returns the id of the image + */ + static async getLogoImage(organizationId: string): Promise { + const organization = await prisma.organization.findUnique({ + where: { organizationId } + }); + + if (!organization) { + throw new NotFoundException('Organization', organizationId); + } + + return organization.logoImageId; + } + + /** + * Sets the description of a given organization. + * @param description the new description + * @param submitter the user making the change (must be admin) + * @param organization the organization whos description is changing + * @throws if the user is not an admin + */ + static async setOrganizationDescription( + description: string, + submitter: User, + organization: Organization + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('set description'); + } + const updatedOrg = prisma.organization.update({ + where: { + organizationId: organization.organizationId + }, + data: { + description + } + }); + return updatedOrg; + } + + /** + * Gets the featured projects for the given organization Id + * @param organizationId the organization to get the projects for + * @returns all the featured projects for the organization + */ + static async getOrganizationFeaturedProjects(organizationId: string) { + const organization = await prisma.organization.findUnique({ + where: { organizationId }, + include: { featuredProjects: getProjectQueryArgs(organizationId) } + }); + + if (!organization) { + throw new NotFoundException('Organization', organizationId); + } + + return organization.featuredProjects.map(projectTransformer); + } + + /** + * sets the slack workspace id of the organization + * @param workspaceId workspace id to set + * @param submitter user who submitted the workspace id + * @param organizationId id of organization to update with workspace id + * @returns updated organization + */ + static async setSlackWorkspaceId(workspaceId: string, submitter: User, organizationId: string) { + if (!(await userHasPermission(submitter.userId, organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('set workspace id'); + } + const updatedOrg = await prisma.organization.update({ + where: { organizationId }, + data: { slackWorkspaceId: workspaceId } + }); + + return updatedOrg; + } } 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..cf70419bc6 --- /dev/null +++ b/src/backend/src/services/pop-up.services.ts @@ -0,0 +1,108 @@ +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: { + usersReceived: { + some: { userId } + }, + organizationId + }, + ...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 popUp = await prisma.popUp.findUnique({ + where: { popUpId } + }); + + if (!popUp) throw new NotFoundException('Pop Up', popUpId); + + 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, + organizationId + }, + ...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/slack.services.ts b/src/backend/src/services/slack.services.ts new file mode 100644 index 0000000000..f6ece7ba85 --- /dev/null +++ b/src/backend/src/services/slack.services.ts @@ -0,0 +1,169 @@ +import { getChannelName, getUserName } from '../integrations/slack'; +import AnnouncementService from './announcement.service'; +import { Announcement } from 'shared'; +import prisma from '../prisma/prisma'; +import { blockToMentionedUsers, blockToString } from '../utils/slack.utils'; +import { NotFoundException } from '../utils/errors.utils'; + +/** + * Represents a slack event for a message in a channel. + */ +export interface SlackMessageEvent { + type: 'message'; + subtype?: string; + channel: string; + event_ts: string; + channel_type: string; + [key: string]: any; +} + +/** + * Represents a slack message event for a standard sent message. + */ +export interface SlackMessage extends SlackMessageEvent { + user: string; + client_msg_id: string; + text: string; + blocks: { + type: string; + block_id: string; + elements: any[]; + }[]; +} + +/** + * Represents a slack message event for a deleted message. + */ +export interface SlackDeletedMessage extends SlackMessageEvent { + subtype: 'message_deleted'; + previous_message: SlackMessage; +} + +/** + * Represents a slack message event for an edited message. + */ +export interface SlackUpdatedMessage extends SlackMessageEvent { + subtype: 'message_changed'; + message: SlackMessage; + previous_message: SlackMessage; +} + +/** + * Represents a block of information within a message. These blocks with an array + * make up all the information needed to represent the content of a message. + */ +export interface SlackRichTextBlock { + type: 'broadcast' | 'color' | 'channel' | 'date' | 'emoji' | 'link' | 'text' | 'user' | 'usergroup'; + range?: string; + value?: string; + channel_id?: string; + timestamp?: number; + name?: string; + unicode?: string; + url?: string; + text?: string; + user_id?: string; + usergroup_id?: string; +} + +export default class SlackServices { + /** + * Given a slack event representing a message in a channel, + * make the appropriate announcement change in prisma. + * @param event the slack event that will be processed + * @param organizationId the id of the organization represented by the slack api + * @returns an annoucement if an announcement was processed and created/modified/deleted + */ + static async processMessageSent(event: SlackMessageEvent, organizationId: string): Promise { + //get the name of the channel from the slack api + const slackChannelName: string = (await getChannelName(event.channel)) ?? `Unknown_Channel:${event.channel}`; + const dateCreated = new Date(1000 * Number(event.event_ts)); + + //get the message that will be processed either as the event or within a subtype + let eventMessage: SlackMessage; + + if (event.subtype) { + switch (event.subtype) { + case 'message_deleted': + //delete the message using the client_msg_id + eventMessage = (event as SlackDeletedMessage).previous_message; + return AnnouncementService.deleteAnnouncement(eventMessage.client_msg_id, organizationId); + case 'message_changed': + eventMessage = (event as SlackUpdatedMessage).message; + break; + default: + //other events that do not effect announcements + return; + } + } else { + eventMessage = event as SlackMessage; + } + + //loop through the blocks of the meta data while accumulating the + //text and users notified + let messageText = ''; + let userIdsToNotify: string[] = []; + + //get the name of the user that sent the message from slack + let userName = (await getUserName(eventMessage.user)) ?? ''; + + //if slack could not produce the name of the user, look for their name in prisma + if (!userName) { + try { + const userWithThatSlackId = await prisma.user.findFirst({ where: { userSettings: { slackId: eventMessage.user } } }); + userName = `${userWithThatSlackId?.firstName} ${userWithThatSlackId?.lastName}`; + } catch { + userName = 'Unknown_User:' + eventMessage.user; + } + } + + //pull out the blocks of data from the metadata within the message event + const richTextBlocks = eventMessage.blocks?.filter((eventBlock: any) => eventBlock.type === 'rich_text'); + + if (richTextBlocks && richTextBlocks.length > 0 && richTextBlocks[0].elements.length > 0) { + for (const element of richTextBlocks[0].elements[0].elements) { + messageText += await blockToString(element); + userIdsToNotify = userIdsToNotify.concat(await blockToMentionedUsers(element, organizationId, event.channel)); + } + } else { + return; + } + + //get rid of duplicates within the users to notify + userIdsToNotify = [...new Set(userIdsToNotify)]; + + //if no users are notified, disregard the message + if (userIdsToNotify.length === 0) { + return; + } + + if (event.subtype === 'message_changed') { + //try to edit the announcement, if no announcement with that id exists create a new announcement + try { + return await AnnouncementService.updateAnnouncement( + messageText, + userIdsToNotify, + userName, + eventMessage.client_msg_id, + slackChannelName, + organizationId + ); + } catch (error) { + //if couldn't find the announcement to edit, create a new one below + if (!(error instanceof NotFoundException)) { + throw error; + } + } + } + + return await AnnouncementService.createAnnouncement( + messageText, + userIdsToNotify, + dateCreated, + userName, + eventMessage.client_msg_id, + slackChannelName, + organizationId + ); + } +} diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index eaca5fe47d..4d89cee82d 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -36,6 +36,8 @@ import { } from '../prisma-query-args/user.query-args'; 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'; export default class UsersService { /** @@ -533,4 +535,37 @@ export default class UsersService { return userScheduleSettingsTransformer(newUserScheduleSettings); } + + /** + * Get's a user's assigned tasks + * @param userId the id of the user who's tasks are being returned + * @param organization the user's organization + * @returns a list of the user's assigned tasks + */ + static async getUserTasks(userId: string, organization: Organization) { + const requestedUser = await prisma.user.findUnique({ + where: { userId }, + include: { assignedTasks: getTaskQueryArgs(organization.organizationId), organizations: true } + }); + if (!requestedUser) throw new NotFoundException('User', userId); + if (!requestedUser.organizations.map((org) => org.organizationId).includes(organization.organizationId)) + throw new HttpException(400, `User ${userId} is not apart of the current organization`); + + return requestedUser.assignedTasks.map(taskTransformer); + } + + /** + * Get all tasks from a list of userIds + * @param userIds list of users to get the tasks from + * @param organization the users' organization + * @returns a list of tasks of the given users + */ + static async getManyUserTasks(userIds: string[], organization: Organization) { + const tasksPromises = userIds.map(async (userId) => { + return UsersService.getUserTasks(userId, organization); + }); + + const resolvedTasks = await Promise.all(tasksPromises); + return resolvedTasks.flat(); + } } 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/auth-user.transformer.ts b/src/backend/src/transformers/auth-user.transformer.ts index b66fc1659a..1231c486f9 100644 --- a/src/backend/src/transformers/auth-user.transformer.ts +++ b/src/backend/src/transformers/auth-user.transformer.ts @@ -6,6 +6,7 @@ import { isAuthUserOnFinance } from '../utils/reimbursement-requests.utils'; import { Prisma } from '@prisma/client'; +import teamTransformer from './teams.transformer'; const authenticatedUserTransformer = ( user: Prisma.UserGetPayload, @@ -27,6 +28,8 @@ const authenticatedUserTransformer = ( changeRequestsToReviewId: user.changeRequestsToReview.map((changeRequest) => changeRequest.crId), organizations: user.organizations.map((organization) => organization.organizationId), currentOrganization: user.organizations.find((organization) => organization.organizationId === organizationId), + teamsAsHead: user.teamsAsHead.map(teamTransformer), + teamsAsLead: user.teamsAsLead.map(teamTransformer), permissions: user.roles .map((role) => getPermissionsForRoleType(role.roleType)) .flat() 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/auth.utils.ts b/src/backend/src/utils/auth.utils.ts index 8520d881e4..3f6b11d291 100644 --- a/src/backend/src/utils/auth.utils.ts +++ b/src/backend/src/utils/auth.utils.ts @@ -2,7 +2,7 @@ import jwt from 'jsonwebtoken'; import { Request, Response, NextFunction } from 'express'; import { JwtPayload, VerifyErrors } from 'jsonwebtoken'; import prisma from '../prisma/prisma'; -import { AccessDeniedException, HttpException, NotFoundException } from './errors.utils'; +import { AccessDeniedException, DeletedException, HttpException, NotFoundException } from './errors.utils'; import { Organization, User, User_Secure_Settings, User_Settings } from '@prisma/client'; import { IncomingHttpHeaders } from 'http'; @@ -31,7 +31,8 @@ export const requireJwtProd = (req: Request, res: Response, next: NextFunction) if ( req.path === '/users/auth/login' || // logins dont have cookies yet req.path === '/' || // base route is available so aws can listen and check the health - req.method === 'OPTIONS' // this is a pre-flight request and those don't send cookies + req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies + req.path === '/slack' // slack http endpoint is only used from slack api ) { return next(); } else if ( @@ -62,7 +63,8 @@ export const requireJwtDev = (req: Request, res: Response, next: NextFunction) = req.path === '/users/auth/login/dev' || // logins dont have cookies yet req.path === '/' || // base route is available so aws can listen and check the health req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies - req.path === '/users' // dev login needs the list of users to log in + req.path === '/users' || // dev login needs the list of users to log in + req.path === '/slack' // slack http endpoint is only used from slack api ) { next(); } else if ( @@ -118,7 +120,7 @@ export type UserWithSecureSettings = UserWithSettings & { userSecureSettings: User_Secure_Settings | null; }; -export const getOrganization = async (headers: IncomingHttpHeaders): Promise => { +export const getOrganization = async (headers: IncomingHttpHeaders, currentUser: User): Promise => { let { organizationid } = headers; const isProd = process.env.NODE_ENV === 'production'; @@ -139,7 +141,8 @@ export const getOrganization = async (headers: IncomingHttpHeaders): Promise user.userId === currentUser.userId)) { + throw new AccessDeniedException('Cannot access this organization'); + } + return organization; }; @@ -172,13 +183,14 @@ export const getUserAndOrganization = async (req: Request, res: Response, next: req.path === '/users/auth/login/dev' || req.path === '/' || // base route is available so aws can listen and check the health req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies - req.path === '/users' // dev login needs the list of users to log in + req.path === '/users' || // dev login needs the list of users to log in + req.path === '/slack' // slack http endpoint is only used from slack api ) { return next(); } try { const user = await getCurrentUser(res); - const organization = await getOrganization(req.headers); + const organization = await getOrganization(req.headers, user); req.currentUser = user; req.organization = organization; return next(); diff --git a/src/backend/src/utils/design-reviews.utils.ts b/src/backend/src/utils/design-reviews.utils.ts index b7fd9ba65f..8a42700a70 100644 --- a/src/backend/src/utils/design-reviews.utils.ts +++ b/src/backend/src/utils/design-reviews.utils.ts @@ -29,12 +29,6 @@ export const isUserOnDesignReview = (user: User, designReview: DesignReview): bo return requiredMembers.includes(user.userId) || optionalMembers.includes(user.userId); }; -export const meetingStartTimePipe = (times: number[]) => { - const time = (times[0] % 12) + 10; - - return time <= 12 ? time + 'am' : time - 12 + 'pm'; -}; - export const transformStartTime = (times: number[]) => { return (times[0] % 12) + 10; }; diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index fc6bafe0f7..6c30280c9d 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -139,5 +139,7 @@ export type ExceptionObjectNames = | 'Car' | 'Milestone' | 'Faq' + | 'Pop Up' + | 'Announcement' | 'Graph' | 'Graph Collection'; 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/src/utils/projects.utils.ts b/src/backend/src/utils/projects.utils.ts index 7256468e7d..9f8f5e3238 100644 --- a/src/backend/src/utils/projects.utils.ts +++ b/src/backend/src/utils/projects.utils.ts @@ -1,7 +1,7 @@ -import { WBS_Element_Status } from '@prisma/client'; +import { Project, WBS_Element_Status } from '@prisma/client'; import prisma from '../prisma/prisma'; import { DescriptionBulletPreview, LinkCreateArgs, WbsElementStatus } from 'shared'; -import { DeletedException, NotFoundException } from './errors.utils'; +import { DeletedException, HttpException, NotFoundException } from './errors.utils'; import { ChangeCreateArgs, createChange, createListChanges, getDescriptionBulletChanges } from './changes.utils'; import { DescriptionBulletDestination, addRawDescriptionBullets, editDescriptionBullets } from './description-bullets.utils'; import { linkToChangeListValue, updateLinks } from './links.utils'; @@ -9,6 +9,10 @@ import { getLinkQueryArgs } from '../prisma-query-args/links.query-args'; import { getDescriptionBulletQueryArgs } from '../prisma-query-args/description-bullets.query-args'; import { getProjectQueryArgs } from '../prisma-query-args/projects.query-args'; +export type ProjectWithId = { + projectId: String; +}; + /** * calculate the project's status based on its workpacakges' status * @param proj a given project to be calculated on its status @@ -213,3 +217,32 @@ export const checkMaterialInputs = async ( if (!unit) throw new NotFoundException('Unit', unitName); } }; + +/** + * Produce a array of primsa formated projectIDs, given the array of Project + * @param projectIds the projectIds to get as users + * @returns projectIds in prisma format + */ +export const getProjects = async (projectIds: string[], organizationId: string) => { + const projects = await prisma.project.findMany({ + where: { projectId: { in: projectIds }, wbsElement: { organizationId, dateDeleted: null } }, + ...getProjectQueryArgs(organizationId) + }); + + validateFoundProjects(projects, projectIds); + + return projects; +}; + +/** + * Validates that the projects found in the database match the given projectIds + * @param projects the projects found in the database + * @param projectIds the requested projectIds to retrieve + */ +const validateFoundProjects = (projects: Project[], projectIds: string[]) => { + if (projects.length !== projectIds.length) { + const primsaProjectIds = projects.map((project) => project.projectId); + const missingProjectIds = projectIds.filter((id) => !primsaProjectIds.includes(id)); + throw new HttpException(404, `Projects(s) with the following ids not found: ${missingProjectIds.join(', ')}`); + } +}; diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index ecc264610b..73d040f7ab 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -1,16 +1,26 @@ -import { ChangeRequest, daysBetween, Task, UserPreview, wbsPipe, calculateEndDate } from 'shared'; +import { ChangeRequest, daysBetween, Task, UserPreview, wbsPipe, calculateEndDate, meetingStartTimePipe } from 'shared'; import { User } from '@prisma/client'; -import { editMessage, reactToMessage, replyToMessageInThread, sendMessage } from '../integrations/slack'; +import { + editMessage, + getChannelName, + getUserName, + getUsersInChannel, + reactToMessage, + replyToMessageInThread, + sendMessage +} from '../integrations/slack'; import { getUserFullName, getUserSlackId } from './users.utils'; import prisma from '../prisma/prisma'; import { HttpException } from './errors.utils'; import { Change_Request, Design_Review, Team, WBS_Element } from '@prisma/client'; import { UserWithSettings } from './auth.utils'; import { usersToSlackPings, userToSlackPing } from './notifications.utils'; -import { addHours, meetingStartTimePipe } from './design-reviews.utils'; +import { addHours } from './design-reviews.utils'; import { WorkPackageQueryArgs } from '../prisma-query-args/work-packages.query-args'; import { Prisma } from '@prisma/client'; import { userTransformer } from '../transformers/user.transformer'; +import { SlackRichTextBlock } from '../services/slack.services'; +import UsersService from '../services/users.services'; interface SlackMessageThread { messageInfoId: string; @@ -470,3 +480,99 @@ export const addSlackThreadsToChangeRequest = async (crId: string, threads: { ch ); await Promise.all(promises); }; + +/** + * Converts a SlackRichTextBlock into a string representation for an announcement. + * @param block the block of information from slack + * @returns the string that will be combined with other block's strings to create the announcement + */ +export const blockToString = async (block: SlackRichTextBlock) => { + switch (block.type) { + case 'broadcast': + return '@' + block.range; + case 'color': + return block.value ?? ''; + case 'channel': + //channels are represented as an id, get the name from the slack api + const channelName: string = + (await getChannelName(block.channel_id ?? '')) ?? `ISSUE PARSING CHANNEL:${block.channel_id}`; + return '#' + channelName; + case 'date': + return new Date(block.timestamp ?? 0).toISOString(); + case 'emoji': + //if the emoji is a unicode emoji, convert the unicode to a string, + //if it is a slack emoji just use the name of the emoji + if (block.unicode) { + return String.fromCodePoint(parseInt(block.unicode, 16)); + } + return 'emoji:' + block.name; + case 'link': + if (block.text) { + return `${block.text}:(${block.url})`; + } + return block.url ?? ''; + case 'text': + return block.text ?? ''; + case 'user': + //users are represented as an id, get the name of the user from the slack api + const userName: string = (await getUserName(block.user_id ?? '')) ?? `Unknown User:${block.user_id}`; + return '@' + userName; + case 'usergroup': + return `usergroup:${block.usergroup_id}`; + } +}; + +/** + * Gets the users notified in a specific SlackRichTextBlock. + * @param block the block that may contain mentioned user/users + * @param orgainzationId the id of the organization corresponding to this slack channel + * @param channelId the id of the channel that the block is being sent in + * @returns an array of prisma user ids of users to be notified + */ +export const blockToMentionedUsers = async ( + block: SlackRichTextBlock, + organizationId: string, + channelId: string +): Promise => { + switch (block.type) { + case 'broadcast': + switch (block.range) { + case 'everyone': + const usersInOrg = await UsersService.getAllUsers(organizationId); + return usersInOrg.map((user) => user.userId); + case 'channel': + case 'here': + //@here behaves the same as @channel; notifies all the users in that channel + const slackIds: string[] = await getUsersInChannel(channelId); + const prismaIds: (string | undefined)[] = await Promise.all(slackIds.map(getUserIdFromSlackId)); + return prismaIds.filter((id): id is string => id !== undefined); + default: + return []; + } + case 'user': + const prismaId = await getUserIdFromSlackId(block.user_id ?? ''); + return prismaId ? [prismaId] : []; + default: + //only broadcasts and specific user mentions add recievers to announcements + return []; + } +}; + +/** + * given a slack id, produce the user id of the corresponding user + * @param slackId the slack id in the settings of the user + * @returns the user id, or undefined if no users were found + */ +export const getUserIdFromSlackId = async (slackId: string): Promise => { + const user = await prisma.user.findFirst({ + where: { + userSettings: { + slackId + } + } + }); + + if (!user) return undefined; + + return user.userId; +}; diff --git a/src/backend/src/utils/teams.utils.ts b/src/backend/src/utils/teams.utils.ts index 087ebc37ad..8fb7c7ce21 100644 --- a/src/backend/src/utils/teams.utils.ts +++ b/src/backend/src/utils/teams.utils.ts @@ -1,5 +1,7 @@ -import { Prisma, User, Team } from '@prisma/client'; +import { Prisma, User, Team, Project } from '@prisma/client'; +import prisma from '../prisma/prisma'; import { UserWithSettings } from './auth.utils'; +import { NotFoundException } from './errors.utils'; const teamQueryArgsMembersOnly = Prisma.validator()({ include: { @@ -88,3 +90,23 @@ export const removeUsersFromList = (currentUsers: UserWithId[], usersToRemove: U const userIdsToRemove = usersToRemove.map((user) => user.userId); return currentUsers.filter((user) => !userIdsToRemove.includes(user.userId)); }; + +/** + * Given a team id, produces all of the projects assigned to that team + * @param teamId the id of the team + * @returns array of projects currently assigned to the given team (errors if no team is found) + */ +export const getTeamProjects = async (teamId: string): Promise => { + const team = await prisma.team.findUnique({ + where: { + teamId + }, + include: { + projects: true + } + }); + if (!team) { + throw new NotFoundException('Team', teamId); + } + return team.projects; +}; diff --git a/src/backend/tests/integration/slackMessages.test.ts b/src/backend/tests/integration/slackMessages.test.ts new file mode 100644 index 0000000000..703db97c1a --- /dev/null +++ b/src/backend/tests/integration/slackMessages.test.ts @@ -0,0 +1,399 @@ +import { Organization, User } from '@prisma/client'; +import { createSlackMessageEvent, createTestOrganization, createTestUser, resetUsers } from '../test-utils'; +import { + batmanAppAdmin, + batmanSettings, + supermanAdmin, + supermanSettings, + wonderwomanGuest, + wonderwomanSettings +} from '../test-data/users.test-data'; +import * as apiFunctions from '../../src/integrations/slack'; +import AnnouncementService from '../../src/services/announcement.service'; +import slackServices from '../../src/services/slack.services'; +import { vi } from 'vitest'; +import prisma from '../../src/prisma/prisma'; + +vi.mock('../../src/integrations/slack', async (importOriginal) => { + return { + ...(await importOriginal()), + getUserName: vi.fn(), + getChannelName: vi.fn(), + getUsersInChannel: vi.fn() + }; +}); + +describe('Slack message tests', () => { + let orgId: string; + let organization: Organization; + let batman: User; + let superman: User; + let wonderwoman: User; + + beforeEach(async () => { + await resetUsers(); + organization = await createTestOrganization(); + orgId = organization.organizationId; + batman = await createTestUser(batmanAppAdmin, orgId, batmanSettings); + superman = await createTestUser(supermanAdmin, orgId, supermanSettings); + wonderwoman = await createTestUser(wonderwomanGuest, orgId, wonderwomanSettings); + await prisma.organization.update({ + where: { + organizationId: orgId + }, + data: { + users: { + set: [{ userId: batman.userId }, { userId: superman.userId }, { userId: wonderwoman.userId }] + } + } + }); + }); + + afterEach(async () => { + await resetUsers(); + vi.clearAllMocks(); + }); + + it('adds message to everyone with @everyone', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'text', text: 'test with ' }, + { type: 'broadcast', range: 'everyone' }, + { type: 'text', text: ' broadcast (@everyone)' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with @everyone broadcast (@everyone)', + [batman.userId, superman.userId, wonderwoman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @everyone broadcast (@everyone)'); + expect(announcement?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(3); + }); + + it('Adds message to people in channel with @channel and @mention (w/o duplicates)', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + vi.mocked(apiFunctions.getUsersInChannel).mockReturnValue(Promise.resolve(['slack', 'slackWW'])); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'text', text: 'test with ' }, + { type: 'broadcast', range: 'channel' }, + { type: 'text', text: ' broadcast (@channel)' }, + { type: 'user', user_id: 'slackWW' }, + { type: 'user', user_id: 'slackSM' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with @channel broadcast (@channel)@Slack User Name@Slack User Name', + [batman.userId, wonderwoman.userId, superman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @channel broadcast (@channel)@Slack User Name@Slack User Name'); + expect(announcement?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(3); + }); + + it('Sends the announcement to a single person with a mention', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'text', text: 'test with ' }, + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' broadcast (@wonderwoman)' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with @Slack User Name broadcast (@wonderwoman)', + [wonderwoman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @Slack User Name broadcast (@wonderwoman)'); + expect(announcement?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(1); + }); + + it('Correctly processes other types of blocks', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'text', text: 'test with: ' }, + { type: 'link', url: 'http://www.example.com', text: 'link' }, + { type: 'text', text: 'Italics', style: { italic: true } }, + { type: 'text', text: ' and a unicode emoji: ' }, + { + type: 'emoji', + name: 'stuck_out_tongue_closed_eyes', + unicode: '1f61d' + }, + { type: 'text', text: ' and a slack emoji: ' }, + { + type: 'emoji', + name: 'birthday-parrot' + }, + { type: 'user', user_id: 'slackWW' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with: link:(http://www.example.com)Italics and a unicode emoji: 😝 and a slack emoji: emoji:birthday-parrot@Slack User Name', + [wonderwoman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe( + 'test with: link:(http://www.example.com)Italics and a unicode emoji: 😝 and a slack emoji: emoji:birthday-parrot@Slack User Name' + ); + expect(announcement?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(1); + }); + + it("Doesn't create an announcement if no one is mentioned", async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [{ type: 'text', text: 'just a text message' }]), + orgId + ); + + expect(spy).toBeCalledTimes(0); + + expect(announcement).toBeUndefined(); + }); + + it('Updates an edit made to a message', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + const updateSpy = vi.spyOn(AnnouncementService, 'updateAnnouncement'); + + await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + orgId + ); + expect(createSpy).toBeCalledWith( + '@Slack User Name', + [wonderwoman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + const announcement2 = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_changed', + channel: 'channel id', + event_ts: '1', + channel_type: 'channel', + message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' added text' } + ]), + previous_message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + + expect(updateSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement2?.text).toBe('@Slack User Name added text'); + expect(announcement2?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); + expect(announcement2?.senderName).toBe('Slack User Name'); + expect(announcement2?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement2?.slackEventId).toBe('id_1'); + expect(announcement2?.usersReceived).toHaveLength(1); + + expect(createSpy).toBeCalledTimes(1); + expect(updateSpy).toBeCalledTimes(1); + }); + + it('Creates a new announcement if the announcement to update is not found', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + const updateSpy = vi.spyOn(AnnouncementService, 'updateAnnouncement'); + + const announcement2 = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_changed', + channel: 'channel id', + event_ts: '1', + channel_type: 'channel', + message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' added text' } + ]), + previous_message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + + expect(updateSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(createSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement2?.text).toBe('@Slack User Name added text'); + expect(announcement2?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); + expect(announcement2?.senderName).toBe('Slack User Name'); + expect(announcement2?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement2?.slackEventId).toBe('id_1'); + expect(announcement2?.usersReceived).toHaveLength(1); + + expect(createSpy).toBeCalledTimes(1); + expect(updateSpy).toBeCalledTimes(1); + }); + + it('Creates and deletes and announcement', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + const deleteSpy = vi.spyOn(AnnouncementService, 'deleteAnnouncement'); + + await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + orgId + ); + expect(createSpy).toBeCalledWith( + '@Slack User Name', + [wonderwoman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_deleted', + channel: 'channel id', + event_ts: '1', + channel_type: 'channel', + previous_message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + expect(createSpy).toBeCalledTimes(1); + expect(deleteSpy).toBeCalledTimes(1); + expect(deleteSpy).toBeCalledWith('id_1', orgId); + }); + + it('Does nothing if recieves other message subtype', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'other-nonprocessed-subtype', + channel: 'channel id', + event_ts: '1', + channel_type: 'channel', + bogus_data: 'other data' + }, + orgId + ); + expect(createSpy).toBeCalledTimes(0); + expect(announcement).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index f6bfb7961d..0665047826 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -15,7 +15,7 @@ import prisma from '../src/prisma/prisma'; import { dbSeedAllUsers } from '../src/prisma/seed-data/users.seed'; import TeamsService from '../src/services/teams.services'; import ReimbursementRequestService from '../src/services/reimbursement-requests.services'; -import { ClubAccount, Permission, RoleEnum } from 'shared'; +import { ClubAccount, Permission, RoleEnum, TaskPriority, TaskStatus } from 'shared'; import { batmanAppAdmin, batmanScheduleSettings, @@ -25,6 +25,9 @@ import { } from './test-data/users.test-data'; import { getWorkPackageTemplateQueryArgs } from '../src/prisma-query-args/work-package-template.query-args'; import DesignReviewsService from '../src/services/design-reviews.services'; +import TasksService from '../src/services/tasks.services'; +import ProjectsService from '../src/services/projects.services'; +import { SlackMessage } from '../src/services/slack.services'; export interface CreateTestUserParams { firstName: string; @@ -56,7 +59,8 @@ export const createTestUser = async ( organizationId } }, - additionalPermissions: permissions + additionalPermissions: permissions, + organizations: { connect: { organizationId } } } }); @@ -129,6 +133,8 @@ export const resetUsers = async () => { await prisma.frequentlyAskedQuestion.deleteMany(); await prisma.graph.deleteMany(); await prisma.graph_Collection.deleteMany(); + await prisma.announcement.deleteMany(); + await prisma.popUp.deleteMany(); await prisma.organization.deleteMany(); await prisma.user.deleteMany(); }; @@ -564,3 +570,71 @@ export const createTestTask = async ( }); return task; }; + +export const createTestTaskWithOrganization = async (user: User, organization?: Organization) => { + if (!organization) organization = await createTestOrganization(); + const orgId = organization.organizationId; + const team = await TeamsService.createTeam(user, 'Test team', user.userId, 'Test', '', false, organization); + if (!team) throw new Error('Failed to create team'); + const project = await createTestProject(user, organization.organizationId); + if (!project) throw new Error('Failed to create project'); + await ProjectsService.setProjectTeam( + user, + { + carNumber: 0, + projectNumber: 1, + workPackageNumber: 0 + }, + team.teamId, + organization + ); + + const task = await TasksService.createTask( + user, + { + carNumber: 0, + projectNumber: 1, + workPackageNumber: 0 + }, + 'Test task', + 'Test', + TaskPriority.High, + TaskStatus.IN_PROGRESS, + [user.userId], + organization, + new Date() + ); + + if (!task) throw new Error('Failed to create task'); + return { task, organization, orgId }; +}; + +export const createSlackMessageEvent = ( + channel: string, + event_ts: string, + user: string, + client_msg_id: string, + elements: any[] +): SlackMessage => { + return { + type: 'message', + channel, + event_ts, + channel_type: 'channel', + user, + client_msg_id, + text: 'sample text', + blocks: [ + { + type: 'rich_text', + block_id: 'block id', + elements: [ + { + type: 'rich_text_section', + elements + } + ] + } + ] + }; +}; diff --git a/src/backend/tests/unit/announcements.test.ts b/src/backend/tests/unit/announcements.test.ts new file mode 100644 index 0000000000..8f701ec41d --- /dev/null +++ b/src/backend/tests/unit/announcements.test.ts @@ -0,0 +1,223 @@ +import { Organization, User } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers } from '../test-utils'; +import { + batmanAppAdmin, + batmanSettings, + supermanAdmin, + supermanSettings, + wonderwomanGuest, + wonderwomanSettings +} from '../test-data/users.test-data'; +import AnnouncementService from '../../src/services/announcement.service'; +import { NotFoundException } from '../../src/utils/errors.utils'; + +describe('announcement tests', () => { + let orgId: string; + let organization: Organization; + let batman: User; + let superman: User; + let wonderwoman: User; + + beforeEach(async () => { + await resetUsers(); + organization = await createTestOrganization(); + orgId = organization.organizationId; + batman = await createTestUser(batmanAppAdmin, orgId, batmanSettings); + superman = await createTestUser(supermanAdmin, orgId, supermanSettings); + wonderwoman = await createTestUser(wonderwomanGuest, orgId, wonderwomanSettings); + }); + + afterEach(async () => { + await resetUsers(); + }); + + it('creates announcements which can be recieved via users', async () => { + const announcement = await AnnouncementService.createAnnouncement( + 'text', + [superman.userId, batman.userId], + new Date(1000000000000), + 'sender name', + 'slack id', + 'channel name', + orgId + ); + expect(announcement?.text).toBe('text'); + expect(announcement?.usersReceived).toHaveLength(2); + expect(announcement?.senderName).toBe('sender name'); + expect(announcement?.dateMessageSent).toStrictEqual(new Date(1000000000000)); + expect(announcement?.slackEventId).toBe('slack id'); + expect(announcement?.slackChannelName).toBe('channel name'); + expect(announcement?.dateDeleted).toBeUndefined(); + + const smAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(superman.userId, orgId); + const bmAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, orgId); + const wwAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(wonderwoman.userId, orgId); + + expect(smAnnouncements).toHaveLength(1); + expect(smAnnouncements[0]?.text).toBe('text'); + expect(smAnnouncements[0]?.usersReceived).toHaveLength(2); + expect(smAnnouncements[0]?.senderName).toBe('sender name'); + expect(smAnnouncements[0]?.dateMessageSent).toStrictEqual(new Date(1000000000000)); + expect(smAnnouncements[0]?.slackEventId).toBe('slack id'); + expect(smAnnouncements[0]?.slackChannelName).toBe('channel name'); + expect(smAnnouncements[0]?.dateDeleted).toBeUndefined(); + + expect(bmAnnouncements).toHaveLength(1); + expect(wwAnnouncements).toHaveLength(0); + }); + + it('updates an announcement', async () => { + await AnnouncementService.createAnnouncement( + 'text', + [superman.userId, batman.userId], + new Date(1000000000000), + 'sender name', + 'slack id', + 'channel name', + orgId + ); + + let smAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(superman.userId, orgId); + let bmAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, orgId); + let wwAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(wonderwoman.userId, orgId); + + expect(smAnnouncements).toHaveLength(1); + expect(bmAnnouncements).toHaveLength(1); + expect(wwAnnouncements).toHaveLength(0); + + const updatedAnnouncement = await AnnouncementService.updateAnnouncement( + 'new text', + [batman.userId, wonderwoman.userId], + 'sender name', + 'slack id', + 'channel name', + orgId + ); + + smAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(superman.userId, orgId); + bmAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, orgId); + wwAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(wonderwoman.userId, orgId); + + expect(smAnnouncements).toHaveLength(0); + expect(bmAnnouncements).toHaveLength(1); + expect(wwAnnouncements).toHaveLength(1); + expect(bmAnnouncements[0]?.text).toBe('new text'); + expect(wwAnnouncements[0]?.text).toBe('new text'); + expect(updatedAnnouncement?.text).toBe('new text'); + }); + + it('fails to update if there is no slack id', async () => { + await expect( + async () => + await AnnouncementService.updateAnnouncement( + 'new text', + [batman.userId, wonderwoman.userId], + 'sender name', + 'slack id', + 'channel name', + orgId + ) + ).rejects.toThrow(new NotFoundException('Announcement', 'slack id')); + }); + + it('deletes an announcement', async () => { + await AnnouncementService.createAnnouncement( + 'text', + [superman.userId, batman.userId], + new Date(1000000000000), + 'sender name', + 'slack id', + 'channel name', + orgId + ); + + let smAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(superman.userId, orgId); + let bmAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, orgId); + + expect(smAnnouncements).toHaveLength(1); + expect(bmAnnouncements).toHaveLength(1); + + const deletedAnnouncement = await AnnouncementService.deleteAnnouncement('slack id', orgId); + + smAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(superman.userId, orgId); + bmAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, orgId); + + expect(smAnnouncements).toHaveLength(0); + expect(bmAnnouncements).toHaveLength(0); + expect(deletedAnnouncement?.text).toBe('text'); + expect(deletedAnnouncement?.dateDeleted).toBeDefined(); + }); + + it('throws if it cannot find the announcement to delete', async () => { + await expect(async () => await AnnouncementService.deleteAnnouncement('non-existent id', orgId)).rejects.toThrow( + new NotFoundException('Announcement', 'non-existent id') + ); + }); + + describe('Get Announcements', () => { + it('Succeeds and gets user announcements', async () => { + await AnnouncementService.createAnnouncement( + 'test1', + [batman.userId], + new Date(), + 'Thomas Emrax', + '1', + 'software', + organization.organizationId + ); + await AnnouncementService.createAnnouncement( + 'test2', + [batman.userId], + new Date(), + 'Superman', + '50', + 'mechanical', + organization.organizationId + ); + + const announcements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, organization.organizationId); + + expect(announcements).toHaveLength(2); + expect(announcements[0].text).toBe('test1'); + expect(announcements[1].text).toBe('test2'); + }); + }); + + describe('Remove Announcement', () => { + it('Succeeds and removes user announcement', async () => { + await AnnouncementService.createAnnouncement( + 'test1', + [batman.userId], + new Date(), + 'Thomas Emrax', + '1', + 'software', + organization.organizationId + ); + await AnnouncementService.createAnnouncement( + 'test2', + [batman.userId], + new Date(), + 'Superman', + '50', + 'mechanical', + organization.organizationId + ); + + const announcements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, organization.organizationId); + + expect(announcements).toHaveLength(2); + expect(announcements[0].text).toBe('test1'); + expect(announcements[1].text).toBe('test2'); + + const updatedAnnouncements = await AnnouncementService.removeUserAnnouncement( + batman.userId, + announcements[0].announcementId, + organization.organizationId + ); + + expect(updatedAnnouncements).toHaveLength(1); + expect(updatedAnnouncements[0].text).toBe('test2'); + }); + }); +}); diff --git a/src/backend/tests/unit/design-review.test.ts b/src/backend/tests/unit/design-review.test.ts new file mode 100644 index 0000000000..3fb4307b44 --- /dev/null +++ b/src/backend/tests/unit/design-review.test.ts @@ -0,0 +1,71 @@ +import { financeMember, supermanAdmin } from '../test-data/users.test-data'; +import DesignReviewsService from '../../src/services/design-reviews.services'; +import { AccessDeniedException } from '../../src/utils/errors.utils'; +import { createTestDesignReview, createTestUser, resetUsers } from '../test-utils'; +import prisma from '../../src/prisma/prisma'; +import { getUserQueryArgs } from '../../src/prisma-query-args/user.query-args'; +import { DesignReviewStatus } from 'shared'; +import { Design_Review, Organization } from '@prisma/client'; + +describe('Design Reviews', () => { + let designReview: Design_Review; + let organizationId: string; + let organization: Organization; + beforeEach(async () => { + const { dr, organization: org, orgId } = await createTestDesignReview(); + designReview = dr; + organization = org; + organizationId = orgId; + }); + + afterEach(async () => { + await resetUsers(); + }); + + // change with admin who is not creator + test('Set status works when an admin who is not the creator sets', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + await DesignReviewsService.setStatus(user, designReview.designReviewId, DesignReviewStatus.CONFIRMED, organization); + const updatedDR = await prisma.design_Review.findUnique({ + where: { + designReviewId: designReview.designReviewId + } + }); + // check that status changed to correct status + expect(updatedDR?.status).toBe(DesignReviewStatus.CONFIRMED); + }); + + // Set status works when creator is not admin + test('Set status works when set with creator who is not admin', async () => { + const drCreator = await prisma.user.findUnique({ + where: { + userId: designReview.userCreatedId + }, + ...getUserQueryArgs(organizationId) + }); + if (!drCreator) { + throw new Error('User not found in database'); + } + await DesignReviewsService.setStatus(drCreator, designReview.designReviewId, DesignReviewStatus.CONFIRMED, organization); + const updatedDR = await prisma.design_Review.findUnique({ + where: { + designReviewId: designReview.designReviewId + } + }); + expect(updatedDR?.status).toBe(DesignReviewStatus.CONFIRMED); + }); + + // fails when user is not admin or creator + test('Set status fails when user is not admin or creator', async () => { + await expect(async () => + DesignReviewsService.setStatus( + await createTestUser(financeMember, organizationId), + designReview.designReviewId, + DesignReviewStatus.CONFIRMED, + organization + ) + ).rejects.toThrow( + new AccessDeniedException('admin and app-admin only have the ability to set the status of a design review') + ); + }); +}); diff --git a/src/backend/tests/unit/organization.test.ts b/src/backend/tests/unit/organization.test.ts new file mode 100644 index 0000000000..798c5ed593 --- /dev/null +++ b/src/backend/tests/unit/organization.test.ts @@ -0,0 +1,333 @@ +import { LinkCreateArgs } from 'shared'; +import { AccessDeniedAdminOnlyException, HttpException, NotFoundException } from '../../src/utils/errors.utils'; +import { batmanAppAdmin, wonderwomanGuest } from '../test-data/users.test-data'; +import { createTestLinkType, createTestOrganization, createTestProject, createTestUser, resetUsers } from '../test-utils'; +import prisma from '../../src/prisma/prisma'; +import { testLink1 } from '../test-data/organizations.test-data'; +import { uploadFile } from '../../src/utils/google-integration.utils'; +import { Mock, vi } from 'vitest'; +import OrganizationsService from '../../src/services/organizations.services'; +import { Organization } from '@prisma/client'; + +vi.mock('../../src/utils/google-integration.utils', () => ({ + uploadFile: vi.fn() +})); + +describe('Organization Tests', () => { + let orgId: string; + let organization: Organization; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Get Current Organization', () => { + it('Fails if organization does not exist', async () => { + await expect(async () => await OrganizationsService.getCurrentOrganization('1')).rejects.toThrow( + new NotFoundException('Organization', '1') + ); + }); + + it('Succeeds and gets the organization', async () => { + const org = await OrganizationsService.getCurrentOrganization(orgId); + + expect(org).not.toBeNull(); + expect(org.organizationId).toBe(orgId); + expect(org.name).toBe(organization.name); + }); + }); + + describe('Set Images', () => { + const file1 = { originalname: 'image1.png' } as Express.Multer.File; + const file2 = { originalname: 'image2.png' } as Express.Multer.File; + const file3 = { originalname: 'image3.png' } as Express.Multer.File; + it('Fails if user is not an admin', async () => { + await expect( + OrganizationsService.setImages(file1, file2, await createTestUser(wonderwomanGuest, orgId), organization) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('update images')); + }); + + it('Succeeds and updates all the images', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + (uploadFile as Mock).mockImplementation((file) => { + return Promise.resolve({ name: `${file.originalname}`, id: `uploaded-${file.originalname}` }); + }); + + await OrganizationsService.setImages(file1, file2, testBatman, organization); + + const oldOrganization = await prisma.organization.findUnique({ + where: { + organizationId: orgId + } + }); + + expect(oldOrganization).not.toBeNull(); + expect(oldOrganization?.applyInterestImageId).toBe('uploaded-image1.png'); + expect(oldOrganization?.exploreAsGuestImageId).toBe('uploaded-image2.png'); + + await OrganizationsService.setImages(file1, file3, testBatman, organization); + + const updatedOrganization = await prisma.organization.findUnique({ + where: { + organizationId: orgId + } + }); + + expect(updatedOrganization?.exploreAsGuestImageId).toBe('uploaded-image3.png'); + }); + }); + + describe('Set Useful Links', () => { + it('Fails if user is not an admin', async () => { + await expect( + OrganizationsService.setUsefulLinks(await createTestUser(wonderwomanGuest, orgId), orgId, []) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('update useful links')); + }); + + it('Fails if a link type does not exist', async () => { + await expect( + OrganizationsService.setUsefulLinks(await createTestUser(batmanAppAdmin, orgId), orgId, testLink1) + ).rejects.toThrow(new HttpException(400, `Link type with name 'example link type' not found`)); + }); + + it('Succeeds and updates all the links', async () => { + const testLinks1: LinkCreateArgs[] = [ + { + linkId: '-1', + linkTypeName: 'Link type 1', + url: 'link 1' + }, + { + linkId: '-1', + linkTypeName: 'Link type 1', + url: 'link 2' + } + ]; + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await createTestLinkType(testBatman, orgId); + await OrganizationsService.setUsefulLinks(testBatman, orgId, testLinks1); + + const organization = await prisma.organization.findUnique({ + where: { + organizationId: orgId + }, + include: { + usefulLinks: true + } + }); + + expect(organization).not.toBeNull(); + expect(organization!.usefulLinks.length).toBe(2); + expect(organization!.usefulLinks[0].url).toBe('link 1'); + expect(organization!.usefulLinks[1].url).toBe('link 2'); + + // ensuring previous links are deleted and only these ones remain + const testLinks2: LinkCreateArgs[] = [ + { + linkId: '-1', + linkTypeName: 'Link type 1', + url: 'link 3' + }, + { + linkId: '-1', + linkTypeName: 'Link type 1', + url: 'link 4' + } + ]; + await OrganizationsService.setUsefulLinks(testBatman, orgId, testLinks2); + + const updatedOrganization = await prisma.organization.findUnique({ + where: { + organizationId: orgId + }, + include: { + usefulLinks: true + } + }); + + expect(updatedOrganization).not.toBeNull(); + expect(updatedOrganization!.usefulLinks.length).toBe(2); + expect(updatedOrganization!.usefulLinks[0].url).toBe('link 3'); + expect(updatedOrganization!.usefulLinks[1].url).toBe('link 4'); + }); + }); + + describe('Get all Useful Links', () => { + it('Succeeds and gets all the links', async () => { + const testLinks1: LinkCreateArgs[] = [ + { + linkId: '1', + linkTypeName: 'Link type 1', + url: 'link 1' + }, + { + linkId: '2', + linkTypeName: 'Link type 1', + url: 'link 2' + } + ]; + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await createTestLinkType(testBatman, orgId); + await OrganizationsService.setUsefulLinks(testBatman, orgId, testLinks1); + const links = await OrganizationsService.getAllUsefulLinks(orgId); + + expect(links).not.toBeNull(); + expect(links.length).toBe(2); + expect(links[0].url).toBe('link 1'); + expect(links[1].url).toBe('link 2'); + }); + }); + + describe('Get all featured projects', () => { + it('Fails if an organizaion does not exist', async () => { + await expect(async () => await OrganizationsService.getOrganizationFeaturedProjects('1')).rejects.toThrow( + new NotFoundException('Organization', '1') + ); + }); + + it('Succeeds and gets featured projects', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + const testProject1 = await createTestProject(testBatman, orgId); + + await OrganizationsService.setFeaturedProjects([testProject1.projectId], organization, testBatman); + + const projects = await OrganizationsService.getOrganizationFeaturedProjects(orgId); + + expect(projects).not.toBeNull(); + expect(projects.length).toBe(1); + expect(projects[0].id).toBe(testProject1.projectId); + }); + }); + + describe('Get Organization Images', () => { + it('Fails if an organization does not exist', async () => { + await expect(async () => await OrganizationsService.getOrganizationImages('1')).rejects.toThrow( + new NotFoundException('Organization', '1') + ); + }); + + it('Succeeds and gets all the images', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await createTestLinkType(testBatman, orgId); + await OrganizationsService.setImages( + { originalname: 'image1.png' } as Express.Multer.File, + { originalname: 'image2.png' } as Express.Multer.File, + testBatman, + organization + ); + const images = await OrganizationsService.getOrganizationImages(orgId); + + expect(images).not.toBeNull(); + expect(images.applyInterestImage).toBe('uploaded-image1.png'); + expect(images.exploreAsGuestImage).toBe('uploaded-image2.png'); + }); + }); + + describe('Set Logo', () => { + const file1 = { originalname: 'image1.png' } as Express.Multer.File; + const file2 = { originalname: 'image2.png' } as Express.Multer.File; + it('Fails if user is not an admin', async () => { + await expect( + OrganizationsService.setLogoImage(file1, await createTestUser(wonderwomanGuest, orgId), organization) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('update logo')); + }); + + it('Succeeds and updates the logo', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + (uploadFile as Mock).mockImplementation((file) => { + return Promise.resolve({ name: `${file.originalname}`, id: `uploaded-${file.originalname}` }); + }); + + await OrganizationsService.setLogoImage(file1, testBatman, organization); + + const oldOrganization = await prisma.organization.findUnique({ + where: { + organizationId: orgId + } + }); + + expect(oldOrganization).not.toBeNull(); + expect(oldOrganization?.logoImageId).toBe('uploaded-image1.png'); + + await OrganizationsService.setLogoImage(file2, testBatman, organization); + + const updatedOrganization = await prisma.organization.findUnique({ + where: { + organizationId: orgId + } + }); + + expect(updatedOrganization?.logoImageId).toBe('uploaded-image2.png'); + }); + }); + + describe('Get Organization Logo', () => { + it('Fails if an organization does not exist', async () => { + await expect(async () => await OrganizationsService.getLogoImage('1')).rejects.toThrow( + new NotFoundException('Organization', '1') + ); + }); + + it('Succeeds and gets the image', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await OrganizationsService.setLogoImage( + { originalname: 'image1.png' } as Express.Multer.File, + testBatman, + organization + ); + const image = await OrganizationsService.getLogoImage(orgId); + + expect(image).not.toBeNull(); + expect(image).toBe('uploaded-image1.png'); + }); + }); + + describe('Set Organization Description', () => { + it('Fails if user is not an admin', async () => { + await expect( + OrganizationsService.setOrganizationDescription( + 'test description', + await createTestUser(wonderwomanGuest, orgId), + organization + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('set description')); + }); + + it('Succeeds and updates the description', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + + const returnedOrganization = await OrganizationsService.setOrganizationDescription( + 'sample description', + testBatman, + organization + ); + + const oldOrganization = await prisma.organization.findUnique({ + where: { + organizationId: orgId + } + }); + + expect(oldOrganization).not.toBeNull(); + expect(oldOrganization?.description).toBe('sample description'); + expect(oldOrganization?.organizationId).toBe(returnedOrganization.organizationId); + expect(oldOrganization?.description).toBe(returnedOrganization.description); + }); + }); + + describe('Set Organization Workspace Id', () => { + it('Succeeds and updates the workspace id', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + + const updatedOrganization = await OrganizationsService.setSlackWorkspaceId('1234', testBatman, orgId); + + expect(updatedOrganization).not.toBeNull(); + expect(updatedOrganization.slackWorkspaceId).toBe('1234'); + }); + }); +}); diff --git a/src/backend/tests/unit/pop-up.test.ts b/src/backend/tests/unit/pop-up.test.ts new file mode 100644 index 0000000000..4421aea4f8 --- /dev/null +++ b/src/backend/tests/unit/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/unit/recruitment.test.ts b/src/backend/tests/unit/recruitment.test.ts new file mode 100644 index 0000000000..dddb6a6ba6 --- /dev/null +++ b/src/backend/tests/unit/recruitment.test.ts @@ -0,0 +1,339 @@ +import prisma from '../../src/prisma/prisma'; +import { Organization } from '@prisma/client'; +import RecruitmentServices from '../../src/services/recruitment.services'; +import { + AccessDeniedAdminOnlyException, + DeletedException, + HttpException, + NotFoundException +} from '../../src/utils/errors.utils'; +import { + createTestMilestone, + createTestFaq, + createTestFAQ, + createTestOrganization, + createTestUser, + resetUsers +} from '../test-utils'; +import { + batmanAppAdmin, + wonderwomanGuest, + supermanAdmin, + member, + theVisitorGuest, + flashAdmin, + alfred +} from '../test-data/users.test-data'; + +describe('Recruitment Tests', () => { + let orgId: string; + let organization: Organization; + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Get All FAQs', () => { + it('Succeeds and gets all the FAQs', async () => { + const faq1 = await RecruitmentServices.createFaq( + await createTestUser(batmanAppAdmin, orgId), + 'question', + 'answer', + organization + ); + const faq2 = await RecruitmentServices.createFaq( + await createTestUser(supermanAdmin, orgId), + 'question2', + 'answer2', + organization + ); + const result = await RecruitmentServices.getAllFaqs(organization); + expect(result).toStrictEqual([faq1, faq2]); + }); + + describe('Edit FAQ', () => { + it('Fails if user is not an admin', async () => { + await expect( + async () => + await RecruitmentServices.editFAQ( + 'What is your return policy?', + 'You can return any item within 30 days of purchase.', + await createTestUser(wonderwomanGuest, orgId), + organization, + 'faq123' + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('edit frequently asked questions')); + }); + + it('Fails if FAQ does not exist', async () => { + await expect( + async () => + await RecruitmentServices.editFAQ( + 'What is your return policy?', + 'You can return any item within 30 days of purchase.', + await createTestUser(batmanAppAdmin, orgId), + organization, + 'nonExistentFaqId' + ) + ).rejects.toThrow(new NotFoundException('Faq', 'nonExistentFaqId')); + }); + + it('Succeeds and edits an FAQ', async () => { + await createTestFAQ(orgId, 'faq123'); + const result = await RecruitmentServices.editFAQ( + 'What is your return policy?', + 'You can return any item within 60 days of purchase.', + await createTestUser(batmanAppAdmin, orgId), + organization, + 'faq123' + ); + + expect(result.question).toEqual('What is your return policy?'); + expect(result.answer).toEqual('You can return any item within 60 days of purchase.'); + }); + }); + + describe('Create Milestone', () => { + it('Fails if user is not an admin', async () => { + await expect( + async () => + await RecruitmentServices.createMilestone( + await createTestUser(wonderwomanGuest, orgId), + 'name', + 'description', + new Date(), + organization + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a milestone')); + }); + + it('Succeeds and creates a milestone', async () => { + const result = await RecruitmentServices.createMilestone( + await createTestUser(batmanAppAdmin, orgId), + 'name', + 'description', + new Date('11/12/24'), + organization + ); + + expect(result.name).toEqual('name'); + expect(result.description).toEqual('description'); + expect(result.dateOfEvent).toEqual(new Date('11/12/24')); + }); + }); + + describe('Edit Milestone', () => { + it('Fails if user is not an admin', async () => { + await expect( + async () => + await RecruitmentServices.editMilestone( + await createTestUser(wonderwomanGuest, orgId), + 'name', + 'description', + new Date(), + '1', + organization + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a milestone')); + }); + + it('Fails if milestone doesn`t exist', async () => { + await expect( + async () => + await RecruitmentServices.editMilestone( + await createTestUser(batmanAppAdmin, orgId), + 'name', + 'description', + new Date('11/12/24'), + '1', + organization + ) + ).rejects.toThrow(new NotFoundException('Milestone', 1)); + }); + + it('Fails if milestone is deleted', async () => { + const milestone = await RecruitmentServices.createMilestone( + await createTestUser(batmanAppAdmin, orgId), + 'name', + 'description', + new Date('11/12/24'), + organization + ); + + await prisma.milestone.delete({ + where: { + milestoneId: milestone.milestoneId + } + }); + + await expect( + async () => + await RecruitmentServices.editMilestone( + await createTestUser(supermanAdmin, orgId), + 'name', + 'description', + new Date('11/12/24'), + milestone.milestoneId, + organization + ) + ).rejects.toThrow(new NotFoundException('Milestone', milestone.milestoneId)); + }); + + it('Succeeds and edits a milestone', async () => { + const milestone = await RecruitmentServices.createMilestone( + await createTestUser(batmanAppAdmin, orgId), + 'name', + 'description', + new Date('11/12/24'), + organization + ); + + const updatedMilestone = await RecruitmentServices.editMilestone( + await createTestUser(supermanAdmin, orgId), + 'new name', + 'new description', + new Date('11/14/24'), + milestone.milestoneId, + organization + ); + + expect(updatedMilestone.name).toEqual('new name'); + expect(updatedMilestone.description).toEqual('new description'); + expect(updatedMilestone.dateOfEvent).toEqual(new Date('11/14/24')); + }); + }); + + describe('Get All Milestones', () => { + it('Succeeds and gets all the milestones', async () => { + const milestone1 = await RecruitmentServices.createMilestone( + await createTestUser(batmanAppAdmin, orgId), + 'name', + 'description', + new Date('11/11/24'), + organization + ); + + const milestone2 = await RecruitmentServices.createMilestone( + await createTestUser(supermanAdmin, orgId), + 'name2', + 'description2', + new Date('1/1/1'), + organization + ); + const result = await RecruitmentServices.getAllMilestones(organization); + expect(result).toStrictEqual([milestone1, milestone2]); + }); + }); + + describe('Create FAQ', () => { + it('Fails if user is not an admin', async () => { + await expect( + async () => + await RecruitmentServices.createFaq(await createTestUser(member, orgId), 'question', 'answer', organization) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('create an faq')); + }); + + describe('Delete a single milestone', () => { + it('Fails if user is not admin', async () => { + await expect( + async () => + await RecruitmentServices.deleteMilestone(await createTestUser(wonderwomanGuest, orgId), 'id', organization) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('delete milestone')); + }); + + it('Fails if milestoneId is not found', async () => { + await expect( + async () => + await RecruitmentServices.deleteMilestone(await createTestUser(batmanAppAdmin, orgId), 'id1', organization) + ).rejects.toThrow(new HttpException(400, 'Milestone with id: id1 not found!')); + }); + + it('Fails if milestone is already deleted', async () => { + const testSuperman = await createTestUser(supermanAdmin, orgId); + const testMilestone = await createTestMilestone(testSuperman, orgId); + await RecruitmentServices.deleteMilestone(testSuperman, testMilestone.milestoneId, organization); + + await expect( + async () => await RecruitmentServices.deleteMilestone(testSuperman, testMilestone.milestoneId, organization) + ).rejects.toThrow(new DeletedException('Milestone', testMilestone.milestoneId)); + }); + + it('Succeeds and deletes milestone', async () => { + const testSuperman = await createTestUser(supermanAdmin, orgId); + const testMilestone1 = await createTestMilestone(testSuperman, orgId); + + await RecruitmentServices.deleteMilestone(testSuperman, testMilestone1.milestoneId, organization); + + const updatedTestMilestone1 = await prisma.milestone.findUnique({ + where: { milestoneId: testMilestone1.milestoneId } + }); + + expect(updatedTestMilestone1?.dateDeleted).not.toBe(null); + }); + + describe('Create FAQ', () => { + it('Fails if user is not an admin', async () => { + await expect( + async () => + await RecruitmentServices.createFaq(await createTestUser(member, orgId), 'question', 'answer', organization) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('create an faq')); + }); + + it('Succeeds and creates an FAQ', async () => { + const result = await RecruitmentServices.createFaq( + await createTestUser(batmanAppAdmin, orgId), + 'question', + 'answer', + organization + ); + + expect(result.question).toEqual('question'); + expect(result.answer).toEqual('answer'); + }); + }); + }); + }); + }); + + describe('Delete FAQ', () => { + it('Fails if user is not an admin', async () => { + const testFaq = await createTestFaq(await createTestUser(batmanAppAdmin, orgId), orgId); + await expect( + async () => + await RecruitmentServices.deleteFaq(await createTestUser(theVisitorGuest, orgId), testFaq.faqId, organization) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('delete an faq')); + }); + + it('Fails if faq doesn`t exist', async () => { + await expect( + async () => await RecruitmentServices.deleteFaq(await createTestUser(batmanAppAdmin, orgId), '1', organization) + ).rejects.toThrow(new NotFoundException('Faq', '1')); + }); + + it('Fails if faq is already deleted', async () => { + const testFaq = await createTestFaq(await createTestUser(batmanAppAdmin, orgId), orgId); + await RecruitmentServices.deleteFaq(await createTestUser(flashAdmin, orgId), testFaq.faqId, organization); + + await expect( + async () => + await RecruitmentServices.deleteFaq(await createTestUser(supermanAdmin, orgId), testFaq.faqId, organization) + ).rejects.toThrow(new DeletedException('Faq', testFaq.faqId)); + }); + + it('Succeeds and deletes an FAQ', async () => { + const testFaq = await createTestFaq(await createTestUser(batmanAppAdmin, orgId), orgId); + + await RecruitmentServices.deleteFaq(await createTestUser(alfred, orgId), testFaq.faqId, organization); + + const deletedTestFaq = await prisma.frequentlyAskedQuestion.findUnique({ + where: { faqId: testFaq.faqId } + }); + + expect(deletedTestFaq?.dateDeleted).not.toBe(null); + }); + }); +}); diff --git a/src/backend/tests/unit/reimbursement-requests.test.ts b/src/backend/tests/unit/reimbursement-requests.test.ts new file mode 100644 index 0000000000..f1efc8d3fe --- /dev/null +++ b/src/backend/tests/unit/reimbursement-requests.test.ts @@ -0,0 +1,232 @@ +import { alfred } from '../test-data/users.test-data'; +import ReimbursementRequestService from '../../src/services/reimbursement-requests.services'; +import { AccessDeniedException, HttpException } from '../../src/utils/errors.utils'; +import { createTestReimbursementRequest, createTestUser, resetUsers } from '../test-utils'; +import prisma from '../../src/prisma/prisma'; +import { assert } from 'console'; +import { addDaysToDate, ClubAccount, ReimbursementRequest } from 'shared'; +import { Account_Code, Organization, Vendor } from '@prisma/client'; +import { UserWithSecureSettings } from '../../src/utils/auth.utils'; + +describe('Reimbursement Requests', () => { + let org: Organization; + let reimbursementRequest: ReimbursementRequest; + let createdVendor: Vendor; + let createdAccountCode: Account_Code; + let createdUser: UserWithSecureSettings; + + beforeEach(async () => { + const result = await createTestReimbursementRequest(); + org = result.organization; + reimbursementRequest = result.rr; + createdVendor = result.vendor; + createdAccountCode = result.accountCode; + createdUser = result.user; + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Deleting a reimbursement request', () => { + test('Delete Reimbursement Request fails when deleter is not a finance lead', async () => { + await expect(async () => + ReimbursementRequestService.deleteReimbursementRequest( + reimbursementRequest.reimbursementRequestId, + await createTestUser(alfred, org.organizationId), + org + ) + ).rejects.toThrow( + new AccessDeniedException( + 'You do not have access to delete this reimbursement request, reimbursement requests can only be deleted by their creator or finance leads and above' + ) + ); + }); + + test('Delete Reimbursement Request succeeds when the deleter is a finance lead', async () => { + const financeLead = await prisma.user.findUnique({ + where: { + googleAuthId: 'financeLead' + } + }); + + if (!financeLead) { + console.log('No finance lead found, please run createFinanceTeamAndLead before this function'); + assert(false); + throw new Error('No finance lead found, please run createFinanceTeamAndLead before this function'); + } + await ReimbursementRequestService.deleteReimbursementRequest( + reimbursementRequest.reimbursementRequestId, + financeLead, + org + ); + }); + + test('Delete Reimbursement Request succeeds when the deleter is a head of finance', async () => { + const financeHead = await prisma.user.findUnique({ + where: { + googleAuthId: 'financeHead' + } + }); + + if (!financeHead) { + console.log('No finance head found, please run createFinanceTeamAndLead before this function'); + assert(false); + throw new Error('No finance head found, please run createFinanceTeamAndLead before this function'); + } + await ReimbursementRequestService.deleteReimbursementRequest( + reimbursementRequest.reimbursementRequestId, + financeHead, + org + ); + }); + }); + + describe('Creating a reimbursement request', () => { + test('Creating a Reimbursement Request Succeeds without a date', async () => { + const rr = await ReimbursementRequestService.createReimbursementRequest( + createdUser, + createdVendor.vendorId, + ClubAccount.CASH, + [], + [ + { + name: 'GLUE', + reason: { + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0 + }, + cost: 200000 + } + ], + createdAccountCode.accountCodeId, + 100, + org + ); + + expect(rr.accountCode).toStrictEqual({ ...createdAccountCode, dateDeleted: undefined }); + expect(rr.account).toEqual(ClubAccount.CASH); + expect(rr.vendor.vendorId).toEqual(createdVendor.vendorId); + expect(rr.recipient.userId).toEqual(createdUser.userId); + expect(rr.dateOfExpense).toEqual(undefined); + expect(rr.reimbursementProducts).toHaveLength(1); + expect(rr.reimbursementProducts[0].name).toEqual('GLUE'); + expect(rr.reimbursementProducts[0].cost).toEqual(200000); + expect((rr.reimbursementProducts[0].reimbursementProductReason as any).wbsNum).toEqual({ + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0 + }); + expect(rr.totalCost).toEqual(100); + expect(rr.reimbursementStatuses).toHaveLength(1); + expect(rr.reimbursementStatuses[0].type).toEqual('PENDING_LEADERSHIP_APPROVAL'); + expect(rr.identifier).toEqual(2); + }); + + test('Creating a Reimbursement Request Succeeds with a date', async () => { + const rr = await ReimbursementRequestService.createReimbursementRequest( + createdUser, + createdVendor.vendorId, + ClubAccount.CASH, + [], + [ + { + name: 'GLUE', + reason: { + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0 + }, + cost: 200000 + } + ], + createdAccountCode.accountCodeId, + 100, + org, + new Date('12-29-2023') + ); + + expect(rr.accountCode).toStrictEqual({ ...createdAccountCode, dateDeleted: undefined }); + expect(rr.account).toEqual(ClubAccount.CASH); + expect(rr.vendor.vendorId).toEqual(createdVendor.vendorId); + expect(rr.recipient.userId).toEqual(createdUser.userId); + expect(rr.dateOfExpense).toEqual(new Date('12-29-2023')); + expect(rr.reimbursementProducts).toHaveLength(1); + expect(rr.reimbursementProducts[0].name).toEqual('GLUE'); + expect(rr.reimbursementProducts[0].cost).toEqual(200000); + expect((rr.reimbursementProducts[0].reimbursementProductReason as any).wbsNum).toEqual({ + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0 + }); + expect(rr.totalCost).toEqual(100); + expect(rr.reimbursementStatuses).toHaveLength(1); + expect(rr.reimbursementStatuses[0].type).toEqual('PENDING_LEADERSHIP_APPROVAL'); + expect(rr.identifier).toEqual(2); + }); + }); + + describe('Marking a reimbursement request as delivered', () => { + test('cannot mark as delivered if delivery is before expense date', async () => { + // to get around the type checker + const rrExpenseDate: Date = reimbursementRequest.dateOfExpense ?? new Date('2022-11-22T00:00:01'); + + await expect(async () => + ReimbursementRequestService.markReimbursementRequestAsDelivered( + createdUser, + reimbursementRequest.reimbursementRequestId, + org, + addDaysToDate(rrExpenseDate, -1) + ) + ).rejects.toThrow(new HttpException(400, 'Items cannot be delivered before the expense date.')); + }); + + test('cannot mark as delivered if delivery is after today', async () => { + await expect(async () => + ReimbursementRequestService.markReimbursementRequestAsDelivered( + createdUser, + reimbursementRequest.reimbursementRequestId, + org, + addDaysToDate(new Date(), 1) + ) + ).rejects.toThrow(new HttpException(400, 'Delivery date cannot be in the future.')); + }); + + test('adds delivered date to reimbursement request', async () => { + // we don't want to just check today - set date of expense to some time in the past + const oldReimbursementRequest = await ReimbursementRequestService.createReimbursementRequest( + createdUser, + reimbursementRequest.vendor.vendorId, + reimbursementRequest.account, + [], + [ + { + name: 'GLUE', + reason: { + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0 + }, + cost: 200000 + } + ], + reimbursementRequest.accountCode.accountCodeId, + reimbursementRequest.totalCost, + org, + new Date('2022-11-22T00:00:01') + ); + + const dateToSetAsDelivered = addDaysToDate(new Date(), -5); + + const updatedRR = await ReimbursementRequestService.markReimbursementRequestAsDelivered( + createdUser, + oldReimbursementRequest.reimbursementRequestId, + org, + dateToSetAsDelivered + ); + + expect(updatedRR.dateDelivered).toEqual(dateToSetAsDelivered); + }); + }); +}); diff --git a/src/backend/tests/unit/team-type.test.ts b/src/backend/tests/unit/team-type.test.ts new file mode 100644 index 0000000000..82318ad00b --- /dev/null +++ b/src/backend/tests/unit/team-type.test.ts @@ -0,0 +1,117 @@ +import { Organization } from '@prisma/client'; +import TeamsService from '../../src/services/teams.services'; +import { AccessDeniedAdminOnlyException, HttpException, NotFoundException } from '../../src/utils/errors.utils'; +import { batmanAppAdmin, supermanAdmin, wonderwomanGuest } from '../test-data/users.test-data'; +import { createTestOrganization, createTestUser, resetUsers } from '../test-utils'; + +describe('Team Type Tests', () => { + let orgId: string; + let organization: Organization; + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Create Team Type', () => { + it('Create team type fails if user is not an admin', async () => { + await expect( + async () => + await TeamsService.createTeamType( + await createTestUser(wonderwomanGuest, orgId), + 'Team 2', + 'Warning icon', + 'team2 Description', + organization + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a team type')); + }); + + it('Create team type fails if there is already another team type with the same name', async () => { + await TeamsService.createTeamType( + await createTestUser(supermanAdmin, orgId), + 'teamType1', + 'YouTubeIcon', + 'teamType1 Description', + organization + ); + await expect( + async () => + await TeamsService.createTeamType( + await createTestUser(batmanAppAdmin, orgId), + 'teamType1', + 'Warning icon', + 'teamType1 Description', + organization + ) + ).rejects.toThrow(new HttpException(400, 'Cannot create a teamType with a name that already exists')); + }); + + it('Create team type works', async () => { + const result = await TeamsService.createTeamType( + await createTestUser(supermanAdmin, orgId), + 'teamType3', + 'YouTubeIcon', + 'teamType3 Description', + organization + ); + + expect(result).toEqual({ + name: 'teamType3', + iconName: 'YouTubeIcon', + organizationId: orgId, + teamTypeId: result.teamTypeId, + calendarId: null, + dateDeleted: undefined, + deletedById: undefined, + description: 'teamType3 Description', + imageFileId: null + }); + }); + }); + + describe('Get all team types works', () => { + it('Get all team types works', async () => { + const teamType1 = await TeamsService.createTeamType( + await createTestUser(supermanAdmin, orgId), + 'teamType1', + 'YouTubeIcon', + 'teamType1 Description', + organization + ); + const teamType2 = await TeamsService.createTeamType( + await createTestUser(batmanAppAdmin, orgId), + 'teamType2', + 'WarningIcon', + 'teamType1 Description', + organization + ); + const result = await TeamsService.getAllTeamTypes(organization); + expect(result).toStrictEqual([teamType1, teamType2]); + }); + }); + + describe('Get a single team type', () => { + it('Get a single team type works', async () => { + const teamType1 = await TeamsService.createTeamType( + await createTestUser(supermanAdmin, orgId), + 'teamType1', + 'YouTubeIcon', + 'teamType1 Description', + organization + ); + const result = await TeamsService.getSingleTeamType(teamType1.teamTypeId, organization); + expect(result).toStrictEqual(teamType1); + }); + + it('Get a single team type fails', async () => { + const nonExistingTeamTypeId = 'nonExistingId'; + await expect(async () => TeamsService.getSingleTeamType(nonExistingTeamTypeId, organization)).rejects.toThrow( + new NotFoundException('Team Type', nonExistingTeamTypeId) + ); + }); + }); +}); diff --git a/src/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts new file mode 100644 index 0000000000..bb5de4d78a --- /dev/null +++ b/src/backend/tests/unit/users.test.ts @@ -0,0 +1,51 @@ +import { Organization } from '@prisma/client'; +import { createTestOrganization, createTestTaskWithOrganization, createTestUser, resetUsers } from '../test-utils'; +import { batmanAppAdmin } from '../test-data/users.test-data'; +import UsersService from '../../src/services/users.services'; +import { NotFoundException } from '../../src/utils/errors.utils'; + +describe('User Tests', () => { + let orgId: string; + let organization: Organization; + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Get Users Tasks', () => { + it('fails on invalid user id', async () => { + await expect(async () => await UsersService.getUserTasks('1', organization)).rejects.toThrow( + new NotFoundException('User', '1') + ); + }); + + it("Succeeds and gets user's assigned tasks", async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + + const { task } = await createTestTaskWithOrganization(testBatman, organization); + const userTasks = await UsersService.getUserTasks(testBatman.userId, organization); + + expect(userTasks).toStrictEqual([task]); + }); + }); + + describe('Get Many Users Tasks', () => { + it('fails on invalid user id', async () => { + await expect(async () => await UsersService.getManyUserTasks(['1'], organization)).rejects.toThrow( + new NotFoundException('User', '1') + ); + }); + + it("Succeeds and gets all user' tasks in the list", async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + const { task: batmanTask } = await createTestTaskWithOrganization(testBatman, organization); + const userTasks = await UsersService.getManyUserTasks([testBatman.userId, testBatman.userId], organization); + + expect(userTasks).toStrictEqual([batmanTask, batmanTask]); + }); + }); +}); diff --git a/src/backend/tests/unit/work-package-template.test.ts b/src/backend/tests/unit/work-package-template.test.ts new file mode 100644 index 0000000000..cb842a6165 --- /dev/null +++ b/src/backend/tests/unit/work-package-template.test.ts @@ -0,0 +1,167 @@ +import { + AccessDeniedGuestException, + AccessDeniedAdminOnlyException, + DeletedException, + HttpException +} from '../../src/utils/errors.utils'; +import { createTestOrganization, createTestUser, createTestWorkPackageTemplate, resetUsers } from '../test-utils'; +import { batmanAppAdmin, supermanAdmin, theVisitorGuest } from '../test-data/users.test-data'; +import { workPackageTemplateTransformer } from '../../src/transformers/work-package-template.transformer'; +import prisma from '../../src/prisma/prisma'; +import WorkPackageTemplatesService from '../../src/services/work-package-template.services'; +import { Organization } from '@prisma/client'; + +describe('Work Package Template Tests', () => { + let orgId: string; + let organization: Organization; + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Get single work package template', () => { + it('fails if user is a guest', async () => { + await expect( + async () => + await WorkPackageTemplatesService.getSingleWorkPackageTemplate( + await createTestUser(theVisitorGuest, orgId), + 'id', + organization + ) + ).rejects.toThrow(new AccessDeniedGuestException('get a work package template')); + }); + + it('fails is the work package template ID is not found', async () => { + await expect( + async () => + await WorkPackageTemplatesService.getSingleWorkPackageTemplate( + await createTestUser(batmanAppAdmin, orgId), + 'id1', + organization + ) + ).rejects.toThrow(new HttpException(400, `Work package template with id id1 not found`)); + }); + + it('get single work package template succeeds', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + const createdWorkPackageTemplate = await createTestWorkPackageTemplate(testBatman, orgId); + + const recievedWorkPackageTemplate = await WorkPackageTemplatesService.getSingleWorkPackageTemplate( + await createTestUser(supermanAdmin, orgId), + createdWorkPackageTemplate.workPackageTemplateId, + organization + ); + + expect(recievedWorkPackageTemplate).toStrictEqual(workPackageTemplateTransformer(createdWorkPackageTemplate)); + }); + }); + + describe('Delete single work package template', () => { + it('fails if user is a guest', async () => { + await expect( + async () => + await WorkPackageTemplatesService.deleteWorkPackageTemplate( + await createTestUser(theVisitorGuest, orgId), + 'id', + organization + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('delete work package template')); + }); + + it('fails is the work package template ID is not found', async () => { + await expect( + async () => + await WorkPackageTemplatesService.deleteWorkPackageTemplate( + await createTestUser(supermanAdmin, orgId), + 'id1', + organization + ) + ).rejects.toThrow(new HttpException(400, `Work Package Template with id: id1 not found!`)); + }); + + it('fails is the work package template has already been deleted', async () => { + const testSuperman = await createTestUser(supermanAdmin, orgId); + const testWorkPackageTemplate = await createTestWorkPackageTemplate(testSuperman, orgId); + await WorkPackageTemplatesService.deleteWorkPackageTemplate( + testSuperman, + testWorkPackageTemplate.workPackageTemplateId, + organization + ); + + await expect( + async () => + await WorkPackageTemplatesService.deleteWorkPackageTemplate( + testSuperman, + testWorkPackageTemplate.workPackageTemplateId, + organization + ) + ).rejects.toThrow(new DeletedException('Work Package Template', testWorkPackageTemplate.workPackageTemplateId)); + }); + + it('succeeds and deletes all blocking templates', async () => { + const testSuperman = await createTestUser(supermanAdmin, orgId); + const [testWorkPackageTemplate1, testWorkPackageTemplate2, testWorkPackageTemplate3] = await Promise.all([ + createTestWorkPackageTemplate(testSuperman, orgId), + createTestWorkPackageTemplate(testSuperman, orgId), + createTestWorkPackageTemplate(testSuperman, orgId) + ]); + + await prisma.work_Package_Template.update({ + where: { + workPackageTemplateId: testWorkPackageTemplate3.workPackageTemplateId + }, + data: { + blockedBy: { + connect: { + workPackageTemplateId: testWorkPackageTemplate2.workPackageTemplateId + } + } + } + }); + + await prisma.work_Package_Template.update({ + where: { + workPackageTemplateId: testWorkPackageTemplate2.workPackageTemplateId + }, + data: { + blockedBy: { + connect: { + workPackageTemplateId: testWorkPackageTemplate1.workPackageTemplateId + } + } + } + }); + + await WorkPackageTemplatesService.deleteWorkPackageTemplate( + testSuperman, + testWorkPackageTemplate1.workPackageTemplateId, + organization + ); + + const updatedTestWorkPackageTemplate1 = await WorkPackageTemplatesService.getSingleWorkPackageTemplate( + testSuperman, + testWorkPackageTemplate1.workPackageTemplateId, + organization + ); + + const updatedTestWorkPackageTemplate2 = await WorkPackageTemplatesService.getSingleWorkPackageTemplate( + testSuperman, + testWorkPackageTemplate2.workPackageTemplateId, + organization + ); + const updatedTestWorkPackageTemplate3 = await WorkPackageTemplatesService.getSingleWorkPackageTemplate( + testSuperman, + testWorkPackageTemplate3.workPackageTemplateId, + organization + ); + + expect(updatedTestWorkPackageTemplate1.dateDeleted).not.toBe(null); + expect(updatedTestWorkPackageTemplate2.dateDeleted).not.toBe(null); + expect(updatedTestWorkPackageTemplate3.dateDeleted).not.toBe(null); + }); + }); +}); diff --git a/src/backend/tests/unmocked/announcements.test.ts b/src/backend/tests/unmocked/announcements.test.ts new file mode 100644 index 0000000000..3981d1feb9 --- /dev/null +++ b/src/backend/tests/unmocked/announcements.test.ts @@ -0,0 +1,91 @@ +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'); + }); + }); + + describe('Remove Announcement', () => { + it('Succeeds and removes user announcement', 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'); + + const updatedAnnouncements = await AnnouncementService.removeUserAnnouncement( + testBatman.userId, + announcements[0].announcementId, + organization.organizationId + ); + + expect(updatedAnnouncements).toHaveLength(1); + expect(updatedAnnouncements[0].text).toBe('test2'); + }); + }); +}); diff --git a/src/frontend/src/apis/announcements.api.ts b/src/frontend/src/apis/announcements.api.ts new file mode 100644 index 0000000000..6bac7d1c8e --- /dev/null +++ b/src/frontend/src/apis/announcements.api.ts @@ -0,0 +1,26 @@ +import { Announcement } from 'shared'; +import axios from '../utils/axios'; +import { apiUrls } from '../utils/urls'; +import { announcementsTransformer } from './transformers/announcements.transformer'; + +/* + * Gets all unread announcement of the user with the given id + */ +export const getAnnouncements = () => { + return axios.get(apiUrls.announcementsCurrentUser(), { + transformResponse: (data) => JSON.parse(data).map(announcementsTransformer) + }); +}; + +/* + * Removes a notification from the user with the given id + */ +export const removeAnnouncement = (announcementId: string) => { + return axios.post( + apiUrls.announcementsRemove(announcementId), + {}, + { + transformResponse: (data) => JSON.parse(data).map(announcementsTransformer) + } + ); +}; diff --git a/src/frontend/src/apis/onboarding.api.ts b/src/frontend/src/apis/onboarding.api.ts new file mode 100644 index 0000000000..7912e80516 --- /dev/null +++ b/src/frontend/src/apis/onboarding.api.ts @@ -0,0 +1,16 @@ +import axios from 'axios'; +import { apiUrls } from '../utils/urls'; + +/** + * API Call to download a google image + * @param fileId file id to be downloaded + * @returns an image blob + */ +export const downloadGoogleImage = async (fileId: string): Promise => { + const response = await axios.get(apiUrls.imageById(fileId), { + responseType: 'arraybuffer' // Set the response type to 'arraybuffer' to receive the image as a Buffer + }); + const imageBuffer = new Uint8Array(response.data); + const imageBlob = new Blob([imageBuffer], { type: response.headers['content-type'] }); + return imageBlob; +}; diff --git a/src/frontend/src/apis/organizations.api.ts b/src/frontend/src/apis/organizations.api.ts index 3eb4fb3756..6821c74543 100644 --- a/src/frontend/src/apis/organizations.api.ts +++ b/src/frontend/src/apis/organizations.api.ts @@ -1,5 +1,5 @@ import axios from '../utils/axios'; -import { Organization } from 'shared'; +import { Organization, Project } from 'shared'; import { apiUrls } from '../utils/urls'; /** @@ -11,3 +11,54 @@ export const getCurrentOrganization = async () => { transformResponse: (data) => JSON.parse(data) }); }; + +export const getFeaturedProjects = async () => { + return axios.get(apiUrls.organizationsFeaturedProjects(), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +export const setOrganizationDescription = async (description: string) => { + return axios.post(apiUrls.organizationsSetDescription(), { + description + }); +}; + +export const getOrganizationLogo = async () => { + return axios.get(apiUrls.organizationsLogoImage(), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +export const setOrganizationLogo = async (file: File) => { + const formData = new FormData(); + formData.append('logo', file); + return axios.post(apiUrls.organizationsSetLogoImage(), formData); +}; + +export const setOrganizationFeaturedProjects = async (featuredProjectIds: string[]) => { + return axios.post(apiUrls.organizationsSetFeaturedProjects(), { + projectIds: featuredProjectIds + }); +}; + +export const setOrganizationWorkspaceId = async (workspaceId: string) => { + return axios.post(apiUrls.organizationsSetWorkspaceId(), { + workspaceId + }); +}; + +/** + * Downloads a given fileId from google drive into a blob + * + * @param fileId the google id of the file to download + * @returns the downloaded file as a Blob + */ +export const downloadGoogleImage = async (fileId: string): Promise => { + const response = await axios.get(apiUrls.imageById(fileId), { + responseType: 'arraybuffer' // Set the response type to 'arraybuffer' to receive the image as a Buffer + }); + const imageBuffer = new Uint8Array(response.data); + const imageBlob = new Blob([imageBuffer], { type: response.headers['content-type'] }); + return imageBlob; +}; 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/transformers/announcements.transformer.ts b/src/frontend/src/apis/transformers/announcements.transformer.ts new file mode 100644 index 0000000000..821b8d6043 --- /dev/null +++ b/src/frontend/src/apis/transformers/announcements.transformer.ts @@ -0,0 +1,9 @@ +import { Announcement } from 'shared'; + +export const announcementsTransformer = (announcement: Announcement): Announcement => { + return { + ...announcement, + dateMessageSent: new Date(announcement.dateMessageSent), + dateDeleted: announcement.dateDeleted ? new Date(announcement.dateDeleted) : undefined + }; +}; diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index 44d7a40d70..afa5ea00f6 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -7,6 +7,7 @@ import axios from '../utils/axios'; import { Project, SetUserScheduleSettingsPayload, + Task, User, UserScheduleSettings, UserSecureSettings, @@ -21,6 +22,7 @@ import { } from './transformers/users.transformers'; import { AuthenticatedUser, UserSettings } from 'shared'; import { projectTransformer } from './transformers/projects.transformers'; +import { taskTransformer } from './transformers/tasks.transformers'; /** * Fetches all users. @@ -141,3 +143,19 @@ export const updateUserScheduleSettings = (settings: SetUserScheduleSettingsPayl export const updateUserRole = (id: string, role: string) => { return axios.post<{ message: string }>(apiUrls.userRoleByUserId(id), { role }); }; + +export const getUserTasks = (id: string) => { + return axios.get(apiUrls.userTasks(id), { + transformResponse: (data) => JSON.parse(data).map(taskTransformer) + }); +}; + +export const getManyUserTasks = (userIds: string[]) => { + return axios.post( + apiUrls.manyUserTasks(), + { userIds }, + { + transformResponse: (data) => JSON.parse(data).map(taskTransformer) + } + ); +}; diff --git a/src/frontend/src/components/ChangeRequestDetailCard.tsx b/src/frontend/src/components/ChangeRequestDetailCard.tsx index 852f6d0007..c94beaffdc 100644 --- a/src/frontend/src/components/ChangeRequestDetailCard.tsx +++ b/src/frontend/src/components/ChangeRequestDetailCard.tsx @@ -68,8 +68,19 @@ interface ChangeRequestDetailCardProps { } const ChangeRequestDetailCard: React.FC = ({ changeRequest }) => { + const theme = useTheme(); return ( - + diff --git a/src/frontend/src/components/PageLayout.tsx b/src/frontend/src/components/PageLayout.tsx index df9940a606..da5fe0a202 100644 --- a/src/frontend/src/components/PageLayout.tsx +++ b/src/frontend/src/components/PageLayout.tsx @@ -21,6 +21,8 @@ interface PageLayoutProps { stickyHeader?: boolean; } +export const PAGE_GRID_HEIGHT = 85; + const PageLayout: React.FC = ({ children, title, 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/PopUpCard.tsx b/src/frontend/src/components/PopUpCard.tsx new file mode 100644 index 0000000000..e905ca2ad6 --- /dev/null +++ b/src/frontend/src/components/PopUpCard.tsx @@ -0,0 +1,74 @@ +import { Box, Card, Icon, IconButton, Typography, useTheme } from '@mui/material'; +import React from 'react'; +import { PopUp } from 'shared'; +import CloseIcon from '@mui/icons-material/Close'; + +interface PopUpCardProps { + popUp: PopUp; + removePopUp: (popUp: PopUp) => Promise; + onClick: (popUp: PopUp) => Promise; +} + +const PopUpCard: React.FC = ({ popUp, removePopUp, onClick }) => { + const theme = useTheme(); + return ( + + + await onClick(popUp)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + cursor: !!popUp.eventLink ? 'pointer' : 'default' + }} + > + + + {popUp.iconName} + + + {popUp.text} + + removePopUp(popUp)}> + + + + + ); +}; + +export default PopUpCard; diff --git a/src/frontend/src/hooks/announcements.hooks.ts b/src/frontend/src/hooks/announcements.hooks.ts new file mode 100644 index 0000000000..50cceea661 --- /dev/null +++ b/src/frontend/src/hooks/announcements.hooks.ts @@ -0,0 +1,36 @@ +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { Announcement } from 'shared'; +import { getAnnouncements, removeAnnouncement } from '../apis/announcements.api'; + +/** + * Curstom react hook to get all unread announcements from a user + * @param userId id of user to get unread announcements from + * @returns + */ +export const useUserAnnouncements = () => { + return useQuery(['announcements', 'current-user'], async () => { + const { data } = await getAnnouncements(); + return data; + }); +}; + +/** + * Curstom react hook to remove a announcement from a user's unread announcements + * @param userId id of user to get unread announcements from + * @returns + */ +export const useRemoveUserAnnouncement = () => { + const queryClient = useQueryClient(); + return useMutation( + ['announcements', 'current-user', 'remove'], + async (announcement: Announcement) => { + const { data } = await removeAnnouncement(announcement.announcementId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['announcements', 'current-user']); + } + } + ); +}; diff --git a/src/frontend/src/hooks/organizations.hooks.ts b/src/frontend/src/hooks/organizations.hooks.ts index 48e0c8abc3..c317f040b8 100644 --- a/src/frontend/src/hooks/organizations.hooks.ts +++ b/src/frontend/src/hooks/organizations.hooks.ts @@ -1,8 +1,17 @@ import { useContext, useState } from 'react'; import { OrganizationContext } from '../app/AppOrganizationContext'; -import { useQuery } from 'react-query'; -import { Organization } from 'shared'; -import { getCurrentOrganization } from '../apis/organizations.api'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { Organization, Project } from 'shared'; +import { + getFeaturedProjects, + getCurrentOrganization, + setOrganizationDescription, + setOrganizationFeaturedProjects, + setOrganizationWorkspaceId, + setOrganizationLogo, + getOrganizationLogo +} from '../apis/organizations.api'; +import { downloadGoogleImage } from '../apis/organizations.api'; interface OrganizationProvider { organizationId: string; @@ -30,9 +39,87 @@ export const useCurrentOrganization = () => { }); }; +export const useFeaturedProjects = () => { + return useQuery(['organizations', 'featured-projects'], async () => { + const { data } = await getFeaturedProjects(); + return data; + }); +}; + // Hook for child components to get the auth object export const useOrganization = () => { const context = useContext(OrganizationContext); if (context === undefined) throw Error('Organization must be used inside of an organizational context.'); return context; }; + +/** + * Custom React Hook to set the description of an organization + * @returns the updated organization + */ +export const useSetOrganizationDescription = () => { + const queryClient = useQueryClient(); + return useMutation( + ['organizations', 'description'], + async (description: string) => { + const { data } = await setOrganizationDescription(description); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['organizations']); + } + } + ); +}; + +export const useSetFeaturedProjects = () => { + const queryClient = useQueryClient(); + return useMutation( + ['organizations', 'featured-projects'], + async (featuredProjects: Project[]) => { + const { data } = await setOrganizationFeaturedProjects(featuredProjects.map((project) => project.id)); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['organizations']); + } + } + ); +}; + +export const useSetWorkspaceId = () => { + const queryClient = useQueryClient(); + return useMutation( + ['organizations', 'featured-projects'], + async (workspaceId: string) => { + const { data } = await setOrganizationWorkspaceId(workspaceId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['organizations']); + } + } + ); +}; + +export const useSetOrganizationLogo = () => { + const queryClient = useQueryClient(); + return useMutation(['reimbursement-requsts', 'edit'], async (file: File) => { + const { data } = await setOrganizationLogo(file); + queryClient.invalidateQueries(['organizations']); + return data; + }); +}; + +export const useOrganizationLogo = () => { + return useQuery(['organizations', 'logo'], async () => { + const { data: fileId } = await getOrganizationLogo(); + if (!fileId) { + return; + } + return await downloadGoogleImage(fileId); + }); +}; 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 b0c76b09b9..96b659c1f1 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -17,7 +17,9 @@ import { getCurrentUserSecureSettings, getUserSecureSettings, getUserScheduleSettings, - updateUserScheduleSettings + updateUserScheduleSettings, + getUserTasks, + getManyUserTasks } from '../apis/users.api'; import { User, @@ -28,7 +30,8 @@ import { UserSecureSettings, UserScheduleSettings, UserWithScheduleSettings, - SetUserScheduleSettingsPayload + SetUserScheduleSettingsPayload, + Task } from 'shared'; import { useAuth } from './auth.hooks'; import { useContext } from 'react'; @@ -233,3 +236,27 @@ export const useUpdateUserRole = () => { } ); }; + +/** + * Custom React Hook to get the user's assigned tasks + * @param userId user to get assigned tasks of + * @returns user's assigned task + */ +export const useUserTasks = (userId: string) => { + return useQuery(['users', userId, 'tasks'], async () => { + const { data } = await getUserTasks(userId); + return data; + }); +}; + +/** + * Custom react hook to get the assigned tasks of all users in the list + * @param userIds ids of users to get assigned tasks from + * @returns tasks assigned to all users in list + */ +export const useManyUserTasks = (userIds: string[]) => { + return useQuery(['users', userIds, 'tasks'], async () => { + const { data } = await getManyUserTasks(userIds); + return data; + }); +}; diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx index adb4582193..10d71056ab 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx @@ -18,6 +18,8 @@ import NERTabs from '../../components/Tabs'; import { routes } from '../../utils/routes'; import { Box } from '@mui/system'; import AdminToolsRecruitmentConfig from './RecruitmentConfig/AdminToolsRecruitmentConfig'; +import GuestViewConfig from './EditGuestView/GuestViewConfig'; +import AdminToolsWorkspaceId from './AdminToolsSlackWorkspaceId'; const AdminToolsPage: React.FC = () => { const currentUser = useCurrentUser(); @@ -40,6 +42,7 @@ const AdminToolsPage: React.FC = () => { } if (isUserAdmin) { tabs.push({ tabUrlValue: 'recruitment', tabName: 'Recruitment' }); + tabs.push({ tabUrlValue: 'guest-view', tabName: 'Guest View' }); tabs.push({ tabUrlValue: 'miscellaneous', tabName: 'Miscellaneous' }); } @@ -81,11 +84,14 @@ const AdminToolsPage: React.FC = () => { ) : tabIndex === 3 ? ( + ) : tabIndex === 4 ? ( + ) : ( + )} diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackWorkspaceId.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackWorkspaceId.tsx new file mode 100644 index 0000000000..57404352d9 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackWorkspaceId.tsx @@ -0,0 +1,72 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { NERButton } from '../../components/NERButton'; +import { Box, Link, TextField, Typography } from '@mui/material'; +import { useState } from 'react'; +import { useToast } from '../../hooks/toasts.hooks'; +import { useCurrentOrganization, useSetWorkspaceId } from '../../hooks/organizations.hooks'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import { Organization } from 'shared'; +import HelpIcon from '@mui/icons-material/Help'; + +interface AdminToolsWorkspaceIdViewProps { + organization: Organization; +} + +const AdminToolsWorkspaceId: React.FC = () => { + const { data: organization, isLoading, isError, error } = useCurrentOrganization(); + if (!organization || isLoading) return ; + if (isError) return ; + + return ; +}; + +const AdminToolsWorkspaceIdView: React.FC = ({ organization }) => { + const toast = useToast(); + const { mutateAsync, isLoading } = useSetWorkspaceId(); + const [workspaceId, setWorkspaceId] = useState(organization.slackWorkspaceId ?? ''); + + if (isLoading) return ; + + const slackWorkspaceId = async () => { + try { + await mutateAsync(workspaceId); + toast.success('Successfully updated the slack workspace id'); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + return ( + + + {organization.name} Slack Workspace Id + + + + + + setWorkspaceId(e.target.value)} /> + + + Update + + + + + ); +}; + +export default AdminToolsWorkspaceId; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditDescription.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditDescription.tsx new file mode 100644 index 0000000000..e00599f669 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditDescription.tsx @@ -0,0 +1,83 @@ +import { Box, Card, TextField, Typography, useTheme } from '@mui/material'; +import React, { useState } from 'react'; +import { useCurrentOrganization, useSetOrganizationDescription } from '../../../hooks/organizations.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { NERButton } from '../../../components/NERButton'; +import EditDescriptionForm, { EditDescriptionInput } from './EditDescriptionForm'; +import { useToast } from '../../../hooks/toasts.hooks'; + +const EditDescription: React.FC = () => { + const [isEditMode, setIsEditMode] = useState(false); + const theme = useTheme(); + const { data: organization, isLoading, isError, error } = useCurrentOrganization(); + const { mutateAsync: setOrganizationDescription, isLoading: mutateIsLoading } = useSetOrganizationDescription(); + const toast = useToast(); + + const handleClose = () => { + setIsEditMode(false); + }; + + const onSubmit = async (formInput: EditDescriptionInput) => { + try { + await setOrganizationDescription(formInput.description); + toast.success('Description updated successfully!'); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + handleClose(); + }; + + if (isLoading || mutateIsLoading || !organization) return ; + if (isError) return ; + + return ( + + + {organization.name} Description + + {isEditMode ? ( + + ) : ( + + + + setIsEditMode(true)}> + Update + + + + )} + + ); +}; + +export default EditDescription; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditDescriptionForm.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditDescriptionForm.tsx new file mode 100644 index 0000000000..048a24180d --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditDescriptionForm.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import * as yup from 'yup'; +import { Controller, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Box, FormControl, TextField } from '@mui/material'; +import { countWords, isUnderWordCount, Organization } from 'shared'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; + +const schema = yup.object().shape({ + description: yup.string() +}); + +export interface EditDescriptionInput { + description: string; +} + +interface EditDescriptionFormProps { + organization: Organization; + onSubmit: (formInput: EditDescriptionInput) => Promise; + onHide: () => void; + isEditMode: boolean; +} + +const EditDescriptionForm: React.FC = ({ organization, onSubmit, onHide, isEditMode }) => { + const { handleSubmit, control, reset } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + description: organization.description ?? '' + } + }); + + const onSubmitWrapper = async (data: EditDescriptionInput) => { + await onSubmit(data); + reset(); + }; + + const onHideWrapper = () => { + onHide(); + reset(); + }; + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + handleSubmit(onSubmit)(e); + reset(); + }} + onKeyPress={(e) => { + e.key === 'Enter' && e.preventDefault(); + }} + > + + ( + + )} + /> + + {isEditMode && ( + + + Cancel + + + Save + + + )} +
+ ); +}; + +export default EditDescriptionForm; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditFeaturedProjects.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditFeaturedProjects.tsx new file mode 100644 index 0000000000..2a798dd67f --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditFeaturedProjects.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import EditFeaturedProjectsForm, { EditFeaturedProjectsFormInput } from './EditFeaturedProjectsForm'; +import { useFeaturedProjects, useSetFeaturedProjects } from '../../../hooks/organizations.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { Box, Card, Chip, Typography, useTheme } from '@mui/material'; +import { NERButton } from '../../../components/NERButton'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { projectWbsNamePipe } from '../../../utils/pipes'; + +const EditFeaturedProjects = () => { + const { data: featuredProjects, isLoading, isError, error } = useFeaturedProjects(); + const { mutateAsync: setFeaturedProjects, isLoading: setFeaturedProjectsIsLoading } = useSetFeaturedProjects(); + const [isEditMode, setIsEditMode] = useState(false); + const theme = useTheme(); + const toast = useToast(); + + if (isLoading || !featuredProjects || setFeaturedProjectsIsLoading || !setFeaturedProjects) return ; + if (isError) return ; + + const handleClose = () => { + setIsEditMode(false); + }; + + const onSubmit = async (formInput: EditFeaturedProjectsFormInput) => { + try { + await setFeaturedProjects(formInput.featuredProjects); + toast.success('Featured Projects updated successfully!'); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + handleClose(); + }; + + return ( + + + Featured Projects + + {isEditMode ? ( + + ) : ( + + + {featuredProjects.map((project) => ( + + ))} + + + setIsEditMode(true)}> + Update + + + + )} + + ); +}; + +export default EditFeaturedProjects; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditFeaturedProjectsDropdown.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditFeaturedProjectsDropdown.tsx new file mode 100644 index 0000000000..254e776e29 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditFeaturedProjectsDropdown.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Autocomplete, Chip, TextField } from '@mui/material'; +import { Project } from 'shared'; +import { projectWbsNamePipe } from '../../../utils/pipes'; +import { useAllProjects } from '../../../hooks/projects.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; + +interface EditFeatureProjectsDropdownProps { + onChange: (value: Project[] | null) => void; + value: Project[] | undefined; +} + +const EditFeaturedProjectsDropdown: React.FC = ({ onChange, value }) => { + const { data: allProjects, isLoading, isError, error } = useAllProjects(); + + if (isLoading || !allProjects) return ; + if (isError) return ; + + return ( + `${projectWbsNamePipe(option)}`} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={value} + onChange={(_, newValue) => onChange(newValue)} + renderInput={(params) => } + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } + placeholder={'Add a Project to Feature'} + /> + ); +}; + +export default EditFeaturedProjectsDropdown; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditFeaturedProjectsForm.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditFeaturedProjectsForm.tsx new file mode 100644 index 0000000000..8d89a2da07 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditFeaturedProjectsForm.tsx @@ -0,0 +1,95 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import EditFeaturedProjectsDropdown from './EditFeaturedProjectsDropdown'; +import { Box, FormControl } from '@mui/material'; +import { Project } from 'shared'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; + +const schema = yup.object().shape({ + description: yup.array().of(yup.string()) +}); + +export interface EditFeaturedProjectsFormInput { + featuredProjects: Project[]; +} + +interface EditFeaturedProjectsFormProps { + featuredProjects: Project[]; + onSubmit: (formInput: EditFeaturedProjectsFormInput) => Promise; + onHide: () => void; + isEditMode: boolean; +} + +const EditFeaturedProjectsForm: React.FC = ({ + featuredProjects, + onSubmit, + onHide, + isEditMode +}) => { + const { handleSubmit, control, reset } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + featuredProjects: featuredProjects ?? [] + } + }); + + const onSubmitWrapper = async (data: EditFeaturedProjectsFormInput) => { + await onSubmit(data); + reset(); + }; + + const onHideWrapper = () => { + onHide(); + reset(); + }; + + return ( + + ); +}; + +export default EditFeaturedProjectsForm; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditLogo.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditLogo.tsx new file mode 100644 index 0000000000..d4c10f0e76 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditLogo.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import { useCurrentOrganization, useOrganizationLogo, useSetOrganizationLogo } from '../../../hooks/organizations.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import EditLogoForm, { EditLogoInput } from './EditLogoForm'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { Box, Card, Typography, useTheme } from '@mui/material'; +import LogoDisplay from '../../HomePage/components/LogoDisplay'; +import { NERButton } from '../../../components/NERButton'; +import ErrorPage from '../../ErrorPage'; + +const EditLogo = () => { + const { + data: organization, + isLoading: organizationIsLoading, + isError: organizationIsError, + error: organizationError + } = useCurrentOrganization(); + const { data: imageData, isLoading: imageDataIsLoading, isError: imageIsError, error: imageError } = useOrganizationLogo(); + const { mutateAsync, isLoading } = useSetOrganizationLogo(); + const toast = useToast(); + const [isEditMode, setIsEditMode] = useState(false); + const theme = useTheme(); + + if (isLoading || !mutateAsync || organizationIsLoading || !organization || imageDataIsLoading) return ; + if (organizationIsError) return ; + if (imageIsError) return ; + + const handleClose = () => { + setIsEditMode(false); + }; + + const onSubmit = async (logoInput: EditLogoInput) => { + try { + if (!logoInput.logoImage) { + toast.error('No logo image submitted.'); + handleClose(); + return; + } + await mutateAsync(logoInput.logoImage); + toast.success('Logo updated successfully!'); + } catch (e) { + if (e instanceof Error) { + toast.error(e.message); + } + } + handleClose(); + }; + + return ( + + + {organization.name} Logo + + {isEditMode ? ( + + ) : ( + <> + + + + setIsEditMode(true)}> + Update + + + + + )} + + ); +}; + +export default EditLogo; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditLogoForm.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditLogoForm.tsx new file mode 100644 index 0000000000..72be9d618e --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditLogoForm.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Box, Button, FormControl, Stack, Typography } from '@mui/material'; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +import { Controller, useForm } from 'react-hook-form'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; +import ImageIcon from '@mui/icons-material/Image'; + +export interface EditLogoInput { + logoImage?: File; +} + +interface EditLogoFormProps { + onSubmit: (logoImage: EditLogoInput) => Promise; + onHide: () => void; + orgLogo?: File; +} + +const EditLogoForm: React.FC = ({ onSubmit, orgLogo, onHide }) => { + const { handleSubmit, control, reset } = useForm({ + defaultValues: { + logoImage: orgLogo + } + }); + + const onHideWrapper = () => { + onHide(); + reset(); + }; + + return ( + + ); +}; + +export default EditLogoForm; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx new file mode 100644 index 0000000000..419bb2548c --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx @@ -0,0 +1,22 @@ +import { Stack, Grid } from '@mui/material'; +import EditDescription from './EditDescription'; +import EditFeaturedProjects from './EditFeaturedProjects'; +import EditLogo from './EditLogo'; + +const GuestViewConfig: React.FC = () => { + return ( + + + + + + + + + + + + ); +}; + +export default GuestViewConfig; diff --git a/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsOverview.tsx b/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsOverview.tsx index 09def14418..eedf560754 100644 --- a/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsOverview.tsx +++ b/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsOverview.tsx @@ -6,12 +6,12 @@ import { useAllChangeRequests } from '../../hooks/change-requests.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; -import { isLeadership, isHead, ChangeRequest, Project, WorkPackage, equalsWbsNumber } from 'shared'; +import { isLeadership, isHead } from 'shared'; import { useAllProjects } from '../../hooks/projects.hooks'; import { useAllWorkPackages } from '../../hooks/work-packages.hooks'; import { useCurrentUser } from '../../hooks/users.hooks'; -import { makeTeamList } from '../../utils/teams.utils'; import ChangeRequestRow from '../../components/ChangeRequestRow'; +import { getCRsApproved, getCRsToReview, getCRsUnreviewed } from '../../utils/change-request.utils'; const ChangeRequestsOverview: React.FC = () => { const user = useCurrentUser(); @@ -29,53 +29,9 @@ const ChangeRequestsOverview: React.FC = () => { if (projectIsError) return ; if (wpIsError) return ; - // projects whose change requests the user would have to review - const myProjects = projects.filter((project: Project) => { - const projectMemberIds = project.teams.flatMap((team) => makeTeamList(team)).map((user) => user.userId); - return ( - projectMemberIds.includes(user.userId) || - (project.lead && project.lead.userId === user.userId) || - (project.manager && project.manager.userId === user.userId) - ); - }); - - // work packages whose change requests the user would have to review - const myWorkPackages = workPackages.filter( - (wp: WorkPackage) => - (wp.lead ? wp.lead.userId === user.userId : false) || (wp.manager ? wp.manager.userId === user.userId : false) - ); - - // all of the wbs numbers (in x.x.x string format) corresponding to projects and work packages - // whose change requests the user would have to review - const myWbsNumbers = myProjects - .map((project: Project) => project.wbsNum) - .concat(myWorkPackages.map((wp: WorkPackage) => wp.wbsNum)); - - const currentDate = new Date(); - - const crToReview = changeRequests - .filter( - (cr) => - !cr.dateReviewed && - cr.submitter.userId !== user.userId && - (myWbsNumbers.some((wbsNum) => equalsWbsNumber(wbsNum, cr.wbsNum)) || - cr.requestedReviewers.map((user) => user.userId).includes(user.userId)) - ) - .sort((a, b) => b.dateSubmitted.getTime() - a.dateSubmitted.getTime()); - - const crUnreviewed = changeRequests - .filter((cr: ChangeRequest) => !cr.dateReviewed && cr.submitter.userId === user.userId) - .sort((a, b) => b.dateSubmitted.getTime() - a.dateSubmitted.getTime()); - - const crApproved = changeRequests - .filter( - (cr: ChangeRequest) => - cr.dateReviewed && - cr.accepted && - cr.submitter.userId === user.userId && - currentDate.getTime() - cr.dateReviewed.getTime() <= 1000 * 60 * 60 * 24 * 5 - ) - .sort((a, b) => (a.dateReviewed && b.dateReviewed ? b.dateReviewed.getTime() - a.dateReviewed.getTime() : 0)); + const crToReview = getCRsToReview(projects, workPackages, user, changeRequests); + const crUnreviewed = getCRsUnreviewed(user, changeRequests); + const crApproved = getCRsApproved(user, changeRequests); return ( <> diff --git a/src/frontend/src/pages/HomePage/AdminHomePage.tsx b/src/frontend/src/pages/HomePage/AdminHomePage.tsx new file mode 100644 index 0000000000..79c1a7678c --- /dev/null +++ b/src/frontend/src/pages/HomePage/AdminHomePage.tsx @@ -0,0 +1,60 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Typography, Grid, Box } from '@mui/material'; +import { useSingleUserSettings } from '../../hooks/users.hooks'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import PageLayout, { PAGE_GRID_HEIGHT } from '../../components/PageLayout'; +import { AuthenticatedUser } from 'shared'; +import WorkPackagesSelectionView from './components/WorkPackagesSelectionView'; +import ChangeRequestsToReview from './components/ChangeRequestsToReview'; +import GeneralAnnouncements from './components/GeneralAnnouncements'; +import UpcomingDesignReviews from './components/UpcomingDesignReviews'; + +interface AdminHomePageProps { + user: AuthenticatedUser; +} + +const AdminHomePage = ({ user }: AdminHomePageProps) => { + const { isLoading, isError, error, data: userSettingsData } = useSingleUserSettings(user.userId); + + if (isLoading || !userSettingsData) return ; + if (isError) return ; + + return ( + + + Welcome, {user.firstName}! + + + + + + + + + + + + + + + + + + + ); +}; + +export default AdminHomePage; diff --git a/src/frontend/src/pages/HomePage/GuestHomePage.tsx b/src/frontend/src/pages/HomePage/GuestHomePage.tsx index 9297a2a008..297559c47a 100644 --- a/src/frontend/src/pages/HomePage/GuestHomePage.tsx +++ b/src/frontend/src/pages/HomePage/GuestHomePage.tsx @@ -1,42 +1,64 @@ -import { Typography, Box } from '@mui/material'; -import PageLayout from '../../components/PageLayout'; +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box, Grid, Stack, Typography } from '@mui/material'; +import { useSingleUserSettings } from '../../hooks/users.hooks'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import PageLayout, { PAGE_GRID_HEIGHT } from '../../components/PageLayout'; import { AuthenticatedUser } from 'shared'; -import ImageWithButton from './components/ImageWithButton'; -import emitter from '../../app/EventBus'; +import MemberEncouragement from './components/MemberEncouragement'; +import GuestOrganizationInfo from './components/GuestOrganizationInfo'; +import FeaturedProjects from './components/FeaturedProjects'; +import OrganizationLogo from './components/OrganizationLogo'; interface GuestHomePageProps { user: AuthenticatedUser; - setOnMemberHomePage: (e: boolean) => void; } -const GuestHomePage = ({ user, setOnMemberHomePage }: GuestHomePageProps) => { - const handleClick = () => { - emitter.emit('memberHomePage', true); - setOnMemberHomePage(true); - }; +const GuestHomePage = ({ user }: GuestHomePageProps) => { + const { isLoading, isError, error, data: userSettingsData } = useSingleUserSettings(user.userId); + + if (isLoading || !userSettingsData) return ; + if (isError) return ; return ( - - {user ? `Welcome, ${user.firstName}!` : 'Welcome, Guest!'} + + Welcome, {user.firstName}! - - - {}} - /> - + + + + + + + + + + + + + + + + + + ); }; + export default GuestHomePage; diff --git a/src/frontend/src/pages/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 25da7a5979..1ce8f7c7f6 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -4,18 +4,33 @@ */ import { useCurrentUser } from '../../hooks/users.hooks'; -import { isGuest } from 'shared'; +import { isAdmin, isGuest, isLead, isMember } from 'shared'; +import IntroGuestHomePage from './IntroGuestHomePage'; import GuestHomePage from './GuestHomePage'; -import MemberHomePage from './MemberHomePage'; import { useState } from 'react'; +import MemberHomePage from './MemberHomePage'; +import LeadHomePage from './LeadHomePage'; +import AdminHomePage from './AdminHomePage'; +import PopUpAlert from '../../components/PopUpAlert'; const Home = () => { const user = useCurrentUser(); const [onMemberHomePage, setOnMemberHomePage] = useState(false); - return isGuest(user.role) && !onMemberHomePage ? ( - - ) : ( - + return ( + <> + {!onMemberHomePage && } + {isGuest(user.role) && !onMemberHomePage ? ( + + ) : isMember(user.role) ? ( + + ) : isLead(user.role) ? ( + + ) : isAdmin(user.role) ? ( + + ) : ( + + )} + ); }; diff --git a/src/frontend/src/pages/HomePage/IntroGuestHomePage.tsx b/src/frontend/src/pages/HomePage/IntroGuestHomePage.tsx new file mode 100644 index 0000000000..deed2d767b --- /dev/null +++ b/src/frontend/src/pages/HomePage/IntroGuestHomePage.tsx @@ -0,0 +1,42 @@ +import { Typography, Box } from '@mui/material'; +import PageLayout from '../../components/PageLayout'; +import { AuthenticatedUser } from 'shared'; +import ImageWithButton from './components/ImageWithButton'; +import emitter from '../../app/EventBus'; + +interface IntroGuestHomePageProps { + user: AuthenticatedUser; + setOnMemberHomePage: (e: boolean) => void; +} + +const IntroGuestHomePage = ({ user, setOnMemberHomePage }: IntroGuestHomePageProps) => { + const handleClick = () => { + emitter.emit('memberHomePage', true); + setOnMemberHomePage(true); + }; + + return ( + + + {user ? `Welcome, ${user.firstName}!` : 'Welcome, Guest!'} + + + + {}} + /> + + + + + ); +}; +export default IntroGuestHomePage; diff --git a/src/frontend/src/pages/HomePage/LeadHomePage.tsx b/src/frontend/src/pages/HomePage/LeadHomePage.tsx new file mode 100644 index 0000000000..8e1c2b9f6d --- /dev/null +++ b/src/frontend/src/pages/HomePage/LeadHomePage.tsx @@ -0,0 +1,60 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box, Grid, Typography } from '@mui/material'; +import { useSingleUserSettings } from '../../hooks/users.hooks'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import PageLayout, { PAGE_GRID_HEIGHT } from '../../components/PageLayout'; +import { AuthenticatedUser } from 'shared'; +import ChangeRequestsToReview from './components/ChangeRequestsToReview'; +import MyTeamsOverdueTasks from './components/MyTeamsOverdueTasks'; +import UpcomingDesignReviews from './components/UpcomingDesignReviews'; +import GeneralAnnouncements from './components/GeneralAnnouncements'; + +interface LeadHomePageProps { + user: AuthenticatedUser; +} + +const LeadHomePage = ({ user }: LeadHomePageProps) => { + const { isLoading, isError, error, data: userSettingsData } = useSingleUserSettings(user.userId); + + if (isLoading || !userSettingsData) return ; + if (isError) return ; + + return ( + + + Welcome, {user.firstName}! + + + + + + + + + + + + + + + + + + + ); +}; + +export default LeadHomePage; diff --git a/src/frontend/src/pages/HomePage/MemberHomePage.tsx b/src/frontend/src/pages/HomePage/MemberHomePage.tsx index 00f96da519..209b6c09a4 100644 --- a/src/frontend/src/pages/HomePage/MemberHomePage.tsx +++ b/src/frontend/src/pages/HomePage/MemberHomePage.tsx @@ -3,16 +3,15 @@ * See the LICENSE file in the repository root folder for details. */ -import { Typography } from '@mui/material'; -import OverdueWorkPackageAlerts from './components/OverdueWorkPackageAlerts'; -import UsefulLinks from './components/UsefulLinks'; -import WorkPackagesByTimelineStatus from './components/WorkPackagesByTimelineStatus'; -import UpcomingDeadlines from './components/UpcomingDeadlines'; +import { Box, Grid, Typography } from '@mui/material'; import { useSingleUserSettings } from '../../hooks/users.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; -import PageLayout from '../../components/PageLayout'; +import PageLayout, { PAGE_GRID_HEIGHT } from '../../components/PageLayout'; import { AuthenticatedUser } from 'shared'; +import MyTasks from './components/MyTasks'; +import TeamWorkPackageDisplay from './components/TeamWorkPackageDisplay'; +import GeneralAnnouncements from './components/GeneralAnnouncements'; interface MemberHomePageProps { user: AuthenticatedUser; @@ -29,10 +28,28 @@ const MemberHomePage = ({ user }: MemberHomePageProps) => { Welcome, {user.firstName}! - - - - + + + + + + + + + + + + + + + ); }; diff --git a/src/frontend/src/pages/HomePage/components/ChangeRequestsToReview.tsx b/src/frontend/src/pages/HomePage/components/ChangeRequestsToReview.tsx new file mode 100644 index 0000000000..148ed6c822 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/ChangeRequestsToReview.tsx @@ -0,0 +1,51 @@ +import { useAllWorkPackages } from '../../../hooks/work-packages.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { useAllChangeRequests } from '../../../hooks/change-requests.hooks'; +import { useAllProjects } from '../../../hooks/projects.hooks'; +import { getCRsToReview } from '../../../utils/change-request.utils'; +import ScrollablePageBlock from './ScrollablePageBlock'; +import { AuthenticatedUser, ChangeRequest } from 'shared'; +import ChangeRequestDetailCard from '../../../components/ChangeRequestDetailCard'; +import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; + +interface ChangeRequestsToReviewProps { + user: AuthenticatedUser; +} + +const NoChangeRequestsToReview: React.FC = () => { + return ( + } + heading={`You're all caught up!`} + message={'You have no unreviewed change requests!'} + /> + ); +}; + +const ChangeRequestsToReview: React.FC = ({ user }) => { + const { data: changeRequests, isError: crIsError, isLoading: crIsLoading, error: crError } = useAllChangeRequests(); + const { data: projects, isError: projectIsError, isLoading: projectLoading, error: projectError } = useAllProjects(); + const { data: workPackages, isError: wpIsError, isLoading: wpLoading, error: wpError } = useAllWorkPackages(); + + if (crIsLoading || projectLoading || wpLoading || !changeRequests || !projects || !workPackages) + return ; + if (crIsError) return ; + if (projectIsError) return ; + if (wpIsError) return ; + + const crsToReview = getCRsToReview(projects, workPackages, user, changeRequests); + + return ( + + {crsToReview.length === 0 ? ( + + ) : ( + crsToReview.map((cr: ChangeRequest) => ) + )} + + ); +}; + +export default ChangeRequestsToReview; diff --git a/src/frontend/src/pages/HomePage/components/DesignReviewCard.tsx b/src/frontend/src/pages/HomePage/components/DesignReviewCard.tsx new file mode 100644 index 0000000000..b2896398e6 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/DesignReviewCard.tsx @@ -0,0 +1,116 @@ +import { Box, Card, CardContent, Link, Stack, Typography, useTheme } from '@mui/material'; +import { DesignReview, meetingStartTimePipe, User } from 'shared'; +import { datePipe, projectWbsPipe } from '../../../utils/pipes'; +import { routes } from '../../../utils/routes'; +import { Link as RouterLink } from 'react-router-dom'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import { LocationOnOutlined, Computer } from '@mui/icons-material'; +import { useHistory } from 'react-router-dom'; +import { NERButton } from '../../../components/NERButton'; +import { timezoneOffset } from '../../../utils/datetime.utils'; + +interface DesignReviewProps { + designReview: DesignReview; + user: User; +} + +const DesignReviewInfo = ({ icon, text, link }: { icon: React.ReactNode; text: string; link?: boolean }) => { + return ( + + {icon} + {link ? ( + + + {text} + + + ) : ( + + {text} + + )} + + ); +}; + +const DisplayStatus: React.FC = ({ designReview, user }) => { + const history = useHistory(); + const confirmedMemberIds = designReview.confirmedMembers.map((user) => user.userId); + + return ( + <> + {!confirmedMemberIds.includes(user.userId) ? ( + { + history.push(`${routes.SETTINGS_PREFERENCES}?drId=${designReview.designReviewId}`); + }} + component={RouterLink} + > + Confirm Availibility + + ) : ( + {designReview.status} + )} + + ); +}; + +const getWeekday = (date: Date): string => { + const weekdays: string[] = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + return weekdays[date.getDay()]; +}; + +const removeYear = (str: string): string => { + return str.substring(0, str.length - 5); +}; + +const UpcomingDesignReviewsCard: React.FC = ({ designReview, user }) => { + const theme = useTheme(); + const timezoneAdjustedDate = timezoneOffset(designReview.dateScheduled); + return ( + + + + + + + {designReview.wbsName} + + + + {} + + {getWeekday(timezoneAdjustedDate) + + ', ' + + removeYear(datePipe(timezoneAdjustedDate)) + + ' @ ' + + meetingStartTimePipe(designReview.meetingTimes)} + + + {designReview.isInPerson && !!designReview.location && ( + } text={designReview.location} /> + )} + {designReview.isOnline && !!designReview.zoomLink && ( + } text={designReview.zoomLink} link /> + )} + + + + + + ); +}; + +export default UpcomingDesignReviewsCard; diff --git a/src/frontend/src/pages/HomePage/components/EmptyPageBlockDisplay.tsx b/src/frontend/src/pages/HomePage/components/EmptyPageBlockDisplay.tsx new file mode 100644 index 0000000000..1cb8301b13 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/EmptyPageBlockDisplay.tsx @@ -0,0 +1,30 @@ +import { Box, Typography } from '@mui/material'; +import React from 'react'; + +interface EmptyPageBlockDisplayProps { + icon: React.ReactNode; + heading: String; + message: String; +} + +const EmptyPageBlockDisplay: React.FC = ({ icon, heading, message }) => { + return ( + + {icon} + {heading} + {message} + + ); +}; + +export default EmptyPageBlockDisplay; diff --git a/src/frontend/src/pages/HomePage/components/FeaturedProjects.tsx b/src/frontend/src/pages/HomePage/components/FeaturedProjects.tsx new file mode 100644 index 0000000000..958e3f05f8 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/FeaturedProjects.tsx @@ -0,0 +1,56 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import FeaturedProjectsCard from './FeaturedProjectsCard'; +import { useFeaturedProjects } from '../../../hooks/organizations.hooks'; +import ErrorPage from '../../ErrorPage'; +import { wbsPipe } from 'shared'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ScrollablePageBlock from './ScrollablePageBlock'; +import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; +import { Box } from '@mui/material'; +import { Error } from '@mui/icons-material'; + +const NoFeaturedProjectsDisplay: React.FC = () => { + return ( + + } + heading={'No Featured Projects'} + message={'There are no Featured Projects to Display'} + /> + + ); +}; + +const FeaturedProjects: React.FC = () => { + const { data: featuredProjects, isLoading, isError, error } = useFeaturedProjects(); + + if (isLoading || !featuredProjects) return ; + if (isError) return ; + + const fullDisplay = ( + + {featuredProjects.length === 0 ? ( + + ) : ( + featuredProjects.map((p) => ) + )} + + ); + + return fullDisplay; +}; + +export default FeaturedProjects; diff --git a/src/frontend/src/pages/HomePage/components/FeaturedProjectsCard.tsx b/src/frontend/src/pages/HomePage/components/FeaturedProjectsCard.tsx new file mode 100644 index 0000000000..ddc6bc25eb --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/FeaturedProjectsCard.tsx @@ -0,0 +1,60 @@ +import { Construction, Work } from '@mui/icons-material'; +import { Box, Card, CardContent, Chip, Link, Stack, Typography, useTheme } from '@mui/material'; +import { wbsPipe, Project } from 'shared'; +import { datePipe, fullNamePipe, projectWbsPipe } from '../../../utils/pipes'; +import { routes } from '../../../utils/routes'; +import { Link as RouterLink } from 'react-router-dom'; + +interface ProjectCardProps { + project: Project; +} + +const FeaturedProjectsCard: React.FC = ({ project }) => { + const theme = useTheme(); + return ( + + + + + + + {projectWbsPipe(project.wbsNum)} - {project.teams.map((project) => project.teamName)} + + + + + {wbsPipe(project.wbsNum)} - {project.name} + + + + {datePipe(project.startDate) + ' ⟝ ' + project.duration + ' wks ⟞ ' + datePipe(project.endDate)} + + + + + } + label={fullNamePipe(project.lead)} + size="medium" + /> + } label={fullNamePipe(project.manager)} size="medium" /> + + + + ); +}; + +export default FeaturedProjectsCard; diff --git a/src/frontend/src/pages/HomePage/components/GeneralAnnouncementCard.tsx b/src/frontend/src/pages/HomePage/components/GeneralAnnouncementCard.tsx new file mode 100644 index 0000000000..b7e09741f1 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/GeneralAnnouncementCard.tsx @@ -0,0 +1,52 @@ +import { Card, CardContent, Typography, IconButton, Box } from '@mui/material'; +import { useTheme } from '@mui/system'; +import React from 'react'; +import { Announcement } from 'shared'; +import { datePipe } from '../../../utils/pipes'; +import CloseIcon from '@mui/icons-material/Close'; + +interface GeneralAnnouncementCardProps { + announcement: Announcement; + removeAnnouncement: (announcement: Announcement) => Promise; +} + +const GeneralAnnouncementCard: React.FC = ({ announcement, removeAnnouncement }) => { + const theme = useTheme(); + return ( + + + + removeAnnouncement(announcement)}> + + + + + {announcement.senderName} ({datePipe(announcement.dateMessageSent)}) + + #{announcement.slackChannelName} + {announcement.text} + + + ); +}; + +export default GeneralAnnouncementCard; diff --git a/src/frontend/src/pages/HomePage/components/GeneralAnnouncements.tsx b/src/frontend/src/pages/HomePage/components/GeneralAnnouncements.tsx new file mode 100644 index 0000000000..17e5334199 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/GeneralAnnouncements.tsx @@ -0,0 +1,44 @@ +import ScrollablePageBlock from './ScrollablePageBlock'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; +import GeneralAnnouncementCard from './GeneralAnnouncementCard'; +import { useRemoveUserAnnouncement, useUserAnnouncements } from '../../../hooks/announcements.hooks'; +import ErrorPage from '../../ErrorPage'; + +const NoGeneralAnnouncementsDisplay = () => { + return ( + } + heading={"You're all caught up!"} + message={'You have read all current general announcements!'} + /> + ); +}; + +const GeneralAnnouncements: React.FC = () => { + const { + data: unreadAnnouncements, + isLoading, + isError: announcementsIsError, + error: announcementsError + } = useUserAnnouncements(); + const { mutateAsync: removeAnnouncement, isLoading: removeAnnouncementIsLoading } = useRemoveUserAnnouncement(); + + if (announcementsIsError) return ; + if (isLoading || removeAnnouncementIsLoading || !unreadAnnouncements) return ; + + return ( + + {unreadAnnouncements.length === 0 ? ( + + ) : ( + unreadAnnouncements.map((announcement) => ( + + )) + )} + + ); +}; + +export default GeneralAnnouncements; diff --git a/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx b/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx new file mode 100644 index 0000000000..2cd6779fbe --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx @@ -0,0 +1,79 @@ +import { Box, Card, Icon, Typography, useTheme } from '@mui/material'; +import { Grid } from '@mui/material'; +import { useCurrentOrganization } from '../../../hooks/organizations.hooks'; +import React from 'react'; +import { NERButton } from '../../../components/NERButton'; +import { useAllLinkTypes, useAllUsefulLinks } from '../../../hooks/projects.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; + +interface GuestOrganizationInfoButtonProps { + href?: string; + buttonText: string; + iconName: string; +} + +const NERGuestButton: React.FC = ({ href, buttonText, iconName }) => { + return ( + + + {iconName} + + {buttonText} + + + + ); +}; + +const GuestOrganizationInfo = () => { + const theme = useTheme(); + const { data: organization, isLoading, isError, error } = useCurrentOrganization(); + const { + data: usefulLinks, + isLoading: usefulLinksIsLoading, + isError: usefulLinksIsError, + error: usefulLinksError + } = useAllUsefulLinks(); + const { data: linkTypes, isLoading: linkTypesIsLoading } = useAllLinkTypes(); + + if (isLoading || !organization) return ; + if (isError) return ; + + if (!usefulLinks || usefulLinksIsLoading || !linkTypes || linkTypesIsLoading) return ; + if (usefulLinksIsError) return ; + + return ( + + + + {organization.name} + + {organization.description} + + + {usefulLinks.map((link) => ( + + ))} + + + ); +}; + +export default GuestOrganizationInfo; diff --git a/src/frontend/src/pages/HomePage/components/LogoDisplay.tsx b/src/frontend/src/pages/HomePage/components/LogoDisplay.tsx new file mode 100644 index 0000000000..0ebeb68db5 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/LogoDisplay.tsx @@ -0,0 +1,35 @@ +import { Box, useTheme, Card } from '@mui/material'; +import React from 'react'; + +interface LogoDisplayProps { + imageUrl?: string; +} + +const LogoDisplay: React.FC = ({ imageUrl }) => { + const theme = useTheme(); + return ( + + {imageUrl && ( + + )} + + ); +}; + +export default LogoDisplay; diff --git a/src/frontend/src/pages/HomePage/components/MemberEncouragement.tsx b/src/frontend/src/pages/HomePage/components/MemberEncouragement.tsx new file mode 100644 index 0000000000..dc117e16d3 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/MemberEncouragement.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Box, Alert, Typography, Grid } from '@mui/material'; +import { routes } from '../../../utils/routes'; +import { NERButton } from '../../../components/NERButton'; +import { useHistory } from 'react-router-dom'; + +const MemberEncouragement: React.FC = () => { + const history = useHistory(); + return ( + + + + + + Already a member? + + + Talk to the head of your team to become a member and get added to the team on FinishLine! + + + + { + history.push(routes.TEAMS); + }} + > + See Teams > + + + + + + ); +}; + +export default MemberEncouragement; diff --git a/src/frontend/src/pages/HomePage/components/MyTasks.tsx b/src/frontend/src/pages/HomePage/components/MyTasks.tsx new file mode 100644 index 0000000000..9b12b233d3 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/MyTasks.tsx @@ -0,0 +1,45 @@ +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useCurrentUser, useUserTasks } from '../../../hooks/users.hooks'; +import TaskDetailCard from './TaskDetailCard'; +import ErrorPage from '../../ErrorPage'; +import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; +import ScrollablePageBlock from './ScrollablePageBlock'; + +const NoTasksDisplay: React.FC = () => { + return ( + } + heading={"You're all caught up!"} + message={"You've completed all of your assigned tasks!"} + /> + ); +}; + +const MyTasks: React.FC = () => { + const currentUser = useCurrentUser(); + + const { + data: userTasks, + isLoading: userTasksIsLoading, + error: userTasksError, + isError: userTasksIsError + } = useUserTasks(currentUser.userId); + + if (userTasksIsLoading || !userTasks) return ; + if (userTasksIsError) return ; + + return ( + + {userTasks.length === 0 ? ( + + ) : ( + userTasks.map((task, index) => { + return ; + }) + )} + + ); +}; + +export default MyTasks; diff --git a/src/frontend/src/pages/HomePage/components/MyTeamsOverdueTasks.tsx b/src/frontend/src/pages/HomePage/components/MyTeamsOverdueTasks.tsx new file mode 100644 index 0000000000..95eab5b66b --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/MyTeamsOverdueTasks.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import ScrollablePageBlock from './ScrollablePageBlock'; +import { AuthenticatedUser } from 'shared'; +import { useManyUserTasks } from '../../../hooks/users.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import TeamTaskCard from './TeamTaskCard'; +import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; +import { getOverdueTasks } from '../../../utils/task.utils'; + +interface MyTeamsOverdueTasksProps { + user: AuthenticatedUser; +} + +const NoOverdueTeamTaskDisplay = () => { + return ( + } + heading={"You're team is all caught up!"} + message={"You're team has no overdue tasks!"} + /> + ); +}; + +const MyTeamsOverdueTasks: React.FC = ({ user }) => { + const teamsAsHead = user.teamsAsHead ?? []; + const teamsAsLead = user.teamsAsLead ?? []; + const teamsAsLeadership = [...teamsAsHead, ...teamsAsLead]; + // converting to set for no duplicate members + const allMembers = new Set(teamsAsLeadership.map((team) => team.members).flat()); + const { data: tasks, isLoading, isError, error } = useManyUserTasks([...allMembers].map((member) => member.userId)); + + if (isLoading || !tasks) return ; + if (isError) return ; + + const overdueTasks = getOverdueTasks(tasks); + + return ( + + {overdueTasks.length === 0 ? ( + + ) : ( + overdueTasks.map((task, index) => ) + )} + + ); +}; + +export default MyTeamsOverdueTasks; diff --git a/src/frontend/src/pages/HomePage/components/OrganizationLogo.tsx b/src/frontend/src/pages/HomePage/components/OrganizationLogo.tsx new file mode 100644 index 0000000000..c16a273c31 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/OrganizationLogo.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import LogoDisplay from './LogoDisplay'; +import { useOrganizationLogo } from '../../../hooks/organizations.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; + +const OrganizationLogo = () => { + const { data: imageData, isLoading, isError, error } = useOrganizationLogo(); + if (isLoading) return ; + if (isError) return ; + + return ; +}; + +export default OrganizationLogo; diff --git a/src/frontend/src/pages/HomePage/components/OverdueWorkPackageCard.tsx b/src/frontend/src/pages/HomePage/components/OverdueWorkPackageCard.tsx new file mode 100644 index 0000000000..deb86badb5 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/OverdueWorkPackageCard.tsx @@ -0,0 +1,145 @@ +import { Construction, Work, CalendarMonth } from '@mui/icons-material'; +import { + Box, + Card, + CardContent, + Chip, + CircularProgress, + CircularProgressProps, + Link, + Stack, + Typography, + useTheme +} from '@mui/material'; +import { wbsPipe, WorkPackage } from 'shared'; +import { datePipe, fullNamePipe, projectWbsPipe } from '../../../utils/pipes'; +import { routes } from '../../../utils/routes'; +import { Link as RouterLink } from 'react-router-dom'; +import { useGetManyWorkPackages } from '../../../hooks/work-packages.hooks'; +import { daysOverdue } from '../../../utils/datetime.utils'; +import LoadingIndicator from '../../../components/LoadingIndicator'; + +export const CircularProgressWithLabel = (props: CircularProgressProps & { value: number }) => { + return ( + + +
+ {`${Math.round(props.value)}%`} +
+
+ ); +}; + +const OverdueWorkPackageCard = ({ wp }: { wp: WorkPackage }) => { + const theme = useTheme(); + const { data: blockedByWps, isLoading } = useGetManyWorkPackages(wp.blockedBy); + const numDaysOverdue = daysOverdue(new Date(wp.endDate)); + if (isLoading || !blockedByWps) return ; + return ( + + + + + + + {projectWbsPipe(wp.wbsNum)} - {wp.projectName} + + + + + {wbsPipe(wp.wbsNum)} - {wp.name} + + + + Blocked By: + +
    + {blockedByWps.length === 0 ? ( +
  • + + No Blockers + +
  • + ) : ( + blockedByWps.map((wp) => ( +
  • + + + {projectWbsPipe(wp.wbsNum)} - {wp.projectName} + + +
  • + )) + )} +
+
+ + + } label={fullNamePipe(wp.lead)} size={'small'} /> + } label={fullNamePipe(wp.manager)} size={'small'} /> + } label={datePipe(new Date(wp.endDate))} size={'small'} /> + + + + + {numDaysOverdue} + + + + Days + + + Overdue + + + + +
+
+
+ ); +}; + +export default OverdueWorkPackageCard; diff --git a/src/frontend/src/pages/HomePage/components/OverdueWorkPackageView.tsx b/src/frontend/src/pages/HomePage/components/OverdueWorkPackageView.tsx new file mode 100644 index 0000000000..d0d42294b1 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/OverdueWorkPackageView.tsx @@ -0,0 +1,96 @@ +import { Box, Card, CardContent, Typography, useTheme } from '@mui/material'; +import { WorkPackage } from 'shared'; +import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; +import OverdueWorkPackageCard from './OverdueWorkPackageCard'; + +interface OverdueWorkPackagesViewProps { + workPackages: WorkPackage[]; +} + +const NoOverdueWPsDisplay: React.FC = () => { + return ( + } + heading={'Great Job Team!'} + message={'Your team has no overdue work packages!'} + /> + ); +}; + +const OverdueWorkPackagesView: React.FC = ({ workPackages }) => { + const theme = useTheme(); + const isEmpty = workPackages.length === 0; + return ( + + + + All Overdue Work Packages + + + + + + {isEmpty ? : workPackages.map((wp) => )} + + + + + ); +}; + +export default OverdueWorkPackagesView; diff --git a/src/frontend/src/pages/HomePage/components/OverdueWorkPackages.tsx b/src/frontend/src/pages/HomePage/components/OverdueWorkPackages.tsx new file mode 100644 index 0000000000..7e90c4e10a --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/OverdueWorkPackages.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { AuthenticatedUser, isAdmin, Team } from 'shared'; +import { useAllWorkPackages, useGetManyWorkPackages } from '../../../hooks/work-packages.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { daysOverdue } from '../../../utils/datetime.utils'; +import OverdueWorkPackagesView from './OverdueWorkPackageView'; + +interface OverdueWorkPackagesProps { + user: AuthenticatedUser; +} + +const getAllWbsNumFromTeams = (teams: Team[]) => { + const projects = teams.map((team) => team.projects).flat(); + const workPackages = projects.map((project) => project.workPackages).flat(); + return workPackages.map((wp) => wp.wbsNum); +}; + +const OverdueWorkPackages: React.FC = ({ user }) => { + const teamsAsHead = user.teamsAsHead ?? []; + const teamsAsLead = user.teamsAsLead ?? []; + const teamsAsLeadership = [...teamsAsHead, ...teamsAsLead]; + const { data: allWps, isLoading: isLoadingAllWps, isError: isErrorAllWps, error: errorAllWps } = useAllWorkPackages(); + const { + data: teamWps, + isLoading: isLoadingTeamWps, + isError: isErrorTeamWps, + error: errorTeamWps + } = useGetManyWorkPackages(getAllWbsNumFromTeams(teamsAsLeadership)); + + if (isLoadingAllWps || isLoadingTeamWps || !allWps || !teamWps) return ; + if (isErrorAllWps) return ; + if (isErrorTeamWps) return ; + + const displayedWps = isAdmin(user.role) ? allWps : teamWps; + const overdueWps = displayedWps.filter((wp) => daysOverdue(wp.endDate) > 0); + + return ; +}; + +export default OverdueWorkPackages; diff --git a/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx b/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx new file mode 100644 index 0000000000..54b11ce434 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx @@ -0,0 +1,65 @@ +import { Box, Card, CardContent, Typography, useTheme } from '@mui/material'; +import React from 'react'; + +interface ScrollablePageBlockProps { + children: React.ReactNode; + title?: String; + horizontal?: boolean; +} + +const ScrollablePageBlock: React.FC = ({ children, title, horizontal }) => { + const theme = useTheme(); + return ( + + + {title && ( + + {title} + + )} + + {children} + + + + ); +}; +export default ScrollablePageBlock; diff --git a/src/frontend/src/pages/HomePage/components/TaskDetailCard.tsx b/src/frontend/src/pages/HomePage/components/TaskDetailCard.tsx new file mode 100644 index 0000000000..87a9d90910 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/TaskDetailCard.tsx @@ -0,0 +1,127 @@ +import { Card, CardContent, useTheme, Stack, Link, Typography, Box, Chip } from '@mui/material'; +import Tooltip, { TooltipProps, tooltipClasses } from '@mui/material/Tooltip'; +import { Task, wbsPipe } from 'shared'; +import { Link as RouterLink } from 'react-router-dom'; +import { routes } from '../../../utils/routes'; +import { taskPriorityColor } from '../../../utils/task.utils'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import { useState } from 'react'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import { styled } from '@mui/material/styles'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { useSingleProject } from '../../../hooks/projects.hooks'; +import { daysOverdue, formatDate } from '../../../utils/datetime.utils'; + +interface TaskDetailCardProps { + task: Task; + taskNumber: number; +} + +const NERToolTip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.arrow}`]: { + color: theme.palette.error.dark + }, + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.palette.error.dark + } +})); + +const TaskDetailCard: React.FC = ({ task, taskNumber }) => { + const theme = useTheme(); + const taskDaysOverdue = task.deadline ? daysOverdue(new Date(task.deadline)) : 0; + const taskOverdue = taskDaysOverdue > 0; + const [hover, setHover] = useState(false); + const { data: project, isLoading, isError, error } = useSingleProject(task.wbsNum); + + if (isLoading || !project) return ; + if (isError) return ; + + return ( + + + + Task #{taskNumber} is {taskDaysOverdue} Days Overdue! + + + } + open={taskOverdue && hover} + placement="right" + arrow + > + setHover(true)} + onMouseLeave={() => setHover(false)} + sx={{ + width: '100%', + height: 'fit-content', + mr: 3, + background: theme.palette.background.default, + border: taskOverdue && hover ? '1px solid red' : undefined + }} + > + + + + + + + Task #{taskNumber} - {task.title} + + {taskOverdue && ( + + )} + + + + {wbsPipe(task.wbsNum)} - {project.name} + + + + + + + } + label={task.deadline ? formatDate(new Date(task.deadline)) : 'No Deadline'} + size="small" + /> + + + + + Notes:
{task.notes} +
+
+
+
+
+ ); +}; + +export default TaskDetailCard; diff --git a/src/frontend/src/pages/HomePage/components/TeamTaskCard.tsx b/src/frontend/src/pages/HomePage/components/TeamTaskCard.tsx new file mode 100644 index 0000000000..02f382c8c9 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/TeamTaskCard.tsx @@ -0,0 +1,79 @@ +import { Box, Card, CardContent, Chip, Link, Stack, Typography } from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; +import { useTheme } from '@mui/system'; +import React from 'react'; +import { Task, wbsPipe } from 'shared'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { useSingleProject } from '../../../hooks/projects.hooks'; +import { routes } from '../../../utils/routes'; +import { fullNamePipe } from '../../../utils/pipes'; +import PeopleAltIcon from '@mui/icons-material/PeopleAlt'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import { taskPriorityColor } from '../../../utils/task.utils'; +import { formatDate } from '../../../utils/datetime.utils'; + +interface TeamTaskCardProps { + task: Task; + taskNumber: number; +} + +const TeamTaskCard: React.FC = ({ task, taskNumber }) => { + const theme = useTheme(); + const { data: project, isLoading, isError, error } = useSingleProject(task.wbsNum); + if (isLoading || !project) return ; + if (isError) return ; + + return ( + + + + + + Task #{taskNumber} - {task.title} + + + + {wbsPipe(task.wbsNum)} - {project.name} + + + + + {task.assignees.map(fullNamePipe).join(', ')} + + + + + } + label={task.deadline ? formatDate(new Date(task.deadline)) : 'No Deadline'} + size="medium" + /> + + + + + ); +}; + +export default TeamTaskCard; diff --git a/src/frontend/src/pages/HomePage/components/TeamWorkPackageDisplay.tsx b/src/frontend/src/pages/HomePage/components/TeamWorkPackageDisplay.tsx new file mode 100644 index 0000000000..202bb32504 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/TeamWorkPackageDisplay.tsx @@ -0,0 +1,57 @@ +import WorkPackageCard from './WorkPackageCard'; +import ScrollablePageBlock from './ScrollablePageBlock'; +import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; +import { AuthenticatedUser, WbsElementStatus, WorkPackage } from 'shared'; +import { useAllTeams } from '../../../hooks/teams.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { isUserOnTeam } from '../../../utils/teams.utils'; + +interface TeamWorkPackageDisplayProps { + user: AuthenticatedUser; +} + +const NoTeamWorkPackagesDisplay: React.FC = () => { + return ( + } + heading={'No Active Work Packages'} + message={'There are no active work packages assigned to your team!'} + /> + ); +}; + +const TeamWorkPackageDisplay: React.FC = ({ user }) => { + const { isLoading, isError, data: teams, error } = useAllTeams(); + + if (isLoading || !teams) return ; + if (isError) return ; + + const myTeams = teams.filter((team) => isUserOnTeam(team, user)); + + const workPackages = myTeams + //convert list of teams into list of work packages in projects in those teams + .map((team) => { + return team.projects.map((project) => { + return project.workPackages.filter((wp) => wp.status === WbsElementStatus.Active); + }); + }) + //flatten into 1 dimensional list of work packages + .flat(2) + //remove duplicate work packages + .reduce((acc: WorkPackage[], wp: WorkPackage) => { + if (acc.filter((addedWp) => addedWp.id === wp.id).length === 0) { + acc.push(wp); + } + return acc; + }, []); + + return ( + + {workPackages.length === 0 ? : workPackages.map((wp) => )} + + ); +}; + +export default TeamWorkPackageDisplay; diff --git a/src/frontend/src/pages/HomePage/components/UpcomingDesignReviews.tsx b/src/frontend/src/pages/HomePage/components/UpcomingDesignReviews.tsx new file mode 100644 index 0000000000..7d0d4c0565 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/UpcomingDesignReviews.tsx @@ -0,0 +1,67 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import DesignReviewCard from './DesignReviewCard'; +import { useAllDesignReviews } from '../../../hooks/design-reviews.hooks'; +import ErrorPage from '../../ErrorPage'; +import { AuthenticatedUser, DesignReviewStatus, wbsPipe } from 'shared'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ScrollablePageBlock from './ScrollablePageBlock'; +import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; +import { Error } from '@mui/icons-material'; + +interface UpcomingDesignReviewProps { + user: AuthenticatedUser; +} + +const NoUpcomingDesignReviewsDisplay: React.FC = () => { + return ( + } + heading={'No Upcoming Design Reviews'} + message={'There are no Upcoming Design Reviews to Display'} + /> + ); +}; + +const UpcomingDesignReviews: React.FC = ({ user }) => { + const { data: designReviews, isLoading, isError, error } = useAllDesignReviews(); + + if (isLoading || !designReviews) return ; + if (isError) return ; + + const filteredDesignReviews = designReviews.filter((review) => { + const scheduledDate = review.dateScheduled; + const currentDate = new Date(); + const inTwoWeeks = new Date(); + inTwoWeeks.setDate(currentDate.getDate() + 14); + const memberUserIds = [ + ...review.requiredMembers.map((user) => user.userId), + ...review.optionalMembers.map((user) => user.userId) + ]; + // added in case the person who created the design review forgets to add their name onto the required members + memberUserIds.concat(review.userCreated.userId); + return ( + scheduledDate >= currentDate && + scheduledDate <= inTwoWeeks && + review.status !== DesignReviewStatus.DONE && + memberUserIds.includes(user.userId) + ); + }); + + const fullDisplay = ( + + {filteredDesignReviews.length === 0 ? ( + + ) : ( + filteredDesignReviews.map((d) => ) + )} + + ); + + return fullDisplay; +}; + +export default UpcomingDesignReviews; diff --git a/src/frontend/src/pages/HomePage/components/WorkPackageCard.tsx b/src/frontend/src/pages/HomePage/components/WorkPackageCard.tsx index 57814b4f3f..e6ddd11d14 100644 --- a/src/frontend/src/pages/HomePage/components/WorkPackageCard.tsx +++ b/src/frontend/src/pages/HomePage/components/WorkPackageCard.tsx @@ -40,7 +40,8 @@ const WorkPackageCard = ({ wp }: { wp: WorkPackage }) => { void; + selected?: number; +} + +const WorkPackageSelect: React.FC = ({ options, onSelect, selected = 0 }) => { + const theme = useTheme(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const handleSelect = (option: string) => { + setIsOpen(false); + onSelect(options.indexOf(option)); + }; + + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + useEffect(() => { + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + return ( + + setIsOpen(!isOpen)} + variant="h5" + sx={{ paddingX: 2, paddingY: 1, display: 'inline-block', cursor: 'pointer' }} + > + + {options[selected]} + + {isOpen && ( + setIsOpen(!isOpen)} sx={{ position: 'absolute', top: '-40%', cursor: 'pointer' }}> + + + {options[selected]} + + {options + .filter((option) => option !== options.at(selected)) + .map((option) => ( + { + handleSelect(option); + }} + sx={{ + cursor: 'pointer', + paddingX: 2, + paddingY: 1, + backgroundColor: theme.palette.background.paper, + position: 'relative', + '&:hover': { + backgroundColor: theme.palette.action.hover + } + }} + variant="h5" + > + {option} + + ))} + + + )} + + ); +}; + +export default WorkPackageSelect; diff --git a/src/frontend/src/pages/HomePage/components/WorkPackagesSelectionView.tsx b/src/frontend/src/pages/HomePage/components/WorkPackagesSelectionView.tsx new file mode 100644 index 0000000000..2b05907c4d --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/WorkPackagesSelectionView.tsx @@ -0,0 +1,129 @@ +import { WorkPackage } from 'shared'; +import { Box, Card, CardContent, useTheme } from '@mui/material'; +import { + getInProgressWorkPackages, + getOverdueWorkPackages, + getUpcomingWorkPackages +} from '../../../utils/work-package.utils'; +import { useCurrentUser } from '../../../hooks/users.hooks'; +import WorkPackageCard from './WorkPackageCard'; +import WorkPackageSelect from './WorkPackageSelect'; +import React, { useState } from 'react'; +import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; + +const NoWorkPackages: React.FC = () => { + return ( + } + heading={`You're all set!`} + message={'You have no pending work packages of this type!'} + /> + ); +}; + +const WorkPackagesSelectionView: React.FC = () => { + const user = useCurrentUser(); + const theme = useTheme(); + + const teamsAsHead = user.teamsAsHead ?? []; + const teamsAsLead = user.teamsAsLead ?? []; + const teamsAsLeadership = [...teamsAsHead, ...teamsAsLead]; + + const relevantWPs = teamsAsLeadership.map((team) => team.projects.map((project) => project.workPackages)).flat(2); + + const upcomingWPs: WorkPackage[] = getUpcomingWorkPackages(relevantWPs); + const inProgressWPs: WorkPackage[] = getInProgressWorkPackages(relevantWPs); + const overdueWPs: WorkPackage[] = getOverdueWorkPackages(relevantWPs); + + // options for selection + const workPackageOptions: [string, WorkPackage[]][] = [ + [`Upcoming Work Packages (${upcomingWPs.length})`, upcomingWPs], + [`In Progress Work Packages (${inProgressWPs.length})`, inProgressWPs], + [`Overdue Work Packages (${overdueWPs.length})`, overdueWPs] + ]; + + let defaultFirstDisplay = 2; + if (workPackageOptions[2][1].length === 0) { + defaultFirstDisplay = 1; + if (workPackageOptions[1][1].length === 0) { + defaultFirstDisplay = 0; + } + } + + const [currentDisplayedWPs, setCurrentDisplayedWPs] = useState(defaultFirstDisplay); + + // destructuring tuple to get wps of selected option + const [, currentWps] = workPackageOptions[currentDisplayedWPs]; + + const WorkPackagesDisplay = (workPackages: WorkPackage[]) => ( + + {workPackages.map((wp) => ( + + ))} + + ); + + return ( + + + wp[0])} + onSelect={setCurrentDisplayedWPs} + selected={currentDisplayedWPs} + /> + + {currentWps.length === 0 ? : WorkPackagesDisplay(currentWps)} + + + + ); +}; + +export default WorkPackagesSelectionView; diff --git a/src/frontend/src/tests/pages/HomePage/Home.test.tsx b/src/frontend/src/tests/pages/HomePage/Home.test.tsx index 5f1c3c9f03..e76fff2d39 100644 --- a/src/frontend/src/tests/pages/HomePage/Home.test.tsx +++ b/src/frontend/src/tests/pages/HomePage/Home.test.tsx @@ -63,8 +63,5 @@ describe('home component', () => { it('renders welcome', () => { renderComponent(); expect(screen.getByText(`Welcome, ${exampleAdminUser.firstName}!`)).toBeInTheDocument(); - expect(screen.getByText('useful-links')).toBeInTheDocument(); - expect(screen.getByText('upcoming-deadlines')).toBeInTheDocument(); - expect(screen.getByText('work-packages-by-timeline-status')).toBeInTheDocument(); }); }); diff --git a/src/frontend/src/utils/change-request.utils.ts b/src/frontend/src/utils/change-request.utils.ts index cef541fe04..3f498f5575 100644 --- a/src/frontend/src/utils/change-request.utils.ts +++ b/src/frontend/src/utils/change-request.utils.ts @@ -3,8 +3,70 @@ * See the LICENSE file in the repository root folder for details. */ -import { StandardChangeRequest } from 'shared'; +import { ChangeRequest, equalsWbsNumber, Project, StandardChangeRequest, User, WorkPackage } from 'shared'; +import { makeTeamList } from './teams.utils'; export const hasProposedChanges = (cr: StandardChangeRequest) => { return cr.workPackageProposedChanges || cr.projectProposedChanges; }; + +export const getCRsToReview = ( + projects: Project[], + workPackages: WorkPackage[], + user: User, + changeRequests: ChangeRequest[] +): ChangeRequest[] => { + // projects whose change requests the user would have to review + const myProjects = projects.filter((project: Project) => { + const projectMemberIds = project.teams.flatMap((team) => makeTeamList(team)).map((user) => user.userId); + return ( + projectMemberIds.includes(user.userId) || + (project.lead && project.lead.userId === user.userId) || + (project.manager && project.manager.userId === user.userId) + ); + }); + + // work packages whose change requests the user would have to review + const myWorkPackages = workPackages.filter( + (wp: WorkPackage) => + (wp.lead ? wp.lead.userId === user.userId : false) || (wp.manager ? wp.manager.userId === user.userId : false) + ); + + // all of the wbs numbers (in x.x.x string format) corresponding to projects and work packages + // whose change requests the user would have to review + const myWbsNumbers = myProjects + .map((project: Project) => project.wbsNum) + .concat(myWorkPackages.map((wp: WorkPackage) => wp.wbsNum)); + const crToReview = changeRequests + .filter( + (cr) => + !cr.dateReviewed && + cr.submitter.userId !== user.userId && + (myWbsNumbers.some((wbsNum) => equalsWbsNumber(wbsNum, cr.wbsNum)) || + cr.requestedReviewers.map((user) => user.userId).includes(user.userId)) + ) + .sort((a, b) => b.dateSubmitted.getTime() - a.dateSubmitted.getTime()); + return crToReview; +}; + +export const getCRsUnreviewed = (user: User, changeRequests: ChangeRequest[]) => { + const crUnreviewed = changeRequests + .filter((cr: ChangeRequest) => !cr.dateReviewed && cr.submitter.userId === user.userId) + .sort((a, b) => b.dateSubmitted.getTime() - a.dateSubmitted.getTime()); + return crUnreviewed; +}; + +export const getCRsApproved = (user: User, changeRequests: ChangeRequest[]) => { + const currentDate = new Date(); + const crApproved = changeRequests + .filter( + (cr: ChangeRequest) => + cr.dateReviewed && + cr.accepted && + cr.submitter.userId === user.userId && + currentDate.getTime() - cr.dateReviewed.getTime() <= 1000 * 60 * 60 * 24 * 5 + ) + .sort((a, b) => (a.dateReviewed && b.dateReviewed ? b.dateReviewed.getTime() - a.dateReviewed.getTime() : 0)); + + return crApproved; +}; diff --git a/src/frontend/src/utils/datetime.utils.ts b/src/frontend/src/utils/datetime.utils.ts index de14dfd9df..1b23c43118 100644 --- a/src/frontend/src/utils/datetime.utils.ts +++ b/src/frontend/src/utils/datetime.utils.ts @@ -24,3 +24,18 @@ export const transformDate = (date: Date) => { const day = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate().toString(); return `${date.getFullYear().toString()}/${month}/${day}`; }; + +export const formatDate = (date: Date) => { + const month = date.getMonth() + 1 < 10 ? `0${date.getMonth() + 1}` : (date.getMonth() + 1).toString(); + const day = date.getDate() + 1 < 10 ? `0${date.getDate() + 1}` : (date.getDate() + 1).toString(); + return `${month}/${day}/${date.getFullYear().toString()}`; +}; + +export const daysOverdue = (deadline: Date) => { + return Math.round((new Date().getTime() - deadline.getTime()) / (1000 * 60 * 60 * 24)); +}; + +export const timezoneOffset = (date: Date) => { + const timestamp = new Date(date).getTime() - new Date(date).getTimezoneOffset() * -60000; + return new Date(timestamp); +}; diff --git a/src/frontend/src/utils/task.utils.ts b/src/frontend/src/utils/task.utils.ts index 31a04723d0..59046cf887 100644 --- a/src/frontend/src/utils/task.utils.ts +++ b/src/frontend/src/utils/task.utils.ts @@ -8,6 +8,7 @@ import { Project, Task, TaskPriority, TaskStatus, TeamPreview, User, UserPreview import { EditTaskFormInput } from '../pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal'; import { fullNamePipe } from './pipes'; import { makeTeamList } from './teams.utils'; +import { daysOverdue } from './datetime.utils'; //this is needed to fix some weird bug with getActions() //see comment by michaldudak commented on Dec 5, 2022 @@ -68,3 +69,18 @@ export const taskUserToAutocompleteOption = (user: User): { label: string; id: s export const getTaskAssigneeOptions = (teams: TeamPreview[]): User[] => { return teams.map((team) => makeTeamList(team)).flat(); }; + +export const taskPriorityColor = (task: Task) => { + return task.priority === TaskPriority.Low + ? '#1CAC19' + : task.priority === TaskPriority.Medium + ? '#ffc700' + : task.priority === TaskPriority.High + ? '#EF4345' + : ''; +}; + +export const getOverdueTasks = (tasks: Task[]) => { + const overdueTasks = new Set(tasks.filter((task) => (task.deadline ? daysOverdue(new Date(task.deadline)) : 0) > 0)); + return [...overdueTasks]; +}; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index c0f505f89e..b014790845 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -24,6 +24,8 @@ const userFavoriteProjects = (id: string) => `${usersById(id)}/favorite-projects const userSecureSettings = (id: string) => `${usersById(id)}/secure-settings`; 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`; /**************** Projects Endpoints ****************/ const projects = () => `${API_URL}/projects`; @@ -176,6 +178,12 @@ const organizations = () => `${API_URL}/organizations`; const currentOrganization = () => `${organizations()}/current`; const organizationsUsefulLinks = () => `${organizations()}/useful-links`; const organizationsSetUsefulLinks = () => `${organizationsUsefulLinks()}/set`; +const organizationsSetDescription = () => `${organizations()}/description/set`; +const organizationsFeaturedProjects = () => `${organizations()}/featured-projects`; +const organizationsLogoImage = () => `${organizations()}/logo`; +const organizationsSetLogoImage = () => `${organizations()}/logo/update`; +const organizationsSetFeaturedProjects = () => `${organizationsFeaturedProjects()}/set`; +const organizationsSetWorkspaceId = () => `${organizations()}/workspaceId/set`; /******************* Car Endpoints ********************/ const cars = () => `${API_URL}/cars`; @@ -192,6 +200,19 @@ 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`; + +/************** Announcement Endpoints ***************/ +const announcements = () => `${API_URL}/announcements`; +const announcementsCurrentUser = () => `${announcements()}/current-user`; +const announcementsRemove = (id: string) => `${announcements()}/${id}/remove`; + +/************** Onboarding Endpoints ***************/ +const onboarding = () => `${API_URL}/onboarding`; +const imageById = (imageId: string) => `${onboarding()}/image/${imageId}`; /************** Statistics Endpoints ***************/ const statistics = () => `${API_URL}/statistics`; const createGraph = () => `${statistics()}/graph/create`; @@ -218,6 +239,8 @@ export const apiUrls = { userSecureSettings, userScheduleSettings, userScheduleSettingsSet, + userTasks, + manyUserTasks, projects, allProjects, @@ -344,6 +367,12 @@ export const apiUrls = { currentOrganization, organizationsUsefulLinks, organizationsSetUsefulLinks, + organizationsFeaturedProjects, + organizationsSetDescription, + organizationsLogoImage, + organizationsSetLogoImage, + organizationsSetFeaturedProjects, + organizationsSetWorkspaceId, cars, carsCreate, @@ -357,6 +386,15 @@ export const apiUrls = { faqCreate, faqEdit, faqDelete, + imageById, + + popUps, + popUpsCurrentUser, + popUpsRemove, + + announcements, + announcementsCurrentUser, + announcementsRemove, statistics, createGraph, diff --git a/src/frontend/src/utils/work-package.utils.ts b/src/frontend/src/utils/work-package.utils.ts index 2e1dfefefd..5e39ad6b7d 100644 --- a/src/frontend/src/utils/work-package.utils.ts +++ b/src/frontend/src/utils/work-package.utils.ts @@ -1,4 +1,4 @@ -import { WbsElement, WbsElementStatus, wbsPipe, WorkPackage } from 'shared'; +import { addWeeksToDate, WbsElement, WbsElementStatus, wbsPipe, WorkPackage } from 'shared'; import { WPFormType } from './form'; export const getTitleFromFormType = (formType: WPFormType, wbsElement: WbsElement): string => { @@ -13,22 +13,36 @@ export const getTitleFromFormType = (formType: WPFormType, wbsElement: WbsElemen }; /** - * Given a list of work packages, return work packages with a start date within the next 2 weeks of the current day + * Given a list of work packages, return the work packages that are overdue. * @param wpList a list of work packages. - * @returns a list of work packages with a start date within the next 2 weeks of the current day + * @returns a sub-list of work packages that are not complete, and have end dates before the current date. + */ +export const getOverdueWorkPackages = (wpList: WorkPackage[]): WorkPackage[] => { + return wpList.filter((wp) => wp.status !== WbsElementStatus.Complete && new Date(wp.endDate) <= new Date()); +}; + +/** + * Given a list of work packages, return the work packages that are upcoming. + * @param wpList a list of work packages. + * @returns a sub-list of work packages that are active and have a start date within the next 2 weeks. */ export const getUpcomingWorkPackages = (wpList: WorkPackage[]): WorkPackage[] => { - const currentTime = new Date(); - const twoWeeks = new Date(); - twoWeeks.setDate(currentTime.getDate() + 14); - return wpList.filter(({ startDate }) => currentTime < startDate && startDate <= twoWeeks); + return wpList.filter( + (wp) => + wp.status !== WbsElementStatus.Complete && + new Date(wp.startDate) <= addWeeksToDate(new Date(), 2) && + new Date(wp.startDate) >= new Date() + ); }; /** - * Given a list of work packages, return the work packages that are overdue. + * Given a list of work packages, return the work packages that are in progress. * @param wpList a list of work packages. - * @returns a list of work packages that are overdue. + * @returns a sub-list of work packages that are active, have a start date in the past, and an end date in the future. */ -export const getOverdueWorkPackages = (wpList: WorkPackage[]): WorkPackage[] => { - return wpList.filter((wp) => wp.status !== WbsElementStatus.Complete && wp.endDate < new Date()); +export const getInProgressWorkPackages = (wpList: WorkPackage[]): WorkPackage[] => { + return wpList.filter( + (wp) => + wp.status === WbsElementStatus.Active && new Date(wp.endDate) >= new Date() && new Date(wp.startDate) <= new Date() + ); }; diff --git a/src/shared/index.ts b/src/shared/index.ts index 072fe6aac6..e7c9c3f3be 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/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/permission-utils.ts b/src/shared/src/permission-utils.ts index 1dcd33ff63..121da60ed5 100644 --- a/src/shared/src/permission-utils.ts +++ b/src/shared/src/permission-utils.ts @@ -48,6 +48,16 @@ export const isGuest: PermissionCheck = (role?: Role) => { return role === RoleEnum.GUEST; }; +export const isMember: PermissionCheck = (role?: Role) => { + if (!role) return true; + return role === RoleEnum.MEMBER; +}; + +export const isLead: PermissionCheck = (role?: Role) => { + if (!role) return true; + return role === RoleEnum.HEAD || role === RoleEnum.LEADERSHIP; +}; + export type PermissionCheck = (role: Role | undefined) => boolean; const GUEST_PERMISSIONS = [] as Permission[]; 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/pop-up-types.ts b/src/shared/src/types/pop-up-types.ts new file mode 100644 index 0000000000..2b517a3900 --- /dev/null +++ b/src/shared/src/types/pop-up-types.ts @@ -0,0 +1,6 @@ +export interface PopUp { + popUpId: string; + text: string; + iconName: string; + eventLink?: string; +} diff --git a/src/shared/src/types/user-types.ts b/src/shared/src/types/user-types.ts index 72a1ea03da..08acd175a3 100644 --- a/src/shared/src/types/user-types.ts +++ b/src/shared/src/types/user-types.ts @@ -4,6 +4,7 @@ */ import { AvailabilityCreateArgs } from './design-review-types'; +import { Team } from './team-types'; export interface User { userId: string; @@ -55,6 +56,7 @@ export interface Organization { treasurer?: UserPreview; advisor?: UserPreview; description: string; + slackWorkspaceId?: string; } /** @@ -76,6 +78,8 @@ export interface AuthenticatedUser { isAtLeastFinanceLead?: boolean; organizations: string[]; currentOrganization?: OrganizationPreview; + teamsAsHead?: Team[]; + teamsAsLead?: Team[]; permissions: Permission[]; } diff --git a/src/shared/src/utils.ts b/src/shared/src/utils.ts index 2d2500e275..0c7d782815 100644 --- a/src/shared/src/utils.ts +++ b/src/shared/src/utils.ts @@ -24,3 +24,9 @@ export const wbsNamePipe = (wbsElement: { wbsNum: WbsNumber; name: string }) => export const isSubset = (elements: string[], suppliedArray: string[]): boolean => { return elements.every((element) => suppliedArray.includes(element)); }; + +export const meetingStartTimePipe = (times: number[]) => { + const time = (times[0] % 12) + 10; + + return time <= 12 ? time + 'am' : time - 12 + 'pm'; +}; diff --git a/yarn.lock b/yarn.lock index 9cfa10f86a..b6dd97e564 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1614,8 +1614,8 @@ __metadata: linkType: hard "@babel/traverse@npm:^7.1.0, @babel/traverse@npm:^7.12.1, @babel/traverse@npm:^7.25.9, @babel/traverse@npm:^7.7.0": - version: 7.26.3 - resolution: "@babel/traverse@npm:7.26.3" + version: 7.26.4 + resolution: "@babel/traverse@npm:7.26.4" dependencies: "@babel/code-frame": ^7.26.2 "@babel/generator": ^7.26.3 @@ -1624,7 +1624,7 @@ __metadata: "@babel/types": ^7.26.3 debug: ^4.3.1 globals: ^11.1.0 - checksum: 417287d1197b9878af950c0c89bacd67e3749960bdf325eeca646142dbc7e2d959f713ab69c715c2ad48c8a3cd9210ff8a1d2f86968f75aba4f93d5d70cbdfae + checksum: dcdf51b27ab640291f968e4477933465c2910bfdcbcff8f5315d1f29b8ff861864f363e84a71fb489f5e9708e8b36b7540608ce019aa5e57ef7a4ba537e62700 languageName: node linkType: hard @@ -1699,16 +1699,16 @@ __metadata: languageName: node linkType: hard -"@emotion/cache@npm:^11.11.0, @emotion/cache@npm:^11.13.5": - version: 11.13.5 - resolution: "@emotion/cache@npm:11.13.5" +"@emotion/cache@npm:^11.13.5, @emotion/cache@npm:^11.14.0": + version: 11.14.0 + resolution: "@emotion/cache@npm:11.14.0" dependencies: "@emotion/memoize": ^0.9.0 "@emotion/sheet": ^1.4.0 "@emotion/utils": ^1.4.2 "@emotion/weak-memoize": ^0.4.0 stylis: 4.2.0 - checksum: d4429bcac07730dd65707b8203f855be3d1958183e05d265eeefbeab19822e70c87250fad9abaeaea575d844256d1b8fee348211ef905f7715234df0ee088188 + checksum: 0a81591541ea43bc7851742e6444b7800d72e98006f94e775ae6ea0806662d14e0a86ff940f5f19d33b4bb2c427c882aa65d417e7322a6e0d5f20fe65ed920c9 languageName: node linkType: hard @@ -1736,14 +1736,14 @@ __metadata: linkType: hard "@emotion/react@npm:^11.10.4": - version: 11.13.5 - resolution: "@emotion/react@npm:11.13.5" + version: 11.14.0 + resolution: "@emotion/react@npm:11.14.0" dependencies: "@babel/runtime": ^7.18.3 "@emotion/babel-plugin": ^11.13.5 - "@emotion/cache": ^11.13.5 + "@emotion/cache": ^11.14.0 "@emotion/serialize": ^1.3.3 - "@emotion/use-insertion-effect-with-fallbacks": ^1.1.0 + "@emotion/use-insertion-effect-with-fallbacks": ^1.2.0 "@emotion/utils": ^1.4.2 "@emotion/weak-memoize": ^0.4.0 hoist-non-react-statics: ^3.3.1 @@ -1752,7 +1752,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 25c6572bdb1472d622a41817881628eb311deafe507bbb1be95c6a4c2c23a19bdd011c368d5794c5a17a1fdf2b5db327480a4c0b85d9322078d531289c163225 + checksum: 3cf023b11d132b56168713764d6fced8e5a1f0687dfe0caa2782dfd428c8f9e30f9826a919965a311d87b523cd196722aaf75919cd0f6bd0fd57f8a6a0281500 languageName: node linkType: hard @@ -1777,14 +1777,14 @@ __metadata: linkType: hard "@emotion/styled@npm:^11.10.4": - version: 11.13.5 - resolution: "@emotion/styled@npm:11.13.5" + version: 11.14.0 + resolution: "@emotion/styled@npm:11.14.0" dependencies: "@babel/runtime": ^7.18.3 "@emotion/babel-plugin": ^11.13.5 "@emotion/is-prop-valid": ^1.3.0 "@emotion/serialize": ^1.3.3 - "@emotion/use-insertion-effect-with-fallbacks": ^1.1.0 + "@emotion/use-insertion-effect-with-fallbacks": ^1.2.0 "@emotion/utils": ^1.4.2 peerDependencies: "@emotion/react": ^11.0.0-rc.0 @@ -1792,7 +1792,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 316b3e9f98fc9d3faa54a990ad7f3805611c76a4ebffac9a6c28b6e7e9dd6e9343e9b80c79617af7f64524081a606f3789a89373abf67362fa138028f91b62af + checksum: 9c1b842e942e69fb6037d1ab161046d2bcfeff95fd2ccfdab30acaaf6b89dc07b14bb00f8cc8ec14df11e6746c8e4e1d781bc54d10bd739aab44966ded64d4fb languageName: node linkType: hard @@ -1803,12 +1803,12 @@ __metadata: languageName: node linkType: hard -"@emotion/use-insertion-effect-with-fallbacks@npm:^1.1.0": - version: 1.1.0 - resolution: "@emotion/use-insertion-effect-with-fallbacks@npm:1.1.0" +"@emotion/use-insertion-effect-with-fallbacks@npm:^1.2.0": + version: 1.2.0 + resolution: "@emotion/use-insertion-effect-with-fallbacks@npm:1.2.0" peerDependencies: react: ">=16.8.0" - checksum: 63665191773b27de66807c53b90091ef0d10d5161381f62726cfceecfe1d8c944f18594b8021805fc81575b64246fd5ab9c75d60efabec92f940c1c410530949 + checksum: 8ff6aec7f2924526ff8c8f8f93d4b8236376e2e12c435314a18c9a373016e24dfdf984e82bbc83712b8e90ff4783cd765eb39fc7050d1a43245e5728740ddd71 languageName: node linkType: hard @@ -2023,21 +2023,21 @@ __metadata: linkType: hard "@floating-ui/core@npm:^1.6.0": - version: 1.6.8 - resolution: "@floating-ui/core@npm:1.6.8" + version: 1.6.9 + resolution: "@floating-ui/core@npm:1.6.9" dependencies: - "@floating-ui/utils": ^0.2.8 - checksum: 82faa6ea9d57e466779324e51308d6d49c098fb9d184a08d9bb7f4fad83f08cc070fc491f8d56f0cad44a16215fb43f9f829524288413e6c33afcb17303698de + "@floating-ui/utils": ^0.2.9 + checksum: 21cbcac72a40172399570dedf0eb96e4f24b0d829980160e8d14edf08c2955ac6feffb7b94e1530c78fb7944635e52669c9257ad08570e0295efead3b5a9af91 languageName: node linkType: hard "@floating-ui/dom@npm:^1.0.0": - version: 1.6.12 - resolution: "@floating-ui/dom@npm:1.6.12" + version: 1.6.13 + resolution: "@floating-ui/dom@npm:1.6.13" dependencies: "@floating-ui/core": ^1.6.0 - "@floating-ui/utils": ^0.2.8 - checksum: 956514ed100c0c853e73ace9e3c877b7e535444d7c31326f687a7690d49cb1e59ef457e9c93b76141aea0d280e83ed5a983bb852718b62eea581f755454660f6 + "@floating-ui/utils": ^0.2.9 + checksum: eabab9d860d3b5beab1c2d6936287efc4d9ab352de99062380589ef62870d59e8730397489c34a96657e128498001b5672330c4a9da0159fe8b2401ac59fe314 languageName: node linkType: hard @@ -2053,10 +2053,10 @@ __metadata: languageName: node linkType: hard -"@floating-ui/utils@npm:^0.2.8": - version: 0.2.8 - resolution: "@floating-ui/utils@npm:0.2.8" - checksum: deb98bba017c4e073c7ad5740d4dec33a4d3e0942d412e677ac0504f3dade15a68fc6fd164d43c93c0bb0bcc5dc5015c1f4080dfb1a6161140fe660624f7c875 +"@floating-ui/utils@npm:^0.2.9": + version: 0.2.9 + resolution: "@floating-ui/utils@npm:0.2.9" + checksum: d518b80cec5a323e54a069a1dd99a20f8221a4853ed98ac16c75275a0cc22f75de4f8ac5b121b4f8990bd45da7ad1fb015b9a1e4bac27bb1cd62444af84e9784 languageName: node linkType: hard @@ -2457,13 +2457,13 @@ __metadata: linkType: hard "@jridgewell/gen-mapping@npm:^0.3.5": - version: 0.3.5 - resolution: "@jridgewell/gen-mapping@npm:0.3.5" + version: 0.3.8 + resolution: "@jridgewell/gen-mapping@npm:0.3.8" dependencies: "@jridgewell/set-array": ^1.2.1 "@jridgewell/sourcemap-codec": ^1.4.10 "@jridgewell/trace-mapping": ^0.3.24 - checksum: ff7a1764ebd76a5e129c8890aa3e2f46045109dabde62b0b6c6a250152227647178ff2069ea234753a690d8f3c4ac8b5e7b267bbee272bffb7f3b0a370ab6e52 + checksum: c0687b5227461717aa537fe71a42e356bcd1c43293b3353796a148bf3b0d6f59109def46c22f05b60e29a46f19b2e4676d027959a7c53a6c92b9d5b0d87d0420 languageName: node linkType: hard @@ -2573,16 +2573,16 @@ __metadata: languageName: node linkType: hard -"@mui/core-downloads-tracker@npm:^5.16.9": - version: 5.16.9 - resolution: "@mui/core-downloads-tracker@npm:5.16.9" - checksum: 25e7cf746627e12671e2bae4ea8f81967fbb7e05188c268052104d05f249eea1baa3f3d97f66d1242112afa944a7b025a331527392797d620acbcc1dde23a3df +"@mui/core-downloads-tracker@npm:^5.16.14": + version: 5.16.14 + resolution: "@mui/core-downloads-tracker@npm:5.16.14" + checksum: a25658362a69a89f35cdc12ded01b998b7f02df43648029f2523813fc7f259cc85f62bd1877059359d462e7c163e82308bd4cc74fa2d35651d302c5d8bbbc7f4 languageName: node linkType: hard "@mui/icons-material@npm:^5.10.3": - version: 5.16.9 - resolution: "@mui/icons-material@npm:5.16.9" + version: 5.16.14 + resolution: "@mui/icons-material@npm:5.16.14" dependencies: "@babel/runtime": ^7.23.9 peerDependencies: @@ -2592,25 +2592,25 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 84e6884de333afbc819f2871d4449a00960a8c080bfdcb5e9c621a7727179ce2ce97097dc1e4dbcda7d6b234a09aa7593ab6cd172a323429f562625d70511545 + checksum: 14c01298e47972099ce71a8f142e2bb93dfc61a3ac0239030c55b3e5c6719f692e9c2fac131247c52f4922e74b08291ecb1f3398117222cc7baa82ec6f38e073 languageName: node linkType: hard "@mui/material@npm:^5.10.3": - version: 5.16.9 - resolution: "@mui/material@npm:5.16.9" + version: 5.16.14 + resolution: "@mui/material@npm:5.16.14" dependencies: "@babel/runtime": ^7.23.9 - "@mui/core-downloads-tracker": ^5.16.9 - "@mui/system": ^5.16.8 + "@mui/core-downloads-tracker": ^5.16.14 + "@mui/system": ^5.16.14 "@mui/types": ^7.2.15 - "@mui/utils": ^5.16.8 + "@mui/utils": ^5.16.14 "@popperjs/core": ^2.11.8 "@types/react-transition-group": ^4.4.10 clsx: ^2.1.0 csstype: ^3.1.3 prop-types: ^15.8.1 - react-is: ^18.3.1 + react-is: ^19.0.0 react-transition-group: ^4.4.5 peerDependencies: "@emotion/react": ^11.5.0 @@ -2625,16 +2625,16 @@ __metadata: optional: true "@types/react": optional: true - checksum: e3692ba08add0a4b959d7e5f754cf5bf934d30798a9256d1eb5bbf37021f81cf6cc0b86a3cb065815af7f2112c6a359e463a6641618d07559f93516e1374669e + checksum: 0c09353099580ca4f4e214ef1251a5fb1328a5ec19b5195ec4dd628c0ae0b3fae47a8fcae39a807aac1ac8a26bf0109da07eb7eca183975f8cae583d7306de0f languageName: node linkType: hard -"@mui/private-theming@npm:^5.16.8": - version: 5.16.8 - resolution: "@mui/private-theming@npm:5.16.8" +"@mui/private-theming@npm:^5.16.14": + version: 5.16.14 + resolution: "@mui/private-theming@npm:5.16.14" dependencies: "@babel/runtime": ^7.23.9 - "@mui/utils": ^5.16.8 + "@mui/utils": ^5.16.14 prop-types: ^15.8.1 peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -2642,16 +2642,16 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 576cf19188c236482a8a698978d3061c7e8d76ba9d891d323ce96d92d6b80d88e1aa2a67fe4698d76b8a43221ef0f8ed21be74971fc684ce1279dc73fd34316b + checksum: da762a6ccf5d12e2bfad4de29cdfbcfaf7d8229706e1063ecf41a0e724e0cd0317a847a83b0edc553f07e63566dbbb4e6aa15f6b295993224977f969769318be languageName: node linkType: hard -"@mui/styled-engine@npm:^5.16.8": - version: 5.16.8 - resolution: "@mui/styled-engine@npm:5.16.8" +"@mui/styled-engine@npm:^5.16.14": + version: 5.16.14 + resolution: "@mui/styled-engine@npm:5.16.14" dependencies: "@babel/runtime": ^7.23.9 - "@emotion/cache": ^11.11.0 + "@emotion/cache": ^11.13.5 csstype: ^3.1.3 prop-types: ^15.8.1 peerDependencies: @@ -2663,19 +2663,19 @@ __metadata: optional: true "@emotion/styled": optional: true - checksum: 8eab246e985bbe2707af4620a3193166bacefb029df65ac846cfc0ac5d7996fcb72c50327241f60913538f58a2ad9aa6de3ef12b72eb7750c71f8d3c284a420b + checksum: 5868683e6dd4004b34a0ce9de8842c180f7b7618c0b8cff904771659ef2da3aae574ea10b0c5ad6ffe59db396f699773ebb99f3f9d831d8b5adc398ecc618195 languageName: node linkType: hard -"@mui/system@npm:^5.10.16, @mui/system@npm:^5.16.8": - version: 5.16.8 - resolution: "@mui/system@npm:5.16.8" +"@mui/system@npm:^5.10.16, @mui/system@npm:^5.16.14": + version: 5.16.14 + resolution: "@mui/system@npm:5.16.14" dependencies: "@babel/runtime": ^7.23.9 - "@mui/private-theming": ^5.16.8 - "@mui/styled-engine": ^5.16.8 + "@mui/private-theming": ^5.16.14 + "@mui/styled-engine": ^5.16.14 "@mui/types": ^7.2.15 - "@mui/utils": ^5.16.8 + "@mui/utils": ^5.16.14 clsx: ^2.1.0 csstype: ^3.1.3 prop-types: ^15.8.1 @@ -2691,59 +2691,59 @@ __metadata: optional: true "@types/react": optional: true - checksum: 8082b176009fa907cdb0391db59db6cd3589659ad92497caec998c01e1683978ab2eda4ece4d12e523d233b062aa5946d4a438d9a22bf172f0568be1db773e9c + checksum: 20b5d7c1cd1f163c6e28fb267ffb9705e61faf24b62bae5dde08904f2b42a2173ee714d72ae1b5f84a7d50356ad5fa7525a625f9e360d9a886c52b1a1bf6698e languageName: node linkType: hard -"@mui/types@npm:^7.2.14-dev.20240529-082515-213b5e33ab, @mui/types@npm:^7.2.15, @mui/types@npm:^7.2.19": - version: 7.2.19 - resolution: "@mui/types@npm:7.2.19" +"@mui/types@npm:^7.2.14-dev.20240529-082515-213b5e33ab, @mui/types@npm:^7.2.15, @mui/types@npm:^7.2.21": + version: 7.2.21 + resolution: "@mui/types@npm:7.2.21" peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: c3b5723e6f0861d47df834c57878f19347aefecdaf948cf9a25a64b73fbc75791430693d0f540b2bdc01bdfc605dc32bf4ba738113ec415aa9eaf002ce38f064 + checksum: d3c005358777204debb75c684a25ec324df8a0e108440a4b8bd7c658716d6712d993253294fc790c4c9f431156502f1cafd63819643c7a7b9651ac3216ebf9f2 languageName: node linkType: hard -"@mui/utils@npm:^5.10.3, @mui/utils@npm:^5.14.16, @mui/utils@npm:^5.16.8": - version: 5.16.8 - resolution: "@mui/utils@npm:5.16.8" +"@mui/utils@npm:^5.10.3, @mui/utils@npm:^5.14.16, @mui/utils@npm:^5.16.14": + version: 5.16.14 + resolution: "@mui/utils@npm:5.16.14" dependencies: "@babel/runtime": ^7.23.9 "@mui/types": ^7.2.15 "@types/prop-types": ^15.7.12 clsx: ^2.1.1 prop-types: ^15.8.1 - react-is: ^18.3.1 + react-is: ^19.0.0 peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: ed714c6aa583bdf17af9f523c9b9ba3789a47677aa6408bc2afd842fa9eaa6d111361c1309435d481ea03b8c695e22ead98c43dc13239c63a3da481c95e5ad72 + checksum: 311ca0c2c2aa315cdcb351805fc5be2c474492afb0b07ae88d99e7ad7d5ab5bbc477cc3d5f6cbde32e16b65588d52dcc53fbe70c026534134a5e3b942dcc444c languageName: node linkType: hard "@mui/utils@npm:^6.0.0-dev.20240529-082515-213b5e33ab": - version: 6.1.10 - resolution: "@mui/utils@npm:6.1.10" + version: 6.3.1 + resolution: "@mui/utils@npm:6.3.1" dependencies: "@babel/runtime": ^7.26.0 - "@mui/types": ^7.2.19 - "@types/prop-types": ^15.7.13 + "@mui/types": ^7.2.21 + "@types/prop-types": ^15.7.14 clsx: ^2.1.1 prop-types: ^15.8.1 - react-is: ^18.3.1 + react-is: ^19.0.0 peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 92d3e30ca38cb77e7a9de555cc6b3521d1a50aafedfc769d6b6f55d9fd41e8f71ea3b4e716bedb55e52b88d495a25168d9e2dbd5ed23db496edee6a10f28ec6d + checksum: 49740d9b1b465c06d0cdb56bed32f95e5006af998197357681b313c1c4c618ac200b701169be35a1cfc8beb2026e212d2bacb0069b9284acd13885ab872b1aea languageName: node linkType: hard @@ -3216,9 +3216,9 @@ __metadata: linkType: hard "@rushstack/eslint-patch@npm:^1.1.0": - version: 1.10.4 - resolution: "@rushstack/eslint-patch@npm:1.10.4" - checksum: ec17ac954ed01e9c714e29ae00da29099234a71615d6f61f2da5c7beeef283f5619132114faf9481cb1ca7b4417aed74c05a54d416e4d8facc189bb216d49066 + version: 1.10.5 + resolution: "@rushstack/eslint-patch@npm:1.10.5" + checksum: c7df90efeb77e4311f70549c1b0c41455e3a4f0c0cf2696e560d9a535f129d63ab84c98d0a3de95ed2d369d5281b541af819f99002bfd38e185e59c355b58d69 languageName: node linkType: hard @@ -3254,6 +3254,30 @@ __metadata: languageName: node linkType: hard +"@slack/events-api@npm:^3.0.1": + version: 3.0.1 + resolution: "@slack/events-api@npm:3.0.1" + dependencies: + "@types/debug": ^4.1.4 + "@types/express": ^4.17.0 + "@types/lodash.isstring": ^4.0.6 + "@types/node": ">=12.13.0 < 13" + "@types/yargs": ^15.0.4 + debug: ^2.6.1 + express: ^4.0.0 + lodash.isstring: ^4.0.1 + raw-body: ^2.3.3 + tsscmp: ^1.0.6 + yargs: ^15.3.1 + dependenciesMeta: + express: + optional: true + bin: + slack-verify: dist/verify.js + checksum: ce62dc2ee9dd93b88820e18f88f543228740243dc390caf49b3a7e1ad351b298e3961898bd78f5eb43e9f6acac067458257cd34c9661089f684bb5cf4af468c3 + languageName: node + linkType: hard + "@slack/logger@npm:^3.0.0": version: 3.0.0 resolution: "@slack/logger@npm:3.0.0" @@ -3700,7 +3724,7 @@ __metadata: languageName: node linkType: hard -"@types/debug@npm:^4.1.7": +"@types/debug@npm:^4.1.4, @types/debug@npm:^4.1.7": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" dependencies: @@ -3750,15 +3774,27 @@ __metadata: languageName: node linkType: hard +"@types/express-serve-static-core@npm:^4.17.33": + version: 4.19.6 + resolution: "@types/express-serve-static-core@npm:4.19.6" + dependencies: + "@types/node": "*" + "@types/qs": "*" + "@types/range-parser": "*" + "@types/send": "*" + checksum: b0576eddc2d25ccdf10e68ba09598b87a4d7b2ad04a81dc847cb39fe56beb0b6a5cc017b1e00aa0060cb3b38e700384ce96d291a116a0f1e54895564a104aae9 + languageName: node + linkType: hard + "@types/express-serve-static-core@npm:^5.0.0": - version: 5.0.2 - resolution: "@types/express-serve-static-core@npm:5.0.2" + version: 5.0.3 + resolution: "@types/express-serve-static-core@npm:5.0.3" dependencies: "@types/node": "*" "@types/qs": "*" "@types/range-parser": "*" "@types/send": "*" - checksum: 841229b63801b334729e56ec04b5023e084e2962d61dddc95b3508e2fc821e8550bd69c074b6cb0a1c57147eb324c4dc543103d0827e34077f7eb6d230d08a8f + checksum: aacfef3c31d20598a12a3d9f98e924c94b10acd9fc4dca155c91b5cef5861851952821e8c449fa409eb4287b35203d3c470bf2ffa2e69090f8dfa742f1499335 languageName: node linkType: hard @@ -3783,6 +3819,18 @@ __metadata: languageName: node linkType: hard +"@types/express@npm:^4.17.0": + version: 4.17.21 + resolution: "@types/express@npm:4.17.21" + dependencies: + "@types/body-parser": "*" + "@types/express-serve-static-core": ^4.17.33 + "@types/qs": "*" + "@types/serve-static": "*" + checksum: fb238298630370a7392c7abdc80f495ae6c716723e114705d7e3fb67e3850b3859bbfd29391463a3fb8c0b32051847935933d99e719c0478710f8098ee7091c5 + languageName: node + linkType: hard + "@types/file-saver@npm:^2.0.5": version: 2.0.7 resolution: "@types/file-saver@npm:2.0.7" @@ -3826,12 +3874,12 @@ __metadata: linkType: hard "@types/hoist-non-react-statics@npm:^3.3.1": - version: 3.3.5 - resolution: "@types/hoist-non-react-statics@npm:3.3.5" + version: 3.3.6 + resolution: "@types/hoist-non-react-statics@npm:3.3.6" dependencies: "@types/react": "*" hoist-non-react-statics: ^3.3.0 - checksum: b645b062a20cce6ab1245ada8274051d8e2e0b2ee5c6bd58215281d0ec6dae2f26631af4e2e7c8abe238cdcee73fcaededc429eef569e70908f82d0cc0ea31d7 + checksum: f03e43bd081876c49584ffa0eb690d69991f258203efca44dcc30efdda49a50653ff06402917d1edc9cb7e2adebbe9e2d1d0e739bc99c1b5372103b1cc534e47 languageName: node linkType: hard @@ -3933,10 +3981,19 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.14.175": - version: 4.17.13 - resolution: "@types/lodash@npm:4.17.13" - checksum: d0bf8fbd950be71946e0076b30fd40d492293baea75f05931b6b5b906fd62583708c6229abdb95b30205ad24ce1ed2f48bc9d419364f682320edd03405cc0c7e +"@types/lodash.isstring@npm:^4.0.6": + version: 4.0.9 + resolution: "@types/lodash.isstring@npm:4.0.9" + dependencies: + "@types/lodash": "*" + checksum: ef381be69b459caa42d7c5dc4ff5b3653e6b3c9b2393f6e92848efeafe7690438e058b26f036b11b4e535fc7645ff12d1203847b9a82e9ae0593bdd3b25a971b + languageName: node + linkType: hard + +"@types/lodash@npm:*, @types/lodash@npm:^4.14.175": + version: 4.17.14 + resolution: "@types/lodash@npm:4.17.14" + checksum: 2dbeaff92b31cb523f6bc4bb99a3d8c88fbb001d54f2367a888add85784fb213744a9b1600e1e98b6790ab191fdb6ec839eb0e3d63fcf6fb6cf1ebe4c3d21149 languageName: node linkType: hard @@ -3987,11 +4044,11 @@ __metadata: linkType: hard "@types/node@npm:*, @types/node@npm:>=12.0.0": - version: 22.10.1 - resolution: "@types/node@npm:22.10.1" + version: 22.10.5 + resolution: "@types/node@npm:22.10.5" dependencies: undici-types: ~6.20.0 - checksum: 5a9b81500f288a8fb757b61bd939f99f72b6cb59347a5bae52dd1c2c87100ebbaa9da4256ef3cb9add2090e8704cda1d9a1ffc14ccd5db47a6466c8bae10ebcb + checksum: 3b0e966df4e130edac3ad034f1cddbe134e70f11556062468c9fbd749a3b07a44445a3a75a7eec68a104930bf05d4899f1a418c4ae48493d2c8c1544d8594bcc languageName: node linkType: hard @@ -4002,6 +4059,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=12.13.0 < 13": + version: 12.20.55 + resolution: "@types/node@npm:12.20.55" + checksum: e4f86785f4092706e0d3b0edff8dca5a13b45627e4b36700acd8dfe6ad53db71928c8dee914d4276c7fd3b6ccd829aa919811c9eb708a2c8e4c6eb3701178c37 + languageName: node + linkType: hard + "@types/nodemailer@npm:^6.4.0": version: 6.4.17 resolution: "@types/nodemailer@npm:6.4.17" @@ -4032,10 +4096,10 @@ __metadata: languageName: node linkType: hard -"@types/prop-types@npm:*, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.13": - version: 15.7.13 - resolution: "@types/prop-types@npm:15.7.13" - checksum: 8935cad87c683c665d09a055919d617fe951cb3b2d5c00544e3a913f861a2bd8d2145b51c9aa6d2457d19f3107ab40784c40205e757232f6a80cc8b1c815513c +"@types/prop-types@npm:*, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.14": + version: 15.7.14 + resolution: "@types/prop-types@npm:15.7.14" + checksum: d0c5407b9ccc3dd5fae0ccf9b1007e7622ba5e6f1c18399b4f24dff33619d469da4b9fa918a374f19dc0d9fe6a013362aab0b844b606cfc10676efba3f5f736d languageName: node linkType: hard @@ -4109,11 +4173,11 @@ __metadata: linkType: hard "@types/react-transition-group@npm:^4.4.10, @types/react-transition-group@npm:^4.4.8": - version: 4.4.11 - resolution: "@types/react-transition-group@npm:4.4.11" - dependencies: + version: 4.4.12 + resolution: "@types/react-transition-group@npm:4.4.12" + peerDependencies: "@types/react": "*" - checksum: a6e3b2e4363cb019e256ae4f19dadf9d7eb199da1a5e4109bbbf6a132821884044d332e9c74b520b1e5321a7f545502443fd1ce0b18649c8b510fa4220b0e5c2 + checksum: 13d36396cae4d3c316b03d4a0ba299f0d039c59368ba65e04b0c3dc06fd0a16f59d2c669c3e32d6d525a95423f156b84e550d26bff0bdd8df285f305f8f3a0ed languageName: node linkType: hard @@ -4286,7 +4350,7 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^15.0.0": +"@types/yargs@npm:^15.0.0, @types/yargs@npm:^15.0.4": version: 15.0.19 resolution: "@types/yargs@npm:15.0.19" dependencies: @@ -5092,12 +5156,10 @@ __metadata: languageName: node linkType: hard -"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1": - version: 7.1.1 - resolution: "agent-base@npm:7.1.1" - dependencies: - debug: ^4.3.4 - checksum: 51c158769c5c051482f9ca2e6e1ec085ac72b5a418a9b31b4e82fe6c0a6699adb94c1c42d246699a587b3335215037091c79e0de512c516f73b6ea844202f037 +"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": + version: 7.1.3 + resolution: "agent-base@npm:7.1.3" + checksum: 87bb7ee54f5ecf0ccbfcba0b07473885c43ecd76cb29a8db17d6137a19d9f9cd443a2a7c5fd8a3f24d58ad8145f9eb49116344a66b107e1aeab82cf2383f4753 languageName: node linkType: hard @@ -5363,13 +5425,13 @@ __metadata: languageName: node linkType: hard -"array-buffer-byte-length@npm:^1.0.0, array-buffer-byte-length@npm:^1.0.1": - version: 1.0.1 - resolution: "array-buffer-byte-length@npm:1.0.1" +"array-buffer-byte-length@npm:^1.0.0, array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": + version: 1.0.2 + resolution: "array-buffer-byte-length@npm:1.0.2" dependencies: - call-bind: ^1.0.5 - is-array-buffer: ^3.0.4 - checksum: 53524e08f40867f6a9f35318fafe467c32e45e9c682ba67b11943e167344d2febc0f6977a17e699b05699e805c3e8f073d876f8bbf1b559ed494ad2cd0fae09e + call-bound: ^1.0.3 + is-array-buffer: ^3.0.5 + checksum: 0ae3786195c3211b423e5be8dd93357870e6fb66357d81da968c2c39ef43583ef6eece1f9cb1caccdae4806739c65dea832b44b8593414313cd76a89795fca63 languageName: node linkType: hard @@ -5467,26 +5529,26 @@ __metadata: linkType: hard "array.prototype.flat@npm:^1.3.1, array.prototype.flat@npm:^1.3.2": - version: 1.3.2 - resolution: "array.prototype.flat@npm:1.3.2" + version: 1.3.3 + resolution: "array.prototype.flat@npm:1.3.3" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - es-shim-unscopables: ^1.0.0 - checksum: 5d6b4bf102065fb3f43764bfff6feb3295d372ce89591e6005df3d0ce388527a9f03c909af6f2a973969a4d178ab232ffc9236654149173e0e187ec3a1a6b87b + call-bind: ^1.0.8 + define-properties: ^1.2.1 + es-abstract: ^1.23.5 + es-shim-unscopables: ^1.0.2 + checksum: 5d5a7829ab2bb271a8d30a1c91e6271cef0ec534593c0fe6d2fb9ebf8bb62c1e5326e2fddcbbcbbe5872ca04f5e6b54a1ecf092e0af704fb538da9b2bfd95b40 languageName: node linkType: hard -"array.prototype.flatmap@npm:^1.3.2": - version: 1.3.2 - resolution: "array.prototype.flatmap@npm:1.3.2" +"array.prototype.flatmap@npm:^1.3.2, array.prototype.flatmap@npm:^1.3.3": + version: 1.3.3 + resolution: "array.prototype.flatmap@npm:1.3.3" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - es-shim-unscopables: ^1.0.0 - checksum: ce09fe21dc0bcd4f30271f8144083aa8c13d4639074d6c8dc82054b847c7fc9a0c97f857491f4da19d4003e507172a78f4bcd12903098adac8b9cd374f734be3 + call-bind: ^1.0.8 + define-properties: ^1.2.1 + es-abstract: ^1.23.5 + es-shim-unscopables: ^1.0.2 + checksum: 11b4de09b1cf008be6031bb507d997ad6f1892e57dc9153583de6ebca0f74ea403fffe0f203461d359de05048d609f3f480d9b46fed4099652d8b62cc972f284 languageName: node linkType: hard @@ -5518,19 +5580,18 @@ __metadata: languageName: node linkType: hard -"arraybuffer.prototype.slice@npm:^1.0.3": - version: 1.0.3 - resolution: "arraybuffer.prototype.slice@npm:1.0.3" +"arraybuffer.prototype.slice@npm:^1.0.4": + version: 1.0.4 + resolution: "arraybuffer.prototype.slice@npm:1.0.4" dependencies: array-buffer-byte-length: ^1.0.1 - call-bind: ^1.0.5 + call-bind: ^1.0.8 define-properties: ^1.2.1 - es-abstract: ^1.22.3 - es-errors: ^1.2.1 - get-intrinsic: ^1.2.3 + es-abstract: ^1.23.5 + es-errors: ^1.3.0 + get-intrinsic: ^1.2.6 is-array-buffer: ^3.0.4 - is-shared-array-buffer: ^1.0.2 - checksum: 352259cba534dcdd969c92ab002efd2ba5025b2e3b9bead3973150edbdf0696c629d7f4b3f061c5931511e8207bdc2306da614703c820b45dabce39e3daf7e3e + checksum: b1d1fd20be4e972a3779b1569226f6740170dca10f07aa4421d42cefeec61391e79c557cda8e771f5baefe47d878178cd4438f60916ce831813c08132bced765 languageName: node linkType: hard @@ -6355,17 +6416,17 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.0.0, browserslist@npm:^4.12.0, browserslist@npm:^4.24.0, browserslist@npm:^4.24.2, browserslist@npm:^4.6.2, browserslist@npm:^4.6.4": - version: 4.24.2 - resolution: "browserslist@npm:4.24.2" +"browserslist@npm:^4.0.0, browserslist@npm:^4.12.0, browserslist@npm:^4.24.0, browserslist@npm:^4.24.3, browserslist@npm:^4.6.2, browserslist@npm:^4.6.4": + version: 4.24.3 + resolution: "browserslist@npm:4.24.3" dependencies: - caniuse-lite: ^1.0.30001669 - electron-to-chromium: ^1.5.41 - node-releases: ^2.0.18 + caniuse-lite: ^1.0.30001688 + electron-to-chromium: ^1.5.73 + node-releases: ^2.0.19 update-browserslist-db: ^1.1.1 bin: browserslist: cli.js - checksum: cf64085f12132d38638f38937a255edb82c7551b164a98577b055dd79719187a816112f7b97b9739e400c4954cd66479c0d7a843cb816e346f4795dc24fd5d97 + checksum: 016efc9953350e3a7212edcfdd72210cb33b339c1a974a77c0715eb67d23d7e5cd0a073ce1c801ab09235d8c213425ca51b92d41bbb829b833872b45f885fe7c languageName: node linkType: hard @@ -6570,16 +6631,35 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7": - version: 1.0.7 - resolution: "call-bind@npm:1.0.7" +"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1": + version: 1.0.1 + resolution: "call-bind-apply-helpers@npm:1.0.1" dependencies: - es-define-property: ^1.0.0 es-errors: ^1.3.0 function-bind: ^1.1.2 + checksum: 3c55343261bb387c58a4762d15ad9d42053659a62681ec5eb50690c6b52a4a666302a01d557133ce6533e8bd04530ee3b209f23dd06c9577a1925556f8fcccdf + languageName: node + linkType: hard + +"call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": + version: 1.0.8 + resolution: "call-bind@npm:1.0.8" + dependencies: + call-bind-apply-helpers: ^1.0.0 + es-define-property: ^1.0.0 get-intrinsic: ^1.2.4 - set-function-length: ^1.2.1 - checksum: 295c0c62b90dd6522e6db3b0ab1ce26bdf9e7404215bda13cfee25b626b5ff1a7761324d58d38b1ef1607fc65aca2d06e44d2e18d0dfc6c14b465b00d8660029 + set-function-length: ^1.2.2 + checksum: aa2899bce917a5392fd73bd32e71799c37c0b7ab454e0ed13af7f6727549091182aade8bbb7b55f304a5bc436d543241c14090fb8a3137e9875e23f444f4f5a9 + languageName: node + linkType: hard + +"call-bound@npm:^1.0.2, call-bound@npm:^1.0.3": + version: 1.0.3 + resolution: "call-bound@npm:1.0.3" + dependencies: + call-bind-apply-helpers: ^1.0.1 + get-intrinsic: ^1.2.6 + checksum: a93bbe0f2d0a2d6c144a4349ccd0593d5d0d5d9309b69101710644af8964286420062f2cc3114dca120b9bc8cc07507952d4b1b3ea7672e0d7f6f1675efedb32 languageName: node linkType: hard @@ -6651,10 +6731,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001125, caniuse-lite@npm:^1.0.30001669": - version: 1.0.30001686 - resolution: "caniuse-lite@npm:1.0.30001686" - checksum: 9c8a0ce38ec201d5d7039ebd6da548cbda19c67f1449e2a1dd831a9ff6f1f92048e2896899bbc07795b39cfbdc895225393d89e3af97874865f3a0ea9cf680a8 +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001125, caniuse-lite@npm:^1.0.30001688": + version: 1.0.30001690 + resolution: "caniuse-lite@npm:1.0.30001690" + checksum: f2c1b595f15d8de4d9ccd155d61ac9f00ac62f1515870505a0186266fd52aef169fcddc90d8a4814e52b77107244806466fadc2c216662f23f1022a430e735ee languageName: node linkType: hard @@ -6877,11 +6957,11 @@ __metadata: linkType: hard "chokidar@npm:^4.0.0": - version: 4.0.1 - resolution: "chokidar@npm:4.0.1" + version: 4.0.3 + resolution: "chokidar@npm:4.0.3" dependencies: readdirp: ^4.0.1 - checksum: 193da9786b0422a895d59c7552195d15c6c636e6a2293ae43d09e34e243e24ccd02d693f007c767846a65abbeae5fea6bfacb8fc2ddec4ea4d397620d552010d + checksum: a8765e452bbafd04f3f2fad79f04222dd65f43161488bb6014a41099e6ca18d166af613d59a90771908c1c823efa3f46ba36b86ac50b701c20c1b9908c5fe36e languageName: node linkType: hard @@ -7452,18 +7532,18 @@ __metadata: linkType: hard "core-js-compat@npm:^3.38.0, core-js-compat@npm:^3.38.1": - version: 3.39.0 - resolution: "core-js-compat@npm:3.39.0" + version: 3.40.0 + resolution: "core-js-compat@npm:3.40.0" dependencies: - browserslist: ^4.24.2 - checksum: 2d7d087c3271d711d03a55203d4756f6288317a1ce35cdc8bafaf1833ef21fd67a92a50cff8dcf7df1325ac63720906ab3cf514c85b238c95f65fca1040f6ad6 + browserslist: ^4.24.3 + checksum: 7ad00607c481ab2ded13d72be9ca5db5bbf42e221a175e905fb425e1ef520864aea28736c7283f57e9552d570eb6204bed87fbc8b9eab0fcfd9a7830dacccd43 languageName: node linkType: hard "core-js-pure@npm:^3.30.2": - version: 3.39.0 - resolution: "core-js-pure@npm:3.39.0" - checksum: cdcb1eec4eb9308fcf5cfe18a95322c388b05c11e66b5b0ac296a08f8f2106b458ecfe8aca0155d62ed2c5e150485b68073937e7b0a563fbc287563c4475a7c1 + version: 3.40.0 + resolution: "core-js-pure@npm:3.40.0" + checksum: 14e7bd3ef1d39bbeb079b820b0f15f699a0f1589c640818c17679e00ae8c2baf1e0fe8e2734e04562d89b648626d4bc52660e5c44b216107160dbf2fe7e36c5a languageName: node linkType: hard @@ -7475,9 +7555,9 @@ __metadata: linkType: hard "core-js@npm:^3.6.5": - version: 3.39.0 - resolution: "core-js@npm:3.39.0" - checksum: 7a3670e9a2a89e0a049daa288d742d09f6e16d27a8945c5e2ef6fc45dc57e5c4bc5db589da05947486f54ae978d14cf27bd3fb1db0b9907000a611e8af37355b + version: 3.40.0 + resolution: "core-js@npm:3.40.0" + checksum: fc962b93470fd4a129555c765b630c1741fc38706bca68779879f0feaef3b6eec11a33904e3111b2b0e8ba206e8cfbc2a70193271227cfa2f2d13a986f78e557 languageName: node linkType: hard @@ -8008,36 +8088,36 @@ __metadata: languageName: node linkType: hard -"data-view-buffer@npm:^1.0.1": - version: 1.0.1 - resolution: "data-view-buffer@npm:1.0.1" +"data-view-buffer@npm:^1.0.2": + version: 1.0.2 + resolution: "data-view-buffer@npm:1.0.2" dependencies: - call-bind: ^1.0.6 + call-bound: ^1.0.3 es-errors: ^1.3.0 - is-data-view: ^1.0.1 - checksum: ce24348f3c6231223b216da92e7e6a57a12b4af81a23f27eff8feabdf06acfb16c00639c8b705ca4d167f761cfc756e27e5f065d0a1f840c10b907fdaf8b988c + is-data-view: ^1.0.2 + checksum: 1e1cd509c3037ac0f8ba320da3d1f8bf1a9f09b0be09394b5e40781b8cc15ff9834967ba7c9f843a425b34f9fe14ce44cf055af6662c44263424c1eb8d65659b languageName: node linkType: hard -"data-view-byte-length@npm:^1.0.1": - version: 1.0.1 - resolution: "data-view-byte-length@npm:1.0.1" +"data-view-byte-length@npm:^1.0.2": + version: 1.0.2 + resolution: "data-view-byte-length@npm:1.0.2" dependencies: - call-bind: ^1.0.7 + call-bound: ^1.0.3 es-errors: ^1.3.0 - is-data-view: ^1.0.1 - checksum: dbb3200edcb7c1ef0d68979834f81d64fd8cab2f7691b3a4c6b97e67f22182f3ec2c8602efd7b76997b55af6ff8bce485829c1feda4fa2165a6b71fb7baa4269 + is-data-view: ^1.0.2 + checksum: 3600c91ced1cfa935f19ef2abae11029e01738de8d229354d3b2a172bf0d7e4ed08ff8f53294b715569fdf72dfeaa96aa7652f479c0f60570878d88e7e8bddf6 languageName: node linkType: hard -"data-view-byte-offset@npm:^1.0.0": - version: 1.0.0 - resolution: "data-view-byte-offset@npm:1.0.0" +"data-view-byte-offset@npm:^1.0.1": + version: 1.0.1 + resolution: "data-view-byte-offset@npm:1.0.1" dependencies: - call-bind: ^1.0.6 + call-bound: ^1.0.2 es-errors: ^1.3.0 is-data-view: ^1.0.1 - checksum: 7f0bf8720b7414ca719eedf1846aeec392f2054d7af707c5dc9a753cc77eb8625f067fa901e0b5127e831f9da9056138d894b9c2be79c27a21f6db5824f009c2 + checksum: 8dd492cd51d19970876626b5b5169fbb67ca31ec1d1d3238ee6a71820ca8b80cafb141c485999db1ee1ef02f2cc3b99424c5eda8d59e852d9ebb79ab290eb5ee languageName: node linkType: hard @@ -8064,7 +8144,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:2.6.9, debug@npm:^2.2.0, debug@npm:^2.3.3, debug@npm:^2.6.0": +"debug@npm:2.6.9, debug@npm:^2.2.0, debug@npm:^2.3.3, debug@npm:^2.6.0, debug@npm:^2.6.1": version: 2.6.9 resolution: "debug@npm:2.6.9" dependencies: @@ -8083,14 +8163,14 @@ __metadata: linkType: hard "debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5": - version: 4.3.7 - resolution: "debug@npm:4.3.7" + version: 4.4.0 + resolution: "debug@npm:4.4.0" dependencies: ms: ^2.1.3 peerDependenciesMeta: supports-color: optional: true - checksum: 822d74e209cd910ef0802d261b150314bbcf36c582ccdbb3e70f0894823c17e49a50d3e66d96b633524263975ca16b6a833f3e3b7e030c157169a5fabac63160 + checksum: fb42df878dd0e22816fc56e1fdca9da73caa85212fbe40c868b1295a6878f9101ae684f4eeef516c13acfc700f5ea07f1136954f43d4cd2d477a811144136479 languageName: node linkType: hard @@ -8236,7 +8316,7 @@ __metadata: languageName: node linkType: hard -"define-properties@npm:^1.1.2, define-properties@npm:^1.1.3, define-properties@npm:^1.2.0, define-properties@npm:^1.2.1": +"define-properties@npm:^1.1.2, define-properties@npm:^1.1.3, define-properties@npm:^1.2.1": version: 1.2.1 resolution: "define-properties@npm:1.2.1" dependencies: @@ -8613,6 +8693,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: ^1.0.1 + es-errors: ^1.3.0 + gopd: ^1.2.0 + checksum: 149207e36f07bd4941921b0ca929e3a28f1da7bd6b6ff8ff7f4e2f2e460675af4576eeba359c635723dc189b64cdd4787e0255897d5b135ccc5d15cb8685fc90 + languageName: node + linkType: hard + "duplexer@npm:^0.1.1": version: 0.1.2 resolution: "duplexer@npm:0.1.2" @@ -8662,10 +8753,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.3.564, electron-to-chromium@npm:^1.5.41": - version: 1.5.68 - resolution: "electron-to-chromium@npm:1.5.68" - checksum: c2851bdd9aab164de4590d3a114a794b01afebc9ef86e61c1d665b132ede3668399abc11451742bf660cecda70e5516765ad2dfc6d23ba4d396734ab701bf393 +"electron-to-chromium@npm:^1.3.564, electron-to-chromium@npm:^1.5.73": + version: 1.5.78 + resolution: "electron-to-chromium@npm:1.5.78" + checksum: 84034073990470a9bad79bbe9c7b3fcc7a92aea9ccb745ce411415562e2112ad089e924817c7e34ba9e08e581b9fc88f33c3acdc6d6ef2812d559bd5cfb6f801 languageName: node linkType: hard @@ -8822,57 +8913,62 @@ __metadata: languageName: node linkType: hard -"es-abstract@npm:^1.17.2, es-abstract@npm:^1.17.5, es-abstract@npm:^1.22.1, es-abstract@npm:^1.22.3, es-abstract@npm:^1.23.0, es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.3, es-abstract@npm:^1.23.5": - version: 1.23.5 - resolution: "es-abstract@npm:1.23.5" +"es-abstract@npm:^1.17.2, es-abstract@npm:^1.17.5, es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.3, es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.6, es-abstract@npm:^1.23.9": + version: 1.23.9 + resolution: "es-abstract@npm:1.23.9" dependencies: - array-buffer-byte-length: ^1.0.1 - arraybuffer.prototype.slice: ^1.0.3 + array-buffer-byte-length: ^1.0.2 + arraybuffer.prototype.slice: ^1.0.4 available-typed-arrays: ^1.0.7 - call-bind: ^1.0.7 - data-view-buffer: ^1.0.1 - data-view-byte-length: ^1.0.1 - data-view-byte-offset: ^1.0.0 - es-define-property: ^1.0.0 + call-bind: ^1.0.8 + call-bound: ^1.0.3 + data-view-buffer: ^1.0.2 + data-view-byte-length: ^1.0.2 + data-view-byte-offset: ^1.0.1 + es-define-property: ^1.0.1 es-errors: ^1.3.0 es-object-atoms: ^1.0.0 - es-set-tostringtag: ^2.0.3 - es-to-primitive: ^1.2.1 - function.prototype.name: ^1.1.6 - get-intrinsic: ^1.2.4 - get-symbol-description: ^1.0.2 + es-set-tostringtag: ^2.1.0 + es-to-primitive: ^1.3.0 + function.prototype.name: ^1.1.8 + get-intrinsic: ^1.2.7 + get-proto: ^1.0.0 + get-symbol-description: ^1.1.0 globalthis: ^1.0.4 - gopd: ^1.0.1 + gopd: ^1.2.0 has-property-descriptors: ^1.0.2 - has-proto: ^1.0.3 - has-symbols: ^1.0.3 + has-proto: ^1.2.0 + has-symbols: ^1.1.0 hasown: ^2.0.2 - internal-slot: ^1.0.7 - is-array-buffer: ^3.0.4 + internal-slot: ^1.1.0 + is-array-buffer: ^3.0.5 is-callable: ^1.2.7 - is-data-view: ^1.0.1 - is-negative-zero: ^2.0.3 - is-regex: ^1.1.4 - is-shared-array-buffer: ^1.0.3 - is-string: ^1.0.7 - is-typed-array: ^1.1.13 - is-weakref: ^1.0.2 + is-data-view: ^1.0.2 + is-regex: ^1.2.1 + is-shared-array-buffer: ^1.0.4 + is-string: ^1.1.1 + is-typed-array: ^1.1.15 + is-weakref: ^1.1.0 + math-intrinsics: ^1.1.0 object-inspect: ^1.13.3 object-keys: ^1.1.1 - object.assign: ^4.1.5 + object.assign: ^4.1.7 + own-keys: ^1.0.1 regexp.prototype.flags: ^1.5.3 - safe-array-concat: ^1.1.2 - safe-regex-test: ^1.0.3 - string.prototype.trim: ^1.2.9 - string.prototype.trimend: ^1.0.8 + safe-array-concat: ^1.1.3 + safe-push-apply: ^1.0.0 + safe-regex-test: ^1.1.0 + set-proto: ^1.0.0 + string.prototype.trim: ^1.2.10 + string.prototype.trimend: ^1.0.9 string.prototype.trimstart: ^1.0.8 - typed-array-buffer: ^1.0.2 - typed-array-byte-length: ^1.0.1 - typed-array-byte-offset: ^1.0.2 - typed-array-length: ^1.0.6 - unbox-primitive: ^1.0.2 - which-typed-array: ^1.1.15 - checksum: 17c81f8a42f0322fd11e0025d3c2229ecfd7923560c710906b8e68660e19c42322750dcedf8ba5cf28bae50d5befd8174d3903ac50dbabb336d3efc3aabed2ee + typed-array-buffer: ^1.0.3 + typed-array-byte-length: ^1.0.3 + typed-array-byte-offset: ^1.0.4 + typed-array-length: ^1.0.7 + unbox-primitive: ^1.1.0 + which-typed-array: ^1.1.18 + checksum: f3ee2614159ca197f97414ab36e3f406ee748ce2f97ffbf09e420726db5a442ce13f1e574601468bff6e6eb81588e6c9ce1ac6c03868a37c7cd48ac679f8485a languageName: node linkType: hard @@ -8883,16 +8979,14 @@ __metadata: languageName: node linkType: hard -"es-define-property@npm:^1.0.0": - version: 1.0.0 - resolution: "es-define-property@npm:1.0.0" - dependencies: - get-intrinsic: ^1.2.4 - checksum: f66ece0a887b6dca71848fa71f70461357c0e4e7249696f81bad0a1f347eed7b31262af4a29f5d726dc026426f085483b6b90301855e647aa8e21936f07293c6 +"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 0512f4e5d564021c9e3a644437b0155af2679d10d80f21adaf868e64d30efdfbd321631956f20f42d655fedb2e3a027da479fad3fa6048f768eb453a80a5f80a languageName: node linkType: hard -"es-errors@npm:^1.2.1, es-errors@npm:^1.3.0": +"es-errors@npm:^1.3.0": version: 1.3.0 resolution: "es-errors@npm:1.3.0" checksum: ec1414527a0ccacd7f15f4a3bc66e215f04f595ba23ca75cdae0927af099b5ec865f9f4d33e9d7e86f512f252876ac77d4281a7871531a50678132429b1271b5 @@ -8916,26 +9010,27 @@ __metadata: languageName: node linkType: hard -"es-iterator-helpers@npm:^1.1.0": - version: 1.2.0 - resolution: "es-iterator-helpers@npm:1.2.0" +"es-iterator-helpers@npm:^1.2.1": + version: 1.2.1 + resolution: "es-iterator-helpers@npm:1.2.1" dependencies: - call-bind: ^1.0.7 + call-bind: ^1.0.8 + call-bound: ^1.0.3 define-properties: ^1.2.1 - es-abstract: ^1.23.3 + es-abstract: ^1.23.6 es-errors: ^1.3.0 es-set-tostringtag: ^2.0.3 function-bind: ^1.1.2 - get-intrinsic: ^1.2.4 + get-intrinsic: ^1.2.6 globalthis: ^1.0.4 - gopd: ^1.0.1 + gopd: ^1.2.0 has-property-descriptors: ^1.0.2 - has-proto: ^1.0.3 - has-symbols: ^1.0.3 - internal-slot: ^1.0.7 - iterator.prototype: ^1.1.3 - safe-array-concat: ^1.1.2 - checksum: c5f5ff10d57f956539581aca7a2d8726c5a8a3e49e6285700d74dcd8b64c7a337b9ab5e81b459b079dac745d2fe02e4f6b80a842e3df45d9cfe3f12325fda8c0 + has-proto: ^1.2.0 + has-symbols: ^1.1.0 + internal-slot: ^1.1.0 + iterator.prototype: ^1.1.4 + safe-array-concat: ^1.1.3 + checksum: 952808dd1df3643d67ec7adf20c30b36e5eecadfbf36354e6f39ed3266c8e0acf3446ce9bc465e38723d613cb1d915c1c07c140df65bdce85da012a6e7bda62b languageName: node linkType: hard @@ -8948,18 +9043,19 @@ __metadata: languageName: node linkType: hard -"es-set-tostringtag@npm:^2.0.3": - version: 2.0.3 - resolution: "es-set-tostringtag@npm:2.0.3" +"es-set-tostringtag@npm:^2.0.3, es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" dependencies: - get-intrinsic: ^1.2.4 + es-errors: ^1.3.0 + get-intrinsic: ^1.2.6 has-tostringtag: ^1.0.2 - hasown: ^2.0.1 - checksum: 7227fa48a41c0ce83e0377b11130d324ac797390688135b8da5c28994c0165be8b252e15cd1de41e1325e5a5412511586960213e88f9ab4a5e7d028895db5129 + hasown: ^2.0.2 + checksum: 789f35de4be3dc8d11fdcb91bc26af4ae3e6d602caa93299a8c45cf05d36cc5081454ae2a6d3afa09cceca214b76c046e4f8151e092e6fc7feeb5efb9e794fc6 languageName: node linkType: hard -"es-shim-unscopables@npm:^1.0.0, es-shim-unscopables@npm:^1.0.2": +"es-shim-unscopables@npm:^1.0.2": version: 1.0.2 resolution: "es-shim-unscopables@npm:1.0.2" dependencies: @@ -8968,7 +9064,7 @@ __metadata: languageName: node linkType: hard -"es-to-primitive@npm:^1.2.1": +"es-to-primitive@npm:^1.3.0": version: 1.3.0 resolution: "es-to-primitive@npm:1.3.0" dependencies: @@ -9607,30 +9703,30 @@ __metadata: linkType: hard "eslint-plugin-react@npm:^7.21.5, eslint-plugin-react@npm:^7.27.1": - version: 7.37.2 - resolution: "eslint-plugin-react@npm:7.37.2" + version: 7.37.3 + resolution: "eslint-plugin-react@npm:7.37.3" dependencies: array-includes: ^3.1.8 array.prototype.findlast: ^1.2.5 - array.prototype.flatmap: ^1.3.2 + array.prototype.flatmap: ^1.3.3 array.prototype.tosorted: ^1.1.4 doctrine: ^2.1.0 - es-iterator-helpers: ^1.1.0 + es-iterator-helpers: ^1.2.1 estraverse: ^5.3.0 hasown: ^2.0.2 jsx-ast-utils: ^2.4.1 || ^3.0.0 minimatch: ^3.1.2 object.entries: ^1.1.8 object.fromentries: ^2.0.8 - object.values: ^1.2.0 + object.values: ^1.2.1 prop-types: ^15.8.1 resolve: ^2.0.0-next.5 semver: ^6.3.1 - string.prototype.matchall: ^4.0.11 + string.prototype.matchall: ^4.0.12 string.prototype.repeat: ^1.0.0 peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - checksum: 7f5203afee7fbe3702b27fdd2b9a3c0ccbbb47d0672f58311b9d8a08dea819c9da4a87c15e8bd508f2562f327a9d29ee8bd9cd189bf758d8dc903de5648b0bfa + checksum: 670dcee215f560a394b8b9966aecfc3c5ee5c15603a690f5333b0e16863275958f9c1853b12355eb0e36ef74dfac8bf645e4f440cb9b985a3bae2ac09d5ed55a languageName: node linkType: hard @@ -10072,9 +10168,9 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.17.1": - version: 4.21.1 - resolution: "express@npm:4.21.1" +"express@npm:^4.0.0, express@npm:^4.17.1": + version: 4.21.2 + resolution: "express@npm:4.21.2" dependencies: accepts: ~1.3.8 array-flatten: 1.1.1 @@ -10095,7 +10191,7 @@ __metadata: methods: ~1.1.2 on-finished: 2.4.1 parseurl: ~1.3.3 - path-to-regexp: 0.1.10 + path-to-regexp: 0.1.12 proxy-addr: ~2.0.7 qs: 6.13.0 range-parser: ~1.2.1 @@ -10107,7 +10203,7 @@ __metadata: type-is: ~1.6.18 utils-merge: 1.0.1 vary: ~1.1.2 - checksum: 5ac2b26d8aeddda5564fc0907227d29c100f90c0ead2ead9d474dc5108e8fb306c2de2083c4e3ba326e0906466f2b73417dbac16961f4075ff9f03785fd940fe + checksum: 3aef1d355622732e20b8f3a7c112d4391d44e2131f4f449e1f273a309752a41abfad714e881f177645517cbe29b3ccdc10b35e7e25c13506114244a5b72f549d languageName: node linkType: hard @@ -10228,15 +10324,15 @@ __metadata: linkType: hard "fast-glob@npm:^3.1.1, fast-glob@npm:^3.2.9": - version: 3.3.2 - resolution: "fast-glob@npm:3.3.2" + version: 3.3.3 + resolution: "fast-glob@npm:3.3.3" dependencies: "@nodelib/fs.stat": ^2.0.2 "@nodelib/fs.walk": ^1.2.3 glob-parent: ^5.1.2 merge2: ^1.3.0 - micromatch: ^4.0.4 - checksum: 900e4979f4dbc3313840078419245621259f349950411ca2fa445a2f9a1a6d98c3b5e7e0660c5ccd563aa61abe133a21765c6c0dec8e57da1ba71d8000b05ec1 + micromatch: ^4.0.8 + checksum: 0704d7b85c0305fd2cef37777337dfa26230fdd072dce9fb5c82a4b03156f3ffb8ed3e636033e65d45d2a5805a4e475825369a27404c0307f2db0c8eb3366fbd languageName: node linkType: hard @@ -10269,9 +10365,9 @@ __metadata: linkType: hard "fast-uri@npm:^3.0.1": - version: 3.0.3 - resolution: "fast-uri@npm:3.0.3" - checksum: c52e6c86465f5c240e84a4485fb001088cc743d261a4b54b0050ce4758b1648bdbe53da1328ef9620149dca1435e3de64184f226d7c0a3656cb5837b3491e149 + version: 3.0.5 + resolution: "fast-uri@npm:3.0.5" + checksum: b56cda8e7355bad9adcc3c2eacd94cb592eaa9536497a4779a9527784f4f95a3755f30525c63583bd85807c493b396ac89926c970f19a60905ed875121ca78fd languageName: node linkType: hard @@ -10283,11 +10379,11 @@ __metadata: linkType: hard "fastq@npm:^1.6.0": - version: 1.17.1 - resolution: "fastq@npm:1.17.1" + version: 1.18.0 + resolution: "fastq@npm:1.18.0" dependencies: reusify: ^1.0.4 - checksum: a8c5b26788d5a1763f88bae56a8ddeee579f935a831c5fe7a8268cea5b0a91fbfe705f612209e02d639b881d7b48e461a50da4a10cfaa40da5ca7cc9da098d88 + checksum: fb8d94318c2e5545a1913c1647b35e8b7825caaba888a98ef9887085e57f5a82104aefbb05f26c81d4e220f02b2ea6f2c999132186d8c77e6c681d91870191ba languageName: node linkType: hard @@ -10474,6 +10570,7 @@ __metadata: "@babel/plugin-transform-object-assign": ^7.18.6 "@babel/preset-react": ^7.18.6 "@babel/preset-typescript": ^7.18.6 + "@slack/events-api": ^3.0.1 "@types/jest": ^28.1.6 "@types/node": 18.17.1 "@typescript-eslint/eslint-plugin": 4.18.0 @@ -10845,15 +10942,17 @@ __metadata: languageName: node linkType: hard -"function.prototype.name@npm:^1.1.6": - version: 1.1.6 - resolution: "function.prototype.name@npm:1.1.6" +"function.prototype.name@npm:^1.1.6, function.prototype.name@npm:^1.1.8": + version: 1.1.8 + resolution: "function.prototype.name@npm:1.1.8" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 + call-bind: ^1.0.8 + call-bound: ^1.0.3 + define-properties: ^1.2.1 functions-have-names: ^1.2.3 - checksum: 7a3f9bd98adab09a07f6e1f03da03d3f7c26abbdeaeee15223f6c04a9fb5674792bdf5e689dac19b97ac71de6aad2027ba3048a9b883aa1b3173eed6ab07f479 + hasown: ^2.0.2 + is-callable: ^1.2.7 + checksum: 3a366535dc08b25f40a322efefa83b2da3cd0f6da41db7775f2339679120ef63b6c7e967266182609e655b8f0a8f65596ed21c7fd72ad8bd5621c2340edd4010 languageName: node linkType: hard @@ -10914,16 +11013,21 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.3, get-intrinsic@npm:^1.2.4": - version: 1.2.4 - resolution: "get-intrinsic@npm:1.2.4" +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7": + version: 1.2.7 + resolution: "get-intrinsic@npm:1.2.7" dependencies: + call-bind-apply-helpers: ^1.0.1 + es-define-property: ^1.0.1 es-errors: ^1.3.0 + es-object-atoms: ^1.0.0 function-bind: ^1.1.2 - has-proto: ^1.0.1 - has-symbols: ^1.0.3 - hasown: ^2.0.0 - checksum: 414e3cdf2c203d1b9d7d33111df746a4512a1aa622770b361dadddf8ed0b5aeb26c560f49ca077e24bfafb0acb55ca908d1f709216ccba33ffc548ec8a79a951 + get-proto: ^1.0.0 + gopd: ^1.2.0 + has-symbols: ^1.1.0 + hasown: ^2.0.2 + math-intrinsics: ^1.1.0 + checksum: a1597b3b432074f805b6a0ba1182130dd6517c0ea0c4eecc4b8834c803913e1ea62dfc412865be795b3dacb1555a21775b70cf9af7a18b1454ff3414e5442d4a languageName: node linkType: hard @@ -10941,6 +11045,16 @@ __metadata: languageName: node linkType: hard +"get-proto@npm:^1.0.0, get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: ^1.0.1 + es-object-atoms: ^1.0.0 + checksum: 4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b + languageName: node + linkType: hard + "get-stream@npm:^4.0.0": version: 4.1.0 resolution: "get-stream@npm:4.1.0" @@ -10959,14 +11073,14 @@ __metadata: languageName: node linkType: hard -"get-symbol-description@npm:^1.0.2": - version: 1.0.2 - resolution: "get-symbol-description@npm:1.0.2" +"get-symbol-description@npm:^1.1.0": + version: 1.1.0 + resolution: "get-symbol-description@npm:1.1.0" dependencies: - call-bind: ^1.0.5 + call-bound: ^1.0.3 es-errors: ^1.3.0 - get-intrinsic: ^1.2.4 - checksum: e1cb53bc211f9dbe9691a4f97a46837a553c4e7caadd0488dc24ac694db8a390b93edd412b48dcdd0b4bbb4c595de1709effc75fc87c0839deedc6968f5bd973 + get-intrinsic: ^1.2.6 + checksum: 655ed04db48ee65ef2ddbe096540d4405e79ba0a7f54225775fef43a7e2afcb93a77d141c5f05fdef0afce2eb93bcbfb3597142189d562ac167ff183582683cd languageName: node linkType: hard @@ -11063,9 +11177,9 @@ __metadata: linkType: hard "globals@npm:^15.11.0": - version: 15.13.0 - resolution: "globals@npm:15.13.0" - checksum: 3f98514ce25a21150b246fbd63aeaeb271a93b3340cf7f4f6e9934d3b37dbb4b0fddef9c470183097dcfd2e8757bb86bbae701588f0e376667d8d9d6f665db3b + version: 15.14.0 + resolution: "globals@npm:15.14.0" + checksum: fa993433a01bf4a118904fbafbcff34db487fce83f73da75fb4a8653afc6dcd72905e6208c49bab307ff0980928273d0ecd1cfc67e1a4782dabfbd92c234ab68 languageName: node linkType: hard @@ -11172,7 +11286,7 @@ __metadata: languageName: node linkType: hard -"gopd@npm:^1.0.1, gopd@npm:^1.1.0": +"gopd@npm:^1.0.1, gopd@npm:^1.2.0": version: 1.2.0 resolution: "gopd@npm:1.2.0" checksum: cc6d8e655e360955bdccaca51a12a474268f95bb793fc3e1f2bdadb075f28bfd1fd988dab872daf77a61d78cbaf13744bc8727a17cfb1d150d76047d805375f3 @@ -11194,9 +11308,9 @@ __metadata: linkType: hard "graphql@npm:^16.3.0": - version: 16.9.0 - resolution: "graphql@npm:16.9.0" - checksum: 8cb3d54100e9227310383ce7f791ca48d12f15ed9f2021f23f8735f1121aafe4e5e611a853081dd935ce221724ea1ae4638faef5d2921fb1ad7c26b5f46611e9 + version: 16.10.0 + resolution: "graphql@npm:16.10.0" + checksum: 969c2d1061d69ad6fe08a7fe642428212b0b8485a2f9b5d8650203eb6c3221479e81ec6a757708f849d84b85afcb3ebc5a8ff2f71778bb66c5e4850f051c170e languageName: node linkType: hard @@ -11252,9 +11366,9 @@ __metadata: linkType: hard "has-bigints@npm:^1.0.2": - version: 1.0.2 - resolution: "has-bigints@npm:1.0.2" - checksum: 390e31e7be7e5c6fe68b81babb73dfc35d413604d7ee5f56da101417027a4b4ce6a27e46eff97ad040c835b5d228676eae99a9b5c3bc0e23c8e81a49241ff45b + version: 1.1.0 + resolution: "has-bigints@npm:1.1.0" + checksum: 79730518ae02c77e4af6a1d1a0b6a2c3e1509785532771f9baf0241e83e36329542c3d7a0e723df8cbc85f74eff4f177828a2265a01ba576adbdc2d40d86538b languageName: node linkType: hard @@ -11288,23 +11402,23 @@ __metadata: languageName: node linkType: hard -"has-proto@npm:^1.0.1, has-proto@npm:^1.0.3": - version: 1.1.0 - resolution: "has-proto@npm:1.1.0" +"has-proto@npm:^1.2.0": + version: 1.2.0 + resolution: "has-proto@npm:1.2.0" dependencies: - call-bind: ^1.0.7 - checksum: 0335b8acd01a0de9bb6f7f89c4ef4f1512b48cec25f1c23e847a68d65afb8c579f168907e79969b01dc7025d707b48c71d481bca140579a40d735b071b2cc1bc + dunder-proto: ^1.0.0 + checksum: f55010cb94caa56308041d77967c72a02ffd71386b23f9afa8447e58bc92d49d15c19bf75173713468e92fe3fb1680b03b115da39c21c32c74886d1d50d3e7ff languageName: node linkType: hard -"has-symbols@npm:^1.0.1, has-symbols@npm:^1.0.3": +"has-symbols@npm:^1.0.1, has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": version: 1.1.0 resolution: "has-symbols@npm:1.1.0" checksum: b2316c7302a0e8ba3aaba215f834e96c22c86f192e7310bdf689dd0e6999510c89b00fbc5742571507cebf25764d68c988b3a0da217369a73596191ac0ce694b languageName: node linkType: hard -"has-tostringtag@npm:^1.0.0, has-tostringtag@npm:^1.0.2": +"has-tostringtag@npm:^1.0.2": version: 1.0.2 resolution: "has-tostringtag@npm:1.0.2" dependencies: @@ -11390,7 +11504,7 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.0, hasown@npm:^2.0.1, hasown@npm:^2.0.2": +"hasown@npm:^2.0.0, hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" dependencies: @@ -11699,12 +11813,12 @@ __metadata: linkType: hard "https-proxy-agent@npm:^7.0.1": - version: 7.0.5 - resolution: "https-proxy-agent@npm:7.0.5" + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" dependencies: - agent-base: ^7.0.2 + agent-base: ^7.1.2 debug: 4 - checksum: 2e1a28960f13b041a50702ee74f240add8e75146a5c37fc98f1960f0496710f6918b3a9fe1e5aba41e50f58e6df48d107edd9c405c5f0d73ac260dabf2210857 + checksum: b882377a120aa0544846172e5db021fa8afbf83fea2a897d397bd2ddd8095ab268c24bc462f40a15f2a8c600bf4aa05ce52927f70038d4014e68aefecfa94e8d languageName: node linkType: hard @@ -11977,14 +12091,14 @@ __metadata: languageName: node linkType: hard -"internal-slot@npm:^1.0.4, internal-slot@npm:^1.0.7": - version: 1.0.7 - resolution: "internal-slot@npm:1.0.7" +"internal-slot@npm:^1.1.0": + version: 1.1.0 + resolution: "internal-slot@npm:1.1.0" dependencies: es-errors: ^1.3.0 - hasown: ^2.0.0 - side-channel: ^1.0.4 - checksum: cadc5eea5d7d9bc2342e93aae9f31f04c196afebb11bde97448327049f492cd7081e18623ae71388aac9cd237b692ca3a105be9c68ac39c1dec679d7409e33eb + hasown: ^2.0.2 + side-channel: ^1.1.0 + checksum: 8e0991c2d048cc08dab0a91f573c99f6a4215075887517ea4fa32203ce8aea60fa03f95b177977fa27eb502e5168366d0f3e02c762b799691411d49900611861 languageName: node linkType: hard @@ -12060,22 +12174,23 @@ __metadata: linkType: hard "is-arguments@npm:^1.0.4, is-arguments@npm:^1.1.1": - version: 1.1.1 - resolution: "is-arguments@npm:1.1.1" + version: 1.2.0 + resolution: "is-arguments@npm:1.2.0" dependencies: - call-bind: ^1.0.2 - has-tostringtag: ^1.0.0 - checksum: 7f02700ec2171b691ef3e4d0e3e6c0ba408e8434368504bb593d0d7c891c0dbfda6d19d30808b904a6cb1929bca648c061ba438c39f296c2a8ca083229c49f27 + call-bound: ^1.0.2 + has-tostringtag: ^1.0.2 + checksum: aae9307fedfe2e5be14aebd0f48a9eeedf6b8c8f5a0b66257b965146d1e94abdc3f08e3dce3b1d908e1fa23c70039a88810ee1d753905758b9b6eebbab0bafeb languageName: node linkType: hard -"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.4": - version: 3.0.4 - resolution: "is-array-buffer@npm:3.0.4" +"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": + version: 3.0.5 + resolution: "is-array-buffer@npm:3.0.5" dependencies: - call-bind: ^1.0.2 - get-intrinsic: ^1.2.1 - checksum: e4e3e6ef0ff2239e75371d221f74bc3c26a03564a22efb39f6bb02609b598917ddeecef4e8c877df2a25888f247a98198959842a5e73236bc7f22cabdf6351a7 + call-bind: ^1.0.8 + call-bound: ^1.0.3 + get-intrinsic: ^1.2.6 + checksum: f137a2a6e77af682cdbffef1e633c140cf596f72321baf8bba0f4ef22685eb4339dde23dfe9e9ca430b5f961dee4d46577dcf12b792b68518c8449b134fb9156 languageName: node linkType: hard @@ -12094,11 +12209,14 @@ __metadata: linkType: hard "is-async-function@npm:^2.0.0": - version: 2.0.0 - resolution: "is-async-function@npm:2.0.0" + version: 2.1.0 + resolution: "is-async-function@npm:2.1.0" dependencies: - has-tostringtag: ^1.0.0 - checksum: e3471d95e6c014bf37cad8a93f2f4b6aac962178e0a5041e8903147166964fdc1c5c1d2ef87e86d77322c370ca18f2ea004fa7420581fa747bcaf7c223069dbd + call-bound: ^1.0.3 + get-proto: ^1.0.1 + has-tostringtag: ^1.0.2 + safe-regex-test: ^1.1.0 + checksum: e8dfa81561eb7cd845d626bf49675c735a177013943eb6919185e1f358fe8b16fd11fa477397df8ddddd31ade47092de8243997530931a4ec17cb2b9d15479c9 languageName: node linkType: hard @@ -12129,13 +12247,13 @@ __metadata: languageName: node linkType: hard -"is-boolean-object@npm:^1.2.0": - version: 1.2.0 - resolution: "is-boolean-object@npm:1.2.0" +"is-boolean-object@npm:^1.2.1": + version: 1.2.1 + resolution: "is-boolean-object@npm:1.2.1" dependencies: - call-bind: ^1.0.7 + call-bound: ^1.0.2 has-tostringtag: ^1.0.2 - checksum: cebc780cc3881dfb0c6c933e308f6a8eccf07ef92a7ea533fb2ee4fb7d704473b476f0b345fea4f2f45fe70937ef568a2f450eb6000d08b99350d87280927ff8 + checksum: 2672609f0f2536172873810a38ec006a415e43ddc6a240f7638a1659cb20dfa91cc75c8a1bed36247bb046aa8f0eab945f20d1203bc69606418bd129c745f861 languageName: node linkType: hard @@ -12185,12 +12303,12 @@ __metadata: languageName: node linkType: hard -"is-core-module@npm:^2.0.0, is-core-module@npm:^2.13.0, is-core-module@npm:^2.15.1": - version: 2.15.1 - resolution: "is-core-module@npm:2.15.1" +"is-core-module@npm:^2.0.0, is-core-module@npm:^2.13.0, is-core-module@npm:^2.15.1, is-core-module@npm:^2.16.0": + version: 2.16.1 + resolution: "is-core-module@npm:2.16.1" dependencies: hasown: ^2.0.2 - checksum: df134c168115690724b62018c37b2f5bba0d5745fa16960b329c5a00883a8bea6a5632fdb1e3efcce237c201826ba09f93197b7cd95577ea56b0df335be23633 + checksum: 6ec5b3c42d9cbf1ac23f164b16b8a140c3cec338bf8f884c076ca89950c7cc04c33e78f02b8cae7ff4751f3247e3174b2330f1fe4de194c7210deb8b1ea316a7 languageName: node linkType: hard @@ -12203,21 +12321,24 @@ __metadata: languageName: node linkType: hard -"is-data-view@npm:^1.0.1": - version: 1.0.1 - resolution: "is-data-view@npm:1.0.1" +"is-data-view@npm:^1.0.1, is-data-view@npm:^1.0.2": + version: 1.0.2 + resolution: "is-data-view@npm:1.0.2" dependencies: + call-bound: ^1.0.2 + get-intrinsic: ^1.2.6 is-typed-array: ^1.1.13 - checksum: 4ba4562ac2b2ec005fefe48269d6bd0152785458cd253c746154ffb8a8ab506a29d0cfb3b74af87513843776a88e4981ae25c89457bf640a33748eab1a7216b5 + checksum: 31600dd19932eae7fd304567e465709ffbfa17fa236427c9c864148e1b54eb2146357fcf3aed9b686dee13c217e1bb5a649cb3b9c479e1004c0648e9febde1b2 languageName: node linkType: hard -"is-date-object@npm:^1.0.5": - version: 1.0.5 - resolution: "is-date-object@npm:1.0.5" +"is-date-object@npm:^1.0.5, is-date-object@npm:^1.1.0": + version: 1.1.0 + resolution: "is-date-object@npm:1.1.0" dependencies: - has-tostringtag: ^1.0.0 - checksum: baa9077cdf15eb7b58c79398604ca57379b2fc4cf9aa7a9b9e295278648f628c9b201400c01c5e0f7afae56507d741185730307cbe7cad3b9f90a77e5ee342fc + call-bound: ^1.0.2 + has-tostringtag: ^1.0.2 + checksum: d6c36ab9d20971d65f3fc64cef940d57a4900a2ac85fb488a46d164c2072a33da1cb51eefcc039e3e5c208acbce343d3480b84ab5ff0983f617512da2742562a languageName: node linkType: hard @@ -12295,11 +12416,11 @@ __metadata: linkType: hard "is-finalizationregistry@npm:^1.1.0": - version: 1.1.0 - resolution: "is-finalizationregistry@npm:1.1.0" + version: 1.1.1 + resolution: "is-finalizationregistry@npm:1.1.1" dependencies: - call-bind: ^1.0.7 - checksum: 480818ab86e112a00444410a2fd551a5363bca0c39c7bc66e29df665b1e47c803ba107227c1db86d67264a3f020779fab257061463ce02b01b6abbe5966e33b8 + call-bound: ^1.0.3 + checksum: 38c646c506e64ead41a36c182d91639833311970b6b6c6268634f109eef0a1a9d2f1f2e499ef4cb43c744a13443c4cdd2f0812d5afdcee5e9b65b72b28c48557 languageName: node linkType: hard @@ -12325,11 +12446,14 @@ __metadata: linkType: hard "is-generator-function@npm:^1.0.10, is-generator-function@npm:^1.0.7": - version: 1.0.10 - resolution: "is-generator-function@npm:1.0.10" + version: 1.1.0 + resolution: "is-generator-function@npm:1.1.0" dependencies: - has-tostringtag: ^1.0.0 - checksum: d54644e7dbaccef15ceb1e5d91d680eb5068c9ee9f9eb0a9e04173eb5542c9b51b5ab52c5537f5703e48d5fddfd376817c1ca07a84a407b7115b769d4bdde72b + call-bound: ^1.0.3 + get-proto: ^1.0.0 + has-tostringtag: ^1.0.2 + safe-regex-test: ^1.1.0 + checksum: f7f7276131bdf7e28169b86ac55a5b080012a597f9d85a0cbef6fe202a7133fa450a3b453e394870e3cb3685c5a764c64a9f12f614684b46969b1e6f297bed6b languageName: node linkType: hard @@ -12379,13 +12503,6 @@ __metadata: languageName: node linkType: hard -"is-negative-zero@npm:^2.0.3": - version: 2.0.3 - resolution: "is-negative-zero@npm:2.0.3" - checksum: c1e6b23d2070c0539d7b36022d5a94407132411d01aba39ec549af824231f3804b1aea90b5e4e58e807a65d23ceb538ed6e355ce76b267bdd86edb757ffcbdcd - languageName: node - linkType: hard - "is-node-process@npm:^1.0.1": version: 1.2.0 resolution: "is-node-process@npm:1.2.0" @@ -12393,13 +12510,13 @@ __metadata: languageName: node linkType: hard -"is-number-object@npm:^1.1.0": - version: 1.1.0 - resolution: "is-number-object@npm:1.1.0" +"is-number-object@npm:^1.1.1": + version: 1.1.1 + resolution: "is-number-object@npm:1.1.1" dependencies: - call-bind: ^1.0.7 + call-bound: ^1.0.3 has-tostringtag: ^1.0.2 - checksum: 965f91493e5c02a44bb9c5d8dd4ae40da20bd9bd1cff9cd92e2f2e66a486935a0a01f8a4744eab033c450888f01a4ec3226e1c75bbcff973ce12d06ed79eb17b + checksum: 6517f0a0e8c4b197a21afb45cd3053dc711e79d45d8878aa3565de38d0102b130ca8732485122c7b336e98c27dacd5236854e3e6526e0eb30cae64956535662f languageName: node linkType: hard @@ -12495,15 +12612,15 @@ __metadata: languageName: node linkType: hard -"is-regex@npm:^1.1.4": - version: 1.2.0 - resolution: "is-regex@npm:1.2.0" +"is-regex@npm:^1.1.4, is-regex@npm:^1.2.1": + version: 1.2.1 + resolution: "is-regex@npm:1.2.1" dependencies: - call-bind: ^1.0.7 - gopd: ^1.1.0 + call-bound: ^1.0.2 + gopd: ^1.2.0 has-tostringtag: ^1.0.2 hasown: ^2.0.2 - checksum: dd2693d71866850d1276815204a2629d28dc1d24bd56b734e57a39f56b777cd87030d57552e7093d91a2ac331d99af9dba49a0a641fa4e4435d40e944d4dde12 + checksum: 99ee0b6d30ef1bb61fa4b22fae7056c6c9b3c693803c0c284ff7a8570f83075a7d38cda53b06b7996d441215c27895ea5d1af62124562e13d91b3dbec41a5e13 languageName: node linkType: hard @@ -12535,12 +12652,12 @@ __metadata: languageName: node linkType: hard -"is-shared-array-buffer@npm:^1.0.2, is-shared-array-buffer@npm:^1.0.3": - version: 1.0.3 - resolution: "is-shared-array-buffer@npm:1.0.3" +"is-shared-array-buffer@npm:^1.0.2, is-shared-array-buffer@npm:^1.0.4": + version: 1.0.4 + resolution: "is-shared-array-buffer@npm:1.0.4" dependencies: - call-bind: ^1.0.7 - checksum: a4fff602c309e64ccaa83b859255a43bb011145a42d3f56f67d9268b55bc7e6d98a5981a1d834186ad3105d6739d21547083fe7259c76c0468483fc538e716d8 + call-bound: ^1.0.3 + checksum: 1611fedc175796eebb88f4dfc393dd969a4a8e6c69cadaff424ee9d4464f9f026399a5f84a90f7c62d6d7ee04e3626a912149726de102b0bd6c1ee6a9868fa5a languageName: node linkType: hard @@ -12558,33 +12675,33 @@ __metadata: languageName: node linkType: hard -"is-string@npm:^1.0.7, is-string@npm:^1.1.0": - version: 1.1.0 - resolution: "is-string@npm:1.1.0" +"is-string@npm:^1.0.7, is-string@npm:^1.1.1": + version: 1.1.1 + resolution: "is-string@npm:1.1.1" dependencies: - call-bind: ^1.0.7 + call-bound: ^1.0.3 has-tostringtag: ^1.0.2 - checksum: 1e330e9fe0984cdf37371f704f9babf9b56d50b1e9d2e6c19b8b78443be3e9771c33309b4aadde9ba2a8870769374538681e01f54113a335dd393c80a72e7d11 + checksum: 2eeaaff605250f5e836ea3500d33d1a5d3aa98d008641d9d42fb941e929ffd25972326c2ef912987e54c95b6f10416281aaf1b35cdf81992cfb7524c5de8e193 languageName: node linkType: hard -"is-symbol@npm:^1.0.4, is-symbol@npm:^1.1.0": - version: 1.1.0 - resolution: "is-symbol@npm:1.1.0" +"is-symbol@npm:^1.0.4, is-symbol@npm:^1.1.1": + version: 1.1.1 + resolution: "is-symbol@npm:1.1.1" dependencies: - call-bind: ^1.0.7 - has-symbols: ^1.0.3 - safe-regex-test: ^1.0.3 - checksum: 3623c934c8e61ddd6ef0927a17eb3da3cb9a9894f2fb8a96d447887d085d43e5d8bb59a8f97e46b54a919fc3f8845df29686672ad693d028570627bc661bcb6c + call-bound: ^1.0.2 + has-symbols: ^1.1.0 + safe-regex-test: ^1.1.0 + checksum: bfafacf037af6f3c9d68820b74be4ae8a736a658a3344072df9642a090016e281797ba8edbeb1c83425879aae55d1cb1f30b38bf132d703692b2570367358032 languageName: node linkType: hard -"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.3": - version: 1.1.13 - resolution: "is-typed-array@npm:1.1.13" +"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15, is-typed-array@npm:^1.1.3": + version: 1.1.15 + resolution: "is-typed-array@npm:1.1.15" dependencies: - which-typed-array: ^1.1.14 - checksum: 150f9ada183a61554c91e1c4290086d2c100b0dff45f60b028519be72a8db964da403c48760723bf5253979b8dffe7b544246e0e5351dcd05c5fdb1dcc1dc0f0 + which-typed-array: ^1.1.16 + checksum: ea7cfc46c282f805d19a9ab2084fd4542fed99219ee9dbfbc26284728bd713a51eac66daa74eca00ae0a43b61322920ba334793607dc39907465913e921e0892 languageName: node linkType: hard @@ -12609,22 +12726,22 @@ __metadata: languageName: node linkType: hard -"is-weakref@npm:^1.0.2": - version: 1.0.2 - resolution: "is-weakref@npm:1.0.2" +"is-weakref@npm:^1.0.2, is-weakref@npm:^1.1.0": + version: 1.1.0 + resolution: "is-weakref@npm:1.1.0" dependencies: - call-bind: ^1.0.2 - checksum: 95bd9a57cdcb58c63b1c401c60a474b0f45b94719c30f548c891860f051bc2231575c290a6b420c6bc6e7ed99459d424c652bd5bf9a1d5259505dc35b4bf83de + call-bound: ^1.0.2 + checksum: 2a2f3a1746ee1baecf9ac6483d903cd3f8ef3cca88e2baa42f2e85ea064bd246d218eed5f6d479fc1c76dae2231e71133b6b86160e821d176932be9fae3da4da languageName: node linkType: hard "is-weakset@npm:^2.0.3": - version: 2.0.3 - resolution: "is-weakset@npm:2.0.3" + version: 2.0.4 + resolution: "is-weakset@npm:2.0.4" dependencies: - call-bind: ^1.0.7 - get-intrinsic: ^1.2.4 - checksum: 8b6a20ee9f844613ff8f10962cfee49d981d584525f2357fee0a04dfbcde9fd607ed60cb6dab626dbcc470018ae6392e1ff74c0c1aced2d487271411ad9d85ae + call-bound: ^1.0.3 + get-intrinsic: ^1.2.6 + checksum: 5c6c8415a06065d78bdd5e3a771483aa1cd928df19138aa73c4c51333226f203f22117b4325df55cc8b3085a6716870a320c2d757efee92d7a7091a039082041 languageName: node linkType: hard @@ -12766,16 +12883,17 @@ __metadata: languageName: node linkType: hard -"iterator.prototype@npm:^1.1.3": - version: 1.1.3 - resolution: "iterator.prototype@npm:1.1.3" +"iterator.prototype@npm:^1.1.4": + version: 1.1.5 + resolution: "iterator.prototype@npm:1.1.5" dependencies: - define-properties: ^1.2.1 - get-intrinsic: ^1.2.1 - has-symbols: ^1.0.3 - reflect.getprototypeof: ^1.0.4 - set-function-name: ^2.0.1 - checksum: 7d2a1f8bcbba7b76f72e956faaf7b25405f4de54430c9d099992e6fb9d571717c3044604e8cdfb8e624cb881337d648030ee8b1541d544af8b338835e3f47ebe + define-data-property: ^1.1.4 + es-object-atoms: ^1.0.0 + get-intrinsic: ^1.2.6 + get-proto: ^1.0.0 + has-symbols: ^1.1.0 + set-function-name: ^2.0.2 + checksum: 7db23c42629ba4790e6e15f78b555f41dbd08818c85af306988364bd19d86716a1187cb333444f3a0036bfc078a0e9cb7ec67fef3a61662736d16410d7f77869 languageName: node linkType: hard @@ -13540,7 +13658,16 @@ __metadata: languageName: node linkType: hard -"jsesc@npm:^3.0.2, jsesc@npm:~3.0.2": +"jsesc@npm:^3.0.2": + version: 3.1.0 + resolution: "jsesc@npm:3.1.0" + bin: + jsesc: bin/jsesc + checksum: 19c94095ea026725540c0d29da33ab03144f6bcf2d4159e4833d534976e99e0c09c38cefa9a575279a51fc36b31166f8d6d05c9fe2645d5f15851d690b41f17f + languageName: node + linkType: hard + +"jsesc@npm:~3.0.2": version: 3.0.2 resolution: "jsesc@npm:3.0.2" bin: @@ -14155,11 +14282,11 @@ __metadata: linkType: hard "magic-string@npm:^0.30.0": - version: 0.30.14 - resolution: "magic-string@npm:0.30.14" + version: 0.30.17 + resolution: "magic-string@npm:0.30.17" dependencies: "@jridgewell/sourcemap-codec": ^1.5.0 - checksum: 67b3b2d817a7c4e94cb63e2dcaffbeee3b76ff0798eeaee8159a6ff4faee30db824375b2cadbf43807b56c6802fe6373b40d02567d489593017012d74ec0b719 + checksum: f4b4ed17c5ada64f77fc98491847302ebad64894a905c417c943840c0384662118c9b37f9f68bb86add159fa4749ff6f118c4627d69a470121b46731f8debc6d languageName: node linkType: hard @@ -14252,6 +14379,13 @@ __metadata: languageName: node linkType: hard +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 0e513b29d120f478c85a70f49da0b8b19bc638975eca466f2eeae0071f3ad00454c621bf66e16dd435896c208e719fc91ad79bbfba4e400fe0b372e7c1c9c9a2 + languageName: node + linkType: hard + "md5.js@npm:^1.3.4": version: 1.3.5 resolution: "md5.js@npm:1.3.5" @@ -14443,7 +14577,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5": +"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -14765,7 +14899,7 @@ __metadata: languageName: node linkType: hard -"mlly@npm:^1.4.0, mlly@npm:^1.7.2": +"mlly@npm:^1.4.0, mlly@npm:^1.7.3": version: 1.7.3 resolution: "mlly@npm:1.7.3" dependencies: @@ -15132,10 +15266,10 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.18": - version: 2.0.18 - resolution: "node-releases@npm:2.0.18" - checksum: ef55a3d853e1269a6d6279b7692cd6ff3e40bc74947945101138745bfdc9a5edabfe72cb19a31a8e45752e1910c4c65c77d931866af6357f242b172b7283f5b3 +"node-releases@npm:^2.0.19": + version: 2.0.19 + resolution: "node-releases@npm:2.0.19" + checksum: 917dbced519f48c6289a44830a0ca6dc944c3ee9243c468ebd8515a41c97c8b2c256edb7f3f750416bc37952cc9608684e6483c7b6c6f39f6bd8d86c52cfe658 languageName: node linkType: hard @@ -15299,7 +15433,7 @@ __metadata: languageName: node linkType: hard -"object-inspect@npm:^1.13.1, object-inspect@npm:^1.13.3": +"object-inspect@npm:^1.13.3": version: 1.13.3 resolution: "object-inspect@npm:1.13.3" checksum: 8c962102117241e18ea403b84d2521f78291b774b03a29ee80a9863621d88265ffd11d0d7e435c4c2cea0dc2a2fbf8bbc92255737a05536590f2df2e8756f297 @@ -15332,15 +15466,17 @@ __metadata: languageName: node linkType: hard -"object.assign@npm:^4.1.4, object.assign@npm:^4.1.5": - version: 4.1.5 - resolution: "object.assign@npm:4.1.5" +"object.assign@npm:^4.1.4, object.assign@npm:^4.1.7": + version: 4.1.7 + resolution: "object.assign@npm:4.1.7" dependencies: - call-bind: ^1.0.5 + call-bind: ^1.0.8 + call-bound: ^1.0.3 define-properties: ^1.2.1 - has-symbols: ^1.0.3 + es-object-atoms: ^1.0.0 + has-symbols: ^1.1.0 object-keys: ^1.1.1 - checksum: f9aeac0541661370a1fc86e6a8065eb1668d3e771f7dbb33ee54578201336c057b21ee61207a186dd42db0c62201d91aac703d20d12a79fc79c353eed44d4e25 + checksum: 60e07d2651cf4f5528c485f1aa4dbded9b384c47d80e8187cefd11320abb1aebebf78df5483451dfa549059f8281c21f7b4bf7d19e9e5e97d8d617df0df298de languageName: node linkType: hard @@ -15402,14 +15538,15 @@ __metadata: languageName: node linkType: hard -"object.values@npm:^1.1.0, object.values@npm:^1.1.6, object.values@npm:^1.2.0": - version: 1.2.0 - resolution: "object.values@npm:1.2.0" +"object.values@npm:^1.1.0, object.values@npm:^1.1.6, object.values@npm:^1.2.0, object.values@npm:^1.2.1": + version: 1.2.1 + resolution: "object.values@npm:1.2.1" dependencies: - call-bind: ^1.0.7 + call-bind: ^1.0.8 + call-bound: ^1.0.3 define-properties: ^1.2.1 es-object-atoms: ^1.0.0 - checksum: 51fef456c2a544275cb1766897f34ded968b22adfc13ba13b5e4815fdaf4304a90d42a3aee114b1f1ede048a4890381d47a5594d84296f2767c6a0364b9da8fa + checksum: f9b9a2a125ccf8ded29414d7c056ae0d187b833ee74919821fc60d7e216626db220d9cb3cf33f965c84aaaa96133626ca13b80f3c158b673976dc8cfcfcd26bb languageName: node linkType: hard @@ -15558,6 +15695,17 @@ __metadata: languageName: node linkType: hard +"own-keys@npm:^1.0.1": + version: 1.0.1 + resolution: "own-keys@npm:1.0.1" + dependencies: + get-intrinsic: ^1.2.6 + object-keys: ^1.1.1 + safe-push-apply: ^1.0.0 + checksum: cc9dd7d85c4ccfbe8109fce307d581ac7ede7b26de892b537873fbce2dc6a206d89aea0630dbb98e47ce0873517cefeaa7be15fcf94aaf4764a3b34b474a5b61 + languageName: node + linkType: hard + "p-each-series@npm:^2.1.0": version: 2.2.0 resolution: "p-each-series@npm:2.2.0" @@ -15634,9 +15782,9 @@ __metadata: linkType: hard "p-map@npm:^7.0.2": - version: 7.0.2 - resolution: "p-map@npm:7.0.2" - checksum: bc128c2b244ef5d4619392b2247d718a3fe471d5fa4a73834fd96182a237f460ec7e0ad0f95139ef7103a6b50ed164228c62e2f8e41ba2b15360fe1c20d13563 + version: 7.0.3 + resolution: "p-map@npm:7.0.3" + checksum: 8c92d533acf82f0d12f7e196edccff773f384098bbb048acdd55a08778ce4fc8889d8f1bde72969487bd96f9c63212698d79744c20bedfce36c5b00b46d369f8 languageName: node linkType: hard @@ -15893,10 +16041,10 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:0.1.10": - version: 0.1.10 - resolution: "path-to-regexp@npm:0.1.10" - checksum: ab7a3b7a0b914476d44030340b0a65d69851af2a0f33427df1476100ccb87d409c39e2182837a96b98fb38c4ef2ba6b87bdad62bb70a2c153876b8061760583c +"path-to-regexp@npm:0.1.12": + version: 0.1.12 + resolution: "path-to-regexp@npm:0.1.12" + checksum: ab237858bee7b25ecd885189f175ab5b5161e7b712b360d44f5c4516b8d271da3e4bf7bf0a7b9153ecb04c7d90ce8ff5158614e1208819cf62bac2b08452722e languageName: node linkType: hard @@ -16060,13 +16208,13 @@ __metadata: linkType: hard "pkg-types@npm:^1.2.1": - version: 1.2.1 - resolution: "pkg-types@npm:1.2.1" + version: 1.3.0 + resolution: "pkg-types@npm:1.3.0" dependencies: confbox: ^0.1.8 - mlly: ^1.7.2 + mlly: ^1.7.3 pathe: ^1.1.2 - checksum: d2e3ad7aef36cc92b17403e61c04db521bf0beb175ccb4d432c284239f00ec32ff37feb072a260613e9ff727911cff1127a083fd52f91b9bec6b62970f385702 + checksum: 9637de6bbaceb4cf7c99c098b9286de63382cdd916e9e02c243dcd9f8725650aba7025a80cd7ac085176054b71c07656045b558fe452fe4ed2445383b51d6e24 languageName: node linkType: hard @@ -17416,7 +17564,7 @@ __metadata: languageName: node linkType: hard -"raw-body@npm:2.5.2": +"raw-body@npm:2.5.2, raw-body@npm:^2.3.3": version: 2.5.2 resolution: "raw-body@npm:2.5.2" dependencies: @@ -17615,11 +17763,11 @@ __metadata: linkType: hard "react-hook-form@npm:^7.34.0": - version: 7.53.2 - resolution: "react-hook-form@npm:7.53.2" + version: 7.54.2 + resolution: "react-hook-form@npm:7.54.2" peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 - checksum: 37e2dd0e79cd8d3376a7a2cb72ad7b59f0594be499daa898d2c6bec896fc30c2f86e62e8b41bc9d325f77220bd8d76cb31e917f77f1c92ad5740adb1a4cc69e2 + checksum: 49a867ece9894dca82f6552e5eefd012b7db962c56a7543f9275ae0b6ec202d549973c3694e7f97436afc2396549cb8fc8777241dd660b71793547aa9c8e5686 languageName: node linkType: hard @@ -17637,13 +17785,20 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0, react-is@npm:^18.3.1": +"react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 languageName: node linkType: hard +"react-is@npm:^19.0.0": + version: 19.0.0 + resolution: "react-is@npm:19.0.0" + checksum: fbb3060bcb6b3e8e525b17f0872d1cf62a40b73fa7c5de02419069e2edd3e01cf1e8e86c8888f0733cff006175ee76ae927b40b6f0c4332bdda21020505ac90b + languageName: node + linkType: hard + "react-markdown@npm:^6.0.0": version: 6.0.3 resolution: "react-markdown@npm:6.0.3" @@ -18024,18 +18179,19 @@ __metadata: languageName: node linkType: hard -"reflect.getprototypeof@npm:^1.0.4, reflect.getprototypeof@npm:^1.0.6": - version: 1.0.7 - resolution: "reflect.getprototypeof@npm:1.0.7" +"reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": + version: 1.0.10 + resolution: "reflect.getprototypeof@npm:1.0.10" dependencies: - call-bind: ^1.0.7 + call-bind: ^1.0.8 define-properties: ^1.2.1 - es-abstract: ^1.23.5 + es-abstract: ^1.23.9 es-errors: ^1.3.0 - get-intrinsic: ^1.2.4 - gopd: ^1.0.1 - which-builtin-type: ^1.1.4 - checksum: e023846d4d9631b46476a2315f5cdebb1f98782e145e807d985b47df8314776220b0d82244c9f3e51718acb09da79149f406afa9872e4fb4ca473dcc4e980598 + es-object-atoms: ^1.0.0 + get-intrinsic: ^1.2.7 + get-proto: ^1.0.1 + which-builtin-type: ^1.2.1 + checksum: ccc5debeb66125e276ae73909cecb27e47c35d9bb79d9cc8d8d055f008c58010ab8cb401299786e505e4aab733a64cba9daf5f312a58e96a43df66adad221870 languageName: node linkType: hard @@ -18102,15 +18258,17 @@ __metadata: languageName: node linkType: hard -"regexp.prototype.flags@npm:^1.5.1, regexp.prototype.flags@npm:^1.5.2, regexp.prototype.flags@npm:^1.5.3": - version: 1.5.3 - resolution: "regexp.prototype.flags@npm:1.5.3" +"regexp.prototype.flags@npm:^1.5.1, regexp.prototype.flags@npm:^1.5.3": + version: 1.5.4 + resolution: "regexp.prototype.flags@npm:1.5.4" dependencies: - call-bind: ^1.0.7 + call-bind: ^1.0.8 define-properties: ^1.2.1 es-errors: ^1.3.0 + get-proto: ^1.0.1 + gopd: ^1.2.0 set-function-name: ^2.0.2 - checksum: 83ff0705b837f7cb6d664010a11642250f36d3f642263dd0f3bdfe8f150261aa7b26b50ee97f21c1da30ef82a580bb5afedbef5f45639d69edaafbeac9bbb0ed + checksum: 18cb667e56cb328d2dda569d7f04e3ea78f2683135b866d606538cf7b1d4271f7f749f09608c877527799e6cf350e531368f3c7a20ccd1bb41048a48926bdeeb languageName: node linkType: hard @@ -18350,15 +18508,15 @@ __metadata: linkType: hard "resolve@npm:^1.10.0, resolve@npm:^1.12.0, resolve@npm:^1.14.2, resolve@npm:^1.17.0, resolve@npm:^1.18.1, resolve@npm:^1.19.0, resolve@npm:^1.22.4, resolve@npm:^1.3.2": - version: 1.22.8 - resolution: "resolve@npm:1.22.8" + version: 1.22.10 + resolution: "resolve@npm:1.22.10" dependencies: - is-core-module: ^2.13.0 + is-core-module: ^2.16.0 path-parse: ^1.0.7 supports-preserve-symlinks-flag: ^1.0.0 bin: resolve: bin/resolve - checksum: f8a26958aa572c9b064562750b52131a37c29d072478ea32e129063e2da7f83e31f7f11e7087a18225a8561cfe8d2f0df9dbea7c9d331a897571c0a2527dbb4c + checksum: ab7a32ff4046fcd7c6fdd525b24a7527847d03c3650c733b909b01b757f92eb23510afa9cc3e9bf3f26a3e073b48c88c706dfd4c1d2fb4a16a96b73b6328ddcf languageName: node linkType: hard @@ -18386,15 +18544,15 @@ __metadata: linkType: hard "resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.12.0#~builtin, resolve@patch:resolve@^1.14.2#~builtin, resolve@patch:resolve@^1.17.0#~builtin, resolve@patch:resolve@^1.18.1#~builtin, resolve@patch:resolve@^1.19.0#~builtin, resolve@patch:resolve@^1.22.4#~builtin, resolve@patch:resolve@^1.3.2#~builtin": - version: 1.22.8 - resolution: "resolve@patch:resolve@npm%3A1.22.8#~builtin::version=1.22.8&hash=07638b" + version: 1.22.10 + resolution: "resolve@patch:resolve@npm%3A1.22.10#~builtin::version=1.22.10&hash=07638b" dependencies: - is-core-module: ^2.13.0 + is-core-module: ^2.16.0 path-parse: ^1.0.7 supports-preserve-symlinks-flag: ^1.0.0 bin: resolve: bin/resolve - checksum: 5479b7d431cacd5185f8db64bfcb7286ae5e31eb299f4c4f404ad8aa6098b77599563ac4257cb2c37a42f59dfc06a1bec2bcf283bb448f319e37f0feb9a09847 + checksum: 8aac1e4e4628bd00bf4b94b23de137dd3fe44097a8d528fd66db74484be929936e20c696e1a3edf4488f37e14180b73df6f600992baea3e089e8674291f16c9d languageName: node linkType: hard @@ -18652,15 +18810,16 @@ __metadata: languageName: node linkType: hard -"safe-array-concat@npm:^1.1.2": - version: 1.1.2 - resolution: "safe-array-concat@npm:1.1.2" +"safe-array-concat@npm:^1.1.2, safe-array-concat@npm:^1.1.3": + version: 1.1.3 + resolution: "safe-array-concat@npm:1.1.3" dependencies: - call-bind: ^1.0.7 - get-intrinsic: ^1.2.4 - has-symbols: ^1.0.3 + call-bind: ^1.0.8 + call-bound: ^1.0.2 + get-intrinsic: ^1.2.6 + has-symbols: ^1.1.0 isarray: ^2.0.5 - checksum: a3b259694754ddfb73ae0663829e396977b99ff21cbe8607f35a469655656da8e271753497e59da8a7575baa94d2e684bea3e10ddd74ba046c0c9b4418ffa0c4 + checksum: 00f6a68140e67e813f3ad5e73e6dedcf3e42a9fa01f04d44b0d3f7b1f4b257af876832a9bfc82ac76f307e8a6cc652e3cf95876048a26cbec451847cf6ae3707 languageName: node linkType: hard @@ -18678,14 +18837,24 @@ __metadata: languageName: node linkType: hard -"safe-regex-test@npm:^1.0.3": - version: 1.0.3 - resolution: "safe-regex-test@npm:1.0.3" +"safe-push-apply@npm:^1.0.0": + version: 1.0.0 + resolution: "safe-push-apply@npm:1.0.0" dependencies: - call-bind: ^1.0.6 es-errors: ^1.3.0 - is-regex: ^1.1.4 - checksum: 6c7d392ff1ae7a3ae85273450ed02d1d131f1d2c76e177d6b03eb88e6df8fa062639070e7d311802c1615f351f18dc58f9454501c58e28d5ffd9b8f502ba6489 + isarray: ^2.0.5 + checksum: 8c11cbee6dc8ff5cc0f3d95eef7052e43494591384015902e4292aef4ae9e539908288520ed97179cee17d6ffb450fe5f05a46ce7a1749685f7524fd568ab5db + languageName: node + linkType: hard + +"safe-regex-test@npm:^1.0.3, safe-regex-test@npm:^1.1.0": + version: 1.1.0 + resolution: "safe-regex-test@npm:1.1.0" + dependencies: + call-bound: ^1.0.2 + es-errors: ^1.3.0 + is-regex: ^1.2.1 + checksum: 3c809abeb81977c9ed6c869c83aca6873ea0f3ab0f806b8edbba5582d51713f8a6e9757d24d2b4b088f563801475ea946c8e77e7713e8c65cdd02305b6caedab languageName: node linkType: hard @@ -18757,8 +18926,8 @@ __metadata: linkType: hard "sass@npm:^1.54.0": - version: 1.82.0 - resolution: "sass@npm:1.82.0" + version: 1.83.1 + resolution: "sass@npm:1.83.1" dependencies: "@parcel/watcher": ^2.4.1 chokidar: ^4.0.0 @@ -18769,7 +18938,7 @@ __metadata: optional: true bin: sass: sass.js - checksum: 3fbfed5361147627097261e69715d6e4826864b636df5a70e4100228fe0e3c1bfd264e2401cfd3e291f739fbee2bd2f56dff27f7f5dc0e21d320356bf51630d0 + checksum: 367a9f270c74a9ad2851955e1cf5b2a05e57d27aec4bf054be1da48eb49858076467b65ec180d8c4392b5c55c0f4d4ba644855f985652ffca15d68a20641d5e0 languageName: node linkType: hard @@ -19015,7 +19184,7 @@ __metadata: languageName: node linkType: hard -"set-function-length@npm:^1.2.1": +"set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" dependencies: @@ -19029,7 +19198,7 @@ __metadata: languageName: node linkType: hard -"set-function-name@npm:^2.0.1, set-function-name@npm:^2.0.2": +"set-function-name@npm:^2.0.2": version: 2.0.2 resolution: "set-function-name@npm:2.0.2" dependencies: @@ -19041,6 +19210,17 @@ __metadata: languageName: node linkType: hard +"set-proto@npm:^1.0.0": + version: 1.0.0 + resolution: "set-proto@npm:1.0.0" + dependencies: + dunder-proto: ^1.0.1 + es-errors: ^1.3.0 + es-object-atoms: ^1.0.0 + checksum: ec27cbbe334598547e99024403e96da32aca3e530583e4dba7f5db1c43cbc4affa9adfbd77c7b2c210b9b8b2e7b2e600bad2a6c44fd62e804d8233f96bbb62f4 + languageName: node + linkType: hard + "set-value@npm:^2.0.0, set-value@npm:^2.0.1": version: 2.0.1 resolution: "set-value@npm:2.0.1" @@ -19142,15 +19322,51 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6": - version: 1.0.6 - resolution: "side-channel@npm:1.0.6" +"side-channel-list@npm:^1.0.0": + version: 1.0.0 + resolution: "side-channel-list@npm:1.0.0" dependencies: - call-bind: ^1.0.7 es-errors: ^1.3.0 - get-intrinsic: ^1.2.4 - object-inspect: ^1.13.1 - checksum: bfc1afc1827d712271453e91b7cd3878ac0efd767495fd4e594c4c2afaa7963b7b510e249572bfd54b0527e66e4a12b61b80c061389e129755f34c493aad9b97 + object-inspect: ^1.13.3 + checksum: 603b928997abd21c5a5f02ae6b9cc36b72e3176ad6827fab0417ead74580cc4fb4d5c7d0a8a2ff4ead34d0f9e35701ed7a41853dac8a6d1a664fcce1a044f86f + languageName: node + linkType: hard + +"side-channel-map@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-map@npm:1.0.1" + dependencies: + call-bound: ^1.0.2 + es-errors: ^1.3.0 + get-intrinsic: ^1.2.5 + object-inspect: ^1.13.3 + checksum: 42501371cdf71f4ccbbc9c9e2eb00aaaab80a4c1c429d5e8da713fd4d39ef3b8d4a4b37ed4f275798a65260a551a7131fd87fe67e922dba4ac18586d6aab8b06 + languageName: node + linkType: hard + +"side-channel-weakmap@npm:^1.0.2": + version: 1.0.2 + resolution: "side-channel-weakmap@npm:1.0.2" + dependencies: + call-bound: ^1.0.2 + es-errors: ^1.3.0 + get-intrinsic: ^1.2.5 + object-inspect: ^1.13.3 + side-channel-map: ^1.0.1 + checksum: a815c89bc78c5723c714ea1a77c938377ea710af20d4fb886d362b0d1f8ac73a17816a5f6640f354017d7e292a43da9c5e876c22145bac00b76cfb3468001736 + languageName: node + linkType: hard + +"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": + version: 1.1.0 + resolution: "side-channel@npm:1.1.0" + dependencies: + es-errors: ^1.3.0 + object-inspect: ^1.13.3 + side-channel-list: ^1.0.0 + side-channel-map: ^1.0.1 + side-channel-weakmap: ^1.0.2 + checksum: bf73d6d6682034603eb8e99c63b50155017ed78a522d27c2acec0388a792c3ede3238b878b953a08157093b85d05797217d270b7666ba1f111345fbe933380ff languageName: node linkType: hard @@ -19296,13 +19512,13 @@ __metadata: linkType: hard "socks-proxy-agent@npm:^8.0.3": - version: 8.0.4 - resolution: "socks-proxy-agent@npm:8.0.4" + version: 8.0.5 + resolution: "socks-proxy-agent@npm:8.0.5" dependencies: - agent-base: ^7.1.1 + agent-base: ^7.1.2 debug: ^4.3.4 socks: ^2.8.3 - checksum: b2ec5051d85fe49072f9a250c427e0e9571fd09d5db133819192d078fd291276e1f0f50f6dbc04329b207738b1071314cee8bdbb4b12e27de42dbcf1d4233c67 + checksum: b4fbcdb7ad2d6eec445926e255a1fb95c975db0020543fbac8dfa6c47aecc6b3b619b7fb9c60a3f82c9b2969912a5e7e174a056ae4d98cb5322f3524d6036e1d languageName: node linkType: hard @@ -19593,11 +19809,12 @@ __metadata: linkType: hard "stop-iteration-iterator@npm:^1.0.0": - version: 1.0.0 - resolution: "stop-iteration-iterator@npm:1.0.0" + version: 1.1.0 + resolution: "stop-iteration-iterator@npm:1.1.0" dependencies: - internal-slot: ^1.0.4 - checksum: d04173690b2efa40e24ab70e5e51a3ff31d56d699550cfad084104ab3381390daccb36652b25755e420245f3b0737de66c1879eaa2a8d4fc0a78f9bf892fcb42 + es-errors: ^1.3.0 + internal-slot: ^1.1.0 + checksum: be944489d8829fb3bdec1a1cc4a2142c6b6eb317305eeace1ece978d286d6997778afa1ae8cb3bd70e2b274b9aa8c69f93febb1e15b94b1359b11058f9d3c3a1 languageName: node linkType: hard @@ -19725,23 +19942,24 @@ __metadata: languageName: node linkType: hard -"string.prototype.matchall@npm:^4.0.11": - version: 4.0.11 - resolution: "string.prototype.matchall@npm:4.0.11" +"string.prototype.matchall@npm:^4.0.12": + version: 4.0.12 + resolution: "string.prototype.matchall@npm:4.0.12" dependencies: - call-bind: ^1.0.7 + call-bind: ^1.0.8 + call-bound: ^1.0.3 define-properties: ^1.2.1 - es-abstract: ^1.23.2 + es-abstract: ^1.23.6 es-errors: ^1.3.0 es-object-atoms: ^1.0.0 - get-intrinsic: ^1.2.4 - gopd: ^1.0.1 - has-symbols: ^1.0.3 - internal-slot: ^1.0.7 - regexp.prototype.flags: ^1.5.2 + get-intrinsic: ^1.2.6 + gopd: ^1.2.0 + has-symbols: ^1.1.0 + internal-slot: ^1.1.0 + regexp.prototype.flags: ^1.5.3 set-function-name: ^2.0.2 - side-channel: ^1.0.6 - checksum: 6ac6566ed065c0c8489c91156078ca077db8ff64d683fda97ae652d00c52dfa5f39aaab0a710d8243031a857fd2c7c511e38b45524796764d25472d10d7075ae + side-channel: ^1.1.0 + checksum: 98a09d6af91bfc6ee25556f3d7cd6646d02f5f08bda55d45528ed273d266d55a71af7291fe3fc76854deffb9168cc1a917d0b07a7d5a178c7e9537c99e6d2b57 languageName: node linkType: hard @@ -19755,26 +19973,30 @@ __metadata: languageName: node linkType: hard -"string.prototype.trim@npm:^1.2.9": - version: 1.2.9 - resolution: "string.prototype.trim@npm:1.2.9" +"string.prototype.trim@npm:^1.2.10": + version: 1.2.10 + resolution: "string.prototype.trim@npm:1.2.10" dependencies: - call-bind: ^1.0.7 + call-bind: ^1.0.8 + call-bound: ^1.0.2 + define-data-property: ^1.1.4 define-properties: ^1.2.1 - es-abstract: ^1.23.0 + es-abstract: ^1.23.5 es-object-atoms: ^1.0.0 - checksum: ea2df6ec1e914c9d4e2dc856fa08228e8b1be59b59e50b17578c94a66a176888f417264bb763d4aac638ad3b3dad56e7a03d9317086a178078d131aa293ba193 + has-property-descriptors: ^1.0.2 + checksum: 87659cd8561237b6c69f5376328fda934693aedde17bb7a2c57008e9d9ff992d0c253a391c7d8d50114e0e49ff7daf86a362f7961cf92f7564cd01342ca2e385 languageName: node linkType: hard -"string.prototype.trimend@npm:^1.0.8": - version: 1.0.8 - resolution: "string.prototype.trimend@npm:1.0.8" +"string.prototype.trimend@npm:^1.0.8, string.prototype.trimend@npm:^1.0.9": + version: 1.0.9 + resolution: "string.prototype.trimend@npm:1.0.9" dependencies: - call-bind: ^1.0.7 + call-bind: ^1.0.8 + call-bound: ^1.0.2 define-properties: ^1.2.1 es-object-atoms: ^1.0.0 - checksum: cc3bd2de08d8968a28787deba9a3cb3f17ca5f9f770c91e7e8fa3e7d47f079bad70fadce16f05dda9f261788be2c6e84a942f618c3bed31e42abc5c1084f8dfd + checksum: cb86f639f41d791a43627784be2175daa9ca3259c7cb83e7a207a729909b74f2ea0ec5d85de5761e6835e5f443e9420c6ff3f63a845378e4a61dd793177bc287 languageName: node linkType: hard @@ -20227,8 +20449,8 @@ __metadata: linkType: hard "terser@npm:^5.3.4": - version: 5.36.0 - resolution: "terser@npm:5.36.0" + version: 5.37.0 + resolution: "terser@npm:5.37.0" dependencies: "@jridgewell/source-map": ^0.3.3 acorn: ^8.8.2 @@ -20236,7 +20458,7 @@ __metadata: source-map-support: ~0.5.20 bin: terser: bin/terser - checksum: 489afd31901a2b170f7766948a3aa0e25da0acb41e9e35bd9f9b4751dfa2fc846e485f6fb9d34f0839a96af77f675b5fbf0a20c9aa54e0b8d7c219cf0b55e508 + checksum: 70c06a8ce1288ff4370a7e481beb6fc8b22fc4995371479f49df1552aa9cf8e794ace66e1da6e87057eda1745644311213f5043bda9a06cf55421eff68b3ac06 languageName: node linkType: hard @@ -20594,6 +20816,13 @@ __metadata: languageName: node linkType: hard +"tsscmp@npm:^1.0.6": + version: 1.0.6 + resolution: "tsscmp@npm:1.0.6" + checksum: 1512384def36bccc9125cabbd4c3b0e68608d7ee08127ceaa0b84a71797263f1a01c7f82fa69be8a3bd3c1396e2965d2f7b52d581d3a5eeaf3967fbc52e3b3bf + languageName: node + linkType: hard + "tsutils@npm:^3.17.1, tsutils@npm:^3.21.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0" @@ -20714,46 +20943,46 @@ __metadata: languageName: node linkType: hard -"typed-array-buffer@npm:^1.0.2": - version: 1.0.2 - resolution: "typed-array-buffer@npm:1.0.2" +"typed-array-buffer@npm:^1.0.3": + version: 1.0.3 + resolution: "typed-array-buffer@npm:1.0.3" dependencies: - call-bind: ^1.0.7 + call-bound: ^1.0.3 es-errors: ^1.3.0 - is-typed-array: ^1.1.13 - checksum: 02ffc185d29c6df07968272b15d5319a1610817916ec8d4cd670ded5d1efe72901541ff2202fcc622730d8a549c76e198a2f74e312eabbfb712ed907d45cbb0b + is-typed-array: ^1.1.14 + checksum: 3fb91f0735fb413b2bbaaca9fabe7b8fc14a3fa5a5a7546bab8a57e755be0e3788d893195ad9c2b842620592de0e68d4c077d4c2c41f04ec25b8b5bb82fa9a80 languageName: node linkType: hard -"typed-array-byte-length@npm:^1.0.1": - version: 1.0.1 - resolution: "typed-array-byte-length@npm:1.0.1" +"typed-array-byte-length@npm:^1.0.3": + version: 1.0.3 + resolution: "typed-array-byte-length@npm:1.0.3" dependencies: - call-bind: ^1.0.7 + call-bind: ^1.0.8 for-each: ^0.3.3 - gopd: ^1.0.1 - has-proto: ^1.0.3 - is-typed-array: ^1.1.13 - checksum: f65e5ecd1cf76b1a2d0d6f631f3ea3cdb5e08da106c6703ffe687d583e49954d570cc80434816d3746e18be889ffe53c58bf3e538081ea4077c26a41055b216d + gopd: ^1.2.0 + has-proto: ^1.2.0 + is-typed-array: ^1.1.14 + checksum: cda9352178ebeab073ad6499b03e938ebc30c4efaea63a26839d89c4b1da9d2640b0d937fc2bd1f049eb0a38def6fbe8a061b601292ae62fe079a410ce56e3a6 languageName: node linkType: hard -"typed-array-byte-offset@npm:^1.0.2": - version: 1.0.3 - resolution: "typed-array-byte-offset@npm:1.0.3" +"typed-array-byte-offset@npm:^1.0.4": + version: 1.0.4 + resolution: "typed-array-byte-offset@npm:1.0.4" dependencies: available-typed-arrays: ^1.0.7 - call-bind: ^1.0.7 + call-bind: ^1.0.8 for-each: ^0.3.3 - gopd: ^1.0.1 - has-proto: ^1.0.3 - is-typed-array: ^1.1.13 - reflect.getprototypeof: ^1.0.6 - checksum: 36728daa80d49a9fa51cd3f0f2b037613f4574666fd4473bd37ac123d7f6f81ea68ff45424c1e2673257964e10bedeb3ebfce73532672913ebbe446999912303 + gopd: ^1.2.0 + has-proto: ^1.2.0 + is-typed-array: ^1.1.15 + reflect.getprototypeof: ^1.0.9 + checksum: 670b7e6bb1d3c2cf6160f27f9f529e60c3f6f9611c67e47ca70ca5cfa24ad95415694c49d1dbfeda016d3372cab7dfc9e38c7b3e1bb8d692cae13a63d3c144d7 languageName: node linkType: hard -"typed-array-length@npm:^1.0.6": +"typed-array-length@npm:^1.0.7": version: 1.0.7 resolution: "typed-array-length@npm:1.0.7" dependencies: @@ -20859,15 +21088,15 @@ __metadata: languageName: node linkType: hard -"unbox-primitive@npm:^1.0.2": - version: 1.0.2 - resolution: "unbox-primitive@npm:1.0.2" +"unbox-primitive@npm:^1.1.0": + version: 1.1.0 + resolution: "unbox-primitive@npm:1.1.0" dependencies: - call-bind: ^1.0.2 + call-bound: ^1.0.3 has-bigints: ^1.0.2 - has-symbols: ^1.0.3 - which-boxed-primitive: ^1.0.2 - checksum: b7a1cf5862b5e4b5deb091672ffa579aa274f648410009c81cca63fed3b62b610c4f3b773f912ce545bb4e31edc3138975b5bc777fc6e4817dca51affb6380e9 + has-symbols: ^1.1.0 + which-boxed-primitive: ^1.1.1 + checksum: 729f13b84a5bfa3fead1d8139cee5c38514e63a8d6a437819a473e241ba87eeb593646568621c7fc7f133db300ef18d65d1a5a60dc9c7beb9000364d93c581df languageName: node linkType: hard @@ -21230,11 +21459,11 @@ __metadata: linkType: hard "use-sync-external-store@npm:^1.0.0": - version: 1.2.2 - resolution: "use-sync-external-store@npm:1.2.2" + version: 1.4.0 + resolution: "use-sync-external-store@npm:1.4.0" peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: fe07c071c4da3645f112c38c0e57beb479a8838616ff4e92598256ecce527f2888c08febc7f9b2f0ce2f0e18540ba3cde41eb2035e4fafcb4f52955037098a81 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: dc3843a1b59ac8bd01417bd79498d4c688d5df8bf4801be50008ef4bfaacb349058c0b1605b5b43c828e0a2d62722d7e861573b3f31cea77a7f23e8b0fc2f7e3 languageName: node linkType: hard @@ -21903,37 +22132,37 @@ __metadata: languageName: node linkType: hard -"which-boxed-primitive@npm:^1.0.2": - version: 1.1.0 - resolution: "which-boxed-primitive@npm:1.1.0" +"which-boxed-primitive@npm:^1.0.2, which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": + version: 1.1.1 + resolution: "which-boxed-primitive@npm:1.1.1" dependencies: is-bigint: ^1.1.0 - is-boolean-object: ^1.2.0 - is-number-object: ^1.1.0 - is-string: ^1.1.0 - is-symbol: ^1.1.0 - checksum: 49ebec9693ed21ee8183b9e353ee7134a03722776c84624019964124885a4a940f469af3d1508ad83022a68cc515aecbef70fb1256ace57a871c43d24d050304 + is-boolean-object: ^1.2.1 + is-number-object: ^1.1.1 + is-string: ^1.1.1 + is-symbol: ^1.1.1 + checksum: ee41d0260e4fd39551ad77700c7047d3d281ec03d356f5e5c8393fe160ba0db53ef446ff547d05f76ffabfd8ad9df7c9a827e12d4cccdbc8fccf9239ff8ac21e languageName: node linkType: hard -"which-builtin-type@npm:^1.1.4": - version: 1.2.0 - resolution: "which-builtin-type@npm:1.2.0" +"which-builtin-type@npm:^1.2.1": + version: 1.2.1 + resolution: "which-builtin-type@npm:1.2.1" dependencies: - call-bind: ^1.0.7 + call-bound: ^1.0.2 function.prototype.name: ^1.1.6 has-tostringtag: ^1.0.2 is-async-function: ^2.0.0 - is-date-object: ^1.0.5 + is-date-object: ^1.1.0 is-finalizationregistry: ^1.1.0 is-generator-function: ^1.0.10 - is-regex: ^1.1.4 + is-regex: ^1.2.1 is-weakref: ^1.0.2 isarray: ^2.0.5 - which-boxed-primitive: ^1.0.2 + which-boxed-primitive: ^1.1.0 which-collection: ^1.0.2 - which-typed-array: ^1.1.15 - checksum: 6d40ecdf33a28c3fdeab13e7e3b4289fb51f7ebd0983e628d50fa42e113d8be1bc7dd0e6eb23c6b6a0c2c0c7667763eca3a2af1f6d768e48efba8073870eb568 + which-typed-array: ^1.1.16 + checksum: 7a3617ba0e7cafb795f74db418df889867d12bce39a477f3ee29c6092aa64d396955bf2a64eae3726d8578440e26777695544057b373c45a8bcf5fbe920bf633 languageName: node linkType: hard @@ -21956,16 +22185,17 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.13, which-typed-array@npm:^1.1.14, which-typed-array@npm:^1.1.15, which-typed-array@npm:^1.1.2": - version: 1.1.16 - resolution: "which-typed-array@npm:1.1.16" +"which-typed-array@npm:^1.1.13, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2": + version: 1.1.18 + resolution: "which-typed-array@npm:1.1.18" dependencies: available-typed-arrays: ^1.0.7 - call-bind: ^1.0.7 + call-bind: ^1.0.8 + call-bound: ^1.0.3 for-each: ^0.3.3 - gopd: ^1.0.1 + gopd: ^1.2.0 has-tostringtag: ^1.0.2 - checksum: 903d398ec234d608011e1df09af6c004e66965bb24d5e1a82856cba0495fa6389ae393d1c9d5411498a9cba8e61b2e39a8e8be7b3005cbeadd317f772b1bdaef + checksum: d2feea7f51af66b3a240397aa41c796585033e1069f18e5b6d4cd3878538a1e7780596fd3ea9bf347c43d9e98e13be09b37d9ea3887cef29b11bc291fd47bb52 languageName: node linkType: hard @@ -22428,7 +22658,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^15.4.1": +"yargs@npm:^15.3.1, yargs@npm:^15.4.1": version: 15.4.1 resolution: "yargs@npm:15.4.1" dependencies: