diff --git a/src/backend/src/controllers/calendar.controllers.ts b/src/backend/src/controllers/calendar.controllers.ts index d9ec720652..1670e9e7f2 100644 --- a/src/backend/src/controllers/calendar.controllers.ts +++ b/src/backend/src/controllers/calendar.controllers.ts @@ -139,13 +139,14 @@ export default class CalendarController { static async createCalendar(req: Request, res: Response, next: NextFunction) { try { - const { name, description, colorHexCode } = req.body; + const { name, description, colorHexCode, isNewMemberCalendar } = req.body; const calendar = await CalendarService.createCalendar( req.currentUser, name, description, colorHexCode, + isNewMemberCalendar, req.organization ); @@ -158,7 +159,7 @@ export default class CalendarController { static async editCalendar(req: Request, res: Response, next: NextFunction) { try { const { calendarId } = req.params as Record; - const { name, colorHexCode, description } = req.body; + const { name, colorHexCode, description, isNewMemberCalendar } = req.body; const updatedCalendar = await CalendarService.editCalendar( req.currentUser, @@ -166,6 +167,7 @@ export default class CalendarController { name, description, colorHexCode, + isNewMemberCalendar, req.organization ); @@ -175,6 +177,15 @@ export default class CalendarController { } } + static async getNewMemberEvents(req: Request, res: Response, next: NextFunction) { + try { + const events = await CalendarService.getNewMemberEvents(req.organization); + res.status(200).json(events); + } catch (error: unknown) { + next(error); + } + } + static async deleteCalendar(req: Request, res: Response, next: NextFunction) { try { const { calendarId } = req.params as Record; diff --git a/src/backend/src/controllers/organizations.controllers.ts b/src/backend/src/controllers/organizations.controllers.ts index 9c6a4951de..a6db421082 100644 --- a/src/backend/src/controllers/organizations.controllers.ts +++ b/src/backend/src/controllers/organizations.controllers.ts @@ -125,31 +125,6 @@ export default class OrganizationsController { } } - static async setNewMemberImage(req: Request, res: Response, next: NextFunction) { - try { - if (!req.file) { - throw new HttpException(400, 'Invalid or undefined image data'); - } - - const updatedOrg = await OrganizationsService.setNewMemberImage(req.file, req.currentUser, req.organization); - - res.status(200).json(updatedOrg); - } catch (error: unknown) { - next(error); - } - } - - static async getOrganizationNewMemberImage(req: Request, res: Response, next: NextFunction) { - try { - const { organization } = req; - - const newMemberImageId = await OrganizationsService.getNewMemberImage(organization.organizationId); - res.status(200).json(newMemberImageId); - } catch (error: unknown) { - next(error); - } - } - static async setOrganizationDescription(req: Request, res: Response, next: NextFunction) { try { const updatedOrg = await OrganizationsService.setOrganizationDescription( diff --git a/src/backend/src/controllers/part-review.controllers.ts b/src/backend/src/controllers/part-review.controllers.ts index c328e25325..0e16569624 100644 --- a/src/backend/src/controllers/part-review.controllers.ts +++ b/src/backend/src/controllers/part-review.controllers.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import PartReviewService from '../services/part-review.services.js'; +import RecruitmentServices from '../services/recruitment.services.js'; import { WbsNumber, validateWBS } from 'shared'; import { HttpException } from '../utils/errors.utils.js'; @@ -255,7 +256,15 @@ export default class PartReviewController { static async createFaq(req: Request, res: Response, next: NextFunction) { try { const { question, answer } = req.body; - const faq = await PartReviewService.createFaq(question, answer, req.currentUser, req.organization.organizationId); + const faq = await RecruitmentServices.createOrganizationFaq( + req.currentUser, + question, + answer, + req.organization, + false, + false, + true + ); res.status(200).json(faq); } catch (error: unknown) { next(error); diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index a1f6d5beb5..6eaff761c3 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -183,7 +183,7 @@ export default class ProjectsController { static async createLinkType(req: Request, res: Response, next: NextFunction) { try { - const { name, iconName, required, isOnGuestHomePage } = req.body; + const { name, iconName, required, isOnGuestHomePage, isOnNewMemberDashboard, isOnOnboardingDashboard } = req.body; const newLinkType = await ProjectsService.createLinkType( req.currentUser, @@ -191,7 +191,9 @@ export default class ProjectsController { iconName, required, req.organization, - isOnGuestHomePage + isOnGuestHomePage, + isOnNewMemberDashboard, + isOnOnboardingDashboard ); res.status(200).json(newLinkType); } catch (error: unknown) { @@ -469,7 +471,14 @@ export default class ProjectsController { static async editLinkType(req: Request, res: Response, next: NextFunction) { try { const { linkTypeName } = req.params as Record; - const { name: newName, iconName, required, isOnGuestHomePage } = req.body; + const { + name: newName, + iconName, + required, + isOnGuestHomePage, + isOnNewMemberDashboard, + isOnOnboardingDashboard + } = req.body; const linkTypeUpdated = await ProjectsService.editLinkType( linkTypeName, iconName, @@ -477,6 +486,8 @@ export default class ProjectsController { req.currentUser, req.organization, isOnGuestHomePage, + isOnNewMemberDashboard, + isOnOnboardingDashboard, newName ); res.status(200).json(linkTypeUpdated); diff --git a/src/backend/src/controllers/recruitment.controllers.ts b/src/backend/src/controllers/recruitment.controllers.ts index f662182b27..8105b3b148 100644 --- a/src/backend/src/controllers/recruitment.controllers.ts +++ b/src/backend/src/controllers/recruitment.controllers.ts @@ -11,15 +11,34 @@ export default class RecruitmentController { } } + static async getNewMemberMilestones(req: Request, res: Response, next: NextFunction) { + try { + const newMemberMilestones = await RecruitmentServices.getNewMemberMilestones(req.organization); + res.status(200).json(newMemberMilestones); + } catch (error: unknown) { + next(error); + } + } + + static async getRecruitingMilestones(req: Request, res: Response, next: NextFunction) { + try { + const recruitingMilestones = await RecruitmentServices.getRecruitingMilestones(req.organization); + res.status(200).json(recruitingMilestones); + } catch (error: unknown) { + next(error); + } + } + static async createMilestone(req: Request, res: Response, next: NextFunction) { try { - const { name, description, dateOfEvent } = req.body; + const { name, description, dateOfEvent, isOnNewMemberDashboard, isOnRecruitingDashboard } = req.body; const milestone = await RecruitmentServices.createMilestone( req.currentUser, name, description, dateOfEvent, + { isOnNewMemberDashboard, isOnRecruitingDashboard }, req.organization ); res.status(200).json(milestone); @@ -57,19 +76,66 @@ export default class RecruitmentController { } } + // TODO rename this method throughout stack + // Changed scope of getAllOrganizationFaqs to include part review, so what this call really wants is + // recruiting FAQs, but I'll change this as part of actual work not schema changes static async getAllOrganizationFaqs(req: Request, res: Response, next: NextFunction) { try { - const allFaqs = await RecruitmentServices.getAllOrganizationFaqs(req.organization); + const allFaqs = await RecruitmentServices.getRecruitingFaqs(req.organization); res.status(200).json(allFaqs); } catch (error: unknown) { next(error); } } - static async createOrganizationFaq(req: Request, res: Response, next: NextFunction) { + static async getRecruitingFaqs(req: Request, res: Response, next: NextFunction) { + try { + const faqs = await RecruitmentServices.getRecruitingFaqs(req.organization); + res.status(200).json(faqs); + } catch (error: unknown) { + next(error); + } + } + + static async getNewMemberFaqs(req: Request, res: Response, next: NextFunction) { + try { + const faqs = await RecruitmentServices.getNewMemberFaqs(req.organization); + res.status(200).json(faqs); + } catch (error: unknown) { + next(error); + } + } + + static async createRecruitingFaq(req: Request, res: Response, next: NextFunction) { try { const { question, answer } = req.body; - const faq = await RecruitmentServices.createOrganizationFaq(req.currentUser, question, answer, req.organization); + const faq = await RecruitmentServices.createOrganizationFaq( + req.currentUser, + question, + answer, + req.organization, + true, + false, + false + ); + res.status(200).json(faq); + } catch (error: unknown) { + next(error); + } + } + + static async createNewMemberFaq(req: Request, res: Response, next: NextFunction) { + try { + const { question, answer } = req.body; + const faq = await RecruitmentServices.createOrganizationFaq( + req.currentUser, + question, + answer, + req.organization, + false, + true, + false + ); res.status(200).json(faq); } catch (error: unknown) { next(error); diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index 6720db4813..55098e3703 100644 --- a/src/backend/src/controllers/slack.controllers.ts +++ b/src/backend/src/controllers/slack.controllers.ts @@ -3,7 +3,8 @@ import OrganizationsService from '../services/organizations.services.js'; import SlackServices, { SlackBlockActionBody, SaboSubmissionActionValue, - CrApprovalActionValue + CrApprovalActionValue, + TeamJoinRequestApprovalActionValue } from '../services/slack.services.js'; import { tryParseJson } from '../utils/slack.utils.js'; @@ -129,4 +130,49 @@ export default class SlackController { throw error; } } + + /** + * Handles the Slack block action for approving a team join request. + * Unlike handleApproveCRAction, all error reporting goes through respond() rather than + * replyToMessageInThread -- team join request notifications are sent as fresh (non-threaded) + * ephemerals, so there's no reliable message thread to reply into. + * + * @param body The validated Slack block action body (general structure validated in routes) + * @param respond Bolt response callback bound to this interaction's response_url + */ + static async handleApproveTeamJoinRequestAction( + body: SlackBlockActionBody, + respond: (msg: { + response_type?: 'ephemeral'; + text?: string; + replace_original?: boolean; + delete_original?: boolean; + }) => Promise + ) { + const { user, actions } = body; + const [firstAction] = actions; + + const parsed = tryParseJson(firstAction.value); + if (!parsed.ok) { + await respond({ + response_type: 'ephemeral', + text: `❌ An error occurred: Invalid action data format.\n\n*Error:* ${parsed.error}` + }); + return; + } + const actionValue = parsed.data; + + if (!actionValue.teamJoinRequestId || typeof actionValue.teamJoinRequestId !== 'string') { + await respond({ + response_type: 'ephemeral', + text: `❌ An error occurred: Missing or invalid team join request ID.` + }); + return; + } + + const userSlackId = user.id; + const { teamJoinRequestId } = actionValue; + + await SlackServices.handleApproveTeamJoinRequestAction(userSlackId, teamJoinRequestId, respond); + } } diff --git a/src/backend/src/controllers/teams.controllers.ts b/src/backend/src/controllers/teams.controllers.ts index 53377b74c9..2454eb08b9 100644 --- a/src/backend/src/controllers/teams.controllers.ts +++ b/src/backend/src/controllers/teams.controllers.ts @@ -149,6 +149,55 @@ export default class TeamsController { } } + static async createTeamJoinRequest(req: Request, res: Response, next: NextFunction) { + try { + const { teamId } = req.params as Record; + + const request = await TeamsService.createTeamJoinRequest(req.currentUser, teamId, req.organization); + res.status(200).json(request); + } catch (error: unknown) { + next(error); + } + } + + static async getMyTeamJoinRequests(req: Request, res: Response, next: NextFunction) { + try { + const requests = await TeamsService.getMyTeamJoinRequests(req.currentUser, req.organization); + res.status(200).json(requests); + } catch (error: unknown) { + next(error); + } + } + + static async getPendingTeamJoinRequests(req: Request, res: Response, next: NextFunction) { + try { + const { teamId } = req.params as Record; + + const requests = await TeamsService.getPendingTeamJoinRequests(teamId, req.currentUser, req.organization); + res.status(200).json(requests); + } catch (error: unknown) { + next(error); + } + } + + static async reviewTeamJoinRequest(req: Request, res: Response, next: NextFunction) { + try { + const { teamJoinRequestId } = req.params as Record; + const { approved, denialReason } = req.body; + + const request = await TeamsService.reviewTeamJoinRequest( + req.currentUser, + teamJoinRequestId, + approved, + denialReason, + req.organization + ); + res.status(200).json(request); + } catch (error: unknown) { + next(error); + } + } + static async deleteTeam(req: Request, res: Response, next: NextFunction) { try { const { teamId } = req.params as Record; diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index db25abece4..589f5f08ea 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -397,17 +397,36 @@ export const checkBotInChannel = async (channelId: string): Promise => }; /** - * Given a slack user id, prood.uces the name of the channel + * Fetches a user's display name from Slack. * @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) => { +const fetchUserName = async (userId: string): Promise => { const client = getSlackClient(); if (!client) return undefined; + const userRes = await client.users.info({ user: userId }); + return userRes.user?.profile?.display_name || userRes.user?.real_name; +}; + +/** + * Caches user display names, which change very rarely, keyed by slack user id. + */ +const userNameCache = new LRUCache({ + max: 1000, + ttl: 1000 * 60 * 60 * 24, // 1 day + fetchMethod: fetchUserName +}); + +/** + * Given a slack user id, produces the display name of the user. + * Results are cached, and concurrent calls for the same user share a single slack request. + * @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 client.users.info({ user: userId }); - return userRes.user?.profile?.display_name || userRes.user?.real_name; + return await userNameCache.fetch(userId); } catch (error) { return undefined; } @@ -437,14 +456,14 @@ export const getWorkspaceId = async () => { /** * Sends a slack ephemeral message to a user * @param channelId - the channel id of the channel to send to - * @param threadTs - the timestamp of the thread to send to + * @param threadTs - the timestamp of the thread to send to, if this ephemeral should be a threaded reply * @param userId - the id of the user to send to * @param text - the text of the message to send (should always be populated in case blocks can't be rendered, but if blocks render text will not) * @param blocks - the blocks of the message to send */ export async function sendEphemeralMessage( channelId: string, - threadTs: string, + threadTs: string | undefined, userId: string, text: string, blocks: any[] @@ -456,7 +475,7 @@ export async function sendEphemeralMessage( await client.chat.postEphemeral({ channel: channelId, user: userId, - thread_ts: threadTs, + ...(threadTs ? { thread_ts: threadTs } : {}), text, blocks }); diff --git a/src/backend/src/prisma-query-args/change-requests.query-args.ts b/src/backend/src/prisma-query-args/change-requests.query-args.ts index b1867ecd57..b4aa4c9806 100644 --- a/src/backend/src/prisma-query-args/change-requests.query-args.ts +++ b/src/backend/src/prisma-query-args/change-requests.query-args.ts @@ -39,7 +39,16 @@ const getWorkPackageProposedChangesQueryArgs = (organizationId: string) => select: { linkId: true, url: true, - linkType: { select: { name: true, required: true, iconName: true, isOnGuestHomePage: true } } + linkType: { + select: { + name: true, + required: true, + iconName: true, + isOnGuestHomePage: true, + isOnNewMemberDashboard: true, + isOnOnboardingDashboard: true + } + } } }, proposedDescriptionBulletChanges: { @@ -75,7 +84,16 @@ const getWbsProposedChangesQueryArgs = (organizationId: string) => select: { linkId: true, url: true, - linkType: { select: { name: true, required: true, iconName: true, isOnGuestHomePage: true } } + linkType: { + select: { + name: true, + required: true, + iconName: true, + isOnGuestHomePage: true, + isOnNewMemberDashboard: true, + isOnOnboardingDashboard: true + } + } } }, proposedDescriptionBulletChanges: { diff --git a/src/backend/src/prisma-query-args/teams.query-args.ts b/src/backend/src/prisma-query-args/teams.query-args.ts index 1d2de0b089..a737ca2e3b 100644 --- a/src/backend/src/prisma-query-args/teams.query-args.ts +++ b/src/backend/src/prisma-query-args/teams.query-args.ts @@ -5,6 +5,7 @@ import { getProjectGanttQueryArgs } from './projects.query-args.js'; export type TeamQueryArgs = ReturnType; export type TeamBaseQueryArgs = ReturnType; export type TeamPreviewQueryArgs = ReturnType; +export type TeamJoinRequestQueryArgs = ReturnType; export const getTeamQueryArgs = (organizationId: string) => Prisma.validator()({ @@ -42,3 +43,12 @@ export const getTeamPreviewQueryArgs = (organizationId: string) => teamType: true } }); + +export const getTeamJoinRequestQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + user: getUserQueryArgs(organizationId), + team: getTeamPreviewQueryArgs(organizationId), + reviewedBy: getUserQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql new file mode 100644 index 0000000000..f33822addc --- /dev/null +++ b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql @@ -0,0 +1,87 @@ +-- CreateEnum +CREATE TYPE "Team_Join_Request_Status" AS ENUM ('PENDING', 'APPROVED', 'DENIED'); + +-- AlterTable +ALTER TABLE "Calendar" ADD COLUMN "isNewMemberCalendar" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable: FrequentlyAskedQuestion - add new columns (nullable first for data migration) +ALTER TABLE "FrequentlyAskedQuestion" +ADD COLUMN "isOnNewMemberDashboard" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isOnPartReviewPage" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isOnRecruitingDashboard" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "organizationId" TEXT; + +-- Populate organizationId from regularFaqOrgId where available +UPDATE "FrequentlyAskedQuestion" +SET "organizationId" = "regularFaqOrgId" +WHERE "regularFaqOrgId" IS NOT NULL; + +-- Fill remaining rows from partReviewFaqOrgId +UPDATE "FrequentlyAskedQuestion" +SET "organizationId" = "partReviewFaqOrgId" +WHERE "organizationId" IS NULL AND "partReviewFaqOrgId" IS NOT NULL; + +-- Populate booleans from old fields +UPDATE "FrequentlyAskedQuestion" +SET "isOnPartReviewPage" = true +WHERE "partReviewFaqOrgId" IS NOT NULL; + +UPDATE "FrequentlyAskedQuestion" +SET "isOnRecruitingDashboard" = true +WHERE "regularFaqOrgId" IS NOT NULL; + +-- Now make organizationId non-nullable +ALTER TABLE "FrequentlyAskedQuestion" +ALTER COLUMN "organizationId" SET NOT NULL; + +-- Drop old FK constraints +ALTER TABLE "FrequentlyAskedQuestion" DROP CONSTRAINT "FrequentlyAskedQuestion_partReviewFaqOrgId_fkey"; +ALTER TABLE "FrequentlyAskedQuestion" DROP CONSTRAINT "FrequentlyAskedQuestion_regularFaqOrgId_fkey"; + +-- Drop old columns +ALTER TABLE "FrequentlyAskedQuestion" +DROP COLUMN "partReviewFaqOrgId", +DROP COLUMN "regularFaqOrgId"; + +-- AddForeignKey +ALTER TABLE "FrequentlyAskedQuestion" ADD CONSTRAINT "FrequentlyAskedQuestion_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AlterTable: Link_Type +ALTER TABLE "Link_Type" ADD COLUMN "isOnNewMemberDashboard" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable: Milestone +ALTER TABLE "Milestone" +ADD COLUMN "isOnNewMemberDashboard" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isOnRecruitingDashboard" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "Team_Join_Request" ( + "teamJoinRequestId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "status" "Team_Join_Request_Status" NOT NULL DEFAULT 'PENDING', + "dateRequested" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "denialReason" TEXT, + "reviewedByUserId" TEXT, + "dateReviewed" TIMESTAMP(3), + CONSTRAINT "Team_Join_Request_pkey" PRIMARY KEY ("teamJoinRequestId") +); + +-- CreateIndex +CREATE INDEX "Team_Join_Request_userId_idx" ON "Team_Join_Request"("userId"); + +-- CreateIndex +CREATE INDEX "Team_Join_Request_teamId_idx" ON "Team_Join_Request"("teamId"); + +-- AddForeignKey for user id +ALTER TABLE "Team_Join_Request" ADD CONSTRAINT "Team_Join_Request_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey for team id +ALTER TABLE "Team_Join_Request" ADD CONSTRAINT "Team_Join_Request_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("teamId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey for reviewed by user id +ALTER TABLE "Team_Join_Request" ADD CONSTRAINT "Team_Join_Request_reviewedByUserId_fkey" FOREIGN KEY ("reviewedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "Link_Type" ADD COLUMN "isOnOnboardingDashboard" BOOLEAN NOT NULL DEFAULT false; + diff --git a/src/backend/src/prisma/migrations/20260801000001_remove_new_member_image/migration.sql b/src/backend/src/prisma/migrations/20260801000001_remove_new_member_image/migration.sql new file mode 100644 index 0000000000..411ea83b2d --- /dev/null +++ b/src/backend/src/prisma/migrations/20260801000001_remove_new_member_image/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Organization" DROP COLUMN "newMemberImageId"; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 4546505e94..cfc701cbb8 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -165,6 +165,12 @@ enum Sponsor_Value_Type { DISCOUNT } +enum Team_Join_Request_Status { + PENDING + APPROVED + DENIED +} + model User { userId String @id @default(uuid()) firstName String @@ -192,6 +198,8 @@ model User { teamsAsMember Team[] @relation(name: "teamsAsMember") teamsAsHead Team[] @relation(name: "teamsAsHead") teamsAsLead Team[] @relation(name: "teamsAsLead") + teamJoinRequests Team_Join_Request[] @relation(name: "teamJoinRequests") + reviewedTeamJoinRequests Team_Join_Request[] @relation(name: "teamJoinRequestReviewer") deletedWBSElements WBS_Element[] @relation(name: "deletedWbsElements") checkedDescriptionBullets Description_Bullet[] @relation(name: "checkDescriptionBullets") createdTasks Task[] @relation(name: "createdBy") @@ -330,11 +338,29 @@ model Team { checklists Checklist[] projectTemplates Project_Template[] meetingAttendances Meeting_Attendance[] + joinRequests Team_Join_Request[] @relation(name: "teamJoinRequests") @@index([headId]) @@index([organizationId]) } +model Team_Join_Request { + teamJoinRequestId String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [userId], name: "teamJoinRequests") + teamId String + team Team @relation(fields: [teamId], references: [teamId], name: "teamJoinRequests") + status Team_Join_Request_Status @default(PENDING) + dateRequested DateTime @default(now()) + denialReason String? + reviewedByUserId String? + reviewedBy User? @relation(fields: [reviewedByUserId], references: [userId], name: "teamJoinRequestReviewer") + dateReviewed DateTime? + + @@index([userId]) + @@index([teamId]) +} + model Session { sessionId String @id @default(uuid()) userId String @@ -552,17 +578,19 @@ model Work_Package { } model Link_Type { - id String @id @default(uuid()) - name String - dateCreated DateTime @default(now()) - iconName String - required Boolean - creatorId String - creator User @relation(name: "linkTypeCreator", fields: [creatorId], references: [userId]) - links Link[] @relation(name: "linkTypes") - organizationId String - organization Organization @relation(fields: [organizationId], references: [organizationId]) - isOnGuestHomePage Boolean @default(false) + id String @id @default(uuid()) + name String + dateCreated DateTime @default(now()) + iconName String + required Boolean + creatorId String + creator User @relation(name: "linkTypeCreator", fields: [creatorId], references: [userId]) + links Link[] @relation(name: "linkTypes") + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + isOnGuestHomePage Boolean @default(false) + isOnNewMemberDashboard Boolean @default(false) + isOnOnboardingDashboard Boolean @default(false) @@unique([name, organizationId], name: "uniqueLinkType") @@index([organizationId]) @@ -1154,19 +1182,20 @@ model Event { } model Calendar { - calendarId String @id @default(uuid()) - name String - dateCreated DateTime @default(now()) - dateDeleted DateTime? - userCreatedId String - userCreated User @relation(name: "calendarCreator", fields: [userCreatedId], references: [userId]) - userDeletedId String? - userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "calendarDeleter") - description String - colorHexCode String - eventTypes Event_Type[] - organizationId String - organization Organization @relation(fields: [organizationId], references: [organizationId]) + calendarId String @id @default(uuid()) + name String + dateCreated DateTime @default(now()) + dateDeleted DateTime? + userCreatedId String + userCreated User @relation(name: "calendarCreator", fields: [userCreatedId], references: [userId]) + userDeletedId String? + userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "calendarDeleter") + description String + colorHexCode String + eventTypes Event_Type[] + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + isNewMemberCalendar Boolean @default(false) } model Event_Type { @@ -1362,7 +1391,6 @@ model Organization { advisor User? @relation(name: "advisor", fields: [advisorId], references: [userId]) advisorId String? description String @default("") - newMemberImageId String? logoImageId String? slackWorkspaceId String? applicationLink String? @@ -1390,8 +1418,7 @@ model Organization { changeRequests Change_Request[] reimbursementReqeusts Reimbursement_Request[] usefulLinks Link[] - frequentlyAskedQuestions FrequentlyAskedQuestion[] @relation(name: "organizationFAQ") - partReviewFAQ FrequentlyAskedQuestion[] @relation(name: "partReviewFAQ") + frequentlyAskedQuestions FrequentlyAskedQuestion[] milestones Milestone[] graphCollections Graph_Collection[] graphs Graph[] @@ -1434,34 +1461,37 @@ model Dashboard { } model FrequentlyAskedQuestion { - faqId String @id @default(uuid()) - question String - answer String - userCreated User @relation(fields: [userCreatedId], references: [userId], name: "frequentlyAskedQuestionCreator") - userCreatedId String - userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "frequentlyAskedQuestionDeleter") - userDeletedId String? - dateCreated DateTime @default(now()) - dateDeleted DateTime? - regularFaqOrgId String? - regularFaqOrg Organization? @relation(fields: [regularFaqOrgId], references: [organizationId], name: "organizationFAQ") - partReviewFaqOrgId String? - partReviewFaqOrg Organization? @relation(fields: [partReviewFaqOrgId], references: [organizationId], name: "partReviewFAQ") + faqId String @id @default(uuid()) + question String + answer String + userCreated User @relation(fields: [userCreatedId], references: [userId], name: "frequentlyAskedQuestionCreator") + userCreatedId String + userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "frequentlyAskedQuestionDeleter") + userDeletedId String? + dateCreated DateTime @default(now()) + dateDeleted DateTime? + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + isOnRecruitingDashboard Boolean @default(false) + isOnNewMemberDashboard Boolean @default(false) + isOnPartReviewPage Boolean @default(false) } model Milestone { - milestoneId String @id @default(uuid()) - name String - dateOfEvent DateTime - description String - userCreated User @relation(fields: [userCreatedId], references: [userId], name: "milestoneCreator") - userCreatedId String - userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "milestoneDeleter") - userDeletedId String? - dateCreated DateTime @default(now()) - dateDeleted DateTime? - organizationId String - organization Organization @relation(fields: [organizationId], references: [organizationId]) + milestoneId String @id @default(uuid()) + name String + dateOfEvent DateTime + description String + userCreated User @relation(fields: [userCreatedId], references: [userId], name: "milestoneCreator") + userCreatedId String + userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "milestoneDeleter") + userDeletedId String? + dateCreated DateTime @default(now()) + dateDeleted DateTime? + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + isOnRecruitingDashboard Boolean @default(false) + isOnNewMemberDashboard Boolean @default(false) @@index([organizationId]) } diff --git a/src/backend/src/prisma/seed-data/teams.seed.ts b/src/backend/src/prisma/seed-data/teams.seed.ts index 8bc30f2158..8985e3dbbf 100644 --- a/src/backend/src/prisma/seed-data/teams.seed.ts +++ b/src/backend/src/prisma/seed-data/teams.seed.ts @@ -19,36 +19,39 @@ The Ravens played the Super Bowl XLVII against the San Francisco 49ers. Baltimor const meanGirlsDescription = ` Mean Girls is a 2004 American teen comedy film. This team helps test slackbot stuff through the #slackbot_land channel.`; -const ravens = (headId: string, organizationId: string): Prisma.TeamCreateArgs => { +const ravens = (headId: string, teamTypeId: string, organizationId: string): Prisma.TeamCreateArgs => { return { data: { teamName: 'Ravens', slackId: 'C06HR7WTTKM', description: ravensDescription, headId, + teamTypeId, organizationId } }; }; -const orioles = (headId: string, organizationId: string): Prisma.TeamCreateArgs => { +const orioles = (headId: string, teamTypeId: string, organizationId: string): Prisma.TeamCreateArgs => { return { data: { teamName: 'Orioles', slackId: 'C06HR7WTTKM', description: oriolesDescription, headId, + teamTypeId, organizationId } }; }; -const justiceLeague = (headId: string, organizationId: string): Prisma.TeamCreateArgs => { +const justiceLeague = (headId: string, teamTypeId: string, organizationId: string): Prisma.TeamCreateArgs => { return { data: { teamName: 'Justice League', slackId: 'C06HR7WTTKM', headId, + teamTypeId, organizationId } }; @@ -66,12 +69,13 @@ const avatarBenders = (headId: string, teamTypeId: string, organizationId: strin }; }; -const plLegends = (headId: string, organizationId: string): Prisma.TeamCreateArgs => { +const plLegends = (headId: string, teamTypeId: string, organizationId: string): Prisma.TeamCreateArgs => { return { data: { teamName: 'PlTeams', slackId: 'C06HR7WTTKM', headId, + teamTypeId, organizationId } }; @@ -91,47 +95,51 @@ const huskies = (headId: string, teamTypeId: string, organizationId: string): Pr }; }; -const financeTeam = (headId: string, organizationId: string): Prisma.TeamCreateArgs => { +const financeTeam = (headId: string, teamTypeId: string, organizationId: string): Prisma.TeamCreateArgs => { return { data: { teamName: 'financeTeam', slackId: 'C06HR7WTTKM', headId, + teamTypeId, organizationId, financeTeam: true } }; }; -const meanGirls = (headId: string, organizationId: string): Prisma.TeamCreateArgs => { +const meanGirls = (headId: string, teamTypeId: string, organizationId: string): Prisma.TeamCreateArgs => { return { data: { teamName: 'Slack Bot Testing', slackId: 'C06HR7WTTKM', description: meanGirlsDescription, headId, + teamTypeId, organizationId } }; }; -const krustyKrabers = (headId: string, organizationId: string): Prisma.TeamCreateArgs => { +const krustyKrabers = (headId: string, teamTypeId: string, organizationId: string): Prisma.TeamCreateArgs => { return { data: { teamName: 'Krusty Krab Crew', slackId: 'C06HR7WTTKM', headId, + teamTypeId, organizationId } }; }; -const penguinsOfMadagascar = (headId: string, organizationId: string): Prisma.TeamCreateArgs => { +const penguinsOfMadagascar = (headId: string, teamTypeId: string, organizationId: string): Prisma.TeamCreateArgs => { return { data: { teamName: 'Penguins of Madagascar', slackId: 'C06HR7WTTKM', headId, + teamTypeId, organizationId } }; diff --git a/src/backend/src/prisma/seed-data/users.seed.ts b/src/backend/src/prisma/seed-data/users.seed.ts index 15bb036d16..de1751d758 100644 --- a/src/backend/src/prisma/seed-data/users.seed.ts +++ b/src/backend/src/prisma/seed-data/users.seed.ts @@ -71,7 +71,10 @@ const guestUser: Prisma.UserCreateInput = { userSettings: { create: { defaultTheme: Theme.DARK, - slackId: SLACK_ID ? SLACK_ID : 'guest' + // always empty (ignores the SLACK_ID env var other seeded users get) so this guest + // exercises the forced slack-id-entry gate once they finish onboarding, instead of + // bypassing it + slackId: '' } } }; diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index e568b24edf..3834b55f55 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -111,9 +111,10 @@ export const CreatePartReviewFAQ = async ( data: { question, answer, - partReviewFaqOrg: { + organization: { connect: { organizationId } }, + isOnPartReviewPage: true, userCreated: { connect: { userId: user.userId } } @@ -137,7 +138,8 @@ const performSeed: () => Promise = async () => { 'https://docs.google.com/forms/d/e/1FAIpQLSeCvG7GqmZm_gmSZiahbVTW9ZFpEWG0YfGQbkSB_whhHzxXpA/closedform', platformDescription: 'Finishline is a Project Management Dashboard developed by the Software Team at Northeastern Electric Racing.', - platformLogoImageId: '1auQO3GYydZOo1-vCn0D2iyCfaxaVFssx' + platformLogoImageId: '1auQO3GYydZOo1-vCn0D2iyCfaxaVFssx', + slackWorkspaceId: 'T7MHAQ5TL' } }); @@ -408,22 +410,39 @@ const performSeed: () => Promise = async () => { 'This is the electrical team', ner ); + const business = await TeamsService.createTeamType(batman, 'Business', 'AttachMoney', 'This is the business team', ner); /** Creating Teams */ - const justiceLeague: Team = await prisma.team.create(dbSeedAllTeams.justiceLeague(batman.userId, organizationId)); + const justiceLeague: Team = await prisma.team.create( + dbSeedAllTeams.justiceLeague(batman.userId, mechanical.teamTypeId, organizationId) + ); const avatarBenders: Team = await prisma.team.create( dbSeedAllTeams.avatarBenders(aang.userId, software.teamTypeId, organizationId) ); - const ravens: Team = await prisma.team.create(dbSeedAllTeams.ravens(johnHarbaugh.userId, organizationId)); - const orioles: Team = await prisma.team.create(dbSeedAllTeams.orioles(brandonHyde.userId, organizationId)); + const ravens: Team = await prisma.team.create( + dbSeedAllTeams.ravens(johnHarbaugh.userId, software.teamTypeId, organizationId) + ); + const orioles: Team = await prisma.team.create( + dbSeedAllTeams.orioles(brandonHyde.userId, business.teamTypeId, organizationId) + ); const huskies: Team = await prisma.team.create( dbSeedAllTeams.huskies(thomasEmrax.userId, electrical.teamTypeId, organizationId) ); - const plLegends: Team = await prisma.team.create(dbSeedAllTeams.plLegends(cristianoRonaldo.userId, organizationId)); - const financeTeam: Team = await prisma.team.create(dbSeedAllTeams.financeTeam(monopolyMan.userId, organizationId)); - const slackBotTeam: Team = await prisma.team.create(dbSeedAllTeams.meanGirls(regina.userId, organizationId)); - const krustykrabTeam: Team = await prisma.team.create(dbSeedAllTeams.krustyKrabers(mrKrabs.userId, organizationId)); - const penguinTeam: Team = await prisma.team.create(dbSeedAllTeams.penguinsOfMadagascar(skipper.userId, organizationId)); + const plLegends: Team = await prisma.team.create( + dbSeedAllTeams.plLegends(cristianoRonaldo.userId, electrical.teamTypeId, organizationId) + ); + const financeTeam: Team = await prisma.team.create( + dbSeedAllTeams.financeTeam(monopolyMan.userId, business.teamTypeId, organizationId) + ); + const slackBotTeam: Team = await prisma.team.create( + dbSeedAllTeams.meanGirls(regina.userId, mechanical.teamTypeId, organizationId) + ); + const krustykrabTeam: Team = await prisma.team.create( + dbSeedAllTeams.krustyKrabers(mrKrabs.userId, software.teamTypeId, organizationId) + ); + const penguinTeam: Team = await prisma.team.create( + dbSeedAllTeams.penguinsOfMadagascar(skipper.userId, electrical.teamTypeId, organizationId) + ); /** Setting Team Members */ await TeamsService.setTeamMembers( batman, @@ -589,11 +608,38 @@ const performSeed: () => Promise = async () => { ); /** Link Types */ - const confluenceLinkType = await ProjectsService.createLinkType(batman, 'Confluence', 'description', true, ner, false); + const confluenceLinkType = await ProjectsService.createLinkType( + batman, + 'Confluence', + 'description', + true, + ner, + false, + false, + true + ); - const bomLinkType = await ProjectsService.createLinkType(batman, 'Bill of Materials', 'bar_chart', true, ner, false); + const bomLinkType = await ProjectsService.createLinkType( + batman, + 'Bill of Materials', + 'bar_chart', + true, + ner, + false, + false, + true + ); - const mainWebsiteLinkType = await ProjectsService.createLinkType(batman, 'NER Website', 'bar_chart', true, ner, false); + const mainWebsiteLinkType = await ProjectsService.createLinkType( + batman, + 'NER Website', + 'bar_chart', + true, + ner, + false, + false, + true + ); const instagramWebsiteLinkType = await ProjectsService.createLinkType( batman, @@ -601,10 +647,17 @@ const performSeed: () => Promise = async () => { 'bar_chart', true, ner, - false + false, + false, + true ); - await ProjectsService.createLinkType(batman, 'Google Drive', 'folder', true, ner, false); + await ProjectsService.createLinkType(batman, 'Google Drive', 'folder', true, ner, false, false, true); + + /** New Member Dashboard link types */ + await ProjectsService.createLinkType(batman, 'NER Handbook', 'menu_book', true, ner, false, true, false); + await ProjectsService.createLinkType(batman, 'Team Directory', 'groups', true, ner, false, true, false); + await ProjectsService.createLinkType(batman, 'NER Merch Store', 'storefront', true, ner, false, true, false); /** * Projects @@ -3314,6 +3367,21 @@ const performSeed: () => Promise = async () => { linkId: '4', linkTypeName: 'NER Instagram', url: 'https://www.instagram.com/nuelectricracing/' + }, + { + linkId: '5', + linkTypeName: 'NER Handbook', + url: 'https://electricracing.northeastern.edu/handbook' + }, + { + linkId: '6', + linkTypeName: 'Team Directory', + url: 'https://electricracing.northeastern.edu/teams' + }, + { + linkId: '7', + linkTypeName: 'NER Merch Store', + url: 'https://electricracing.northeastern.edu/store' } ]); @@ -3329,23 +3397,166 @@ const performSeed: () => Promise = async () => { { userId: regina.userId, title: 'Chief Electrical Engineer' } ]); - await RecruitmentServices.createMilestone(batman, 'Club fair!', 'Also meet us at:', daysAgo(120), ner); - await RecruitmentServices.createMilestone(batman, 'Applications Open', '', daysAgo(70), ner); - await RecruitmentServices.createMilestone(batman, 'Applications Close', '', daysAgo(56), ner); - await RecruitmentServices.createMilestone(batman, 'Decision Day!', '', daysAgo(49), ner); + const recruitingDashboardOnly = { isOnNewMemberDashboard: false, isOnRecruitingDashboard: true }; + const newMemberDashboardOnly = { isOnNewMemberDashboard: true, isOnRecruitingDashboard: false }; + + await RecruitmentServices.createMilestone( + batman, + 'Club fair!', + 'Also meet us at:', + daysAgo(120), + recruitingDashboardOnly, + ner + ); + await RecruitmentServices.createMilestone(batman, 'Applications Open', '', daysAgo(70), recruitingDashboardOnly, ner); + await RecruitmentServices.createMilestone(batman, 'Applications Close', '', daysAgo(56), recruitingDashboardOnly, ner); + await RecruitmentServices.createMilestone(batman, 'Decision Day!', '', daysAgo(49), recruitingDashboardOnly, ner); + + // new member onboarding milestones + await RecruitmentServices.createMilestone( + batman, + 'First Meeting', + 'Attend your first general body meeting', + daysAgo(14), + newMemberDashboardOnly, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'First Bay Time', + 'Get hands-on time in the bay with a team lead', + daysAgo(7), + newMemberDashboardOnly, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'Safety Training Deadline', + 'Complete required safety training to access the bay unsupervised', + daysFromNow(14), + newMemberDashboardOnly, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'Subteam Placement', + 'Officially join a subteam project', + daysFromNow(30), + newMemberDashboardOnly, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'Team Kickoff Meeting', + 'Meet your new subteam and lead', + daysFromNow(37), + newMemberDashboardOnly, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'Design Review Shadow', + 'Sit in on a design review to see how the team works', + daysFromNow(45), + newMemberDashboardOnly, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'First Project Assignment', + 'Get assigned your first project task', + daysFromNow(52), + newMemberDashboardOnly, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'Shop Certification', + 'Complete machine certification for shop tools', + daysFromNow(60), + newMemberDashboardOnly, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'Mid-Semester Check-In', + 'Meet with your lead to discuss progress', + daysFromNow(75), + newMemberDashboardOnly, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'End of Semester Showcase', + 'Present what you worked on this semester', + daysFromNow(100), + newMemberDashboardOnly, + ner + ); - await RecruitmentServices.createOrganizationFaq(batman, 'Who is the Chief Software Engineer?', 'Peyton McKee', ner); + await RecruitmentServices.createOrganizationFaq( + batman, + 'Who is the Chief Software Engineer?', + 'Peyton McKee', + ner, + true, + false, + false + ); await RecruitmentServices.createOrganizationFaq( batman, 'When was FinishLine created?', 'FinishLine was created in 2019', - ner + ner, + true, + false, + false ); await RecruitmentServices.createOrganizationFaq( batman, 'How many developers are working on FinishLine?', '178 as of 2024', - ner + ner, + true, + false, + false + ); + + await RecruitmentServices.createOrganizationFaq( + batman, + 'Where do I go if I have a question during onboarding?', + 'Ask in the #new-members Slack channel — no question is too small!', + ner, + false, + true, + false + ); + await RecruitmentServices.createOrganizationFaq( + batman, + 'How do I get access to the shop?', + 'Complete the safety training checklist item and a lead will grant you access.', + ner, + false, + true, + false + ); + await RecruitmentServices.createOrganizationFaq( + batman, + 'How long until I officially join a team?', + 'Once your join request is approved by a lead, head, or admin, you become a full member of that team right away.', + ner, + false, + true, + false + ); + await RecruitmentServices.createOrganizationFaq( + batman, + 'Can I request to join more than one team?', + "Yes! You can submit a request to join any team you're interested in, even after you've already joined one.", + ner, + false, + true, + false ); await prisma.frequentlyAskedQuestion.create({ @@ -3355,7 +3566,8 @@ const performSeed: () => Promise = async () => { answer: 'answer', userCreated: { connect: { userId: batman.userId } }, dateCreated: new Date(), - partReviewFaqOrg: { connect: { organizationId: ner.organizationId } } + organization: { connect: { organizationId: ner.organizationId } }, + isOnPartReviewPage: true } }); @@ -4334,6 +4546,7 @@ const performSeed: () => Promise = async () => { 'Engineering Team Calendar', 'Tracks all engineering team events, meetings, and deadlines.', '#3498db', + false, ner ); @@ -4342,6 +4555,7 @@ const performSeed: () => Promise = async () => { 'Finishline Projects Calendar', 'Tracks all ongoing projects currently being developed for Finishline', '#911111ff', + false, ner ); @@ -4350,6 +4564,16 @@ const performSeed: () => Promise = async () => { 'Calendar Improvements Calendar', 'Tracks all current improvements and schedulings for the improvement of the Finishline Calendar', '#bf40e6ff', + false, + ner + ); + + const newMemberCalendar = await CalendarService.createCalendar( + thomasEmrax, + 'New Member Events', + 'Tracks all new member onboarding events.', + '#5c6bc0', + true, ner ); @@ -4445,6 +4669,110 @@ const performSeed: () => Promise = async () => { false ); + // educational event type, used for new member onboarding events + const educationalEventType = await CalendarService.createEventType( + thomasEmrax, + 'Educational', + [newMemberCalendar.calendarId], + ner, + false, + true, + true, + true, + true, + true, + false, + false, + false, + false, + true, + true, + false, + false, + true + ); + + await CalendarService.createEvent( + thomasEmrax, + 'New Member Mixer', + educationalEventType.eventTypeId, + ner, + [], + [], + [], + [], + [], + [], + [ + { + startTime: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000), + endTime: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000 + 60 * 60 * 1000), + allDay: false + } + ], + undefined, + [], + electrical.teamTypeId, + undefined, + 'Curry Student Center', + undefined, + 'Come meet the team!' + ); + + await CalendarService.createEvent( + thomasEmrax, + 'New Member Bay Time', + educationalEventType.eventTypeId, + ner, + [], + [], + [], + [], + [], + [], + [ + { + startTime: new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000), + endTime: new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000 + 60 * 60 * 1000), + allDay: false + } + ], + undefined, + [], + mechanical.teamTypeId, + undefined, + 'Richards Hall', + undefined, + 'Hands-on time in the bay with the mechanical team' + ); + + await CalendarService.createEvent( + thomasEmrax, + 'New Member Software Onboarding', + educationalEventType.eventTypeId, + ner, + [], + [], + [], + [], + [], + [], + [ + { + startTime: new Date(new Date().getTime() + 21 * 24 * 60 * 60 * 1000), + endTime: new Date(new Date().getTime() + 21 * 24 * 60 * 60 * 1000 + 90 * 60 * 1000), + allDay: false + } + ], + undefined, + [], + software.teamTypeId, + undefined, + undefined, + 'https://zoom.us/j/123456789', + 'Intro to the FinishLine codebase' + ); + await CalendarService.createEvent( thomasEmrax, 'Weekly Team Sync', diff --git a/src/backend/src/routes/calendar.routes.ts b/src/backend/src/routes/calendar.routes.ts index 228f61b04a..0376d67a7d 100644 --- a/src/backend/src/routes/calendar.routes.ts +++ b/src/backend/src/routes/calendar.routes.ts @@ -41,6 +41,7 @@ calendarRouter.post( nonEmptyString(body('name')), nonEmptyString(body('description')), nonEmptyString(body('colorHexCode')), + body('isNewMemberCalendar').isBoolean(), validateInputs, CalendarController.createCalendar ); @@ -219,6 +220,8 @@ calendarRouter.get('/event/:eventId', CalendarController.getSingleEvent); calendarRouter.get('/event-members/:eventId', CalendarController.getSingleEventWithMembers); +calendarRouter.get('/events/new-member', CalendarController.getNewMemberEvents); + calendarRouter.get('/events', CalendarController.getAllEvents); calendarRouter.get('/event-types', CalendarController.getAllEventTypes); @@ -248,6 +251,7 @@ calendarRouter.post( nonEmptyString(body('name')), nonEmptyString(body('description')), nonEmptyString(body('colorHexCode')), + body('isNewMemberCalendar').isBoolean(), validateInputs, CalendarController.editCalendar ); diff --git a/src/backend/src/routes/organizations.routes.ts b/src/backend/src/routes/organizations.routes.ts index 0be359c127..062af23f0e 100644 --- a/src/backend/src/routes/organizations.routes.ts +++ b/src/backend/src/routes/organizations.routes.ts @@ -50,12 +50,6 @@ organizationRouter.post( OrganizationsController.setPlatformLogoImage ); -organizationRouter.post( - '/new-member-image/update', - upload.single('newMemberImage'), - OrganizationsController.setNewMemberImage -); -organizationRouter.get('/new-member-image', OrganizationsController.getOrganizationNewMemberImage); organizationRouter.post( '/description/set', body('description').isString(), diff --git a/src/backend/src/routes/recruitment.routes.ts b/src/backend/src/routes/recruitment.routes.ts index ec7b8545fc..996237d60b 100644 --- a/src/backend/src/routes/recruitment.routes.ts +++ b/src/backend/src/routes/recruitment.routes.ts @@ -6,6 +6,11 @@ import RecruitmentController from '../controllers/recruitment.controllers.js'; const recruitmentRouter = express.Router(); /* Milestone Section */ + +recruitmentRouter.get('/milestones/new-member', RecruitmentController.getNewMemberMilestones); + +recruitmentRouter.get('/milestones/recruiting', RecruitmentController.getRecruitingMilestones); + recruitmentRouter.get('/milestones', RecruitmentController.getAllMilestones); recruitmentRouter.post( @@ -13,6 +18,8 @@ recruitmentRouter.post( nonEmptyString(body('name')), nonEmptyString(body('description')), isDateOnly(body('dateOfEvent')), + body('isOnNewMemberDashboard').isBoolean(), + body('isOnRecruitingDashboard').isBoolean(), validateInputs, RecruitmentController.createMilestone ); @@ -32,12 +39,24 @@ recruitmentRouter.delete('/milestone/:milestoneId/delete', RecruitmentController recruitmentRouter.get('/faqs', RecruitmentController.getAllOrganizationFaqs); +recruitmentRouter.get('/faqs/recruiting', RecruitmentController.getRecruitingFaqs); + +recruitmentRouter.get('/faqs/new-member', RecruitmentController.getNewMemberFaqs); + +recruitmentRouter.post( + '/faq/recruiting/create', + nonEmptyString(body('question')), + nonEmptyString(body('answer')), + validateInputs, + RecruitmentController.createRecruitingFaq +); + recruitmentRouter.post( - '/faq/create', + '/faq/new-member/create', nonEmptyString(body('question')), nonEmptyString(body('answer')), validateInputs, - RecruitmentController.createOrganizationFaq + RecruitmentController.createNewMemberFaq ); recruitmentRouter.post( diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index 4f103b3aea..a2ea879918 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -145,6 +145,22 @@ if (slackApp) { } }); + // Register interactive action handler for team join request approval + slackApp.action('approve_team_join_request', async ({ ack, body, logger, respond }: any) => { + await ack(); + + try { + if (!validateSlackActionBody(body)) { + logger.error('Invalid Slack action body structure'); + return; + } + + await SlackController.handleApproveTeamJoinRequestAction(body, respond); + } catch (error) { + logger.error('Error handling approve_team_join_request action:', error); + } + }); + // Error handler slackApp.error(async (error: Error) => { console.error('Slack app error:', error); diff --git a/src/backend/src/routes/teams.routes.ts b/src/backend/src/routes/teams.routes.ts index 7ad04e750d..d9a382fba6 100644 --- a/src/backend/src/routes/teams.routes.ts +++ b/src/backend/src/routes/teams.routes.ts @@ -14,6 +14,7 @@ teamsRouter.get('/dropdown', TeamsController.getAllTeamsDropdown); teamsRouter.get('/archive', TeamsController.getAllArchivedTeams); teamsRouter.get('/users-teams', TeamsController.getUsersTeams); teamsRouter.get('/my-team-as-head', TeamsController.getMyTeamAsHead); +teamsRouter.get('/join-requests/mine', TeamsController.getMyTeamJoinRequests); teamsRouter.get('/:teamId', TeamsController.getSingleTeam); teamsRouter.post( @@ -30,6 +31,14 @@ teamsRouter.post( validateInputs, TeamsController.setTeamLeads ); +teamsRouter.get('/:teamId/join-requests', TeamsController.getPendingTeamJoinRequests); +teamsRouter.post('/:teamId/join-request', TeamsController.createTeamJoinRequest); +teamsRouter.post( + '/join-request/:teamJoinRequestId/review', + body('approved').isBoolean(), + validateInputs, + TeamsController.reviewTeamJoinRequest +); teamsRouter.post( '/:teamId/edit-description', body('newDescription').isString(), diff --git a/src/backend/src/services/calendar.services.ts b/src/backend/src/services/calendar.services.ts index dfc2387ece..de2bbb721a 100644 --- a/src/backend/src/services/calendar.services.ts +++ b/src/backend/src/services/calendar.services.ts @@ -2148,6 +2148,7 @@ export default class CalendarService { * @param name The name of the calendar * @param description A summary of what the calendar is used for * @param colorHexCode The color of the calendar + * @param isNewMemberCalendar Whether this calendar is the org's designated new member calendar * @param organization The organization for which the calendar is being created * * @returns The created calendar @@ -2159,6 +2160,7 @@ export default class CalendarService { name: string, description: string, colorHexCode: string, + isNewMemberCalendar: boolean, organization: Organization ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { @@ -2176,15 +2178,25 @@ export default class CalendarService { throw new HttpException(409, "Can't have two calendars with the same name"); } - const newCalendar = await prisma.calendar.create({ - data: { - name, - description, - colorHexCode, - userCreatedId: submitter.userId, - organizationId: organization.organizationId - }, - ...getCalendarQueryArgs(organization.organizationId) + const newCalendar = await prisma.$transaction(async (tx) => { + if (isNewMemberCalendar) { + await tx.calendar.updateMany({ + where: { organizationId: organization.organizationId, isNewMemberCalendar: true }, + data: { isNewMemberCalendar: false } + }); + } + + return tx.calendar.create({ + data: { + name, + description, + colorHexCode, + isNewMemberCalendar, + userCreatedId: submitter.userId, + organizationId: organization.organizationId + }, + ...getCalendarQueryArgs(organization.organizationId) + }); }); return calendarTransformer(newCalendar); @@ -2196,6 +2208,7 @@ export default class CalendarService { * @param name The name of the calendar. * @param description The summary of what the calendar is used for. * @param colorHexCode The color of the calendar. + * @param isNewMemberCalendar Whether this calendar is the org's designated new member calendar * @param organization The organization for which the calendar is being edited. * * @returns The edited calendar. @@ -2211,6 +2224,7 @@ export default class CalendarService { name: string, description: string, colorHexCode: string, + isNewMemberCalendar: boolean, organization: Organization ): Promise { const calendar = await prisma.calendar.findUnique({ @@ -2240,14 +2254,24 @@ export default class CalendarService { throw new HttpException(409, "Can't have two calendars with the same name"); } - const newCalendar = await prisma.calendar.update({ - where: { calendarId }, - data: { - name, - description, - colorHexCode - }, - ...getCalendarQueryArgs(organization.organizationId) + const newCalendar = await prisma.$transaction(async (tx) => { + if (isNewMemberCalendar) { + await tx.calendar.updateMany({ + where: { organizationId: organization.organizationId, isNewMemberCalendar: true, NOT: { calendarId } }, + data: { isNewMemberCalendar: false } + }); + } + + return tx.calendar.update({ + where: { calendarId }, + data: { + name, + description, + colorHexCode, + isNewMemberCalendar + }, + ...getCalendarQueryArgs(organization.organizationId) + }); }); return calendarTransformer(newCalendar); @@ -2651,6 +2675,34 @@ export default class CalendarService { return events.map(eventTransformer); } + /** + * Gets all upcoming events on the organization's designated new member calendar. + * + * @param organization The organization to get new member events for. + * + * @returns The upcoming events on the org's new member calendar, or an empty array if no calendar is designated. + */ + static async getNewMemberEvents(organization: Organization): Promise { + const newMemberCalendar = await prisma.calendar.findFirst({ + where: { + organizationId: organization.organizationId, + isNewMemberCalendar: true, + dateDeleted: null + } + }); + + if (!newMemberCalendar) return []; + + return CalendarService.getFilteredEvents( + { + calendarIds: [newMemberCalendar.calendarId], + startPeriod: new Date(), + endPeriod: new Date(2099, 11, 31) + }, + organization + ); + } + static async getAllShops(organization: Organization): Promise { const shops = await prisma.shop.findMany({ where: { diff --git a/src/backend/src/services/organizations.services.ts b/src/backend/src/services/organizations.services.ts index d1ffa4553f..b11daf7e92 100644 --- a/src/backend/src/services/organizations.services.ts +++ b/src/backend/src/services/organizations.services.ts @@ -312,57 +312,6 @@ export default class OrganizationsService { return organization.logoImageId; } - /** - * Sets the new member image for an organization, User must be admin - * @param newMemberImage the image which will be uploaded and have its id stored in the org - * @param submitter the user submitting the image - * @param organization the organization whose new member image is being set - * @returns the updated organization - * @throws if the user is not an admin - */ - static async setNewMemberImage( - newMemberImage: Express.Multer.File, - submitter: User, - organization: Organization - ): Promise { - if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { - throw new AccessDeniedAdminOnlyException('update new member image'); - } - - const newMemberImageData = await uploadFile(newMemberImage); - - // Ensure name exists for frontend display purposes - if (!newMemberImageData?.name) { - throw new HttpException(500, 'Image Name not found'); - } - - const updatedOrg = await prisma.organization.update({ - where: { organizationId: organization.organizationId }, - data: { - newMemberImageId: newMemberImageData.id - } - }); - - return updatedOrg; - } - - /** - * Gets the new member image of the organization - * @param organizationId the id of the organization - * @returns the id of the image - */ - static async getNewMemberImage(organizationId: string): Promise { - const organization = await prisma.organization.findUnique({ - where: { organizationId } - }); - - if (!organization) { - throw new NotFoundException('Organization', organizationId); - } - - return organization.newMemberImageId; - } - /** * Sets the description of a given organization. * @param description the new description diff --git a/src/backend/src/services/part-review.services.ts b/src/backend/src/services/part-review.services.ts index 6e25f9fce6..70ed747d07 100644 --- a/src/backend/src/services/part-review.services.ts +++ b/src/backend/src/services/part-review.services.ts @@ -537,7 +537,7 @@ export default class PartReviewService { */ static async getAllPartReviewFAQs(organizationId: string) { const partReviewFAQs = await prisma.frequentlyAskedQuestion.findMany({ - where: { dateDeleted: null, partReviewFaqOrgId: organizationId }, + where: { dateDeleted: null, organizationId, isOnPartReviewPage: true }, ...getFaqQueryArgs(organizationId) }); @@ -629,37 +629,6 @@ export default class PartReviewService { await prisma.part_Tag.update({ where: { partTagId }, data: { dateDeleted: new Date() } }); } - /** - * Creates an faq - * @param question the question - * @param answer the answer - * @param creator user creating -- must be admin - * @param organizationId the organization - * @returns the faq - */ - static async createFaq( - question: string, - answer: string, - creator: User, - organizationId: string - ): Promise { - if (!(await userHasPermission(creator.userId, organizationId, isAdmin))) { - throw new AccessDeniedAdminOnlyException('create part review faq'); - } - - const faq = await prisma.frequentlyAskedQuestion.create({ - data: { - question, - answer, - userCreated: { connect: { userId: creator.userId } }, - partReviewFaqOrg: { connect: { organizationId } } - }, - ...getFaqQueryArgs(organizationId) - }); - - return faqTransformer(faq); - } - /** * updates an faq * @param faqId the faq to update @@ -682,7 +651,7 @@ export default class PartReviewService { const faq = await prisma.frequentlyAskedQuestion.findUnique({ where: { faqId } }); - if (!faq || faq.partReviewFaqOrgId !== organizationId) { + if (!faq || faq.organizationId !== organizationId || !faq.isOnPartReviewPage) { throw new NotFoundException('Faq', faqId); } @@ -711,9 +680,9 @@ export default class PartReviewService { throw new AccessDeniedAdminOnlyException('delete faq'); } - const faq = await prisma.frequentlyAskedQuestion.findUnique({ where: { faqId }, ...getFaqQueryArgs }); + const faq = await prisma.frequentlyAskedQuestion.findUnique({ where: { faqId }, ...getFaqQueryArgs(organizationId) }); - if (!faq || faq.partReviewFaqOrgId !== organizationId) { + if (!faq || faq.organizationId !== organizationId || !faq.isOnPartReviewPage) { throw new NotFoundException('Faq', faqId); } diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index 927909d17f..eb1c9d2726 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -601,6 +601,9 @@ export default class ProjectsService { * @param required is the new LinkType required * @param user the user who is creating the new LinkType * @param orgainzationId the organization the link type is being created for + * @param isOnGuestHomePage whether the LinkType shows on the guest home page + * @param isOnNewMemberDashboard whether the LinkType shows on the new member dashboard + * @param isOnOnboardingDashboard whether the LinkType shows on the onboarding checklist page * @throws AccessDeniedException if the submitter of the request is not an admin * @throws HttpException if a LinkType of the given name already exists * @returns the created LinkType @@ -611,7 +614,9 @@ export default class ProjectsService { iconName: string, required: boolean, organization: Organization, - isOnGuestHomePage: boolean + isOnGuestHomePage: boolean, + isOnNewMemberDashboard: boolean, + isOnOnboardingDashboard: boolean ): Promise { if (!(await userHasPermission(user.userId, organization.organizationId, isAdmin))) throw new AccessDeniedException('Only admins can create link types'); @@ -629,7 +634,9 @@ export default class ProjectsService { iconName, required, organizationId: organization.organizationId, - isOnGuestHomePage + isOnGuestHomePage, + isOnNewMemberDashboard, + isOnOnboardingDashboard } }); @@ -643,6 +650,10 @@ export default class ProjectsService { * @param required the new required status * @param submitter user requesting the edit * @param organizationId the organization the user is currently in + * @param isOnGuestHomePage whether the LinkType shows on the guest home page + * @param isOnNewMemberDashboard whether the LinkType shows on the new member dashboard + * @param isOnOnboardingDashboard whether the LinkType shows on the onboarding checklist page + * @param newName the new name of the linkType, if being renamed * @returns the updated linkType */ static async editLinkType( @@ -652,6 +663,8 @@ export default class ProjectsService { submitter: User, organization: Organization, isOnGuestHomePage: boolean, + isOnNewMemberDashboard: boolean, + isOnOnboardingDashboard: boolean, newName?: string ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) @@ -690,7 +703,9 @@ export default class ProjectsService { name: newName && newName ? newName : linkName, iconName, required, - isOnGuestHomePage + isOnGuestHomePage, + isOnNewMemberDashboard, + isOnOnboardingDashboard } }); return linkTypeUpdated; diff --git a/src/backend/src/services/recruitment.services.ts b/src/backend/src/services/recruitment.services.ts index d45f87e5a3..52fbed9457 100644 --- a/src/backend/src/services/recruitment.services.ts +++ b/src/backend/src/services/recruitment.services.ts @@ -20,12 +20,46 @@ export default class RecruitmentServices { return allMilestones; } + /** + * Gets all milestones flagged for the given dashboard, for the given organization + * @param organization the organization to get milestones for + * @param dashboardFlag which dashboard flag to filter milestones by + * @returns all milestones from the given organization flagged for the given dashboard + */ + private static async getMilestonesByDashboardFlag( + organization: Organization, + dashboardFlag: 'isOnNewMemberDashboard' | 'isOnRecruitingDashboard' + ) { + return prisma.milestone.findMany({ + where: { organizationId: organization.organizationId, dateDeleted: null, [dashboardFlag]: true } + }); + } + + /** + * Gets all milestones flagged for the new member dashboard, for the given organization + * @param organization the organization to get new member milestones for + * @returns all new-member-dashboard milestones from the given organization + */ + static async getNewMemberMilestones(organization: Organization) { + return this.getMilestonesByDashboardFlag(organization, 'isOnNewMemberDashboard'); + } + + /** + * Gets all milestones flagged for the recruiting dashboard, for the given organization + * @param organization the organization to get recruiting milestones for + * @returns all recruiting-dashboard milestones from the given organization + */ + static async getRecruitingMilestones(organization: Organization) { + return this.getMilestonesByDashboardFlag(organization, 'isOnRecruitingDashboard'); + } + /** * Creates a new milestone in the given organization * @param submitter a user who is making this request * @param name the name of the user * @param description description of the milestone * @param dateOfEvent date of the event of the milestone + * @param dashboards which dashboards the milestone should show on * @param organizationId the organization Id of the milestone * @returns A newly created milestone */ @@ -34,6 +68,7 @@ export default class RecruitmentServices { name: string, description: string, dateOfEvent: Date, + dashboards: { isOnNewMemberDashboard: boolean; isOnRecruitingDashboard: boolean }, organization: Organization ) { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) @@ -44,6 +79,8 @@ export default class RecruitmentServices { name, description, dateOfEvent, + isOnNewMemberDashboard: dashboards.isOnNewMemberDashboard, + isOnRecruitingDashboard: dashboards.isOnRecruitingDashboard, organizationId: organization.organizationId, userCreatedId: submitter.userId } @@ -108,13 +145,39 @@ export default class RecruitmentServices { */ static async getAllOrganizationFaqs(organization: Organization) { const allFaqs = await prisma.frequentlyAskedQuestion.findMany({ - where: { dateDeleted: null, regularFaqOrgId: organization.organizationId }, + where: { dateDeleted: null, organizationId: organization.organizationId }, ...getFaqQueryArgs(organization.organizationId) }); return allFaqs.map(faqTransformer); } + /** + * Gets all recruiting FAQs for the given organization Id + * @param organizationId organization Id of the faq + * @returns all the faqs from the given organization + */ + static async getRecruitingFaqs(organization: Organization) { + const faqs = await prisma.frequentlyAskedQuestion.findMany({ + where: { dateDeleted: null, organizationId: organization.organizationId, isOnRecruitingDashboard: true }, + ...getFaqQueryArgs(organization.organizationId) + }); + return faqs.map(faqTransformer); + } + + /** + * Gets all new member FAQs for the given organization Id + * @param organizationId organization Id of the faq + * @returns all the faqs from the given organization + */ + static async getNewMemberFaqs(organization: Organization) { + const faqs = await prisma.frequentlyAskedQuestion.findMany({ + where: { dateDeleted: null, organizationId: organization.organizationId, isOnNewMemberDashboard: true }, + ...getFaqQueryArgs(organization.organizationId) + }); + return faqs.map(faqTransformer); + } + /* * Deletes the milestone for the given milestoneId and organizationId * @param deleter the user deleting the milestone @@ -143,9 +206,20 @@ export default class RecruitmentServices { * @param question question to be displayed by the FAQ * @param answer answer to the question of the FAQ * @param organizationId the organization Id of the FAQ + * @param isOnRecruitingDashboard whether the FAQ shows on the recruiting dashboard + * @param isOnNewMemberDashboard whether the FAQ shows on the new member dashboard + * @param isOnPartReviewPage whether the FAQ shows on the part review page * @returns A newly created FAQ */ - static async createOrganizationFaq(submitter: User, question: string, answer: string, organization: Organization) { + static async createOrganizationFaq( + submitter: User, + question: string, + answer: string, + organization: Organization, + isOnRecruitingDashboard: boolean, + isOnNewMemberDashboard: boolean, + isOnPartReviewPage: boolean + ) { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) throw new AccessDeniedAdminOnlyException('create an faq'); @@ -153,8 +227,11 @@ export default class RecruitmentServices { data: { question, answer, - regularFaqOrgId: organization.organizationId, - userCreatedId: submitter.userId + organizationId: organization.organizationId, + userCreatedId: submitter.userId, + isOnRecruitingDashboard, + isOnNewMemberDashboard, + isOnPartReviewPage } }); diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 7878782e13..1f7e689f0f 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -11,6 +11,7 @@ import { } from '../utils/errors.utils.js'; import ReimbursementRequestService from './reimbursement-requests.services.js'; import ChangeRequestsService from './change-requests.services.js'; +import TeamsService from './teams.services.js'; import { userTransformer } from '../transformers/user.transformer.js'; import { getUserQueryArgs } from '../prisma-query-args/user.query-args.js'; import { User } from 'shared'; @@ -141,6 +142,13 @@ export interface CrApprovalActionValue { crId: string; } +/** + * Represents the parsed value from a team join request approval action + */ +export interface TeamJoinRequestApprovalActionValue { + teamJoinRequestId: string; +} + export default class SlackServices { /** * Handles the Slack button click for marking a reimbursement request as SABO submitted. @@ -302,6 +310,86 @@ export default class SlackServices { } } + /** + * Approves a team join request from a Slack interactive button click. + * Auth (admin/head/lead) is enforced inside reviewTeamJoinRequest. Unlike handleApproveCRAction, + * this catches lookup failures too (not just the review call itself) since there's no message + * thread to fall back on for error reporting -- respond() is the only channel available. + * + * @param userSlackId Slack id of the user who clicked the button + * @param teamJoinRequestId the team join request to approve + * @param respond Bolt response callback bound to this interaction's response_url + */ + static async handleApproveTeamJoinRequestAction( + userSlackId: string, + teamJoinRequestId: string, + respond: (msg: { + response_type?: 'ephemeral'; + text?: string; + replace_original?: boolean; + delete_original?: boolean; + }) => Promise + ): Promise { + try { + const teamJoinRequest = await prisma.team_Join_Request.findUnique({ + where: { teamJoinRequestId }, + include: { team: true } + }); + if (!teamJoinRequest) { + throw new NotFoundException('Team Join Request', teamJoinRequestId); + } + + const reviewer = await prisma.user.findFirst({ + where: { + userSettings: { + slackId: userSlackId + } + }, + ...getUserQueryArgs(teamJoinRequest.team.organizationId) + }); + + if (!reviewer) { + console.error('User not found for slack ID:', userSlackId); + throw new NotFoundException('User', userSlackId); + } + + const org = await prisma.organization.findUnique({ + where: { organizationId: teamJoinRequest.team.organizationId } + }); + + if (!org) { + throw new NotFoundException('Organization', teamJoinRequest.team.organizationId); + } + + const reviewerShared: User = userTransformer(reviewer); + const approved = await TeamsService.reviewTeamJoinRequest(reviewerShared, teamJoinRequestId, true, undefined, org); + + await respond({ + replace_original: true, + text: `✅ ${approved.user.firstName} ${approved.user.lastName}'s request to join ${teamJoinRequest.team.teamName} was approved by ${reviewer.firstName} ${reviewer.lastName}.` + }); + } catch (error) { + if (error instanceof AccessDeniedException) { + await respond({ + response_type: 'ephemeral', + text: `❌ You're not authorized to approve this request. Only admins, the team head, or team leads can approve.` + }); + } else if (error instanceof NotFoundException || error instanceof HttpException) { + await respond({ + response_type: 'ephemeral', + text: `❌ ${error.message}` + }); + } else { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.error('Error approving team join request via Slack:', error); + await respond({ + response_type: 'ephemeral', + text: `❌ An unexpected error occurred while approving this request.\n\n*Error:* ${msg}` + }); + } + } + } + /** * Given a slack event representing a message in a channel, * make the appropriate announcement change in prisma. diff --git a/src/backend/src/services/teams.services.ts b/src/backend/src/services/teams.services.ts index 23d5c1658a..2da7394b8f 100644 --- a/src/backend/src/services/teams.services.ts +++ b/src/backend/src/services/teams.services.ts @@ -1,10 +1,25 @@ -import { isAdmin, isHead, TeamDropdownItem, Team, TeamPreview, TeamType, User, WbsElementStatus } from 'shared'; +import { + isAdmin, + isHead, + TeamDropdownItem, + Team, + TeamPreview, + TeamType, + TeamJoinRequest, + User, + WbsElementStatus, + RoleEnum +} from 'shared'; import { Organization } from '@prisma/client'; import prisma from '../prisma/prisma.js'; import { getTeamDropdownQueryArgs } from '../prisma-query-args/dropdown.query-args.js'; import { teamDropdownTransformer } from '../transformers/dropdown.transformer.js'; import { calculateProjectStatus } from '../utils/projects.utils.js'; -import teamTransformer, { teamBaseTransformer, teamPreviewTransformer } from '../transformers/teams.transformer.js'; +import teamTransformer, { + teamBaseTransformer, + teamPreviewTransformer, + teamJoinRequestTransformer +} from '../transformers/teams.transformer.js'; import { NotFoundException, AccessDeniedException, @@ -14,9 +29,15 @@ import { InvalidOrganizationException } from '../utils/errors.utils.js'; import { getPrismaQueryUserIds, getUsers, userHasPermission } from '../utils/users.utils.js'; +import { sendTeamJoinRequestNotification, sendTeamJoinRequestReviewedNotification } from '../utils/slack.utils.js'; import { isUnderWordCount } from 'shared'; import { removeUsersFromList } from '../utils/teams.utils.js'; -import { getTeamBaseQueryArgs, getTeamPreviewQueryArgs, getTeamQueryArgs } from '../prisma-query-args/teams.query-args.js'; +import { + getTeamBaseQueryArgs, + getTeamJoinRequestQueryArgs, + getTeamPreviewQueryArgs, + getTeamQueryArgs +} from '../prisma-query-args/teams.query-args.js'; import { uploadFile } from '../utils/google-integration.utils.js'; import { teamTypeTransformer } from '../transformers/team-types.transformer.js'; import { TeamBase } from '../../../shared/src/types/team-types.js'; @@ -403,6 +424,188 @@ export default class TeamsService { return teamTransformer(updateTeam); } + /** + * Creates a request for the submitter to join the given team + * @param submitter the user requesting to join the team + * @param teamId the id of the team to request to join + * @param organization the organization the team belongs to + * @throws DeletedException if the team is archived + * @throws HttpException if the submitter is already part of the team, or already has a pending request for it + * @returns the created team join request + */ + static async createTeamJoinRequest(submitter: User, teamId: string, organization: Organization): Promise { + const team = await TeamsService.getSingleTeam(teamId, organization); + if (team.dateArchived) throw new DeletedException('Team', teamId); + + const isAlreadyOnTeam = + team.head.userId === submitter.userId || + team.leads.some((lead) => lead.userId === submitter.userId) || + team.members.some((member) => member.userId === submitter.userId); + if (isAlreadyOnTeam) throw new HttpException(400, 'You are already part of this team'); + + const existingPendingRequest = await prisma.team_Join_Request.findFirst({ + where: { userId: submitter.userId, teamId, status: 'PENDING' } + }); + if (existingPendingRequest) throw new HttpException(400, 'You already have a pending request to join this team'); + + const created = await prisma.team_Join_Request.create({ + data: { userId: submitter.userId, teamId }, + ...getTeamJoinRequestQueryArgs(organization.organizationId) + }); + + const transformed = teamJoinRequestTransformer(created); + + // best-effort: a Slack outage/rate-limit shouldn't make request creation look like it failed + // when the request itself was already saved successfully + try { + await sendTeamJoinRequestNotification(transformed, team, organization); + } catch (error: unknown) { + console.error('Error sending team join request Slack notification:', error); + } + + return transformed; + } + + /** + * Gets every team join request made by the given user, across all teams and statuses + * @param user the user to get join requests for + * @param organization the organization the user is in + * @returns the user's team join requests, most recently requested first + */ + static async getMyTeamJoinRequests(user: User, organization: Organization): Promise { + const requests = await prisma.team_Join_Request.findMany({ + where: { userId: user.userId, team: { organizationId: organization.organizationId } }, + ...getTeamJoinRequestQueryArgs(organization.organizationId), + orderBy: { dateRequested: 'desc' } + }); + + return requests.map(teamJoinRequestTransformer); + } + + /** + * Gets the pending team join requests for a given team + * @param teamId the id of the team to get pending join requests for + * @param reviewer the user requesting to view the pending requests + * @param organization the organization the team belongs to + * @throws AccessDeniedException if the reviewer isn't an admin or the team head + * @returns the team's pending join requests, oldest first + */ + static async getPendingTeamJoinRequests( + teamId: string, + reviewer: User, + organization: Organization + ): Promise { + const team = await TeamsService.getSingleTeam(teamId, organization); + await TeamsService.validateJoinRequestReviewer(reviewer, team, organization); + + const requests = await prisma.team_Join_Request.findMany({ + where: { teamId, status: 'PENDING' }, + ...getTeamJoinRequestQueryArgs(organization.organizationId), + orderBy: { dateRequested: 'asc' } + }); + + return requests.map(teamJoinRequestTransformer); + } + + /** + * Approves or denies a pending team join request. Approving adds the requester to the team's members. + * @param reviewer the user reviewing the request + * @param teamJoinRequestId the id of the request being reviewed + * @param approved whether the request is being approved or denied + * @param denialReason an optional reason for denial, ignored if approved + * @param organization the organization the request's team belongs to + * @throws NotFoundException if the request doesn't exist + * @throws InvalidOrganizationException if the request's team isn't in the given organization + * @throws HttpException if the request has already been reviewed + * @throws AccessDeniedException if the reviewer isn't an admin or the team head + * @returns the updated team join request + */ + static async reviewTeamJoinRequest( + reviewer: User, + teamJoinRequestId: string, + approved: boolean, + denialReason: string | undefined, + organization: Organization + ): Promise { + const request = await prisma.team_Join_Request.findUnique({ + where: { teamJoinRequestId }, + include: { team: true } + }); + if (!request) throw new NotFoundException('Team Join Request', teamJoinRequestId); + if (request.team.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Team Join Request'); + } + if (request.status !== 'PENDING') throw new HttpException(400, 'This request has already been reviewed'); + + const team = await TeamsService.getSingleTeam(request.teamId, organization); + await TeamsService.validateJoinRequestReviewer(reviewer, team, organization); + + const updated = await prisma.$transaction(async (tx) => { + const updatedRequest = await tx.team_Join_Request.update({ + where: { teamJoinRequestId }, + data: { + status: approved ? 'APPROVED' : 'DENIED', + reviewedByUserId: reviewer.userId, + dateReviewed: new Date(), + denialReason: approved ? null : denialReason + }, + ...getTeamJoinRequestQueryArgs(organization.organizationId) + }); + + if (approved) { + await tx.team.update({ + where: { teamId: request.teamId }, + data: { members: { connect: { userId: request.userId } } } + }); + + // approval makes a guest a full member -- existing members/leadership/etc. keep their rank + const requesterRole = await tx.role.findFirst({ + where: { userId: request.userId, organizationId: organization.organizationId } + }); + if (!requesterRole || requesterRole.roleType === RoleEnum.GUEST) { + await tx.role.upsert({ + where: { uniqueRole: { userId: request.userId, organizationId: organization.organizationId } }, + update: { roleType: RoleEnum.MEMBER }, + create: { userId: request.userId, organizationId: organization.organizationId, roleType: RoleEnum.MEMBER } + }); + } + } + + return updatedRequest; + }); + + const transformed = teamJoinRequestTransformer(updated); + + try { + await sendTeamJoinRequestReviewedNotification(transformed, team, approved); + } catch (error: unknown) { + console.error('Error sending team join request reviewed Slack notification:', error); + } + + return transformed; + } + + /** + * Validates that the given user is allowed to review join requests for the given team. + * Only admins and the team head can review -- team leads cannot. + * @param reviewer the user attempting to review a join request + * @param team the team the join request is for + * @param organization the organization the team belongs to + * @throws AccessDeniedException if the reviewer isn't an admin or the team head + */ + private static async validateJoinRequestReviewer( + reviewer: User, + team: { head: { userId: string } }, + organization: Organization + ): Promise { + if ( + !(await userHasPermission(reviewer.userId, organization.organizationId, isAdmin)) && + reviewer.userId !== team.head.userId + ) { + throw new AccessDeniedException('you must be an admin or the team head to review join requests for this team'); + } + } + /** * Archives/unarchives a given team * @param submitter a user who's archiving the team diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index bc7a820c0d..3faaf6509d 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -15,7 +15,8 @@ import { isAtLeastRank, BusySlots, IcsBusyInterval, - MemberDropdownItem + MemberDropdownItem, + isValidSlackUserIdFormat } from 'shared'; import prisma from '../prisma/prisma.js'; import { getMemberDropdownQueryArgs } from '../prisma-query-args/dropdown.query-args.js'; @@ -221,6 +222,9 @@ export default class UsersService { * @throws if the user does not exist */ static async updateUserSettings(user: User, defaultTheme: ThemeName, slackId: string): Promise { + if (slackId && !isValidSlackUserIdFormat(slackId)) { + throw new HttpException(400, 'Invalid Slack ID'); + } const { userId } = user; const updatedSettings = await prisma.user_Settings.upsert({ diff --git a/src/backend/src/transformers/calendar.transformer.ts b/src/backend/src/transformers/calendar.transformer.ts index 6a065ab0cb..fd01618f20 100644 --- a/src/backend/src/transformers/calendar.transformer.ts +++ b/src/backend/src/transformers/calendar.transformer.ts @@ -96,7 +96,8 @@ export const calendarTransformer = (calendar: Prisma.CalendarGetPayload { diff --git a/src/backend/src/transformers/teams.transformer.ts b/src/backend/src/transformers/teams.transformer.ts index 0600e2ca72..9171f42799 100644 --- a/src/backend/src/transformers/teams.transformer.ts +++ b/src/backend/src/transformers/teams.transformer.ts @@ -1,6 +1,11 @@ import { Prisma } from '@prisma/client'; -import { Team, TeamPreview, TeamBase } from 'shared'; -import { getTeamBaseQueryArgs, TeamPreviewQueryArgs, TeamQueryArgs } from '../prisma-query-args/teams.query-args.js'; +import { Team, TeamPreview, TeamBase, TeamJoinRequest } from 'shared'; +import { + getTeamBaseQueryArgs, + TeamJoinRequestQueryArgs, + TeamPreviewQueryArgs, + TeamQueryArgs +} from '../prisma-query-args/teams.query-args.js'; import { userTransformer } from './user.transformer.js'; import { projectGanttTransformer } from './projects.transformer.js'; import { teamTypeTransformer } from './team-types.transformer.js'; @@ -43,4 +48,19 @@ export const teamPreviewTransformer = (team: Prisma.TeamGetPayload +): TeamJoinRequest => { + return { + teamJoinRequestId: teamJoinRequest.teamJoinRequestId, + user: userTransformer(teamJoinRequest.user), + team: teamPreviewTransformer(teamJoinRequest.team), + status: teamJoinRequest.status, + dateRequested: teamJoinRequest.dateRequested, + denialReason: teamJoinRequest.denialReason ?? undefined, + reviewedBy: teamJoinRequest.reviewedBy ? userTransformer(teamJoinRequest.reviewedBy) : undefined, + dateReviewed: teamJoinRequest.dateReviewed ?? undefined + }; +}; + export default teamTransformer; diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index 921ee957ca..ba97025189 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -218,4 +218,5 @@ export type ExceptionObjectNames = | 'Meeting Attendance' | 'Task Label' | 'Notification Channel' - | 'Dashboard'; + | 'Dashboard' + | 'Team Join Request'; diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index 8036985646..d7622dae6a 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -8,9 +8,11 @@ import { User, Event, formatForSlack, - SlackMentionType + SlackMentionType, + Team as SharedTeam, + TeamJoinRequest } from 'shared'; -import { Account_Code, Reimbursement_Product_Other_Reason, Sponsor_Task } from '@prisma/client'; +import { Account_Code, Organization, Reimbursement_Product_Other_Reason, Sponsor_Task } from '@prisma/client'; import { editMessage, getChannelName, @@ -724,6 +726,81 @@ export const sendStandardCRCreatedNotification = async ( ); }; +/** + * Sends an ephemeral "Approve this join request?" Slack message with an approve button to the + * team head, if they're a member of the team's Slack channel. Leads and admins can still + * approve/deny from the app, but don't get pinged in Slack. Unlike CRs, there's no prior message + * to thread this off of, so it's sent as a fresh (non-threaded) ephemeral. Denying (or approving + * without Slack) still happens in the app -- reviewTeamJoinRequest still enforces real auth on click. + */ +export const sendTeamJoinRequestNotification = async ( + teamJoinRequest: TeamJoinRequest, + team: SharedTeam, + organization: Organization +): Promise => { + if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return; + if (!team.slackId) return; + + // only the team head gets the ephemeral DM -- leads and admins can still approve/deny from the app, + // but don't get pinged in Slack + const headSlackId = await getUserSlackId(team.head.userId); + if (!headSlackId) return; + + const allSlackIds = new Set([headSlackId]); + + const membersInChannel = new Set(await getUsersInChannel(team.slackId)); + + const messageText = `${teamJoinRequest.user.firstName} ${teamJoinRequest.user.lastName} has requested to join ${team.teamName}. Approve?`; + const approveBlocks = [ + { + type: 'section', + text: { type: 'mrkdwn', text: messageText } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Approve Join Request' }, + style: 'primary', + action_id: 'approve_team_join_request', + value: JSON.stringify({ + teamJoinRequestId: teamJoinRequest.teamJoinRequestId, + organizationId: organization.organizationId + }) + } + ] + } + ]; + + await Promise.all( + [...allSlackIds] + .filter((slackId) => membersInChannel.has(slackId)) + .map((slackId) => sendEphemeralMessage(team.slackId, undefined, slackId, messageText, approveBlocks)) + ); +}; + +/** + * DMs the requester once their team join request has been reviewed, letting them know whether + * they were approved or denied. + */ +export const sendTeamJoinRequestReviewedNotification = async ( + teamJoinRequest: TeamJoinRequest, + team: SharedTeam, + approved: boolean +): Promise => { + if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return; + + const requesterSlackId = await getUserSlackId(teamJoinRequest.user.userId); + if (!requesterSlackId) return; + + const messageText = approved + ? `Your request to join ${team.teamName} has been approved! Welcome to the team.` + : `Your request to join ${team.teamName} has been denied.`; + + await sendMessage(requesterSlackId, messageText); +}; + /** * Adds the relevant slack notifications for a change request to the change request * diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 0c9b04403f..afe15b490d 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -123,6 +123,7 @@ export const resetUsers = async () => { await prisma.material_Type.deleteMany(); await prisma.assembly.deleteMany(); await prisma.meeting_Attendance.deleteMany(); + await prisma.team_Join_Request.deleteMany(); await prisma.team.deleteMany(); await prisma.user_Secure_Settings.deleteMany(); await prisma.receipt.deleteMany(); @@ -243,7 +244,7 @@ export const createTestFAQ = async (orgId: string, faqId: string) => { userId: user.userId } }, - regularFaqOrg: { + organization: { connect: { organizationId: orgId } @@ -328,7 +329,7 @@ export const createTestFaq = async (user: User, organizationId: string) => { data: { question: 'Who is Chief Software Engineer of NER?', answer: 'Peyton McKee!', - regularFaqOrgId: organizationId, + organizationId, userCreatedId: user.userId } }); diff --git a/src/backend/tests/unit/calendar.test.ts b/src/backend/tests/unit/calendar.test.ts index 4f3b6cb8b9..6dcbb5daa3 100644 --- a/src/backend/tests/unit/calendar.test.ts +++ b/src/backend/tests/unit/calendar.test.ts @@ -109,6 +109,7 @@ describe('Calendar Tests', () => { 'Updated Name', 'Updated Description', '#FF0000', + false, organization ) ).rejects.toThrow(new AccessDeniedException('Only admins can edit calendars')); @@ -131,6 +132,7 @@ describe('Calendar Tests', () => { 'Updated Calendar', 'Updated Description', '#0000FF', + false, organization ); @@ -148,6 +150,7 @@ describe('Calendar Tests', () => { 'Updated Name', 'Updated Description', '#FF0000', + false, organization ) ).rejects.toThrow(new NotFoundException('Calendar', 'non-existent-id')); @@ -172,6 +175,7 @@ describe('Calendar Tests', () => { 'Updated Name', 'Updated Description', '#FF0000', + false, organization ) ).rejects.toThrow(new DeletedException('Calendar', calendar.calendarId)); @@ -600,6 +604,7 @@ describe('Calendar Tests', () => { 'Non-Admin Calendar', 'desc', '#3498DB', + false, organization ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create calendar')); @@ -610,6 +615,7 @@ describe('Calendar Tests', () => { 'Cool Calendar', 'A very cool calendar', '#3498DB', + false, organization ); expect(result.name).toBe('Cool Calendar'); @@ -618,13 +624,21 @@ describe('Calendar Tests', () => { expect(result.userCreated.userId).toBe(adminUser.userId); }); it('fails on duplicate name', async () => { - await CalendarService.createCalendar(adminUser, 'Cool Calendar', 'A very cool calendar', '#3498DB', organization); + await CalendarService.createCalendar( + adminUser, + 'Cool Calendar', + 'A very cool calendar', + '#3498DB', + false, + organization + ); await expect( CalendarService.createCalendar( adminUser, 'Cool Calendar', 'A very cool calendar, but not quite as cool', '#0062a3ff', + false, organization ) ).rejects.toBeTruthy(); diff --git a/src/backend/tests/unit/part-review.test.ts b/src/backend/tests/unit/part-review.test.ts index f201c0e2c7..02a892a86f 100644 --- a/src/backend/tests/unit/part-review.test.ts +++ b/src/backend/tests/unit/part-review.test.ts @@ -12,6 +12,7 @@ import { resetUsers } from '../test-utils.js'; import PartReviewService from '../../src/services/part-review.services.js'; +import RecruitmentServices from '../../src/services/recruitment.services.js'; import { batmanAppAdmin, supermanAdmin, @@ -526,14 +527,22 @@ describe('part review tests', () => { }); it('creates a faq, edits it, and deletes it', async () => { - const faq = await PartReviewService.createFaq('some question', 'some answer', batman, orgId); + const faq = await RecruitmentServices.createOrganizationFaq( + batman, + 'some question', + 'some answer', + organization, + false, + false, + true + ); const prismaFaq = await prisma.frequentlyAskedQuestion.findUnique({ where: { faqId: faq.faqId } }); expect(prismaFaq?.question).toBe('some question'); expect(prismaFaq?.answer).toBe('some answer'); expect(prismaFaq?.userCreatedId).toBe(batman.userId); - expect(prismaFaq?.partReviewFaqOrgId).toBe(orgId); - expect(prismaFaq?.regularFaqOrgId).toBeFalsy(); + expect(prismaFaq?.isOnPartReviewPage).toBe(true); + expect(prismaFaq?.isOnRecruitingDashboard).toBe(false); expect(faq?.question).toBe('some question'); expect(faq?.answer).toBe('some answer'); @@ -550,7 +559,7 @@ describe('part review tests', () => { expect(prismaFaq2?.question).toBe('some other question'); expect(prismaFaq2?.answer).toBe('some other answer'); expect(prismaFaq2?.userCreatedId).toBe(batman.userId); - expect(prismaFaq2?.partReviewFaqOrgId).toBe(orgId); + expect(prismaFaq2?.isOnPartReviewPage).toBe(true); expect(prismaFaq2?.dateDeleted).toBeFalsy(); expect(updatedFaq?.question).toBe('some other question'); expect(updatedFaq?.answer).toBe('some other answer'); @@ -565,10 +574,27 @@ describe('part review tests', () => { it('does not let non-admins create, edit, or delete faqs', async () => { await expect( - async () => await PartReviewService.createFaq('some question', 'some answer', nonAdmin, orgId) - ).rejects.toThrow(new AccessDeniedAdminOnlyException('create part review faq')); + async () => + await RecruitmentServices.createOrganizationFaq( + nonAdmin, + 'some question', + 'some answer', + organization, + false, + false, + true + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('create an faq')); - const faq = await PartReviewService.createFaq('some question', 'some answer', batman, orgId); + const faq = await RecruitmentServices.createOrganizationFaq( + batman, + 'some question', + 'some answer', + organization, + false, + false, + true + ); await expect( async () => await PartReviewService.updateFaq(faq.faqId, 'some title2', 'some description2', nonAdmin, orgId) @@ -580,7 +606,15 @@ describe('part review tests', () => { }); it('does not allow updating deleted faqs', async () => { - const faq = await PartReviewService.createFaq('some q', 'some a', batman, orgId); + const faq = await RecruitmentServices.createOrganizationFaq( + batman, + 'some q', + 'some a', + organization, + false, + false, + true + ); await PartReviewService.deleteFaq(faq.faqId, superman, orgId); @@ -792,7 +826,8 @@ describe('part review tests', () => { answer: 'answer1', userCreated: { connect: { userId: batman.userId } }, dateCreated: new Date(), - partReviewFaqOrg: { connect: { organizationId: orgId } } + organization: { connect: { organizationId: orgId } }, + isOnPartReviewPage: true } }); const faq2 = await prisma.frequentlyAskedQuestion.create({ @@ -802,7 +837,8 @@ describe('part review tests', () => { answer: 'answer2', userCreated: { connect: { userId: batman.userId } }, dateCreated: new Date(), - partReviewFaqOrg: { connect: { organizationId: orgId } } + organization: { connect: { organizationId: orgId } }, + isOnPartReviewPage: true } }); const partReviews = await PartReviewService.getAllPartReviewFAQs(orgId); @@ -826,7 +862,8 @@ describe('part review tests', () => { answer: 'faq answer', userCreated: { connect: { userId: batman.userId } }, dateCreated: new Date(), - partReviewFaqOrg: { connect: { organizationId: orgId } } + organization: { connect: { organizationId: orgId } }, + isOnPartReviewPage: true } }); const regularFaq = await prisma.frequentlyAskedQuestion.create({ @@ -836,7 +873,8 @@ describe('part review tests', () => { answer: 'regular answer', userCreated: { connect: { userId: batman.userId } }, dateCreated: new Date(), - regularFaqOrg: { connect: { organizationId: orgId } } + organization: { connect: { organizationId: orgId } }, + isOnRecruitingDashboard: true } }); const partReviews = await PartReviewService.getAllPartReviewFAQs(orgId); diff --git a/src/backend/tests/unit/recruitment.test.ts b/src/backend/tests/unit/recruitment.test.ts index e1173dce76..80e93be085 100644 --- a/src/backend/tests/unit/recruitment.test.ts +++ b/src/backend/tests/unit/recruitment.test.ts @@ -42,9 +42,20 @@ describe('Recruitment Tests', () => { await createTestUser(batmanAppAdmin, orgId), 'question', 'answer', - organization + organization, + true, + false, + false + ); + const faq2 = await RecruitmentServices.createOrganizationFaq( + superman, + 'question2', + 'answer2', + organization, + true, + false, + false ); - const faq2 = await RecruitmentServices.createOrganizationFaq(superman, 'question2', 'answer2', organization); const result = await RecruitmentServices.getAllOrganizationFaqs(organization); expect(result).toHaveLength(2); expect(result[0].question).toEqual(faq1.question); @@ -53,6 +64,36 @@ describe('Recruitment Tests', () => { expect(result[1].answer).toEqual(faq2.answer); }); + it('getRecruitingFaqs and getNewMemberFaqs filter by dashboard', async () => { + const admin = await createTestUser(batmanAppAdmin, orgId); + const recruitingFaq = await RecruitmentServices.createOrganizationFaq( + admin, + 'recruiting question', + 'recruiting answer', + organization, + true, + false, + false + ); + const newMemberFaq = await RecruitmentServices.createOrganizationFaq( + admin, + 'new member question', + 'new member answer', + organization, + false, + true, + false + ); + + const recruitingResult = await RecruitmentServices.getRecruitingFaqs(organization); + expect(recruitingResult).toHaveLength(1); + expect(recruitingResult[0].question).toEqual(recruitingFaq.question); + + const newMemberResult = await RecruitmentServices.getNewMemberFaqs(organization); + expect(newMemberResult).toHaveLength(1); + expect(newMemberResult[0].question).toEqual(newMemberFaq.question); + }); + describe('Edit FAQ', () => { it('Fails if user is not an admin', async () => { await expect( @@ -104,6 +145,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date(), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a milestone')); @@ -115,6 +157,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -159,6 +202,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -187,6 +231,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -212,6 +257,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/11/24'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -220,6 +266,7 @@ describe('Recruitment Tests', () => { 'name2', 'description2', new Date('1/1/1'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); const result = await RecruitmentServices.getAllMilestones(organization); @@ -227,6 +274,74 @@ describe('Recruitment Tests', () => { }); }); + describe('Get New Member Milestones', () => { + it('Only returns milestones flagged for the new member dashboard', async () => { + const newMemberMilestone = await RecruitmentServices.createMilestone( + await createTestUser(batmanAppAdmin, orgId), + 'new member milestone', + 'description', + new Date('11/11/24'), + { isOnNewMemberDashboard: true, isOnRecruitingDashboard: false }, + organization + ); + + await RecruitmentServices.createMilestone( + superman, + 'recruiting milestone', + 'description', + new Date('1/1/1'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: true }, + organization + ); + + await RecruitmentServices.createMilestone( + superman, + 'unflagged milestone', + 'description', + new Date('1/1/1'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, + organization + ); + + const result = await RecruitmentServices.getNewMemberMilestones(organization); + expect(result).toStrictEqual([newMemberMilestone]); + }); + }); + + describe('Get Recruiting Milestones', () => { + it('Only returns milestones flagged for the recruiting dashboard', async () => { + await RecruitmentServices.createMilestone( + await createTestUser(batmanAppAdmin, orgId), + 'new member milestone', + 'description', + new Date('11/11/24'), + { isOnNewMemberDashboard: true, isOnRecruitingDashboard: false }, + organization + ); + + const recruitingMilestone = await RecruitmentServices.createMilestone( + superman, + 'recruiting milestone', + 'description', + new Date('1/1/1'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: true }, + organization + ); + + await RecruitmentServices.createMilestone( + superman, + 'unflagged milestone', + 'description', + new Date('1/1/1'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, + organization + ); + + const result = await RecruitmentServices.getRecruitingMilestones(organization); + expect(result).toStrictEqual([recruitingMilestone]); + }); + }); + describe('Create FAQ', () => { it('Fails if user is not an admin', async () => { await expect( @@ -235,7 +350,10 @@ describe('Recruitment Tests', () => { await createTestUser(member, orgId), 'question', 'answer', - organization + organization, + true, + false, + false ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create an faq')); }); @@ -286,21 +404,44 @@ describe('Recruitment Tests', () => { await createTestUser(member, orgId), 'question', 'answer', - organization + organization, + true, + false, + false ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create an faq')); }); - it('Succeeds and creates an FAQ', async () => { + it('Succeeds and creates a recruiting FAQ', async () => { const result = await RecruitmentServices.createOrganizationFaq( await createTestUser(batmanAppAdmin, orgId), 'question', 'answer', - organization + organization, + true, + false, + false ); expect(result.question).toEqual('question'); expect(result.answer).toEqual('answer'); + expect(result.isOnRecruitingDashboard).toBe(true); + expect(result.isOnNewMemberDashboard).toBe(false); + }); + + it('Succeeds and creates a new member FAQ', async () => { + const result = await RecruitmentServices.createOrganizationFaq( + await createTestUser(batmanAppAdmin, orgId), + 'onboarding question', + 'onboarding answer', + organization, + false, + true, + false + ); + + expect(result.isOnRecruitingDashboard).toBe(false); + expect(result.isOnNewMemberDashboard).toBe(true); }); }); }); @@ -515,6 +656,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); diff --git a/src/backend/tests/unit/team-join-requests.test.ts b/src/backend/tests/unit/team-join-requests.test.ts new file mode 100644 index 0000000000..e3615adbe9 --- /dev/null +++ b/src/backend/tests/unit/team-join-requests.test.ts @@ -0,0 +1,208 @@ +import { Organization, Team } from '@prisma/client'; +import { RoleEnum, User } from 'shared'; +import TeamsService from '../../src/services/teams.services.js'; +import { AccessDeniedException, DeletedException, HttpException, NotFoundException } from '../../src/utils/errors.utils.js'; +import { + aquamanLeadership, + greenlanternHead, + robinMember, + supermanAdmin, + wonderwomanGuest +} from '../test-data/users.test-data.js'; +import { createTestOrganization, createTestTeam, createTestTeamType, createTestUser, resetUsers } from '../test-utils.js'; +import prisma from '../../src/prisma/prisma.js'; + +describe('Team Join Request Tests', () => { + let organization: Organization; + let team: Team; + let admin: User; + let head: User; + let lead: User; + let requester: User; + let outsider: User; + + beforeEach(async () => { + organization = await createTestOrganization(); + const teamType = await createTestTeamType('electrical', organization.organizationId); + admin = await createTestUser(supermanAdmin, organization.organizationId); + head = await createTestUser(greenlanternHead, organization.organizationId); + team = await createTestTeam(head.userId, teamType.teamTypeId, organization.organizationId); + lead = await createTestUser(aquamanLeadership, organization.organizationId); + await TeamsService.setTeamLeads(admin, team.teamId, [lead.userId], organization); + requester = await createTestUser(wonderwomanGuest, organization.organizationId); + outsider = await createTestUser(robinMember, organization.organizationId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Create Team Join Request', () => { + it('fails if the team is archived', async () => { + await TeamsService.archiveTeam(admin, team.teamId, organization); + + await expect( + async () => await TeamsService.createTeamJoinRequest(requester, team.teamId, organization) + ).rejects.toThrow(new DeletedException('Team', team.teamId)); + }); + + it('fails if the submitter is already on the team', async () => { + await expect(async () => await TeamsService.createTeamJoinRequest(head, team.teamId, organization)).rejects.toThrow( + new HttpException(400, 'You are already part of this team') + ); + }); + + it('fails if the submitter already has a pending request for the team', async () => { + await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + + await expect( + async () => await TeamsService.createTeamJoinRequest(requester, team.teamId, organization) + ).rejects.toThrow(new HttpException(400, 'You already have a pending request to join this team')); + }); + + it('works and creates a pending request', async () => { + const result = await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + + expect(result).toMatchObject({ + status: 'PENDING', + team: { teamId: team.teamId }, + user: { userId: requester.userId } + }); + }); + }); + + describe('Get My Team Join Requests', () => { + it('returns all of the requesting users requests, most recent first', async () => { + const otherTeamType = await createTestTeamType('mechanical', organization.organizationId); + const otherHead = await createTestUser( + { + firstName: 'Other', + lastName: 'Head', + email: 'otherhead', + emailId: 'otherhead', + googleAuthId: 'otherhead', + role: RoleEnum.HEAD + }, + organization.organizationId + ); + const otherTeam = await createTestTeam(otherHead.userId, otherTeamType.teamTypeId, organization.organizationId); + const request1 = await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + const request2 = await TeamsService.createTeamJoinRequest(requester, otherTeam.teamId, organization); + + const result = await TeamsService.getMyTeamJoinRequests(requester, organization); + + expect(result.map((request) => request.teamJoinRequestId)).toStrictEqual([ + request2.teamJoinRequestId, + request1.teamJoinRequestId + ]); + }); + }); + + describe('Get Pending Team Join Requests', () => { + it('fails if the reviewer is not an admin or the head', async () => { + await expect( + async () => await TeamsService.getPendingTeamJoinRequests(team.teamId, outsider, organization) + ).rejects.toThrow( + new AccessDeniedException('you must be an admin or the team head to review join requests for this team') + ); + }); + + it('succeeds for the team head', async () => { + await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + + const result = await TeamsService.getPendingTeamJoinRequests(team.teamId, head, organization); + + expect(result).toHaveLength(1); + }); + + it('fails for a team lead', async () => { + await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + + await expect( + async () => await TeamsService.getPendingTeamJoinRequests(team.teamId, lead, organization) + ).rejects.toThrow( + new AccessDeniedException('you must be an admin or the team head to review join requests for this team') + ); + }); + + it('only returns requests that are still pending', async () => { + const created = await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + await TeamsService.reviewTeamJoinRequest(head, created.teamJoinRequestId, true, undefined, organization); + + const result = await TeamsService.getPendingTeamJoinRequests(team.teamId, head, organization); + + expect(result).toHaveLength(0); + }); + }); + + describe('Review Team Join Request', () => { + it('fails if the request does not exist', async () => { + await expect( + async () => await TeamsService.reviewTeamJoinRequest(head, 'nonExistentId', true, undefined, organization) + ).rejects.toThrow(new NotFoundException('Team Join Request', 'nonExistentId')); + }); + + it('fails if the request has already been reviewed', async () => { + const created = await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + await TeamsService.reviewTeamJoinRequest(head, created.teamJoinRequestId, true, undefined, organization); + + await expect( + async () => await TeamsService.reviewTeamJoinRequest(head, created.teamJoinRequestId, true, undefined, organization) + ).rejects.toThrow(new HttpException(400, 'This request has already been reviewed')); + }); + + it('fails if the reviewer is not an admin or the head', async () => { + const created = await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + + await expect( + async () => + await TeamsService.reviewTeamJoinRequest(outsider, created.teamJoinRequestId, true, undefined, organization) + ).rejects.toThrow( + new AccessDeniedException('you must be an admin or the team head to review join requests for this team') + ); + }); + + it('fails for a team lead', async () => { + const created = await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + + await expect( + async () => await TeamsService.reviewTeamJoinRequest(lead, created.teamJoinRequestId, true, undefined, organization) + ).rejects.toThrow( + new AccessDeniedException('you must be an admin or the team head to review join requests for this team') + ); + }); + + it('approving adds the requester to the team members', async () => { + const created = await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + + const result = await TeamsService.reviewTeamJoinRequest( + head, + created.teamJoinRequestId, + true, + undefined, + organization + ); + + expect(result.status).toBe('APPROVED'); + const updatedTeam = await prisma.team.findUnique({ where: { teamId: team.teamId }, include: { members: true } }); + expect(updatedTeam?.members.map((member) => member.userId)).toContain(requester.userId); + }); + + it('denying does not add the requester to the team members and stores the denial reason', async () => { + const created = await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + + const result = await TeamsService.reviewTeamJoinRequest( + head, + created.teamJoinRequestId, + false, + 'Not enough experience', + organization + ); + + expect(result.status).toBe('DENIED'); + expect(result.denialReason).toBe('Not enough experience'); + const updatedTeam = await prisma.team.findUnique({ where: { teamId: team.teamId }, include: { members: true } }); + expect(updatedTeam?.members.map((member) => member.userId)).not.toContain(requester.userId); + }); + }); +}); diff --git a/src/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts index 0c532f579e..13da709343 100644 --- a/src/backend/tests/unit/users.test.ts +++ b/src/backend/tests/unit/users.test.ts @@ -119,4 +119,21 @@ describe('User Tests', () => { ).rejects.toThrow(new AccessDeniedException('Guests and members cannot update user roles!')); }); }); + + describe('Update User Settings', () => { + it('throws when the slack id has an invalid format', async () => { + const testUser = await createTestUser(batmanAppAdmin, orgId); + + await expect(async () => await UsersService.updateUserSettings(testUser, 'DARK', 'la la la')).rejects.toThrow( + 'Invalid Slack ID' + ); + }); + + it('saves successfully when the slack id has a valid format', async () => { + const testUser = await createTestUser(batmanAppAdmin, orgId); + const result = await UsersService.updateUserSettings(testUser, 'DARK', 'U1234ABCD'); + + expect(result.slackId).toBe('U1234ABCD'); + }); + }); }); diff --git a/src/backend/tests/unmocked/recruitment.test.ts b/src/backend/tests/unmocked/recruitment.test.ts index bbf7187bf4..8831641642 100644 --- a/src/backend/tests/unmocked/recruitment.test.ts +++ b/src/backend/tests/unmocked/recruitment.test.ts @@ -38,13 +38,19 @@ describe('Recruitment Tests', () => { await createTestUser(batmanAppAdmin, orgId), 'question', 'answer', - organization + organization, + true, + false, + false ); const faq2 = await RecruitmentServices.createOrganizationFaq( await createTestUser(supermanAdmin, orgId), 'question2', 'answer2', - organization + organization, + true, + false, + false ); const result = await RecruitmentServices.getAllOrganizationFaqs(organization); expect(result).toHaveLength(2); @@ -105,6 +111,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date(), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a milestone')); @@ -116,6 +123,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -160,6 +168,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -188,6 +197,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -213,6 +223,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/11/24'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -221,6 +232,7 @@ describe('Recruitment Tests', () => { 'name2', 'description2', new Date('1/1/1'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); const result = await RecruitmentServices.getAllMilestones(organization); @@ -228,6 +240,78 @@ describe('Recruitment Tests', () => { }); }); + describe('Get New Member Milestones', () => { + it('Only returns milestones flagged for the new member dashboard', async () => { + const admin = await createTestUser(batmanAppAdmin, orgId); + + const newMemberMilestone = await RecruitmentServices.createMilestone( + admin, + 'new member milestone', + 'description', + new Date('11/11/24'), + { isOnNewMemberDashboard: true, isOnRecruitingDashboard: false }, + organization + ); + + await RecruitmentServices.createMilestone( + admin, + 'recruiting milestone', + 'description', + new Date('1/1/1'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: true }, + organization + ); + + await RecruitmentServices.createMilestone( + admin, + 'unflagged milestone', + 'description', + new Date('1/1/1'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, + organization + ); + + const result = await RecruitmentServices.getNewMemberMilestones(organization); + expect(result).toStrictEqual([newMemberMilestone]); + }); + }); + + describe('Get Recruiting Milestones', () => { + it('Only returns milestones flagged for the recruiting dashboard', async () => { + const admin = await createTestUser(batmanAppAdmin, orgId); + + await RecruitmentServices.createMilestone( + admin, + 'new member milestone', + 'description', + new Date('11/11/24'), + { isOnNewMemberDashboard: true, isOnRecruitingDashboard: false }, + organization + ); + + const recruitingMilestone = await RecruitmentServices.createMilestone( + admin, + 'recruiting milestone', + 'description', + new Date('1/1/1'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: true }, + organization + ); + + await RecruitmentServices.createMilestone( + admin, + 'unflagged milestone', + 'description', + new Date('1/1/1'), + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, + organization + ); + + const result = await RecruitmentServices.getRecruitingMilestones(organization); + expect(result).toStrictEqual([recruitingMilestone]); + }); + }); + describe('Create FAQ', () => { it('Fails if user is not an admin', async () => { await expect( @@ -236,7 +320,10 @@ describe('Recruitment Tests', () => { await createTestUser(member, orgId), 'question', 'answer', - organization + organization, + true, + false, + false ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create an faq')); }); @@ -287,7 +374,10 @@ describe('Recruitment Tests', () => { await createTestUser(member, orgId), 'question', 'answer', - organization + organization, + true, + false, + false ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create an faq')); }); @@ -297,11 +387,16 @@ describe('Recruitment Tests', () => { await createTestUser(batmanAppAdmin, orgId), 'question', 'answer', - organization + organization, + true, + false, + false ); expect(result.question).toEqual('question'); expect(result.answer).toEqual('answer'); + expect(result.isOnRecruitingDashboard).toBe(true); + expect(result.isOnNewMemberDashboard).toBe(false); }); }); }); diff --git a/src/frontend/src/apis/calendar.api.ts b/src/frontend/src/apis/calendar.api.ts index f01648f217..0acb448ed0 100644 --- a/src/frontend/src/apis/calendar.api.ts +++ b/src/frontend/src/apis/calendar.api.ts @@ -23,7 +23,12 @@ export const getAllCalendars = () => { }); }; -export const postCreateCalendar = (payload: { name: string; description: string; colorHexCode: string }) => { +export const postCreateCalendar = (payload: { + name: string; + description: string; + colorHexCode: string; + isNewMemberCalendar: boolean; +}) => { return axios.post(apiUrls.calendarCreateCalendar(), payload, { transformResponse: (data) => JSON.parse(data) as Calendar }); @@ -31,7 +36,7 @@ export const postCreateCalendar = (payload: { name: string; description: string; export const postEditCalendar = ( calendarId: string, - payload: { name: string; description: string; colorHexCode: string } + payload: { name: string; description: string; colorHexCode: string; isNewMemberCalendar: boolean } ) => { return axios.post(apiUrls.calendarEditCalendar(calendarId), payload, { transformResponse: (data) => JSON.parse(data) as Calendar @@ -165,6 +170,12 @@ export const getAllEvents = () => { }); }; +export const getNewMemberEvents = () => { + return axios.get(apiUrls.calendarNewMemberEvents(), { + transformResponse: (data) => JSON.parse(data).map(eventTransformer) + }); +}; + export const getAllEventTypes = () => { return axios.get(apiUrls.calendarEventTypes(), { transformResponse: (data) => JSON.parse(data) as EventType[] diff --git a/src/frontend/src/apis/organizations.api.ts b/src/frontend/src/apis/organizations.api.ts index 0ea7e48b30..ba50983a45 100644 --- a/src/frontend/src/apis/organizations.api.ts +++ b/src/frontend/src/apis/organizations.api.ts @@ -60,18 +60,6 @@ export const setOrganizationLogo = async (file: File) => { return axios.post(apiUrls.organizationsSetLogoImage(), formData); }; -export const setOrganizationNewMemberImage = async (file: File) => { - const formData = new FormData(); - formData.append('newMemberImage', file); - return axios.post(apiUrls.organizationsSetNewMemberImage(), formData); -}; - -export const getOrganizationNewMemberImage = async () => { - return axios.get(apiUrls.organizationsNewMemberImage(), { - transformResponse: (data) => JSON.parse(data) - }); -}; - export const setOrganizationPlatformLogoImage = async (file: File) => { const formData = new FormData(); formData.append('platformLogo', file); diff --git a/src/frontend/src/apis/recruitment.api.ts b/src/frontend/src/apis/recruitment.api.ts index ad8691bc2e..4d0090244c 100644 --- a/src/frontend/src/apis/recruitment.api.ts +++ b/src/frontend/src/apis/recruitment.api.ts @@ -1,5 +1,5 @@ import axios from '../utils/axios'; -import { MilestonePayload, FaqPayload, GuestDefinitionPayload } from '../hooks/recruitment.hooks'; +import { MilestonePayload, MilestoneCreatePayload, FaqPayload, GuestDefinitionPayload } from '../hooks/recruitment.hooks'; import { apiUrls } from '../utils/urls'; import { dateToMidnightUTC, GuestDefinition, Milestone } from 'shared'; import { FrequentlyAskedQuestion } from 'shared'; @@ -10,7 +10,19 @@ export const getAllMilestones = () => { }); }; -export const createMilestone = (payload: MilestonePayload) => { +export const getNewMemberMilestones = () => { + return axios.get(apiUrls.newMemberMilestones(), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +export const getRecruitingMilestones = () => { + return axios.get(apiUrls.recruitingMilestones(), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +export const createMilestone = (payload: MilestoneCreatePayload) => { return axios.post(apiUrls.milestoneCreate(), { ...payload, dateOfEvent: dateToMidnightUTC(payload.dateOfEvent) @@ -34,8 +46,26 @@ export const getAllFaqs = () => { }); }; -export const createFaq = (payload: FaqPayload) => { - return axios.post(apiUrls.faqCreate(), { +export const getRecruitingFaqs = () => { + return axios.get(apiUrls.recruitingFaqs(), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +export const getNewMemberFaqs = () => { + return axios.get(apiUrls.newMemberFaqs(), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +export const createRecruitingFaq = (payload: FaqPayload) => { + return axios.post(apiUrls.recruitingFaqCreate(), { + ...payload + }); +}; + +export const createNewMemberFaq = (payload: FaqPayload) => { + return axios.post(apiUrls.newMemberFaqCreate(), { ...payload }); }; diff --git a/src/frontend/src/apis/teams.api.ts b/src/frontend/src/apis/teams.api.ts index a9065e0174..3ee4435426 100644 --- a/src/frontend/src/apis/teams.api.ts +++ b/src/frontend/src/apis/teams.api.ts @@ -4,10 +4,10 @@ */ import axios from '../utils/axios'; -import { Team, TeamBase, TeamPreview } from 'shared'; +import { Team, TeamBase, TeamJoinRequest, TeamPreview } from 'shared'; import { apiUrls } from '../utils/urls'; import { CreateTeamPayload } from '../hooks/teams.hooks'; -import { teamPreviewTransformer, teamTransformer } from './transformers/teams.transformers'; +import { teamJoinRequestTransformer, teamPreviewTransformer, teamTransformer } from './transformers/teams.transformers'; export const getAllTeamPreviews = () => { return axios.get(apiUrls.teamPreviews(), { @@ -82,3 +82,29 @@ export const setTeamLeads = (id: string, userIds: string[]) => { export const getMyTeamAsHead = () => { return axios.get(apiUrls.myTeamAsHead()); }; + +export const getMyTeamJoinRequests = () => { + return axios.get(apiUrls.myTeamJoinRequests(), { + transformResponse: (data) => JSON.parse(data).map(teamJoinRequestTransformer) + }); +}; + +export const getPendingTeamJoinRequests = (teamId: string) => { + return axios.get(apiUrls.teamsPendingJoinRequests(teamId), { + transformResponse: (data) => JSON.parse(data).map(teamJoinRequestTransformer) + }); +}; + +export const createTeamJoinRequest = (teamId: string) => { + return axios.post(apiUrls.teamsCreateJoinRequest(teamId), undefined, { + transformResponse: (data) => teamJoinRequestTransformer(JSON.parse(data)) + }); +}; + +export const reviewTeamJoinRequest = (teamJoinRequestId: string, approved: boolean, denialReason?: string) => { + return axios.post( + apiUrls.teamsReviewJoinRequest(teamJoinRequestId), + { approved, denialReason }, + { transformResponse: (data) => teamJoinRequestTransformer(JSON.parse(data)) } + ); +}; diff --git a/src/frontend/src/apis/transformers/teams.transformers.ts b/src/frontend/src/apis/transformers/teams.transformers.ts index 1cfb0be051..2e6aac11ef 100644 --- a/src/frontend/src/apis/transformers/teams.transformers.ts +++ b/src/frontend/src/apis/transformers/teams.transformers.ts @@ -1,4 +1,4 @@ -import { Team, TeamPreview } from 'shared'; +import { Team, TeamJoinRequest, TeamPreview } from 'shared'; import { projectGanttTransformer } from './projects.transformers'; /** @@ -21,3 +21,12 @@ export const teamPreviewTransformer = (team: TeamPreview): TeamPreview => { ...team }; }; + +export const teamJoinRequestTransformer = (teamJoinRequest: TeamJoinRequest): TeamJoinRequest => { + return { + ...teamJoinRequest, + team: teamPreviewTransformer(teamJoinRequest.team), + dateRequested: new Date(teamJoinRequest.dateRequested), + dateReviewed: teamJoinRequest.dateReviewed ? new Date(teamJoinRequest.dateReviewed) : undefined + }; +}; diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index 5ec72ea4ca..fd912ec143 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -38,9 +38,10 @@ import SidebarLayout from '../layouts/SidebarLayout'; interface AppAuthenticatedProps { userId: string; userRole: Role; + completedOnboarding: boolean; } -const AppAuthenticated: React.FC = ({ userId, userRole }) => { +const AppAuthenticated: React.FC = ({ userId, userRole, completedOnboarding }) => { const { isLoading, isError, error, data: userSettingsData } = useSingleUserSettings(userId); const { @@ -65,7 +66,7 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) return ( - {userSettingsData.slackId || isGuest(userRole) ? ( + {userSettingsData.slackId || (isGuest(userRole) && !completedOnboarding) ? ( diff --git a/src/frontend/src/app/AppPublic.tsx b/src/frontend/src/app/AppPublic.tsx index 0591137c0e..e9be388e0c 100644 --- a/src/frontend/src/app/AppPublic.tsx +++ b/src/frontend/src/app/AppPublic.tsx @@ -34,7 +34,12 @@ const AppPublic: React.FC = () => { return ; } - return ; + //get onboarding completion to pass to authenticated app for routing + const completedOnboarding = auth.user.onboardedTeamTypeIds.length > 0; + + return ( + + ); } if (!auth.user && !auth.triedCurrent) { diff --git a/src/frontend/src/app/HomePageContext.tsx b/src/frontend/src/app/HomePageContext.tsx index 2d7f42db63..ab24dabde8 100644 --- a/src/frontend/src/app/HomePageContext.tsx +++ b/src/frontend/src/app/HomePageContext.tsx @@ -4,11 +4,12 @@ interface HomePageContextProps { onPNMHomePage: boolean; onGuestHomePage: boolean; onOnboardingHomePage: boolean; + onNewMemberHomePage: boolean; onMemberHomePage: boolean; setCurrentHomePage: (homePage: HomePage) => void; } -type HomePage = 'guest' | 'member' | 'pnm' | 'onboarding'; +type HomePage = 'guest' | 'member' | 'pnm' | 'onboarding' | 'new-member'; const HomePageContext = createContext(undefined); @@ -16,34 +17,15 @@ export const HomePageProvider: React.FC<{ children: React.ReactNode }> = ({ chil const [onGuestHomePage, setOnGuestHomePage] = useState(false); const [onPNMHomePage, setOnPNMHomePage] = useState(false); const [onOnboardingHomePage, setOnOnboardingHomePage] = useState(false); + const [onNewMemberHomePage, setOnNewMemberHomePage] = useState(false); const [onMemberHomePage, setOnMemberHomePage] = useState(false); const setCurrentHomePage = (homePage: HomePage) => { - switch (homePage) { - case 'guest': - setOnPNMHomePage(false); - setOnOnboardingHomePage(false); - setOnMemberHomePage(false); - setOnGuestHomePage(true); - break; - case 'member': - setOnGuestHomePage(false); - setOnPNMHomePage(false); - setOnOnboardingHomePage(false); - setOnMemberHomePage(true); - break; - case 'onboarding': - setOnPNMHomePage(false); - setOnGuestHomePage(false); - setOnMemberHomePage(false); - setOnOnboardingHomePage(true); - break; - case 'pnm': - setOnGuestHomePage(false); - setOnMemberHomePage(false); - setOnOnboardingHomePage(false); - setOnPNMHomePage(true); - } + setOnGuestHomePage(homePage === 'guest'); + setOnPNMHomePage(homePage === 'pnm'); + setOnOnboardingHomePage(homePage === 'onboarding'); + setOnNewMemberHomePage(homePage === 'new-member'); + setOnMemberHomePage(homePage === 'member'); }; return ( @@ -52,6 +34,7 @@ export const HomePageProvider: React.FC<{ children: React.ReactNode }> = ({ chil onGuestHomePage, onPNMHomePage, onOnboardingHomePage, + onNewMemberHomePage, onMemberHomePage, setCurrentHomePage }} diff --git a/src/frontend/src/hooks/calendar.hooks.ts b/src/frontend/src/hooks/calendar.hooks.ts index 7577d5d483..277a6e2d4c 100644 --- a/src/frontend/src/hooks/calendar.hooks.ts +++ b/src/frontend/src/hooks/calendar.hooks.ts @@ -35,6 +35,7 @@ import { markUserConfirmed, getSingleEvent, getAllEvents, + getNewMemberEvents, deleteEvent, setEventStatus, getAllEventTypes, @@ -126,7 +127,11 @@ export const useAllCalendars = () => export const useCreateCalendar = () => { const qc = useQueryClient(); - return useMutation( + return useMutation< + Calendar, + Error, + { name: string; description: string; colorHexCode: string; isNewMemberCalendar: boolean } + >( async (payload) => { const { data } = await postCreateCalendar(payload); return data; @@ -141,7 +146,11 @@ export const useCreateCalendar = () => { export const useEditCalendar = (calendarId: string) => { const qc = useQueryClient(); - return useMutation( + return useMutation< + Calendar, + Error, + { name: string; description: string; colorHexCode: string; isNewMemberCalendar: boolean } + >( async (payload) => { const { data } = await postEditCalendar(calendarId, payload); return data; @@ -394,6 +403,13 @@ export const useAllEvents = () => { }); }; +export const useNewMemberEvents = () => { + return useQuery(['events', 'new-member'], async () => { + const { data } = await getNewMemberEvents(); + return data; + }); +}; + export const useFilterEvents = (filterArgs: FilterArgs) => { return useQuery( ['filter-events', filterArgs], diff --git a/src/frontend/src/hooks/organizations.hooks.ts b/src/frontend/src/hooks/organizations.hooks.ts index 5ff47bd075..637f9fae35 100644 --- a/src/frontend/src/hooks/organizations.hooks.ts +++ b/src/frontend/src/hooks/organizations.hooks.ts @@ -19,8 +19,6 @@ import { setSlackSponsorshipNotificationSlackChannelId, getFinanceDelegates, setFinanceDelegates, - setOrganizationNewMemberImage, - getOrganizationNewMemberImage, setOrganizationPlatformLogoImage, getNotificationChannels } from '../apis/organizations.api'; @@ -217,26 +215,6 @@ export const useOrganizationLogo = () => { }); }; -export const useOrganizationNewMemberImage = () => { - return useQuery(['organizations', 'new-member-image'], async () => { - const { data: fileId } = await getOrganizationNewMemberImage(); - if (!fileId) { - return; - } - return await downloadGoogleImage(fileId); - }); -}; - -export const useSetOrganizationNewMemberImage = () => { - const queryClient = useQueryClient(); - return useMutation(['organizations', 'new-member-image'], async (file: File) => { - const { data } = await setOrganizationNewMemberImage(file); - queryClient.invalidateQueries(['organizations']); - queryClient.invalidateQueries(['organizations', 'new-member-image']); - return data; - }); -}; - export const useSetOrganizationPlatformLogoImage = () => { const queryClient = useQueryClient(); return useMutation(['organizations', 'platform-logo'], async (file: File) => { diff --git a/src/frontend/src/hooks/recruitment.hooks.ts b/src/frontend/src/hooks/recruitment.hooks.ts index cea7f41993..947f412435 100644 --- a/src/frontend/src/hooks/recruitment.hooks.ts +++ b/src/frontend/src/hooks/recruitment.hooks.ts @@ -1,7 +1,8 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; import { Milestone, FrequentlyAskedQuestion, GuestDefinition, GuestDefinitionType } from 'shared'; import { - createFaq, + createRecruitingFaq, + createNewMemberFaq, createGuestDefinition, createMilestone, deleteFaq, @@ -11,8 +12,12 @@ import { editGuestDefinition, editMilestone, getAllFaqs, + getRecruitingFaqs, + getNewMemberFaqs, getAllGuestDefinitions, - getAllMilestones + getAllMilestones, + getNewMemberMilestones, + getRecruitingMilestones } from '../apis/recruitment.api'; export interface MilestonePayload { @@ -21,6 +26,11 @@ export interface MilestonePayload { dateOfEvent: Date; } +export interface MilestoneCreatePayload extends MilestonePayload { + isOnNewMemberDashboard: boolean; + isOnRecruitingDashboard: boolean; +} + export interface FaqPayload { question: string; answer: string; @@ -43,9 +53,23 @@ export const useAllMilestones = () => { }); }; +export const useNewMemberMilestones = () => { + return useQuery(['milestones', 'new-member'], async () => { + const { data } = await getNewMemberMilestones(); + return data; + }); +}; + +export const useRecruitingMilestones = () => { + return useQuery(['milestones', 'recruiting'], async () => { + const { data } = await getRecruitingMilestones(); + return data; + }); +}; + export const useCreateMilestone = () => { const queryClient = useQueryClient(); - return useMutation( + return useMutation( ['milestones', 'create'], async (payload) => { const { data } = await createMilestone(payload); @@ -98,12 +122,42 @@ export const useAllFaqs = () => { }); }; -export const useCreateFaq = () => { +export const useRecruitingFaqs = () => { + return useQuery(['faqs', 'recruiting'], async () => { + const { data } = await getRecruitingFaqs(); + return data; + }); +}; + +export const useNewMemberFaqs = () => { + return useQuery(['faqs', 'new-member'], async () => { + const { data } = await getNewMemberFaqs(); + return data; + }); +}; + +export const useCreateRecruitingFaq = () => { + const queryClient = useQueryClient(); + return useMutation( + ['faqs', 'recruiting', 'create'], + async (payload) => { + const { data } = await createRecruitingFaq(payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['faqs']); + } + } + ); +}; + +export const useCreateNewMemberFaq = () => { const queryClient = useQueryClient(); return useMutation( - ['faqs', 'create'], + ['faqs', 'new-member', 'create'], async (payload) => { - const { data } = await createFaq(payload); + const { data } = await createNewMemberFaq(payload); return data; }, { diff --git a/src/frontend/src/hooks/teams.hooks.ts b/src/frontend/src/hooks/teams.hooks.ts index 431c4a3cff..eb86a49845 100644 --- a/src/frontend/src/hooks/teams.hooks.ts +++ b/src/frontend/src/hooks/teams.hooks.ts @@ -4,7 +4,7 @@ */ import { useQuery, useQueryClient, useMutation } from 'react-query'; -import { Team, TeamBase, TeamPreview } from 'shared'; +import { Team, TeamBase, TeamJoinRequest, TeamPreview } from 'shared'; import { getAllTeams, getSingleTeam, @@ -19,7 +19,11 @@ import { getUsersTeams, setTeamSlackId, getMyTeamAsHead, - getAllTeamPreviews + getAllTeamPreviews, + getMyTeamJoinRequests, + getPendingTeamJoinRequests, + createTeamJoinRequest, + reviewTeamJoinRequest } from '../apis/teams.api'; export interface CreateTeamPayload { @@ -199,3 +203,55 @@ export const useMyTeamAsHead = () => { return data; }); }; + +export const useMyTeamJoinRequests = () => { + return useQuery(['teams', 'join-requests', 'mine'], async () => { + const { data } = await getMyTeamJoinRequests(); + return data; + }); +}; + +export const usePendingTeamJoinRequests = (teamId: string) => { + return useQuery(['teams', 'join-requests', teamId], async () => { + const { data } = await getPendingTeamJoinRequests(teamId); + return data; + }); +}; + +export const useCreateTeamJoinRequest = (teamId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['teams', 'join-requests', 'create'], + async () => { + const { data } = await createTeamJoinRequest(teamId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['teams']); + } + } + ); +}; + +export interface ReviewTeamJoinRequestPayload { + teamJoinRequestId: string; + approved: boolean; + denialReason?: string; +} + +export const useReviewTeamJoinRequest = () => { + const queryClient = useQueryClient(); + return useMutation( + ['teams', 'join-requests', 'review'], + async ({ teamJoinRequestId, approved, denialReason }: ReviewTeamJoinRequestPayload) => { + const { data } = await reviewTeamJoinRequest(teamJoinRequestId, approved, denialReason); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['teams']); + } + } + ); +}; diff --git a/src/frontend/src/pages/AcceptedPage/AcceptedPage.tsx b/src/frontend/src/pages/AcceptedPage/AcceptedPage.tsx deleted file mode 100644 index ffc1848737..0000000000 --- a/src/frontend/src/pages/AcceptedPage/AcceptedPage.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Typography, Box, Grid } from '@mui/material'; -import PageLayout from '../../components/PageLayout'; -import { NERButton } from '../../components/NERButton'; -import { useHistory } from 'react-router-dom'; -import { useCurrentUser } from '../../hooks/users.hooks'; -import { routes } from '../../utils/routes'; -import { useCompleteOnboarding } from '../../hooks/team-types.hooks'; -import LoadingIndicator from '../../components/LoadingIndicator'; -import { useCurrentOrganization } from '../../hooks/organizations.hooks'; - -const AcceptedPage = () => { - const history = useHistory(); - const user = useCurrentUser(); - const { data: organization, isLoading: organizationIsLoading } = useCurrentOrganization(); - - const { mutateAsync: completeOnboarding, isLoading: completeOnboardingIsLoading } = useCompleteOnboarding(); - - if (completeOnboardingIsLoading || !organization || organizationIsLoading) { - return ; - } - - const handleClick = async () => { - await completeOnboarding(); - window.location.reload(); - }; - - return ( - - - - Congratulations, {user.firstName}! - - - We are so excited to welcome you to {organization.name}! - - - - - - We can't wait to see you around and all that you'll accomplish! - - - - - - history.push(routes.HOME_SELECT_SUBTEAM)}> - Reject - - - - - Accept - - - - - - ); -}; -export default AcceptedPage; diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx index d68977b8eb..8a483303e4 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx @@ -6,7 +6,9 @@ import { useAllTeamTypes } from '../../../hooks/team-types.hooks'; import { groupChecklists, sortGroupNames } from '../../../utils/onboarding.utils'; import ErrorPage from '../../ErrorPage'; import { AdminChecklist } from './Checklists/AdminChecklist'; -import OnboardingInfoSection from './OnboardingInfoSection'; +import OnboardingConfigSection from './OnboardingConfigSection'; +import NewMemberFAQTable from './NewMemberFAQ/NewMemberFAQTable'; +import NewMemberDashboardUsefulLinksSection from './NewMemberDashboardUsefulLinksSection'; import { Checklist } from 'shared'; type GroupedChecklists = Record; // Change made here @@ -67,9 +69,18 @@ const AdminToolsOnboardingConfig: React.FC = () => { ); })} + + + + + + New Member FAQs + + + - + diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberDashboardUsefulLinksSection.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberDashboardUsefulLinksSection.tsx new file mode 100644 index 0000000000..1a6611d1cb --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberDashboardUsefulLinksSection.tsx @@ -0,0 +1,34 @@ +import { Box, Typography, useTheme } from '@mui/material'; +import UsefulLinksTable from './UsefulLinks/UsefulLinksTable'; +import LinkTypeTable from '../ProjectsConfig/LinkTypes/LinkTypeTable'; + +const NewMemberDashboardUsefulLinksSection: React.FC = () => { + const theme = useTheme(); + + return ( + + + New Member Dashboard Useful Links + + + + + ); +}; + +export default NewMemberDashboardUsefulLinksSection; diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/CreateNewMemberFaqFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/CreateNewMemberFaqFormModal.tsx new file mode 100644 index 0000000000..1067807711 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/CreateNewMemberFaqFormModal.tsx @@ -0,0 +1,21 @@ +import ErrorPage from '../../../ErrorPage'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import { useCreateNewMemberFaq } from '../../../../hooks/recruitment.hooks'; +import React from 'react'; +import FaqFormModal from '../../RecruitmentConfig/FaqFormModal'; + +interface CreateNewMemberFaqFormModalProps { + open: boolean; + handleClose: () => void; +} + +const CreateNewMemberFaqFormModal = ({ open, handleClose }: CreateNewMemberFaqFormModalProps) => { + const { isLoading, isError, error, mutateAsync } = useCreateNewMemberFaq(); + + if (isError) return ; + if (isLoading) return ; + + return ; +}; + +export default CreateNewMemberFaqFormModal; diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/EditNewMemberFaqFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/EditNewMemberFaqFormModal.tsx new file mode 100644 index 0000000000..4ee07a2d70 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/EditNewMemberFaqFormModal.tsx @@ -0,0 +1,22 @@ +import ErrorPage from '../../../ErrorPage'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import { FrequentlyAskedQuestion } from 'shared'; +import { useEditFaq } from '../../../../hooks/recruitment.hooks'; +import FaqFormModal from '../../RecruitmentConfig/FaqFormModal'; + +interface EditNewMemberFaqFormModalProps { + open: boolean; + handleClose: () => void; + faq: FrequentlyAskedQuestion; +} + +const EditNewMemberFaqFormModal = ({ open, handleClose, faq }: EditNewMemberFaqFormModalProps) => { + const { isLoading, isError, error, mutateAsync } = useEditFaq(faq.faqId); + + if (isError) return ; + if (isLoading) return ; + + return ; +}; + +export default EditNewMemberFaqFormModal; diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/NewMemberFAQTable.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/NewMemberFAQTable.tsx new file mode 100644 index 0000000000..a62b139c60 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/NewMemberFAQTable.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { TableRow, TableCell, Box, Table as MuiTable, TableHead, TableBody, Typography, Button } from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { FrequentlyAskedQuestion } from 'shared'; +import { NERButton } from '../../../../components/NERButton'; +import { useNewMemberFaqs, useDeleteFAQ } from '../../../../hooks/recruitment.hooks'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import { useHistoryState } from '../../../../hooks/misc.hooks'; +import ErrorPage from '../../../ErrorPage'; +import CreateNewMemberFaqFormModal from './CreateNewMemberFaqFormModal'; +import EditNewMemberFaqFormModal from './EditNewMemberFaqFormModal'; +import NERDeleteModal from '../../../../components/NERDeleteModal'; +import { useToast } from '../../../../hooks/toasts.hooks'; + +const NewMemberFAQTable = () => { + const [createModalShow, setCreateModalShow] = useHistoryState('', false); + const [faqEditing, setFaqEditing] = useHistoryState('', undefined); + const [faqToDelete, setFaqToDelete] = useState(undefined); + const { mutateAsync: deleteFaq } = useDeleteFAQ(); + const toast = useToast(); + + const { isLoading: faqsIsLoading, isError: faqsIsError, error: faqsError, data: faqs } = useNewMemberFaqs(); + const handleDelete = (id: string) => { + setFaqToDelete(undefined); + try { + deleteFaq(id); + toast.success('Faq deleted successfully'); + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message, 3000); + } + } + }; + + if (!faqs || faqsIsLoading) return ; + if (faqsIsError) return ; + + const FAQsRows = faqs.map((faq: FrequentlyAskedQuestion, index: number) => ( + + + {faq.question} + + + {faq.answer} + + + + + + + )); + + return ( + + setCreateModalShow(false)} /> + {faqEditing && ( + setFaqEditing(undefined)} faq={faqEditing} /> + )} + + + + + + Question + + + Answer + + + + {FAQsRows} + + + { + setCreateModalShow(true); + }} + > + Add FAQ + + + setFaqToDelete(undefined)} + formId="delete-item-form" + dataType="FAQ" + onFormSubmit={() => { + if (faqToDelete) { + handleDelete(faqToDelete.faqId); + } + }} + /> + + ); +}; + +export default NewMemberFAQTable; diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingBlock.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingBlock.tsx index 98fb04442e..228bf49887 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingBlock.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingBlock.tsx @@ -22,7 +22,7 @@ const OnboardingBlock: React.FC = ({ organization, isAdmin }; return ( - + { +const OnboardingConfigSection: React.FC = () => { const theme = useTheme(); const [showModal, setShowModal] = useState(false); - const [addedImage, setAddedImage] = useState(undefined); - const toast = useToast(); const { data: organization, @@ -29,39 +22,10 @@ const OnboardingInfoSection: React.FC = () => { error: organizationError } = useCurrentOrganization(); - const { - data: newMemberImageBlob, - isLoading: imageIsLoading, - error: imageError, - isError: imageIsError - } = useOrganizationNewMemberImage(); - const { mutateAsync: uploadNewMemberImage, isLoading: isUploading } = useSetOrganizationNewMemberImage(); - - const handleImageUpload = async () => { - if (!addedImage) return; - - if (addedImage.size >= MAX_FILE_SIZE) { - toast.error(`File must be less than ${MAX_FILE_SIZE / 1024 / 1024} MB`, 5000); - return; - } - - try { - await uploadNewMemberImage(addedImage); - setAddedImage(undefined); - toast.success('Image uploaded successfully!'); - } catch (error: any) { - toast.error(error?.message || 'Failed to upload image'); - } - }; - if (organizationIsError) { return ; } - if (imageIsError) { - return ; - } - if (!organization || organizationIsLoading) return ; return ( @@ -80,7 +44,8 @@ const OnboardingInfoSection: React.FC = () => { theme.palette.background.paper, + height: '100%', borderRadius: '10px', padding: '16px', width: '100%' @@ -94,35 +59,10 @@ const OnboardingInfoSection: React.FC = () => { marginBottom: '12px' }} > - New Member Events Image + Onboarding Page Useful Links - {isUploading || imageIsLoading ? ( - - - - ) : ( - <> - {!addedImage && newMemberImageBlob && ( - - )} - { - if (e.target.files) { - setAddedImage(e.target.files[0]); - } - }} - onSubmit={handleImageUpload} - addedImage={addedImage} - setAddedImage={setAddedImage} - /> - - )} + + @@ -143,9 +83,9 @@ const OnboardingInfoSection: React.FC = () => { marginBottom: '12px' }} > - Useful Links + Onboarding Milestones - + @@ -168,12 +108,24 @@ const OnboardingInfoSection: React.FC = () => { {organization.contacts.map((contact) => { return ( - - {contact.user.firstName} {contact.user.lastName}: {contact.user.email} - {contact.title} + + {contact.user.firstName} {contact.user.lastName} - {contact.title} ); })} + {organization.slackWorkspaceId && ( + + You can find them on{' '} + + Slack + + + )} { ); }; -export default OnboardingInfoSection; +export default OnboardingConfigSection; diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UpdateContactsModal.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UpdateContactsModal.tsx index 4164795bc7..e7911460ec 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UpdateContactsModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UpdateContactsModal.tsx @@ -10,8 +10,7 @@ import LoadingIndicator from '../../../components/LoadingIndicator'; import * as yup from 'yup'; import { useUpdateOrganizationContacts } from '../../../hooks/organizations.hooks'; // Assume hook exists import { Contact } from 'shared'; -import { useAllMembers } from '../../../hooks/users.hooks'; -import { fullNamePipe } from '../../../utils/pipes'; +import { useMembersDropdown } from '../../../hooks/dropdowns.hooks'; const schema = yup.object().shape({ contacts: yup @@ -48,7 +47,7 @@ const UpdateOnboardingContactsModal: React.FC @@ -116,18 +115,25 @@ const UpdateOnboardingContactsModal: React.FC ( - user.userId)} - getOptionLabel={(option: string) => (option ? fullNamePipe(users.find((u) => u.userId === option)) : '')} - onChange={(_, newValue) => field.onChange(newValue)} - renderInput={(params) => ( - - )} - sx={{ minWidth: '300px' }} - /> - )} + render={({ field }) => { + const memberOptions = users.map((user) => ({ + id: user.userId, + label: `${user.firstName} ${user.lastName}` + })); + return ( + option.id === field.value) ?? null} + getOptionLabel={(option) => option.label} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_, newValue) => field.onChange(newValue?.id ?? '')} + renderInput={(params) => ( + + )} + sx={{ minWidth: '300px' }} + /> + ); + }} /> { +const UsefulLinksTable = ({ isOnGuestHomePage, isOnNewMemberDashboard, isOnOnboardingDashboard }: UsefulLinksTableProps) => { const currentUser = useCurrentUser(); const { data: links, @@ -54,17 +56,22 @@ const UsefulLinksTable = ({ isOnGuestHomePage }: UsefulLinksTableProps) => { setLinkToDelete(undefined); }; - const linkTypes = linkTypesBeforeFilter.filter((linkType) => - isOnGuestHomePage ? linkType.isOnGuestHomePage : !linkType.isOnGuestHomePage - ); + const matchesDashboard = (linkType?: { + isOnGuestHomePage: boolean; + isOnNewMemberDashboard: boolean; + isOnOnboardingDashboard: boolean; + }) => { + if (!linkType) return false; + if (isOnNewMemberDashboard) return linkType.isOnNewMemberDashboard; + if (isOnOnboardingDashboard) return linkType.isOnOnboardingDashboard; + if (isOnGuestHomePage) return linkType.isOnGuestHomePage; + return !linkType.isOnGuestHomePage && !linkType.isOnNewMemberDashboard && !linkType.isOnOnboardingDashboard; + }; - const usefulLinks = links.filter((link) => - isOnGuestHomePage ? link.linkType?.isOnGuestHomePage : !link.linkType?.isOnGuestHomePage - ); + const linkTypes = linkTypesBeforeFilter.filter(matchesDashboard); + + const usefulLinks = links.filter((link) => matchesDashboard(link.linkType)); - console.log('Links: ', links); - console.log('Links after filter: ', usefulLinks); - console.log('isOnGuestHomePage:', isOnGuestHomePage); return ( void; linkTypes: LinkType[]; isOnGuestHomePage?: boolean; + isOnNewMemberDashboard?: boolean; + isOnOnboardingDashboard?: boolean; } -const CreateLinkTypeModal = ({ open, handleClose, linkTypes, isOnGuestHomePage }: CreateLinkTypeModalProps) => { +const CreateLinkTypeModal = ({ + open, + handleClose, + linkTypes, + isOnGuestHomePage, + isOnNewMemberDashboard, + isOnOnboardingDashboard +}: CreateLinkTypeModalProps) => { const { isLoading, isError, error, mutateAsync } = useCreateLinkType(); if (isError) return ; @@ -24,6 +33,8 @@ const CreateLinkTypeModal = ({ open, handleClose, linkTypes, isOnGuestHomePage } onSubmit={mutateAsync} linkTypes={linkTypes} isOnGuestHomePage={isOnGuestHomePage} + isOnNewMemberDashboard={isOnNewMemberDashboard} + isOnOnboardingDashboard={isOnOnboardingDashboard} /> ); }; diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/EditLinkTypeModal.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/EditLinkTypeModal.tsx index a61600c5d3..b6cf826a5d 100644 --- a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/EditLinkTypeModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/EditLinkTypeModal.tsx @@ -25,6 +25,8 @@ const EditLinkTypeModal = ({ open, handleClose, linkType, linkTypes }: EditLinkT defaultValues={linkType} linkTypes={linkTypes} isOnGuestHomePage={linkType.isOnGuestHomePage} + isOnNewMemberDashboard={linkType.isOnNewMemberDashboard} + isOnOnboardingDashboard={linkType.isOnOnboardingDashboard} /> ); }; diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeFormModal.tsx index 2a75d7943b..d83b507495 100644 --- a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeFormModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeFormModal.tsx @@ -17,6 +17,8 @@ interface LinkTypeFormModalProps { onSubmit: (data: LinkTypeCreatePayload) => void; linkTypes: LinkType[]; isOnGuestHomePage?: boolean; + isOnNewMemberDashboard?: boolean; + isOnOnboardingDashboard?: boolean; } const LinkTypeFormModal = ({ @@ -25,7 +27,9 @@ const LinkTypeFormModal = ({ defaultValues, onSubmit, linkTypes, - isOnGuestHomePage + isOnGuestHomePage, + isOnNewMemberDashboard, + isOnOnboardingDashboard }: LinkTypeFormModalProps) => { const toast = useToast(); const creatingNew = defaultValues === undefined; @@ -40,7 +44,9 @@ const LinkTypeFormModal = ({ .test('unique-LinkType-test', 'LinkType name must be unique', uniqueLinkTypeTest), iconName: yup.string().required('Icon name is required'), required: yup.boolean().required('Required field must be specified'), - isOnGuestHomePage: yup.boolean().required('Guest page field must be specified') + isOnGuestHomePage: yup.boolean().required('Guest page field must be specified'), + isOnNewMemberDashboard: yup.boolean().required('New member dashboard field must be specified'), + isOnOnboardingDashboard: yup.boolean().required('Onboarding dashboard field must be specified') }); const theme = useTheme(); @@ -57,7 +63,9 @@ const LinkTypeFormModal = ({ name: defaultValues?.name ?? '', iconName: defaultValues?.iconName ?? '', required: defaultValues?.required ?? false, - isOnGuestHomePage: isOnGuestHomePage ?? false + isOnGuestHomePage: isOnGuestHomePage ?? false, + isOnNewMemberDashboard: isOnNewMemberDashboard ?? false, + isOnOnboardingDashboard: isOnOnboardingDashboard ?? false } }); @@ -98,7 +106,7 @@ const LinkTypeFormModal = ({ {errors.name?.message} - {!isOnGuestHomePage && ( + {!isOnGuestHomePage && !isOnNewMemberDashboard && !isOnOnboardingDashboard && ( Required diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeTable.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeTable.tsx index ec5cf130e0..e442b5b31c 100644 --- a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeTable.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeTable.tsx @@ -12,9 +12,11 @@ import { useCurrentUser } from '../../../../hooks/users.hooks'; interface LinkTypeTableProps { isOnGuestHomePage?: boolean; + isOnNewMemberDashboard?: boolean; + isOnOnboardingDashboard?: boolean; } -const LinkTypeTable = ({ isOnGuestHomePage }: LinkTypeTableProps) => { +const LinkTypeTable = ({ isOnGuestHomePage, isOnNewMemberDashboard, isOnOnboardingDashboard }: LinkTypeTableProps) => { const currentUser = useCurrentUser(); const { data: links, isLoading: linkTypeIsLoading, isError: linkTypeIsError, error: linkTypeError } = useAllLinkTypes(); const [createModalShow, setCreateModalShow] = useState(false); @@ -23,9 +25,12 @@ const LinkTypeTable = ({ isOnGuestHomePage }: LinkTypeTableProps) => { if (!links || linkTypeIsLoading) return ; if (linkTypeIsError) return ; - const linkTypes = links.filter((linkType) => - isOnGuestHomePage ? linkType.isOnGuestHomePage : !linkType.isOnGuestHomePage - ); + const linkTypes = links.filter((linkType) => { + if (isOnNewMemberDashboard) return linkType.isOnNewMemberDashboard; + if (isOnOnboardingDashboard) return linkType.isOnOnboardingDashboard; + if (isOnGuestHomePage) return linkType.isOnGuestHomePage; + return !linkType.isOnGuestHomePage && !linkType.isOnNewMemberDashboard && !linkType.isOnOnboardingDashboard; + }); const linkTypeTableRows = linkTypes.map((linkType, index) => ( { handleClose={() => setCreateModalShow(false)} linkTypes={linkTypes} isOnGuestHomePage={isOnGuestHomePage} + isOnNewMemberDashboard={isOnNewMemberDashboard} + isOnOnboardingDashboard={isOnOnboardingDashboard} /> {clickedLinkType && ( { - Milestones + Recruitment Milestones - + diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/CreateFaqFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/CreateFaqFormModal.tsx index 0b6ebe9786..8b5f5e499d 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/CreateFaqFormModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/CreateFaqFormModal.tsx @@ -1,6 +1,6 @@ import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; -import { useCreateFaq } from '../../../hooks/recruitment.hooks'; +import { useCreateRecruitingFaq } from '../../../hooks/recruitment.hooks'; import React from 'react'; import FaqFormModal from './FaqFormModal'; @@ -10,7 +10,7 @@ interface CreateFaqFormModalProps { } const CreateFaqFormModal = ({ open, handleClose }: CreateFaqFormModalProps) => { - const { isLoading, isError, error, mutateAsync } = useCreateFaq(); + const { isLoading, isError, error, mutateAsync } = useCreateRecruitingFaq(); if (isError) return ; if (isLoading) return ; diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/CreateMilestoneFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/CreateMilestoneFormModal.tsx index 6f52513c57..fd7d343642 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/CreateMilestoneFormModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/CreateMilestoneFormModal.tsx @@ -1,20 +1,23 @@ import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; -import { useCreateMilestone } from '../../../hooks/recruitment.hooks'; +import { MilestonePayload, useCreateMilestone } from '../../../hooks/recruitment.hooks'; import MilestoneFormModal from './MilestoneFormModal'; interface CreateMilestoneFormModalProps { open: boolean; handleClose: () => void; + createDefaults: { isOnNewMemberDashboard: boolean; isOnRecruitingDashboard: boolean }; } -const CreateMilestoneFormModal = ({ open, handleClose }: CreateMilestoneFormModalProps) => { +const CreateMilestoneFormModal = ({ open, handleClose, createDefaults }: CreateMilestoneFormModalProps) => { const { isLoading, isError, error, mutateAsync } = useCreateMilestone(); if (isError) return ; if (isLoading) return ; - return ; + const onSubmit = (data: MilestonePayload) => mutateAsync({ ...data, ...createDefaults }); + + return ; }; export default CreateMilestoneFormModal; diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/FAQTable.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/FAQTable.tsx index 75ea5ea0a6..2f5e8f64ad 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/FAQTable.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/FAQTable.tsx @@ -4,7 +4,7 @@ import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import { FrequentlyAskedQuestion } from 'shared'; import { NERButton } from '../../../components/NERButton'; -import { useAllFaqs, useDeleteFAQ } from '../../../hooks/recruitment.hooks'; +import { useRecruitingFaqs, useDeleteFAQ } from '../../../hooks/recruitment.hooks'; import LoadingIndicator from '../../../components/LoadingIndicator'; import { useHistoryState } from '../../../hooks/misc.hooks'; import ErrorPage from '../../ErrorPage'; @@ -20,7 +20,7 @@ const FAQsTable = () => { const { mutateAsync: deleteFaq } = useDeleteFAQ(); const toast = useToast(); - const { isLoading: faqsIsLoading, isError: faqsIsError, error: faqsError, data: faqs } = useAllFaqs(); + const { isLoading: faqsIsLoading, isError: faqsIsError, error: faqsError, data: faqs } = useRecruitingFaqs(); const handleDelete = (id: string) => { setFaqToDelete(undefined); try { diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx index 2226d409ad..6d81fdb8c8 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx @@ -1,19 +1,47 @@ -import { TableRow, TableCell, Box, Table as MuiTable, TableHead, TableBody, Typography, Button } from '@mui/material'; +import { + TableRow, + TableCell, + Box, + Table as MuiTable, + TableHead, + TableBody, + TableContainer, + Typography, + Button, + IconButton +} from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; -import { Milestone, formatDateOnly } from 'shared'; +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import { isAdmin, Milestone, formatDateOnly } from 'shared'; +import { UseQueryResult } from 'react-query'; import CreateMilestoneFormModal from './CreateMilestoneFormModal'; import EditMilestoneFormModal from './EditMilestoneFormModal'; import LoadingIndicator from '../../../components/LoadingIndicator'; import { useHistoryState } from '../../../hooks/misc.hooks'; -import { useAllMilestones, useDeleteMilestone } from '../../../hooks/recruitment.hooks'; +import { useDeleteMilestone } from '../../../hooks/recruitment.hooks'; +import { useCurrentUser } from '../../../hooks/users.hooks'; import ErrorPage from '../../ErrorPage'; import { NERButton } from '../../../components/NERButton'; import NERDeleteModal from '../../../components/NERDeleteModal'; import { useState } from 'react'; import { useToast } from '../../../hooks/toasts.hooks'; -const MilestoneTable = () => { +interface MilestoneTableProps { + useMilestones: () => UseQueryResult; + createDefaults: { isOnNewMemberDashboard: boolean; isOnRecruitingDashboard: boolean }; + addButtonLabel?: string; + /** 'recruitment' renders the red-header admin-tools table; 'onboarding' renders the dark, borderless widget-card table */ + variant?: 'recruitment' | 'onboarding'; +} + +const MilestoneTable = ({ + useMilestones, + createDefaults, + addButtonLabel = 'Add Milestone', + variant = 'recruitment' +}: MilestoneTableProps) => { + const currentUser = useCurrentUser(); const [createModalShow, setCreateModalShow] = useHistoryState('', false); const [milestoneEditing, setMilestoneEditing] = useHistoryState('', undefined); const { @@ -21,7 +49,13 @@ const MilestoneTable = () => { isError: milestonesIsError, error: milestonesError, data: milestones - } = useAllMilestones(); + } = useMilestones(); + const [milestoneToDelete, setMilestoneToDelete] = useState(undefined); + const { mutateAsync: deleteMilestone } = useDeleteMilestone(); + const toast = useToast(); + + if (milestonesIsError) return ; + if (milestonesIsLoading || !milestones) return ; const handleDelete = (id: string) => { setMilestoneToDelete(undefined); @@ -35,52 +69,20 @@ const MilestoneTable = () => { } }; - const [milestoneToDelete, setMilestoneToDelete] = useState(undefined); - const { mutateAsync: deleteMilestone } = useDeleteMilestone(); - const toast = useToast(); - - if (!milestones || milestonesIsLoading) return ; - if (milestonesIsError) return ; + const sortedMilestones = [...milestones].sort( + (a, b) => new Date(a.dateOfEvent).getTime() - new Date(b.dateOfEvent).getTime() + ); - const sortedMilestones = milestones.sort((a, b) => new Date(a.dateOfEvent).getTime() - new Date(b.dateOfEvent).getTime()); - const milestoneRows = sortedMilestones.map((milestone, index) => ( - - - {formatDateOnly(new Date(milestone.dateOfEvent))} - - - {milestone.name} - - - {milestone.description} - - - - - - - )); + const isOnboardingVariant = variant === 'onboarding'; + const showAddButton = isOnboardingVariant ? isAdmin(currentUser.role) : true; return ( - setCreateModalShow(false)} /> + setCreateModalShow(false)} + createDefaults={createDefaults} + /> {milestoneEditing && ( { milestone={milestoneEditing} /> )} - - - - - Date - - - Name - - + + + + Date + Name + Description + + + + + {sortedMilestones.map((milestone) => ( + setMilestoneEditing(milestone)} + sx={{ cursor: 'pointer' }} + > + + {formatDateOnly(new Date(milestone.dateOfEvent))} + + {milestone.name} + {milestone.description} + + { + event.stopPropagation(); + setMilestoneToDelete(milestone); + }} + > + + + + + ))} + + + + ) : ( + + + + + Date + + + Name + + + Description + + + + + {sortedMilestones.map((milestone, index) => ( + + + {formatDateOnly(new Date(milestone.dateOfEvent))} + + + {milestone.name} + + + {milestone.description} + + + + + + + ))} + + + )} + + {showAddButton && + (isOnboardingVariant ? ( + + ) : ( + setCreateModalShow(true)}> + {addButtonLabel} + + ))} ( + +); + +export default NewMemberMilestoneTable; diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/RecruitingMilestoneTable.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/RecruitingMilestoneTable.tsx new file mode 100644 index 0000000000..ad9ea7fb90 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/RecruitingMilestoneTable.tsx @@ -0,0 +1,12 @@ +import MilestoneTable from './MilestoneTable'; +import { useRecruitingMilestones } from '../../../hooks/recruitment.hooks'; + +const RecruitingMilestoneTable = () => ( + +); + +export default RecruitingMilestoneTable; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/AdminToolsScheduleConfig.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/AdminToolsScheduleConfig.tsx index 311f6d84f5..ad79728b53 100644 --- a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/AdminToolsScheduleConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/AdminToolsScheduleConfig.tsx @@ -20,6 +20,7 @@ import CreateCalendarModal from './Calendar/CreateCalendarModal'; import EditCalendarModal from './Calendar/EditCalendarModal'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; +import CheckIcon from '@mui/icons-material/Check'; import CreateMachineryModal from './Machinery/CreateMachineryModal'; import EditMachineryModal from './Machinery/EditMachineryModal'; import CreateEventTypeModal from './EventType/CreateEventTypeModal'; @@ -161,13 +162,16 @@ const AdminToolsScheduleConfig: React.FC = () => { Color + + New Member + {!calendars || !Array.isArray(calendars) || calendars.length === 0 ? ( - + No calendars yet. @@ -188,6 +192,9 @@ const AdminToolsScheduleConfig: React.FC = () => { }} /> + + {calendar.isNewMemberCalendar && } + diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CalendarModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CalendarModal.tsx index b9e5a3854c..92b5abc835 100644 --- a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CalendarModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CalendarModal.tsx @@ -1,10 +1,10 @@ import React, { useEffect } from 'react'; -import { Box, FormControl, FormHelperText, Typography } from '@mui/material'; +import { Box, Checkbox, FormControl, FormControlLabel, FormHelperText, Typography } from '@mui/material'; import NERFormModal from '../../../../components/NERFormModal'; import ReactHookTextField from '../../../../components/ReactHookTextField'; import ColorPickerInput from '../../../../components/ColorPickerInput'; import { useToast } from '../../../../hooks/toasts.hooks'; -import { useForm } from 'react-hook-form'; +import { useForm, Controller } from 'react-hook-form'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; import type { Calendar } from 'shared'; @@ -13,12 +13,14 @@ export interface CalendarFormValues { name: string; description: string; colorHexCode: string; + isNewMemberCalendar: boolean; } const schema = yup.object({ name: yup.string().required('Calendar Name is required'), description: yup.string().required('Description is required'), - colorHexCode: yup.string().required('Color is required') + colorHexCode: yup.string().required('Color is required'), + isNewMemberCalendar: yup.boolean().required() }); export interface BaseCalendarModalProps { @@ -40,21 +42,27 @@ const CalendarModal: React.FC = ({ open, onClose, onSubm formState: { errors } } = useForm({ resolver: yupResolver(schema), - defaultValues: { name: '', description: '', colorHexCode: '' } + defaultValues: { name: '', description: '', colorHexCode: '', isNewMemberCalendar: false } }); - const frozenValuesRef = React.useRef({ name: '', description: '', colorHexCode: '' }); + const frozenValuesRef = React.useRef({ + name: '', + description: '', + colorHexCode: '', + isNewMemberCalendar: false + }); useEffect(() => { if (open) { frozenValuesRef.current = { name: initialValues?.name ?? '', description: initialValues?.description ?? '', - colorHexCode: initialValues?.colorHexCode ?? '' + colorHexCode: initialValues?.colorHexCode ?? '', + isNewMemberCalendar: initialValues?.isNewMemberCalendar ?? false }; reset(frozenValuesRef.current); } else { - frozenValuesRef.current = { name: '', description: '', colorHexCode: '' }; + frozenValuesRef.current = { name: '', description: '', colorHexCode: '', isNewMemberCalendar: false }; reset(frozenValuesRef.current); } }, [open, initialValues, reset]); @@ -65,7 +73,7 @@ const CalendarModal: React.FC = ({ open, onClose, onSubm try { await onSubmit(data); onClose(); - reset({ name: '', description: '', colorHexCode: '' }); + reset({ name: '', description: '', colorHexCode: '', isNewMemberCalendar: false }); } catch (e: unknown) { if (e instanceof Error) toast.error(e.message); } @@ -82,10 +90,10 @@ const CalendarModal: React.FC = ({ open, onClose, onSubm open={open} onHide={() => { onClose(); - reset({ name: '', description: '', colorHexCode: '' }); + reset({ name: '', description: '', colorHexCode: '', isNewMemberCalendar: false }); }} title={computedTitle} - reset={() => reset({ name: '', description: '', colorHexCode: '' })} + reset={() => reset({ name: '', description: '', colorHexCode: '', isNewMemberCalendar: false })} handleUseFormSubmit={handleSubmit} onFormSubmit={onFormSubmit} formId="calendar-form" @@ -122,6 +130,16 @@ const CalendarModal: React.FC = ({ open, onClose, onSubm {errors.colorHexCode?.message} + + + ( + } label="New member calendar" /> + )} + /> + ); diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CreateCalendarModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CreateCalendarModal.tsx index a8fc428b31..2058a79734 100644 --- a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CreateCalendarModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CreateCalendarModal.tsx @@ -17,7 +17,8 @@ const CreateCalendarModal: React.FC = ({ open, onClose const result = await createCalendar({ name: data.name, description: data.description, - colorHexCode: data.colorHexCode + colorHexCode: data.colorHexCode, + isNewMemberCalendar: data.isNewMemberCalendar }); toast.success('Calendar created successfully'); return result; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/EditCalendarModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/EditCalendarModal.tsx index d4c4bbe7e6..4127115183 100644 --- a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/EditCalendarModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/EditCalendarModal.tsx @@ -17,7 +17,8 @@ const EditCalendarModal: React.FC = ({ open, onClose, ca const initialValues: CalendarFormValues = { name: calendar.name, description: calendar.description ?? '', - colorHexCode: calendar.color ?? '' + colorHexCode: calendar.color ?? '', + isNewMemberCalendar: calendar.isNewMemberCalendar }; const onSubmit = async (data: CalendarFormValues) => { @@ -25,7 +26,8 @@ const EditCalendarModal: React.FC = ({ open, onClose, ca const result = await editCalendar({ name: data.name, description: data.description, - colorHexCode: data.colorHexCode + colorHexCode: data.colorHexCode, + isNewMemberCalendar: data.isNewMemberCalendar }); toast.success('Calendar updated successfully'); return result; diff --git a/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx b/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx index cb5b7236e0..4d280fec68 100644 --- a/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx +++ b/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx @@ -49,6 +49,7 @@ interface CalendarDayCardProps { dayOfWeek?: DayOfWeek; onCreateEventClick: (date: Date) => void; tasks?: CalendarTask[]; + selectedEventId?: string; } // Constants for dynamic event display calculation @@ -64,7 +65,8 @@ const CalendarDayCard: React.FC = ({ calendars = [], dayOfWeek = DayOfWeek.MONDAY, onCreateEventClick, - tasks = [] + tasks = [], + selectedEventId }) => { const theme = useTheme(); @@ -109,6 +111,13 @@ const CalendarDayCard: React.FC = ({ return () => window.removeEventListener('resize', calculateMaxEvents); }, []); + // Open this event's tooltip if it's been deep-linked to via ?eventId= + useEffect(() => { + if (selectedEventId && events.some((event) => event.eventId === selectedEventId)) { + setLockedTooltipEventId(selectedEventId); + } + }, [selectedEventId, events]); + const { mutateAsync: deleteEvent } = useDeleteEvent(selectedEvent?.eventId ?? ''); const { mutateAsync: deleteScheduleSlot } = useDeleteScheduleSlot( selectedEvent?.eventId ?? '', diff --git a/src/frontend/src/pages/CalendarPage/CalendarPage.tsx b/src/frontend/src/pages/CalendarPage/CalendarPage.tsx index 7733cfe33e..e23f3c3b9a 100644 --- a/src/frontend/src/pages/CalendarPage/CalendarPage.tsx +++ b/src/frontend/src/pages/CalendarPage/CalendarPage.tsx @@ -97,6 +97,7 @@ interface NewCalendarPageProps { setDisplayMonthYear: (date: Date) => void; displayWeek: Date; setDisplayWeek: (date: Date) => void; + selectedEventId?: string; } const NewCalendarPage: React.FC = ({ @@ -109,7 +110,8 @@ const NewCalendarPage: React.FC = ({ displayMonthYear, setDisplayMonthYear, displayWeek, - setDisplayWeek + setDisplayWeek, + selectedEventId }) => { const theme = useTheme(); const history = useHistory(); @@ -609,6 +611,7 @@ const NewCalendarPage: React.FC = ({ }} onCreateEventClick={onCreateEventClick} tasks={showTasks && filteredTasks ? filteredTasks : []} + selectedEventId={selectedEventId} /> ) : ( <> @@ -674,6 +677,7 @@ const NewCalendarPage: React.FC = ({ dayOfWeek={dayDict.get(datePipe(cardDate)) ?? DayOfWeek.SUNDAY} onCreateEventClick={onCreateEventClick} tasks={taskDict.get(datePipe(cardDate)) ?? []} + selectedEventId={selectedEventId} /> ); diff --git a/src/frontend/src/pages/CalendarPage/CalendarTab.tsx b/src/frontend/src/pages/CalendarPage/CalendarTab.tsx index 4d959aa514..a7b9c0c2fe 100644 --- a/src/frontend/src/pages/CalendarPage/CalendarTab.tsx +++ b/src/frontend/src/pages/CalendarPage/CalendarTab.tsx @@ -3,20 +3,21 @@ import NewCalendarPage from './CalendarPage'; import PageLayout from '../../components/PageLayout'; import { Box, ToggleButton, ToggleButtonGroup } from '@mui/material'; import FullPageTabs from '../../components/FullPageTabs'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useCurrentUser } from '../../hooks/users.hooks'; import { ConflictStatus, isGuest, isHead, isLead } from 'shared'; -import { useAllCalendars, useAllEventTypes, useFilterEvents } from '../../hooks/calendar.hooks'; +import { useAllCalendars, useAllEventTypes, useFilterEvents, useSingleEvent } from '../../hooks/calendar.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; import { filterEventTransformer } from '../../apis/transformers/calendar.transformer'; import EventsTable from './EventsTable'; import CreateEventModal from './Components/CreateEventModal'; import CalendarCreateTaskModal from './Components/CalendarCreateTaskModal'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; import { NERButton } from '../../components/NERButton'; import { Add } from '@mui/icons-material'; import { eventsToEventInstances, getSundayOfWeek } from '../../utils/calendar.utils'; +import { useToast } from '../../hooks/toasts.hooks'; const CalendarTab: React.FC = () => { const [tabIndex, setTabIndex] = useState(0); @@ -31,8 +32,32 @@ const CalendarTab: React.FC = () => { const [createTaskDefaultDeadline, setCreateTaskDefaultDeadline] = useState(undefined); const user = useCurrentUser(); const history = useHistory(); + const location = useLocation(); + const toast = useToast(); const canViewReviews = isHead(user.role) || isLead(user.role); + const selectedEventId = new URLSearchParams(location.search).get('eventId') ?? undefined; + const { + data: selectedEvent, + isLoading: selectedEventIsLoading, + isError: selectedEventIsError, + error: selectedEventError + } = useSingleEvent(selectedEventId); + + useEffect(() => { + if (selectedEventIsError) { + toast.error(selectedEventError?.message ?? 'Failed to load the linked event'); + return; + } + if (selectedEventIsLoading || !selectedEvent) return; + const eventDate = selectedEvent.initialDateScheduled ?? selectedEvent.scheduledTimes[0]?.startTime; + if (!eventDate) return; + const date = new Date(eventDate); + setDisplayMonthYear(new Date(date.getFullYear(), date.getMonth(), 1)); + setDisplayWeek(getSundayOfWeek(date)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedEvent, selectedEventIsLoading, selectedEventIsError, selectedEventError]); + const handleViewModeToggle = (_: React.MouseEvent, newMode: 'month' | 'week' | null) => { if (!newMode || newMode === viewMode) return; if (newMode === 'week') { @@ -91,6 +116,14 @@ const CalendarTab: React.FC = () => { const yourEvents = untransformedYourEvents?.map(filterEventTransformer); const reviewEvents = untransformedReviewEvents?.map(filterEventTransformer); + if (yourEventsIsError) return ; + + if (reviewEventsIsError) return ; + + if (allEventTypesIsError) return ; + + if (allCalendarsIsError) return ; + if ( !yourEvents || yourEventsLoading || @@ -102,13 +135,6 @@ const CalendarTab: React.FC = () => { allCalendarsLoading ) return ; - if (yourEventsIsError) return ; - - if (reviewEventsIsError) return ; - - if (allEventTypesIsError) return ; - - if (allCalendarsIsError) return ; if (canViewReviews) tabs.push({ tabUrlValue: 'reviews', tabName: 'Review Bookings' }); @@ -191,6 +217,7 @@ const CalendarTab: React.FC = () => { setDisplayMonthYear={setDisplayMonthYear} displayWeek={displayWeek} setDisplayWeek={setDisplayWeek} + selectedEventId={selectedEventId} /> ) : ( void; onCreateEventClick: (date: Date, startTime?: Date, endTime?: Date) => void; tasks?: CalendarTask[]; + selectedEventId?: string; } // ─── Drag state ─────────────────────────────────────────────────────────────── @@ -151,7 +152,8 @@ const CalendarWeekView: React.FC = ({ displayWeek, onNavigateWeek, onCreateEventClick, - tasks = [] + tasks = [], + selectedEventId }) => { const theme = useTheme(); const user = useCurrentUser(); @@ -160,6 +162,15 @@ const CalendarWeekView: React.FC = ({ const [lockedTooltipEventId, setLockedTooltipEventId] = useState(null); const [selectedEvent, setSelectedEvent] = useState(null); + + // Open this event's tooltip if it's been deep-linked to via ?eventId= + useEffect(() => { + if (!selectedEventId) return; + const matchingInstance = eventInstances.find((event) => event.eventId === selectedEventId); + if (matchingInstance) { + setLockedTooltipEventId(matchingInstance.eventId + matchingInstance.scheduleSlotId); + } + }, [selectedEventId, eventInstances]); const [showEditModal, setShowEditModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const [showSeriesDeleteModal, setShowSeriesDeleteModal] = useState(false); diff --git a/src/frontend/src/pages/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 7439241e4a..f7c2a32907 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -6,30 +6,45 @@ import { Redirect, Route, Switch } from 'react-router-dom'; import { routes } from '../../utils/routes'; import PNMHomePage from './PNMHomePage'; import OnboardingHomePage from './OnboardingHomePage'; +import NewMemberHomePage from './NewMemberHomePage'; import SelectSubteamPage from './SelectSubteamPage'; -import AcceptedPage from '../AcceptedPage/AcceptedPage'; import HomePage from './HomePage'; import { useCurrentUser } from '../../hooks/users.hooks'; +import { useGetUsersTeams } from '../../hooks/teams.hooks'; import IntroGuestHomePage from './IntroGuestHomePage'; import { isAdmin, isGuest } from 'shared'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; const Home: React.FC = () => { const user = useCurrentUser(); + const { data: teams, isLoading: teamsIsLoading, isError: teamsIsError, error: teamsError } = useGetUsersTeams(); const onOnboarding = user.onboardingTeamTypeIds.length > 0; const completedOnboarding = user.onboardedTeamTypeIds.length > 0; + if (teamsIsError) return ; + if (teamsIsLoading || !teams) return ; + + // a new member stays on their own dashboard until they join a team -- the moment they're added + // (approval adds them to team.members immediately) they graduate to the standard dashboard + const isNewMember = completedOnboarding && isGuest(user.role) && teams.length === 0; + return ( {completedOnboarding && + !isNewMember && !isAdmin(user.role) && - [routes.HOME_PNM, routes.HOME_ONBOARDING, routes.HOME_ACCEPT].map((path) => ( - + [routes.HOME_PNM, routes.HOME_ONBOARDING, routes.HOME_NEW_MEMBER].map((path) => ( + ))} + {/* new members can still visit HOME_ONBOARDING to look back at what they completed */} + {isNewMember && } {onOnboarding && !completedOnboarding && } + {isNewMember && } - + {!onOnboarding && !completedOnboarding && isGuest(user.role) && ( diff --git a/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx b/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx new file mode 100644 index 0000000000..3c330d89c1 --- /dev/null +++ b/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx @@ -0,0 +1,78 @@ +/* + * 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 { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import PageLayout from '../../components/PageLayout'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import { useHomePageContext } from '../../app/HomePageContext'; +import { useCurrentOrganization } from '../../hooks/organizations.hooks'; +import { routes } from '../../utils/routes'; +import { NERButton } from '../../components/NERButton'; +import NewMemberOnboardingInfoSection from './components/NewMemberOnboardingInfoSection'; +import NewMemberMilestonesAndFAQsSection from './components/NewMemberMilestonesAndFAQsSection'; +import NewMemberUsefulLinksWidget from './components/NewMemberUsefulLinksWidget'; + +const NewMemberHomePage = () => { + const history = useHistory(); + const { setCurrentHomePage } = useHomePageContext(); + const { + data: organization, + isLoading: organizationIsLoading, + isError: organizationIsError, + error: organizationError + } = useCurrentOrganization(); + + useEffect(() => { + setCurrentHomePage('new-member'); + }, [setCurrentHomePage]); + + if (organizationIsError) { + return ; + } + + if (!organization || organizationIsLoading) { + return ; + } + + return ( + + + + Welcome to {organization.name} New Member Dashboard + + You're ready to become a member! Check out the resources below to get started. + + + + + + + + + + + + + + history.push(routes.HOME_ONBOARDING)}> + Click Me to View Your Completed Onboarding Checklist + + + + + + + + + + + + + ); +}; + +export default NewMemberHomePage; diff --git a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx index 7f6b9836a9..5a6e0fd855 100644 --- a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx +++ b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx @@ -4,8 +4,9 @@ import React, { useEffect, useState } from 'react'; import LoadingIndicator from '../../components/LoadingIndicator'; import { useHomePageContext } from '../../app/HomePageContext'; import ChecklistSection from './components/ChecklistSection'; -import OnboardingInfoSection from './components/OnboardingInfoSection'; +import NewMemberOnboardingInfoSection from './components/NewMemberOnboardingInfoSection'; import ConfirmOnboardingChecklistModal from './components/ConfirmOnboardingChecklistModal'; +import SetSlackIdModal from './components/SetSlackIdModal'; import { NERButton } from '../../components/NERButton'; import { useCheckedChecklists, useUsersChecklists, useChecklistProgress } from '../../hooks/onboarding.hook'; import { useHistory } from 'react-router-dom'; @@ -13,14 +14,33 @@ import { routes } from '../../utils/routes'; import { useCurrentOrganization } from '../../hooks/organizations.hooks'; import OnboardingProgressBar from '../../components/OnboardingProgressBar'; import ErrorPage from '../ErrorPage'; +import { useCompleteOnboarding } from '../../hooks/team-types.hooks'; +import { useAuth } from '../../hooks/auth.hooks'; +import { useCurrentUser } from '../../hooks/users.hooks'; +import { SlackIdGateProvider, useSlackIdGate } from './SlackIdGateContext'; -const OnboardingHomePage = () => { +const OnboardingHomePage = () => ( + + + +); + +const OnboardingHomePageContent = () => { const history = useHistory(); + const auth = useAuth(); + const user = useCurrentUser(); + const { hasSlackId, isLoading: slackIdIsLoading } = useSlackIdGate(); const [isModalOpen, setModalOpen] = useState(false); + const [isSlackIdModalOpen, setSlackIdModalOpen] = useState(false); const { setCurrentHomePage } = useHomePageContext(); const { data: organization, isLoading: organizationIsLoading } = useCurrentOrganization(); const theme = useTheme(); + // new members can revisit this page to look back at what they completed -- the "Finished?" + // button must not be clickable again, since completeOnboarding() would re-derive + // onboardedTeamTypeIds from onboardingTeamTypes (now empty) and wipe their completed status + const alreadyCompletedOnboarding = user.onboardedTeamTypeIds.length > 0; + useEffect(() => { setCurrentHomePage('onboarding'); }, [setCurrentHomePage]); @@ -41,6 +61,8 @@ const OnboardingHomePage = () => { const progress = useChecklistProgress(usersChecklists || [], checkedChecklists || []); + const { mutateAsync: completeOnboarding } = useCompleteOnboarding(); + if (usersChecklistsIsError) { return ; } @@ -60,7 +82,11 @@ const OnboardingHomePage = () => { return ; } - const handleOpenModal = () => { + const handleFinishedClick = () => { + if (!hasSlackId) { + setSlackIdModalOpen(true); + return; + } setModalOpen(true); }; @@ -68,20 +94,42 @@ const OnboardingHomePage = () => { setModalOpen(false); }; + const handleSlackIdSuccess = async () => { + setSlackIdModalOpen(false); + // they just had to set their Slack ID to get here, so there's nothing left to confirm -- + // skip the "are you sure?" modal and finish onboarding right away + await handleConfirmModal(); + }; + const handleConfirmModal = async () => { - history.push(routes.HOME_ACCEPT); + await completeOnboarding(); + // the logged-in user object is plain client state, not refetched automatically, + // so it needs to be refreshed here for Home.tsx's routing to see the completed onboarding status + await auth.signInCurrent(); + history.push(routes.HOME); }; return ( - - Welcome to the {organization.name} Team + + Welcome to {organization.name} Onboarding + {organization.onboardingText && ( + + {organization.onboardingText} + + )} - - - Finished? - + + {alreadyCompletedOnboarding ? ( + history.push(routes.HOME_NEW_MEMBER)}> + Back to New Member Dashboard + + ) : ( + + Finished? + + )} { - + @@ -144,6 +192,13 @@ const OnboardingHomePage = () => { title="Confirm Onboarding Checklist" /> )} + {isSlackIdModalOpen && ( + setSlackIdModalOpen(false)} + onSuccess={handleSlackIdSuccess} + /> + )} ); }; diff --git a/src/frontend/src/pages/HomePage/SlackIdGateContext.tsx b/src/frontend/src/pages/HomePage/SlackIdGateContext.tsx new file mode 100644 index 0000000000..8bd3458f3b --- /dev/null +++ b/src/frontend/src/pages/HomePage/SlackIdGateContext.tsx @@ -0,0 +1,33 @@ +/* + * 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 React, { createContext, useContext } from 'react'; +import { useCurrentUser, useSingleUserSettings } from '../../hooks/users.hooks'; + +interface SlackIdGateContextProps { + hasSlackId: boolean; + isLoading: boolean; +} + +const SlackIdGateContext = createContext(undefined); + +/** + * Tracks whether the current user has a Slack ID set, without rendering anything itself -- + * consumers decide what to do (e.g. show a popup) once they know the answer. + */ +export const SlackIdGateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const user = useCurrentUser(); + const { data: userSettings, isLoading } = useSingleUserSettings(user.userId); + const hasSlackId = !!userSettings?.slackId; + + return {children}; +}; + +export const useSlackIdGate = () => { + const context = useContext(SlackIdGateContext); + if (!context) { + throw new Error('useSlackIdGate must be used within a SlackIdGateProvider'); + } + return context; +}; diff --git a/src/frontend/src/pages/HomePage/components/Checklist.tsx b/src/frontend/src/pages/HomePage/components/Checklist.tsx index 342a27a586..79aa4b01c0 100644 --- a/src/frontend/src/pages/HomePage/components/Checklist.tsx +++ b/src/frontend/src/pages/HomePage/components/Checklist.tsx @@ -23,7 +23,7 @@ const Checklist: React.FC<{ - + {checklistName ?? 'General'} Checklist diff --git a/src/frontend/src/pages/HomePage/components/ChecklistSection.tsx b/src/frontend/src/pages/HomePage/components/ChecklistSection.tsx index ac4b35ac2c..9cb7560ce9 100644 --- a/src/frontend/src/pages/HomePage/components/ChecklistSection.tsx +++ b/src/frontend/src/pages/HomePage/components/ChecklistSection.tsx @@ -1,12 +1,8 @@ import React from 'react'; -import { Box, Grid, Typography, useTheme } from '@mui/material'; +import { Box, Grid, Typography } from '@mui/material'; import { groupChecklists } from '../../../utils/onboarding.utils'; import Checklist from './Checklist'; import { Checklist as ChecklistType } from 'shared'; -import { useCurrentOrganization } from '../../../hooks/organizations.hooks'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import ErrorPage from '../../ErrorPage'; -import { useGetImageUrl } from '../../../hooks/onboarding.hook'; interface ChecklistSectionProps { usersChecklists: ChecklistType[]; @@ -15,13 +11,6 @@ interface ChecklistSectionProps { const ChecklistSection: React.FC = ({ usersChecklists, checkedChecklists }) => { const groupedChecklists = groupChecklists(usersChecklists); - const theme = useTheme(); - - const { data: organization, isLoading, error, isError } = useCurrentOrganization(); - const { data: newMemberImageUrl } = useGetImageUrl(organization?.newMemberImageId ?? null); - - if (!organization || isLoading) return ; - if (isError) return ; return ( @@ -44,34 +33,6 @@ const ChecklistSection: React.FC = ({ usersChecklists, ch ))} - {newMemberImageUrl && ( - - - - New Member Events - - - - - )} {!usersChecklists.length && ( - - You sure you want to submit? - - - (After you submit, you will be officially onboarded into NER!) + (After you submit, you will be a new member!) diff --git a/src/frontend/src/pages/HomePage/components/Dropdown.tsx b/src/frontend/src/pages/HomePage/components/Dropdown.tsx index 192a7aa842..1e947fc13b 100644 --- a/src/frontend/src/pages/HomePage/components/Dropdown.tsx +++ b/src/frontend/src/pages/HomePage/components/Dropdown.tsx @@ -1,7 +1,8 @@ -import { Box, Accordion, AccordionSummary, Typography, AccordionDetails } from '@mui/material'; +import { Box, Accordion, AccordionSummary, AccordionDetails } from '@mui/material'; import { ChevronRight } from '@mui/icons-material'; import React, { useState } from 'react'; +import NERMarkdown from '../../../components/NERMarkdown'; interface DropdownProps { title: string; @@ -38,7 +39,9 @@ const Dropdown = ({ title, description }: DropdownProps) => { fontSize: 30 }} /> - {title} + + + { minHeight: '60px' }} > - {description} + + + diff --git a/src/frontend/src/pages/HomePage/components/FAQsSection.tsx b/src/frontend/src/pages/HomePage/components/FAQsSection.tsx index 041d7059bd..22ba3338ab 100644 --- a/src/frontend/src/pages/HomePage/components/FAQsSection.tsx +++ b/src/frontend/src/pages/HomePage/components/FAQsSection.tsx @@ -1,12 +1,12 @@ import { Box } from '@mui/system'; import LoadingIndicator from '../../../components/LoadingIndicator'; -import { useAllFaqs } from '../../../hooks/recruitment.hooks'; +import { useRecruitingFaqs } from '../../../hooks/recruitment.hooks'; import ErrorPage from '../../ErrorPage'; import Dropdown from './Dropdown'; import React from 'react'; const FAQsSection = () => { - const { isLoading, isError, error, data: faqs } = useAllFaqs(); + const { isLoading, isError, error, data: faqs } = useRecruitingFaqs(); if (isLoading || !faqs) return ; if (isError) return ; diff --git a/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx b/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx index 498631321f..fafefff222 100644 --- a/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx +++ b/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx @@ -43,7 +43,7 @@ const GuestOrganizationInfo = () => { if (!links || usefulLinksIsLoading || !linkTypes || linkTypesIsLoading) return ; if (usefulLinksIsError) return ; - const usefulLinks = links?.filter((link) => !link.linkType.isOnGuestHomePage); + const usefulLinks = links?.filter((link) => link.linkType.isOnGuestHomePage); return ( { + const theme = useTheme(); + const { + data: organization, + isLoading: organizationIsLoading, + isError: organizationIsError, + error: organizationError + } = useCurrentOrganization(); + + if (organizationIsError) return ; + if (!organization || organizationIsLoading) return ; + + return ( + + + Questions? + + Feel free to contact: + + {organization.contacts.map((contact) => { + return ( + + {contact.user.firstName} {contact.user.lastName} - {contact.title} + + ); + })} + + {organization.slackWorkspaceId && ( + + You can find them on{' '} + + Slack + + + )} + + ); +}; + +export default NewMemberContactsWidget; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx new file mode 100644 index 0000000000..a11ebbfb55 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx @@ -0,0 +1,115 @@ +/* + * 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 { useMemo, useState } from 'react'; +import { Box, Checkbox, FormControlLabel, FormGroup, Typography, useTheme } from '@mui/material'; +import { formatEventTime } from 'shared'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useNewMemberEvents } from '../../../hooks/calendar.hooks'; +import { useAllTeamTypes } from '../../../hooks/team-types.hooks'; +import { eventsToNextEventInstance } from '../../../utils/calendar.utils'; +import { datePipe } from '../../../utils/pipes'; + +const NewMemberEventsWidget: React.FC = () => { + const theme = useTheme(); + const { data: events, isLoading: eventsIsLoading, isError: eventsIsError, error: eventsError } = useNewMemberEvents(); + const { + data: teamTypes, + isLoading: teamTypesIsLoading, + isError: teamTypesIsError, + error: teamTypesError + } = useAllTeamTypes(); + const [selectedTeamTypeIds, setSelectedTeamTypeIds] = useState([]); + + const upcomingOccurrences = useMemo(() => { + const filteredEvents = + selectedTeamTypeIds.length === 0 + ? (events ?? []) + : (events ?? []).filter((event) => event.teamType && selectedTeamTypeIds.includes(event.teamType.teamTypeId)); + + return eventsToNextEventInstance(filteredEvents) + .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + .slice(0, 3); + }, [events, selectedTeamTypeIds]); + + const toggleTeamType = (teamTypeId: string) => { + setSelectedTeamTypeIds((prev) => + prev.includes(teamTypeId) ? prev.filter((id) => id !== teamTypeId) : [...prev, teamTypeId] + ); + }; + + if (eventsIsError) return ; + if (teamTypesIsError) return ; + if (eventsIsLoading || !events || teamTypesIsLoading || !teamTypes) return ; + + return ( + + + New Member Events + + + {teamTypes.length > 1 && ( + + {teamTypes.map((teamType) => ( + toggleTeamType(teamType.teamTypeId)} + /> + } + label={{teamType.name}} + /> + ))} + + )} + + {upcomingOccurrences.length === 0 ? ( + + No upcoming new member events + + ) : ( + + {upcomingOccurrences.map((event) => ( + + + {datePipe(event.startTime)} · {formatEventTime(new Date(event.startTime))} + + + {event.title} + + + {event.location ? event.location : event.zoomLink ? event.zoomLink : 'N/A'} + + + ))} + + )} + + ); +}; + +export default NewMemberEventsWidget; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberFAQsSection.tsx b/src/frontend/src/pages/HomePage/components/NewMemberFAQsSection.tsx new file mode 100644 index 0000000000..0155b8cd40 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberFAQsSection.tsx @@ -0,0 +1,32 @@ +import { Box, Typography } from '@mui/material'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useNewMemberFaqs } from '../../../hooks/recruitment.hooks'; +import ErrorPage from '../../ErrorPage'; +import Dropdown from './Dropdown'; +import React from 'react'; + +const NewMemberFAQsSection = () => { + const { isLoading, isError, error, data: faqs } = useNewMemberFaqs(); + + if (isError) return ; + + if (isLoading || !faqs) return ; + + if (faqs.length === 0) { + return ( + + No FAQs yet — check back soon. + + ); + } + + return ( + + {faqs.map((faq) => ( + + ))} + + ); +}; + +export default NewMemberFAQsSection; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberMilestonesAndFAQsSection.tsx b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesAndFAQsSection.tsx new file mode 100644 index 0000000000..b2d414815d --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesAndFAQsSection.tsx @@ -0,0 +1,21 @@ +/* + * 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 { useState } from 'react'; +import Tabs from '../../../components/Tabs'; +import NewMemberMilestonesWidget from './NewMemberMilestonesWidget'; +import NewMemberFAQsSection from './NewMemberFAQsSection'; + +const NewMemberMilestonesAndFAQsSection: React.FC = () => { + const [tabValue, setTabValue] = useState(0); + + const tabs = [ + { label: 'Milestones', component: }, + { label: 'FAQs', component: } + ]; + + return ; +}; + +export default NewMemberMilestonesAndFAQsSection; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx new file mode 100644 index 0000000000..35a92f47c9 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx @@ -0,0 +1,101 @@ +import { Grid, Typography, useTheme } from '@mui/material'; +import Timeline from '@mui/lab/Timeline'; +import TimelineItem from '@mui/lab/TimelineItem'; +import TimelineSeparator from '@mui/lab/TimelineSeparator'; +import TimelineConnector from '@mui/lab/TimelineConnector'; +import TimelineContent from '@mui/lab/TimelineContent'; +import TimelineDot from '@mui/lab/TimelineDot'; +import { formatDateOnly } from 'shared'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useNewMemberMilestones } from '../../../hooks/recruitment.hooks'; +import { isPastEvent } from '../../../utils/datetime.utils'; + +const NewMemberMilestonesWidget: React.FC = () => { + const theme = useTheme(); + const { data: milestones, isLoading, isError, error } = useNewMemberMilestones(); + + if (isError) return ; + if (isLoading || !milestones) return ; + + if (milestones.length === 0) { + return ( + + No onboarding milestones yet + + ); + } + + const sortedMilestones = milestones + .map((milestone) => ({ + ...milestone, + dateOfEvent: new Date(milestone.dateOfEvent) + })) + .sort((milestone1, milestone2) => (milestone1.dateOfEvent < milestone2.dateOfEvent ? -1 : 1)); + + const getDotStyle = (date: Date) => ({ + backgroundColor: isPastEvent(date, new Date()) ? 'primary.main' : 'grey', + width: '20px', + height: '20px' + }); + + const getConnectorStyle = (date: Date) => ({ + backgroundColor: isPastEvent(date, new Date()) ? 'primary.main' : 'grey', + flexGrow: 1 + }); + + return ( + + + {sortedMilestones.map((milestone, index) => ( + + + + {index < milestones.length - 1 && } + + + + {milestone.name} + + + {formatDateOnly(milestone.dateOfEvent, 'MMMM D, YYYY')} + + + {milestone.description} + + + + ))} + + + ); +}; + +export default NewMemberMilestonesWidget; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx b/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx new file mode 100644 index 0000000000..189fafd8b7 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx @@ -0,0 +1,34 @@ +import { Grid } from '@mui/material'; +import NewMemberEventsWidget from './NewMemberEventsWidget'; +import NewMemberUsefulLinksWidget from './NewMemberUsefulLinksWidget'; +import NewMemberContactsWidget from './NewMemberContactsWidget'; + +interface NewMemberOnboardingInfoSectionProps { + /** 'full' (default) shows every widget, for the new member dashboard. 'checklist' shows only + * useful links and contacts, for the onboarding checklist page. */ + variant?: 'full' | 'checklist'; +} + +const NewMemberOnboardingInfoSection: React.FC = ({ variant = 'full' }) => { + return ( + + {variant === 'full' && ( + + + + )} + {variant === 'checklist' && ( + <> + + + + + + + + )} + + ); +}; + +export default NewMemberOnboardingInfoSection; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberUsefulLinksWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberUsefulLinksWidget.tsx new file mode 100644 index 0000000000..56dbf130d5 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberUsefulLinksWidget.tsx @@ -0,0 +1,67 @@ +import { Box, Button, Grid, Typography, useTheme } from '@mui/material'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useAllUsefulLinks } from '../../../hooks/projects.hooks'; + +interface NewMemberUsefulLinksWidgetProps { + dashboardFlag?: 'isOnNewMemberDashboard' | 'isOnOnboardingDashboard'; +} + +const NewMemberUsefulLinksWidget: React.FC = ({ + dashboardFlag = 'isOnNewMemberDashboard' +}) => { + const theme = useTheme(); + const { data: usefulLinks, isLoading, isError, error } = useAllUsefulLinks(); + + if (isError) return ; + if (isLoading || !usefulLinks) return ; + + const links = usefulLinks.filter((link) => link.linkType[dashboardFlag]); + + return ( + + + Useful Links + + {links.length === 0 ? ( + + No useful links yet + + ) : ( + + {links.map((link) => ( + + + + ))} + + )} + + ); +}; + +export default NewMemberUsefulLinksWidget; diff --git a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx b/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx deleted file mode 100644 index f3cbadc225..0000000000 --- a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Grid, Typography, ListItem, List, useTheme, Button } from '@mui/material'; -import { Box } from '@mui/system'; -import { useCurrentOrganization } from '../../../hooks/organizations.hooks'; -import ErrorPage from '../../ErrorPage'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import OnboardingBlock from '../../AdminToolsPage/OnboardingConfig/OnboardingBlock'; -import { useAllUsefulLinks } from '../../../hooks/projects.hooks'; - -const OnboardingInfoSection: React.FC = () => { - const theme = useTheme(); - const { - data: organization, - isLoading: organizationIsLoading, - isError: organizationIsError, - error: organizationError - } = useCurrentOrganization(); - - const { data: usefulLinks, isError: linksIsError, error: linksError, isLoading: linksIsLoading } = useAllUsefulLinks(); - - if (organizationIsError) { - return ; - } - - if (linksIsError) return ; - - if (!organization || organizationIsLoading || !usefulLinks || linksIsLoading) return ; - - const links = usefulLinks?.filter((link) => !link.linkType.isOnGuestHomePage); - - return ( - - - - - - Useful Links - - - {links.map((link) => { - return ( - - - - ); - })} - - - - - - - Questions? - - Feel free to contact: - - {organization.contacts.map((contact) => { - return ( - - {contact.user.firstName} {contact.user.lastName}: {contact.user.email} - {contact.title} - - ); - })} - - - - - ); -}; - -export default OnboardingInfoSection; diff --git a/src/frontend/src/pages/HomePage/components/ParentTask.tsx b/src/frontend/src/pages/HomePage/components/ParentTask.tsx index 0d392d9933..49db013f0c 100644 --- a/src/frontend/src/pages/HomePage/components/ParentTask.tsx +++ b/src/frontend/src/pages/HomePage/components/ParentTask.tsx @@ -1,9 +1,10 @@ -import { Typography, Box, IconButton, Checkbox, Tooltip } from '@mui/material'; +import { Box, IconButton, Checkbox, Tooltip } from '@mui/material'; import { useState } from 'react'; import { KeyboardArrowRight, KeyboardArrowDown } from '@mui/icons-material'; import SubtaskSection from './SubtaskSection'; import { Checklist } from 'shared'; import { isChecklistChecked } from '../../../utils/onboarding.utils'; +import NERMarkdown from '../../../components/NERMarkdown'; interface ParentTaskProps { parentTask: Checklist; @@ -53,7 +54,9 @@ const ParentTask: React.FC = ({ parentTask, checkedChecklists } /> - {parentTask.content} + + + {showSubtasks ? : } diff --git a/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx b/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx index 3bce478974..febcdb7f20 100644 --- a/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx +++ b/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx @@ -30,7 +30,7 @@ const ScrollablePageBlock: React.FC = ({ children, tit }} > {title && ( - + {title} )} diff --git a/src/frontend/src/pages/HomePage/components/SetSlackIdModal.tsx b/src/frontend/src/pages/HomePage/components/SetSlackIdModal.tsx new file mode 100644 index 0000000000..bc30b1d005 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/SetSlackIdModal.tsx @@ -0,0 +1,74 @@ +/* + * 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 { useState } from 'react'; +import { Box, TextField, Typography } from '@mui/material'; +import { isValidSlackUserIdFormat } from 'shared'; +import NERModal from '../../../components/NERModal'; +import ExternalLink from '../../../components/ExternalLink'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { useCurrentUser, useSingleUserSettings, useUpdateUserSettings } from '../../../hooks/users.hooks'; + +interface SetSlackIdModalProps { + open: boolean; + onHide: () => void; + onSuccess: () => void; +} + +const SetSlackIdModal: React.FC = ({ open, onHide, onSuccess }) => { + const toast = useToast(); + const user = useCurrentUser(); + const { data: userSettings } = useSingleUserSettings(user.userId); + const { mutateAsync, isLoading } = useUpdateUserSettings(); + const [slackId, setSlackId] = useState(''); + const [formatError, setFormatError] = useState(false); + + const handleSubmit = async () => { + if (!isValidSlackUserIdFormat(slackId)) { + setFormatError(true); + return; + } + if (!userSettings) return; + + try { + await mutateAsync({ ...userSettings, slackId }); + onSuccess(); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + return ( + + + The last step before finishing onboarding is to set your Slack ID. + + { + setSlackId(e.target.value); + setFormatError(false); + }} + error={formatError} + helperText={formatError ? "That doesn't look like a valid Slack ID" : undefined} + /> + + + ); +}; + +export default SetSlackIdModal; diff --git a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx index 74292b5dfd..eac16ab3ff 100644 --- a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx +++ b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx @@ -16,7 +16,6 @@ import NERSuccessButton from '../../../components/NERSuccessButton'; import ReactHookTextField from '../../../components/ReactHookTextField'; import { useToast } from '../../../hooks/toasts.hooks'; import { useUpdateUserSettings } from '../../../hooks/users.hooks'; -import ErrorPage from '../../ErrorPage'; interface SetUserPreferencesProps { userSettings: UserSettings; @@ -24,13 +23,12 @@ interface SetUserPreferencesProps { const SetUserPreferences: React.FC = ({ userSettings }) => { const toast = useToast(); - const { mutateAsync, isLoading, isError, error } = useUpdateUserSettings(); + const { mutateAsync, isLoading } = useUpdateUserSettings(); const { handleSubmit, control } = useForm<{ slackId: string }>({ defaultValues: { slackId: userSettings.slackId } }); if (isLoading) return ; - if (isError) return ; const onSubmit = async ({ slackId }: { slackId: string }) => { try { diff --git a/src/frontend/src/pages/HomePage/components/SubtaskSection.tsx b/src/frontend/src/pages/HomePage/components/SubtaskSection.tsx index cc6f19ba95..e21e413cb8 100644 --- a/src/frontend/src/pages/HomePage/components/SubtaskSection.tsx +++ b/src/frontend/src/pages/HomePage/components/SubtaskSection.tsx @@ -1,4 +1,4 @@ -import { Typography, useTheme, IconButton } from '@mui/material'; +import { useTheme, IconButton } from '@mui/material'; import Checkbox from '@mui/material/Checkbox'; import { Box } from '@mui/system'; import React from 'react'; @@ -84,9 +84,9 @@ const SubtaskSection: React.FC = ({ parentTask, checkedChec /> )} - - {item.content} {item.isOptional && '(Optional)'} - + + + ); } diff --git a/src/frontend/src/pages/HomePage/components/TimelineSection.tsx b/src/frontend/src/pages/HomePage/components/TimelineSection.tsx index ad3ddf4518..35b32ee111 100644 --- a/src/frontend/src/pages/HomePage/components/TimelineSection.tsx +++ b/src/frontend/src/pages/HomePage/components/TimelineSection.tsx @@ -6,17 +6,17 @@ import TimelineSeparator from '@mui/lab/TimelineSeparator'; import TimelineConnector from '@mui/lab/TimelineConnector'; import TimelineContent from '@mui/lab/TimelineContent'; import TimelineDot from '@mui/lab/TimelineDot'; -import { useAllMilestones } from '../../../hooks/recruitment.hooks'; +import { useRecruitingMilestones } from '../../../hooks/recruitment.hooks'; import LoadingIndicator from '../../../components/LoadingIndicator'; import ErrorPage from '../../ErrorPage'; import { isPastEvent } from '../../../utils/datetime.utils'; import { formatDateOnly } from 'shared'; const TimelineSection = () => { - const { isLoading, isError, error, data: milestones } = useAllMilestones(); + const { isLoading, isError, error, data: milestones } = useRecruitingMilestones(); - if (isLoading || !milestones) return ; if (isError) return ; + if (isLoading || !milestones) return ; const sortedMilestones = milestones .map((milestone) => ({ diff --git a/src/frontend/src/pages/TeamsPage/RequestToJoinButton.tsx b/src/frontend/src/pages/TeamsPage/RequestToJoinButton.tsx new file mode 100644 index 0000000000..103f500c5a --- /dev/null +++ b/src/frontend/src/pages/TeamsPage/RequestToJoinButton.tsx @@ -0,0 +1,80 @@ +/* + * 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 { Chip, Tooltip } from '@mui/material'; +import { isGuest, TeamPreview } from 'shared'; +import { NERButton } from '../../components/NERButton'; +import { useCurrentUser } from '../../hooks/users.hooks'; +import { useCreateTeamJoinRequest, useMyTeamJoinRequests } from '../../hooks/teams.hooks'; +import { useToast } from '../../hooks/toasts.hooks'; +import LoadingIndicator from '../../components/LoadingIndicator'; + +interface RequestToJoinButtonProps { + team: TeamPreview; +} + +const RequestToJoinButton: React.FC = ({ team }) => { + const user = useCurrentUser(); + const toast = useToast(); + const { data: joinRequests, isLoading: joinRequestsIsLoading } = useMyTeamJoinRequests(); + const { mutateAsync, isLoading: createIsLoading } = useCreateTeamJoinRequest(team.teamId); + + const isAlreadyOnTeam = + user.userId === team.head.userId || + team.leads.some((lead) => lead.userId === user.userId) || + team.members.some((member) => member.userId === user.userId); + + // guests who haven't finished onboarding yet (PNMs, or currently going through the checklist) + // aren't "new members" yet and shouldn't be able to request a team -- current members (of any + // role) and guests who've reached the new member dashboard both can + const isPreOnboardingGuest = isGuest(user.role) && user.onboardedTeamTypeIds.length === 0; + + if (isAlreadyOnTeam || team.dateArchived || isPreOnboardingGuest) return null; + + if (joinRequestsIsLoading || !joinRequests) return ; + + const latestRequest = joinRequests + .filter((request) => request.team.teamId === team.teamId) + .reduce< + (typeof joinRequests)[number] | undefined + >((latest, request) => (!latest || request.dateRequested > latest.dateRequested ? request : latest), undefined); + + const handleRequest = async () => { + try { + await mutateAsync(); + toast.success(`Request sent to join ${team.teamName}`); + } catch (error: unknown) { + if (error instanceof Error) toast.error(error.message); + } + }; + + if (latestRequest?.status === 'PENDING') { + return ; + } + + const button = ( + + Request to Join + + ); + + if (latestRequest?.status === 'DENIED') { + return ( + + {button} + + ); + } + + return button; +}; + +export default RequestToJoinButton; diff --git a/src/frontend/src/pages/TeamsPage/TeamJoinRequestsPageBlock.tsx b/src/frontend/src/pages/TeamsPage/TeamJoinRequestsPageBlock.tsx new file mode 100644 index 0000000000..12fd85c828 --- /dev/null +++ b/src/frontend/src/pages/TeamsPage/TeamJoinRequestsPageBlock.tsx @@ -0,0 +1,135 @@ +/* + * 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, TextField, Typography } from '@mui/material'; +import { useState } from 'react'; +import { isAdmin, Team } from 'shared'; +import PageBlock from '../../layouts/PageBlock'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import NERModal from '../../components/NERModal'; +import { NERButton } from '../../components/NERButton'; +import { useCurrentUser } from '../../hooks/users.hooks'; +import { usePendingTeamJoinRequests, useReviewTeamJoinRequest } from '../../hooks/teams.hooks'; +import { useToast } from '../../hooks/toasts.hooks'; +import { fullNamePipe } from '../../utils/pipes'; + +interface TeamJoinRequestsPageBlockProps { + team: Team; +} + +const TeamJoinRequestsPageBlock: React.FC = ({ team }) => { + const user = useCurrentUser(); + const toast = useToast(); + const [denyingRequestId, setDenyingRequestId] = useState(null); + const [denialReason, setDenialReason] = useState(''); + + const { + data: joinRequests, + isLoading: joinRequestsIsLoading, + isError: joinRequestsIsError, + error: joinRequestsError + } = usePendingTeamJoinRequests(team.teamId); + const { mutateAsync: reviewRequest, isLoading: reviewIsLoading } = useReviewTeamJoinRequest(); + + // only admins and the team head can review join requests -- team leads cannot + const canReviewJoinRequests = isAdmin(user.role) || user.userId === team.head.userId; + + if (!canReviewJoinRequests) return null; + + if (joinRequestsIsError) return ; + if (joinRequestsIsLoading || !joinRequests) return ; + + const handleApprove = async (teamJoinRequestId: string) => { + try { + await reviewRequest({ teamJoinRequestId, approved: true }); + toast.success('Join request approved'); + } catch (error: unknown) { + if (error instanceof Error) toast.error(error.message); + } + }; + + const handleOpenDeny = (teamJoinRequestId: string) => { + setDenialReason(''); + setDenyingRequestId(teamJoinRequestId); + }; + + const handleDeny = async () => { + if (!denyingRequestId) return; + try { + await reviewRequest({ teamJoinRequestId: denyingRequestId, approved: false, denialReason: denialReason || undefined }); + toast.success('Join request denied'); + setDenyingRequestId(null); + } catch (error: unknown) { + if (error instanceof Error) toast.error(error.message); + } + }; + + return ( + + {joinRequests.length === 0 ? ( + No pending join requests + ) : ( + + {joinRequests.map((request) => ( + + + {fullNamePipe(request.user)} + + handleApprove(request.teamJoinRequestId)} + > + Approve + + handleOpenDeny(request.teamJoinRequestId)} + > + Deny + + + + + ))} + + )} + setDenyingRequestId(null)} + title="Deny Join Request" + submitText="Deny" + onSubmit={handleDeny} + cancelText="Cancel" + > + You may optionally provide a reason the requesting member will see. + setDenialReason(e.target.value)} + /> + + + ); +}; + +export default TeamJoinRequestsPageBlock; diff --git a/src/frontend/src/pages/TeamsPage/TeamSpecificPage.tsx b/src/frontend/src/pages/TeamsPage/TeamSpecificPage.tsx index 02f859dac0..19e0c887c7 100644 --- a/src/frontend/src/pages/TeamsPage/TeamSpecificPage.tsx +++ b/src/frontend/src/pages/TeamsPage/TeamSpecificPage.tsx @@ -2,6 +2,8 @@ import { Box, Grid, ListItemIcon, Menu, MenuItem, Stack, Typography } from '@mui import { useArchiveTeam, useSingleTeam } from '../../hooks/teams.hooks'; import { useParams } from 'react-router-dom'; import TeamMembersPageBlock from './TeamMembersPageBlock'; +import TeamJoinRequestsPageBlock from './TeamJoinRequestsPageBlock'; +import RequestToJoinButton from './RequestToJoinButton'; import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; import PageBlock from '../../layouts/PageBlock'; @@ -87,7 +89,13 @@ const TeamSpecificPage: React.FC = () => { ); const SetDivisionButton = () => ( - setShowTeamTypeModal(true)} disabled={!isAdmin(user.role)}> + setShowTeamTypeModal(true)} + disabled={!isAdmin(user.role)} + sx={{ whiteSpace: 'nowrap' }} + > Set Division ); @@ -108,11 +116,23 @@ const TeamSpecificPage: React.FC = () => { const AttendanceButton = () => ongoingAttendance ? ( - setShowCloseAttendanceConfirm(true)} disabled={!isAttendanceAuthorized}> + setShowCloseAttendanceConfirm(true)} + disabled={!isAttendanceAuthorized} + sx={{ whiteSpace: 'nowrap' }} + > Close Attendance ) : ( - setShowTakeAttendanceModal(true)} disabled={!isAttendanceAuthorized}> + setShowTakeAttendanceModal(true)} + disabled={!isAttendanceAuthorized} + sx={{ whiteSpace: 'nowrap' }} + > Take Attendance ); @@ -145,9 +165,11 @@ const TeamSpecificPage: React.FC = () => { } variant="contained" + size="medium" id="project-actions-dropdown" onClick={handleClick} disabled={isGuest(user.role)} + sx={{ whiteSpace: 'nowrap' }} > Actions @@ -180,7 +202,8 @@ const TeamSpecificPage: React.FC = () => { return ( + + {TeamActionsDropdown} @@ -194,15 +217,12 @@ const TeamSpecificPage: React.FC = () => { ) : null } - previousPages={ - isGuest(user.role) && data.teamType - ? [{ name: data.teamType.name, route: `${routes.TEAMS}/${data.teamType.teamTypeId}` }] - : [{ name: 'Teams', route: routes.TEAMS }] - } + previousPages={[{ name: 'Teams', route: routes.TEAMS }]} > + {data.projects diff --git a/src/frontend/src/pages/TeamsPage/Teams.tsx b/src/frontend/src/pages/TeamsPage/Teams.tsx index 80098b4ab5..d3fe34495d 100644 --- a/src/frontend/src/pages/TeamsPage/Teams.tsx +++ b/src/frontend/src/pages/TeamsPage/Teams.tsx @@ -25,18 +25,24 @@ const TeamOrDivisionPage: React.FC = () => { if (isTeamsError) return ; if (teamsLoading || !teamTypes) return ; - if (isGuest(user.role)) { - if (teamTypes?.some((t) => t.teamTypeId === teamId)) { - return ; - } - return ; + // a teamTypeId (division) in the URL always means "show that division's team list", regardless + // of the viewer's onboarding status -- this can never be a valid team id + if (teamTypes.some((teamType) => teamType.teamTypeId === teamId)) { + return ; } + + // guests who've already finished onboarding are "new members" -- they get the full teams + // experience (including the ability to request to join a team), not the limited guest preview + const isPreOnboardingGuest = isGuest(user.role) && user.onboardedTeamTypeIds.length === 0; + + if (isPreOnboardingGuest) return ; return ; }; const GuestOrMemberTeamsPage: React.FC = () => { const user = useCurrentUser(); - if (isGuest(user.role)) return ; + const isPreOnboardingGuest = isGuest(user.role) && user.onboardedTeamTypeIds.length === 0; + if (isPreOnboardingGuest) return ; return ; }; diff --git a/src/frontend/src/tests/app/AppAuthenticated.test.tsx b/src/frontend/src/tests/app/AppAuthenticated.test.tsx index 33d961cbb4..0d38fc4b11 100644 --- a/src/frontend/src/tests/app/AppAuthenticated.test.tsx +++ b/src/frontend/src/tests/app/AppAuthenticated.test.tsx @@ -30,7 +30,7 @@ const renderComponent = (path?: string, route?: string) => { const RouterWrapper = routerWrapperBuilder({ path, route }); return render( - + ); }; diff --git a/src/frontend/src/tests/pages/HomePage/Home.test.tsx b/src/frontend/src/tests/pages/HomePage/Home.test.tsx index 940067d840..e61fffdfe1 100644 --- a/src/frontend/src/tests/pages/HomePage/Home.test.tsx +++ b/src/frontend/src/tests/pages/HomePage/Home.test.tsx @@ -8,10 +8,15 @@ import { routes } from '../../../utils/routes'; import Home from '../../../pages/HomePage/Home'; import * as authHooks from '../../../hooks/auth.hooks'; import * as userHooks from '../../../hooks/users.hooks'; +import * as teamsHooks from '../../../hooks/teams.hooks'; import { exampleAdminUser } from '../../test-support/test-data/users.stub'; import { mockAuth } from '../../test-support/test-data/test-utils.stub'; -import { mockUseSingleUserSettings } from '../../test-support/mock-hooks'; -import { exampleAuthenticatedAdminUser } from '../../test-support/test-data/authenticated-user.stub'; +import { mockUseSingleUserSettings, mockUseGetUsersTeams } from '../../test-support/mock-hooks'; +import { + exampleAuthenticatedAdminUser, + exampleAuthenticatedNewMemberUser +} from '../../test-support/test-data/authenticated-user.stub'; +import { exampleTeam } from '../../test-support/test-data/teams.stub'; vi.mock('../../../app/AppGlobalCarFilterContext', () => ({ useGlobalCarFilter: () => ({ @@ -50,6 +55,15 @@ vi.mock('../../../pages/HomePage/components/WorkPackagesByTimelineStatus', () => }; }); +vi.mock('../../../pages/HomePage/NewMemberHomePage', () => { + return { + __esModule: true, + default: () => { + return
new-member-home
; + } + }; +}); + /** * Sets up the component under test with the desired values and renders it. */ @@ -67,6 +81,7 @@ describe('home component', () => { vi.spyOn(authHooks, 'useAuth').mockReturnValue(mockAuth(false, exampleAuthenticatedAdminUser)); vi.spyOn(userHooks, 'useCurrentUser').mockReturnValue(exampleAuthenticatedAdminUser); vi.spyOn(userHooks, 'useSingleUserSettings').mockReturnValue(mockUseSingleUserSettings()); + vi.spyOn(teamsHooks, 'useGetUsersTeams').mockReturnValue(mockUseGetUsersTeams()); }); afterAll(() => vi.clearAllMocks()); @@ -75,4 +90,22 @@ describe('home component', () => { renderComponent(); expect(screen.getByText(`Welcome, ${exampleAdminUser.firstName}!`)).toBeInTheDocument(); }); + + it('renders the new member dashboard for a completed-onboarding guest who has not joined a team', () => { + vi.spyOn(userHooks, 'useCurrentUser').mockReturnValue(exampleAuthenticatedNewMemberUser); + vi.spyOn(teamsHooks, 'useGetUsersTeams').mockReturnValue(mockUseGetUsersTeams([])); + + renderComponent(); + + expect(screen.getByText('new-member-home')).toBeInTheDocument(); + }); + + it('renders the standard dashboard once a completed-onboarding guest has joined a team', () => { + vi.spyOn(userHooks, 'useCurrentUser').mockReturnValue(exampleAuthenticatedNewMemberUser); + vi.spyOn(teamsHooks, 'useGetUsersTeams').mockReturnValue(mockUseGetUsersTeams([exampleTeam])); + + renderComponent(); + + expect(screen.queryByText('new-member-home')).not.toBeInTheDocument(); + }); }); diff --git a/src/frontend/src/tests/test-support/mock-hooks.ts b/src/frontend/src/tests/test-support/mock-hooks.ts index 5683a216e5..36d466a41a 100644 --- a/src/frontend/src/tests/test-support/mock-hooks.ts +++ b/src/frontend/src/tests/test-support/mock-hooks.ts @@ -8,6 +8,8 @@ import { Task, TaskPriority, TaskStatus, + Team, + TeamJoinRequest, UserSettings, UserWithRole, WorkPackage @@ -54,6 +56,11 @@ export const mockUseSingleUserSettings = (settings?: UserSettings) => export const mockUseUsersFavoriteProjects = (projects?: Project[]) => mockUseQueryResult(false, false, projects || [], new Error()); +export const mockUseGetUsersTeams = (teams?: Team[]) => mockUseQueryResult(false, false, teams || [], new Error()); + +export const mockUseMyTeamJoinRequests = (joinRequests?: TeamJoinRequest[]) => + mockUseQueryResult(false, false, joinRequests || [], new Error()); + export const mockEditProjectReturnValue = mockUseMutationResult( false, false, diff --git a/src/frontend/src/tests/test-support/test-data/authenticated-user.stub.ts b/src/frontend/src/tests/test-support/test-data/authenticated-user.stub.ts index 6f47f7937d..5129e26ce9 100644 --- a/src/frontend/src/tests/test-support/test-data/authenticated-user.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/authenticated-user.stub.ts @@ -37,3 +37,14 @@ export const exampleAuthenticatedMemberUser: AuthenticatedUser = { onboardingTeamTypeIds: [], onboardedTeamTypeIds: [] }; + +export const exampleAuthenticatedNewMemberUser: AuthenticatedUser = { + userId: '7', + firstName: 'New', + lastName: 'Member', + email: 'newmember@ner.edu', + role: RoleEnum.GUEST, + organizations: ['baz'], + onboardingTeamTypeIds: [], + onboardedTeamTypeIds: ['team-type-1'] +}; diff --git a/src/frontend/src/tests/test-support/test-data/projects.stub.ts b/src/frontend/src/tests/test-support/test-data/projects.stub.ts index 2cd8e0fa4c..1288541dcd 100644 --- a/src/frontend/src/tests/test-support/test-data/projects.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/projects.stub.ts @@ -14,21 +14,27 @@ const exampleConfluenceLinkType: LinkType = { name: 'Confluence', iconName: 'confluence', required: true, - isOnGuestHomePage: false + isOnGuestHomePage: false, + isOnNewMemberDashboard: false, + isOnOnboardingDashboard: false }; const exampleBomLinkType: LinkType = { name: 'BOM', iconName: 'bom', required: true, - isOnGuestHomePage: false + isOnGuestHomePage: false, + isOnNewMemberDashboard: false, + isOnOnboardingDashboard: false }; const exampleGDriveLinkType: LinkType = { name: 'Google Drive', iconName: 'google-drive', required: true, - isOnGuestHomePage: false + isOnGuestHomePage: false, + isOnNewMemberDashboard: false, + isOnOnboardingDashboard: false }; const exampleLinks: Link[] = [ diff --git a/src/frontend/src/utils/routes.ts b/src/frontend/src/utils/routes.ts index 8210e76e38..daa2ba3efe 100644 --- a/src/frontend/src/utils/routes.ts +++ b/src/frontend/src/utils/routes.ts @@ -16,9 +16,9 @@ const CREDITS = `/credits`; const HOME = `/home`; const HOME_PNM = HOME + `/pnm`; const HOME_SELECT_SUBTEAM = HOME + `/select-subteam`; -const HOME_ACCEPT = HOME + `/accept`; const HOME_MEMBER = HOME + `/member`; const HOME_ONBOARDING = HOME + `/onboarding`; +const HOME_NEW_MEMBER = HOME + `/new-member`; /**************** Finance Section ****************/ const FINANCE = `/finance`; @@ -94,7 +94,7 @@ export const routes = { HOME_PNM, HOME_SELECT_SUBTEAM, HOME_ONBOARDING, - HOME_ACCEPT, + HOME_NEW_MEMBER, HOME_MEMBER, TEAMS, diff --git a/src/frontend/src/utils/teams.utils.ts b/src/frontend/src/utils/teams.utils.ts index 6487ca9124..3cf58a53a8 100644 --- a/src/frontend/src/utils/teams.utils.ts +++ b/src/frontend/src/utils/teams.utils.ts @@ -50,6 +50,7 @@ export type SubmitText = | 'Accept' | 'Send' | 'Close Attendance' - | 'Copy BOM'; + | 'Copy BOM' + | 'Deny'; export type CancelText = 'Cancel' | 'Delete' | 'Exit' | 'No'; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 1305b98b8d..e0092f60c9 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -166,6 +166,10 @@ const teamTypesCreate = () => `${teamTypes()}/create`; const teamTypeEdit = (id: string) => `${teamTypes()}/${id}/edit`; const teamTypeSetImage = (id: string) => `${teamTypes()}/${id}/set-image`; const myTeamAsHead = () => `${teams()}/my-team-as-head`; +const myTeamJoinRequests = () => `${teams()}/join-requests/mine`; +const teamsPendingJoinRequests = (id: string) => `${teamsById(id)}/join-requests`; +const teamsCreateJoinRequest = (id: string) => `${teamsById(id)}/join-request`; +const teamsReviewJoinRequest = (teamJoinRequestId: string) => `${teams()}/join-request/${teamJoinRequestId}/review`; /**************** Description Bullet Endpoints ****************/ const descriptionBullets = () => `${API_URL}/description-bullets`; @@ -376,8 +380,6 @@ const organizationsSetPlatformDescription = () => `${organizations()}/platform-d const organizationsFeaturedProjects = () => `${organizations()}/featured-projects`; const organizationsLogoImage = () => `${organizations()}/logo`; const organizationsSetLogoImage = () => `${organizations()}/logo/update`; -const organizationsNewMemberImage = () => `${organizations()}/new-member-image`; -const organizationsSetNewMemberImage = () => `${organizations()}/new-member-image/update`; const organizationsPlatformLogoImage = () => `${organizations()}/platform-logo`; const organizationsSetPlatformLogoImage = () => `${organizationsPlatformLogoImage()}/update`; const organizationsSetFeaturedProjects = () => `${organizationsFeaturedProjects()}/set`; @@ -403,11 +405,16 @@ const teamsDropdown = () => `${teams()}/dropdown`; /************** Recruitment Endpoints ***************/ const recruitment = () => `${API_URL}/recruitment`; const allMilestones = () => `${recruitment()}/milestones`; +const newMemberMilestones = () => `${recruitment()}/milestones/new-member`; +const recruitingMilestones = () => `${recruitment()}/milestones/recruiting`; const milestoneCreate = () => `${recruitment()}/milestone/create`; const milestoneEdit = (id: string) => `${recruitment()}/milestone/${id}/edit`; const milestoneDelete = (id: string) => `${recruitment()}/milestone/${id}/delete`; const allFaqs = () => `${recruitment()}/faqs`; -const faqCreate = () => `${recruitment()}/faq/create`; +const recruitingFaqs = () => `${recruitment()}/faqs/recruiting`; +const newMemberFaqs = () => `${recruitment()}/faqs/new-member`; +const recruitingFaqCreate = () => `${recruitment()}/faq/recruiting/create`; +const newMemberFaqCreate = () => `${recruitment()}/faq/new-member/create`; const faqEdit = (id: string) => `${recruitment()}/faq/${id}/edit`; const faqDelete = (id: string) => `${recruitment()}/faq/${id}/delete`; const allGuestDefinitions = () => `${recruitment()}/guestdefinitions`; @@ -463,6 +470,7 @@ const retrospectiveBudgets = () => `${API_URL}/retrospective/budgets`; const calendar = () => `${API_URL}/calendar`; const calendarShops = () => `${calendar()}/shops`; const calendarEvents = () => `${calendar()}/events`; +const calendarNewMemberEvents = () => `${calendar()}/events/new-member`; const calendarEventsPaginated = () => `${calendar()}/events-paginated`; const calendarEventTypes = () => `${calendar()}/event-types`; const calendarCreateShop = () => `${calendar()}/shop/create`; @@ -672,6 +680,10 @@ export const apiUrls = { teamTypeEdit, teamTypeSetImage, myTeamAsHead, + myTeamJoinRequests, + teamsPendingJoinRequests, + teamsCreateJoinRequest, + teamsReviewJoinRequest, descriptionBulletsCheck, descriptionBulletTypes, @@ -803,8 +815,6 @@ export const apiUrls = { organizationsSetPlatformDescription, organizationsLogoImage, organizationsSetLogoImage, - organizationsNewMemberImage, - organizationsSetNewMemberImage, organizationsPlatformLogoImage, organizationsSetPlatformLogoImage, organizationsSetFeaturedProjects, @@ -822,11 +832,16 @@ export const apiUrls = { recruitment, allMilestones, + newMemberMilestones, + recruitingMilestones, milestoneCreate, milestoneEdit, milestoneDelete, allFaqs, - faqCreate, + recruitingFaqs, + newMemberFaqs, + recruitingFaqCreate, + newMemberFaqCreate, faqEdit, faqDelete, imageById, @@ -883,6 +898,7 @@ export const apiUrls = { calendarGetSingleEventWithMembers, calendarGetConflictingEvent, calendarEvents, + calendarNewMemberEvents, calendarEventsPaginated, calendarEventTypes, calendarDeleteEvent, diff --git a/src/shared/index.ts b/src/shared/index.ts index ac63750c2f..5df2c854dc 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -23,6 +23,7 @@ export * from './src/types/dropdown-types.js'; export * from './src/types/dashboard-types.js'; export * from './src/validate-wbs.js'; +export * from './src/validate-slack-id.js'; export * from './src/date-utils.js'; export * from './src/date-format.js'; diff --git a/src/shared/src/types/calendar-types.ts b/src/shared/src/types/calendar-types.ts index 55e3ee3ff6..791df84957 100644 --- a/src/shared/src/types/calendar-types.ts +++ b/src/shared/src/types/calendar-types.ts @@ -106,6 +106,7 @@ export interface Calendar { userCreated: User; dateCreated: Date; eventTypes: EventType[]; + isNewMemberCalendar: boolean; } export interface ScheduleSlot { diff --git a/src/shared/src/types/milestone-types.ts b/src/shared/src/types/milestone-types.ts index 41bf96287b..094e52d884 100644 --- a/src/shared/src/types/milestone-types.ts +++ b/src/shared/src/types/milestone-types.ts @@ -14,4 +14,6 @@ export interface Milestone { userDeleted?: User; dateCreated: Date; dateDeleted?: Date; + isOnRecruitingDashboard: boolean; + isOnNewMemberDashboard: boolean; } diff --git a/src/shared/src/types/project-types.ts b/src/shared/src/types/project-types.ts index 97316b6f9d..9694ad371b 100644 --- a/src/shared/src/types/project-types.ts +++ b/src/shared/src/types/project-types.ts @@ -134,6 +134,8 @@ export interface LinkType { required: boolean; iconName: string; isOnGuestHomePage: boolean; + isOnNewMemberDashboard: boolean; + isOnOnboardingDashboard: boolean; } export interface Link { @@ -189,6 +191,8 @@ export interface LinkTypeCreatePayload { iconName: string; required: boolean; isOnGuestHomePage: boolean; + isOnNewMemberDashboard: boolean; + isOnOnboardingDashboard: boolean; } export interface DescriptionBulletTypeCreatePayload { diff --git a/src/shared/src/types/recruitment-types.ts b/src/shared/src/types/recruitment-types.ts index 56a8bf2ba4..eac2492f0a 100644 --- a/src/shared/src/types/recruitment-types.ts +++ b/src/shared/src/types/recruitment-types.ts @@ -13,6 +13,9 @@ export interface FrequentlyAskedQuestion { userDeleted?: User; dateCreated: Date; dateDeleted?: Date; + isOnRecruitingDashboard: boolean; + isOnNewMemberDashboard: boolean; + isOnPartReviewPage: boolean; } export enum GuestDefinitionType { diff --git a/src/shared/src/types/team-types.ts b/src/shared/src/types/team-types.ts index 817c88cd9a..9f5f4a7c5f 100644 --- a/src/shared/src/types/team-types.ts +++ b/src/shared/src/types/team-types.ts @@ -28,3 +28,16 @@ export interface TeamPreview extends TeamBase { export interface Team extends TeamPreview { projects: ProjectGantt[]; } + +export type TeamJoinRequestStatus = 'PENDING' | 'APPROVED' | 'DENIED'; + +export interface TeamJoinRequest { + teamJoinRequestId: string; + user: User; + team: TeamPreview; + status: TeamJoinRequestStatus; + dateRequested: Date; + denialReason?: string; + reviewedBy?: User; + dateReviewed?: Date; +} diff --git a/src/shared/src/types/user-types.ts b/src/shared/src/types/user-types.ts index a6119c0eb1..4e99e1be9c 100644 --- a/src/shared/src/types/user-types.ts +++ b/src/shared/src/types/user-types.ts @@ -50,7 +50,6 @@ export type OrganizationPreview = Pick< | 'dateDeleted' | 'description' | 'applicationLink' - | 'newMemberImageId' | 'platformDescription' | 'platformLogoImageId' >; @@ -65,7 +64,6 @@ export interface Organization { treasurer?: User; advisor?: User; description: string; - newMemberImageId?: string; applicationLink?: string; onboardingText?: string; contacts: Contact[]; diff --git a/src/shared/src/validate-slack-id.ts b/src/shared/src/validate-slack-id.ts new file mode 100644 index 0000000000..74f098ddce --- /dev/null +++ b/src/shared/src/validate-slack-id.ts @@ -0,0 +1,15 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +// Slack user ids start with U (or W for some older enterprise grid accounts), followed by +// 8-10 uppercase alphanumeric characters. This is a format check only -- it does not confirm +// the id actually exists in the workspace. +const SLACK_USER_ID_REGEX = /^[UW][A-Z0-9]{8,10}$/; + +/** + * Checks whether a string looks like a valid Slack user id, by format only (no Slack API call) + * @param slackId the string to check + */ +export const isValidSlackUserIdFormat = (slackId: string): boolean => SLACK_USER_ID_REGEX.test(slackId);