From 56a25539a4e4fedeef19bb933007d88cac80592a Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 22 May 2026 15:35:32 -0400 Subject: [PATCH 01/43] schema and shared changes, still need to change faq to boolean pattern --- .../migration.sql | 38 ++++++++ src/backend/src/prisma/schema.prisma | 89 +++++++++++++------ src/shared/src/types/milestone-types.ts | 2 + src/shared/src/types/recruitment-types.ts | 3 + src/shared/src/types/team-types.ts | 11 +++ 5 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql 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..e4b08d4939 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql @@ -0,0 +1,38 @@ +-- CreateEnum +CREATE TYPE "Dashboard_Target" AS ENUM ('RECRUITING', 'ONBOARDING', 'BOTH'); + +-- CreateEnum +CREATE TYPE "Team_Join_Request_Status" AS ENUM ('PENDING', 'APPROVED', 'DENIED'); + +-- AlterTable +ALTER TABLE "FrequentlyAskedQuestion" ADD COLUMN "dashboardTarget" "Dashboard_Target" NOT NULL DEFAULT 'RECRUITING'; + +-- AlterTable +ALTER TABLE "Link_Type" ADD COLUMN "isOnOnboardingDashboard" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "Milestone" ADD COLUMN "dashboardTarget" "Dashboard_Target" NOT NULL DEFAULT 'RECRUITING'; + +-- 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, + + 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 +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 +ALTER TABLE "Team_Join_Request" ADD CONSTRAINT "Team_Join_Request_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("teamId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index a757bbaa43..bc5bc1ceca 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -180,6 +180,18 @@ enum Sponsor_Value_Type { DISCOUNT } +enum Dashboard_Target { + RECRUITING + ONBOARDING + BOTH +} + +enum Team_Join_Request_Status { + PENDING + APPROVED + DENIED +} + model User { userId String @id @default(uuid()) firstName String @@ -207,6 +219,7 @@ model User { teamsAsMember Team[] @relation(name: "teamsAsMember") teamsAsHead Team[] @relation(name: "teamsAsHead") teamsAsLead Team[] @relation(name: "teamsAsLead") + teamJoinRequests Team_Join_Request[] @relation(name: "teamJoinRequests") deletedWBSElements WBS_Element[] @relation(name: "deletedWbsElements") checkedDescriptionBullets Description_Bullet[] @relation(name: "checkDescriptionBullets") createdProposedSolutions Proposed_Solution[] @@ -343,11 +356,26 @@ 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? + + @@index([userId]) + @@index([teamId]) +} + model Session { sessionId String @id @default(uuid()) userId String @@ -603,17 +631,18 @@ 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) + isOnOnboardingDashboard Boolean @default(false) @@unique([name, organizationId], name: "uniqueLinkType") @@index([organizationId]) @@ -1453,34 +1482,36 @@ model Organization { } model FrequentlyAskedQuestion { - faqId String @id @default(uuid()) + faqId String @id @default(uuid()) question String answer String - userCreated User @relation(fields: [userCreatedId], references: [userId], name: "frequentlyAskedQuestionCreator") + userCreated User @relation(fields: [userCreatedId], references: [userId], name: "frequentlyAskedQuestionCreator") userCreatedId String - userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "frequentlyAskedQuestionDeleter") + userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "frequentlyAskedQuestionDeleter") userDeletedId String? - dateCreated DateTime @default(now()) + dateCreated DateTime @default(now()) dateDeleted DateTime? regularFaqOrgId String? - regularFaqOrg Organization? @relation(fields: [regularFaqOrgId], references: [organizationId], name: "organizationFAQ") + regularFaqOrg Organization? @relation(fields: [regularFaqOrgId], references: [organizationId], name: "organizationFAQ") partReviewFaqOrgId String? - partReviewFaqOrg Organization? @relation(fields: [partReviewFaqOrgId], references: [organizationId], name: "partReviewFAQ") + partReviewFaqOrg Organization? @relation(fields: [partReviewFaqOrgId], references: [organizationId], name: "partReviewFAQ") + dashboardTarget Dashboard_Target @default(RECRUITING) } 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]) + dashboardTarget Dashboard_Target @default(RECRUITING) @@index([organizationId]) } diff --git a/src/shared/src/types/milestone-types.ts b/src/shared/src/types/milestone-types.ts index 41bf96287b..a00cfacb57 100644 --- a/src/shared/src/types/milestone-types.ts +++ b/src/shared/src/types/milestone-types.ts @@ -3,6 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ +import { DashboardTarget } from './recruitment-types.js'; import { User } from './user-types.js'; export interface Milestone { @@ -14,4 +15,5 @@ export interface Milestone { userDeleted?: User; dateCreated: Date; dateDeleted?: Date; + dashboardTarget: DashboardTarget; } diff --git a/src/shared/src/types/recruitment-types.ts b/src/shared/src/types/recruitment-types.ts index 56a8bf2ba4..6bf971b810 100644 --- a/src/shared/src/types/recruitment-types.ts +++ b/src/shared/src/types/recruitment-types.ts @@ -5,6 +5,8 @@ import { User } from './user-types.js'; +export type DashboardTarget = 'RECRUITING' | 'ONBOARDING' | 'BOTH'; + export interface FrequentlyAskedQuestion { faqId: string; question: string; @@ -13,6 +15,7 @@ export interface FrequentlyAskedQuestion { userDeleted?: User; dateCreated: Date; dateDeleted?: Date; + dashboardTarget: DashboardTarget; } export enum GuestDefinitionType { diff --git a/src/shared/src/types/team-types.ts b/src/shared/src/types/team-types.ts index 817c88cd9a..a41866ad7e 100644 --- a/src/shared/src/types/team-types.ts +++ b/src/shared/src/types/team-types.ts @@ -28,3 +28,14 @@ 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; +} From ef8946f549369914885c57b42e998e613a28fed0 Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 12 Jun 2026 14:06:55 -0400 Subject: [PATCH 02/43] editing migration for safety --- .../migration.sql | 40 +++++++++ src/backend/src/prisma/schema.prisma | 88 +++++++++---------- 2 files changed, 81 insertions(+), 47 deletions(-) create mode 100644 src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql diff --git a/src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql b/src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql new file mode 100644 index 0000000000..3d2dc3955d --- /dev/null +++ b/src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql @@ -0,0 +1,40 @@ +/* + Warnings: + + - You are about to drop the column `dashboardTarget` on the `FrequentlyAskedQuestion` table. All the data in the column will be lost. + - You are about to drop the column `partReviewFaqOrgId` on the `FrequentlyAskedQuestion` table. All the data in the column will be lost. + - You are about to drop the column `regularFaqOrgId` on the `FrequentlyAskedQuestion` table. All the data in the column will be lost. + - You are about to drop the column `isOnOnboardingDashboard` on the `Link_Type` table. All the data in the column will be lost. + - You are about to drop the column `dashboardTarget` on the `Milestone` table. All the data in the column will be lost. + - Added the required column `organizationId` to the `FrequentlyAskedQuestion` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "FrequentlyAskedQuestion" DROP CONSTRAINT "FrequentlyAskedQuestion_partReviewFaqOrgId_fkey"; + +-- DropForeignKey +ALTER TABLE "FrequentlyAskedQuestion" DROP CONSTRAINT "FrequentlyAskedQuestion_regularFaqOrgId_fkey"; + +-- AlterTable +ALTER TABLE "FrequentlyAskedQuestion" DROP COLUMN "dashboardTarget", +DROP COLUMN "partReviewFaqOrgId", +DROP COLUMN "regularFaqOrgId", +ADD COLUMN "isOnNewMemberDashboard" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isOnPartReviewPage" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isOnRecruitingDashboard" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "organizationId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Link_Type" DROP COLUMN "isOnOnboardingDashboard", +ADD COLUMN "isOnNewMemberDashboard" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "Milestone" DROP COLUMN "dashboardTarget", +ADD COLUMN "isOnNewMemberDashboard" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isOnRecruitingDashboard" BOOLEAN NOT NULL DEFAULT true; + +-- DropEnum +DROP TYPE "Dashboard_Target"; + +-- AddForeignKey +ALTER TABLE "FrequentlyAskedQuestion" ADD CONSTRAINT "FrequentlyAskedQuestion_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index bc5bc1ceca..e45b04448f 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -180,12 +180,6 @@ enum Sponsor_Value_Type { DISCOUNT } -enum Dashboard_Target { - RECRUITING - ONBOARDING - BOTH -} - enum Team_Join_Request_Status { PENDING APPROVED @@ -631,18 +625,18 @@ 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) - isOnOnboardingDashboard 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) @@unique([name, organizationId], name: "uniqueLinkType") @@index([organizationId]) @@ -1455,8 +1449,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[] @@ -1482,36 +1475,37 @@ model Organization { } 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") - dashboardTarget Dashboard_Target @default(RECRUITING) + 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(true) + 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]) - dashboardTarget Dashboard_Target @default(RECRUITING) + 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(true) + isOnNewMemberDashboard Boolean @default(false) @@index([organizationId]) } From b91e4cfa86800838aec8618a25499919a54a947a Mon Sep 17 00:00:00 2001 From: getheobald Date: Tue, 16 Jun 2026 15:42:46 -0400 Subject: [PATCH 03/43] transformers and services --- .../controllers/recruitment.controllers.ts | 5 ++- .../migration.sql | 37 ++++++++++++++++--- .../src/services/part-review.services.ts | 9 +++-- .../src/services/recruitment.services.ts | 33 +++++++++++++++-- .../transformers/recruitment-transformer.ts | 5 ++- src/shared/src/types/milestone-types.ts | 4 +- src/shared/src/types/recruitment-types.ts | 6 +-- 7 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/backend/src/controllers/recruitment.controllers.ts b/src/backend/src/controllers/recruitment.controllers.ts index f662182b27..8c501933b1 100644 --- a/src/backend/src/controllers/recruitment.controllers.ts +++ b/src/backend/src/controllers/recruitment.controllers.ts @@ -57,9 +57,12 @@ 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); diff --git a/src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql b/src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql index 3d2dc3955d..40cdf21ce7 100644 --- a/src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql +++ b/src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql @@ -16,20 +16,47 @@ ALTER TABLE "FrequentlyAskedQuestion" DROP CONSTRAINT "FrequentlyAskedQuestion_p ALTER TABLE "FrequentlyAskedQuestion" DROP CONSTRAINT "FrequentlyAskedQuestion_regularFaqOrgId_fkey"; -- AlterTable -ALTER TABLE "FrequentlyAskedQuestion" DROP COLUMN "dashboardTarget", -DROP COLUMN "partReviewFaqOrgId", -DROP COLUMN "regularFaqOrgId", +-- First add new columns +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 true, -ADD COLUMN "organizationId" TEXT NOT NULL; +ADD COLUMN "organizationId" TEXT; + +-- Populate orgId where available +UPDATE "FrequentlyAskedQuestion" +SET "organizationId" = "regularFaqOrgId" +WHERE "regularFaqOrgId" IS NOT NULL; + +UPDATE "FrequentlyAskedQuestion" +SET "organizationId" = "partReviewFaqOrgId" +WHERE "organizationId" IS NULL AND "partReviewFaqOrgId" IS NOT NULL; + +-- Populate booleans +UPDATE "FrequentlyAskedQuestion" +SET "isOnPartReviewPage" = true +WHERE "partReviewFaqOrgId" IS NOT NULL; + +UPDATE "FrequentlyAskedQuestion" +SET "isOnRecruitingDashboard" = false +WHERE "regularFaqOrgId" IS NULL AND "partReviewFaqOrgId" IS NOT NULL; + +ALTER TABLE "FrequentlyAskedQuestion" +ALTER COLUMN "organizationId" SET NOT NULL; + +-- Drop old columns +ALTER TABLE "FrequentlyAskedQuestion" +DROP COLUMN "dashboardTarget", +DROP COLUMN "partReviewFaqOrgId", +DROP COLUMN "regularFaqOrgId"; -- AlterTable ALTER TABLE "Link_Type" DROP COLUMN "isOnOnboardingDashboard", ADD COLUMN "isOnNewMemberDashboard" BOOLEAN NOT NULL DEFAULT false; -- AlterTable -ALTER TABLE "Milestone" DROP COLUMN "dashboardTarget", +ALTER TABLE "Milestone" +DROP COLUMN "dashboardTarget", ADD COLUMN "isOnNewMemberDashboard" BOOLEAN NOT NULL DEFAULT false, ADD COLUMN "isOnRecruitingDashboard" BOOLEAN NOT NULL DEFAULT true; diff --git a/src/backend/src/services/part-review.services.ts b/src/backend/src/services/part-review.services.ts index 6e25f9fce6..b48408b6df 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: organizationId, isOnPartReviewPage: true }, ...getFaqQueryArgs(organizationId) }); @@ -652,7 +652,8 @@ export default class PartReviewService { question, answer, userCreated: { connect: { userId: creator.userId } }, - partReviewFaqOrg: { connect: { organizationId } } + organization: { connect: { organizationId } }, + isOnPartReviewPage: true }, ...getFaqQueryArgs(organizationId) }); @@ -682,7 +683,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); } @@ -713,7 +714,7 @@ export default class PartReviewService { const faq = await prisma.frequentlyAskedQuestion.findUnique({ where: { faqId }, ...getFaqQueryArgs }); - if (!faq || faq.partReviewFaqOrgId !== organizationId) { + if (!faq || faq.organizationId !== organizationId || !faq.isOnPartReviewPage) { throw new NotFoundException('Faq', faqId); } diff --git a/src/backend/src/services/recruitment.services.ts b/src/backend/src/services/recruitment.services.ts index aa61d5133b..d6ab6f5844 100644 --- a/src/backend/src/services/recruitment.services.ts +++ b/src/backend/src/services/recruitment.services.ts @@ -108,13 +108,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 @@ -153,8 +179,9 @@ export default class RecruitmentServices { data: { question, answer, - regularFaqOrgId: organization.organizationId, - userCreatedId: submitter.userId + organizationId: organization.organizationId, + userCreatedId: submitter.userId, + isOnRecruitingDashboard: true } }); diff --git a/src/backend/src/transformers/recruitment-transformer.ts b/src/backend/src/transformers/recruitment-transformer.ts index 0b6f237389..6258073665 100644 --- a/src/backend/src/transformers/recruitment-transformer.ts +++ b/src/backend/src/transformers/recruitment-transformer.ts @@ -9,7 +9,10 @@ export const faqTransformer = (faq: Prisma.FrequentlyAskedQuestionGetPayload { diff --git a/src/shared/src/types/milestone-types.ts b/src/shared/src/types/milestone-types.ts index a00cfacb57..094e52d884 100644 --- a/src/shared/src/types/milestone-types.ts +++ b/src/shared/src/types/milestone-types.ts @@ -3,7 +3,6 @@ * See the LICENSE file in the repository root folder for details. */ -import { DashboardTarget } from './recruitment-types.js'; import { User } from './user-types.js'; export interface Milestone { @@ -15,5 +14,6 @@ export interface Milestone { userDeleted?: User; dateCreated: Date; dateDeleted?: Date; - dashboardTarget: DashboardTarget; + isOnRecruitingDashboard: boolean; + isOnNewMemberDashboard: boolean; } diff --git a/src/shared/src/types/recruitment-types.ts b/src/shared/src/types/recruitment-types.ts index 6bf971b810..eac2492f0a 100644 --- a/src/shared/src/types/recruitment-types.ts +++ b/src/shared/src/types/recruitment-types.ts @@ -5,8 +5,6 @@ import { User } from './user-types.js'; -export type DashboardTarget = 'RECRUITING' | 'ONBOARDING' | 'BOTH'; - export interface FrequentlyAskedQuestion { faqId: string; question: string; @@ -15,7 +13,9 @@ export interface FrequentlyAskedQuestion { userDeleted?: User; dateCreated: Date; dateDeleted?: Date; - dashboardTarget: DashboardTarget; + isOnRecruitingDashboard: boolean; + isOnNewMemberDashboard: boolean; + isOnPartReviewPage: boolean; } export enum GuestDefinitionType { From 3c7252ba5c4987fd24ebf832e261c052e49fd1af Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 18 Jun 2026 18:25:41 -0400 Subject: [PATCH 04/43] final orgId fixes --- .../migration.sql | 3 +++ src/backend/src/prisma/schema.prisma | 2 +- src/backend/src/prisma/seed.ts | 6 ++++-- src/backend/tests/test-utils.ts | 4 ++-- src/backend/tests/unit/part-review.test.ts | 18 +++++++++++------- 5 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 src/backend/src/prisma/migrations/20260618215318_faq_default_false/migration.sql diff --git a/src/backend/src/prisma/migrations/20260618215318_faq_default_false/migration.sql b/src/backend/src/prisma/migrations/20260618215318_faq_default_false/migration.sql new file mode 100644 index 0000000000..3d6736ac9b --- /dev/null +++ b/src/backend/src/prisma/migrations/20260618215318_faq_default_false/migration.sql @@ -0,0 +1,3 @@ +-- This is an empty migration. + +ALTER TABLE "FrequentlyAskedQuestion" ALTER COLUMN "isOnRecruitingDashboard" SET DEFAULT false; \ No newline at end of file diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index e45b04448f..b49d1bcb61 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -1486,7 +1486,7 @@ model FrequentlyAskedQuestion { dateDeleted DateTime? organizationId String organization Organization @relation(fields: [organizationId], references: [organizationId]) - isOnRecruitingDashboard Boolean @default(true) + isOnRecruitingDashboard Boolean @default(false) isOnNewMemberDashboard Boolean @default(false) isOnPartReviewPage Boolean @default(false) } diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 07c95fa1ed..1ab069e48d 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -112,9 +112,10 @@ export const CreatePartReviewFAQ = async ( data: { question, answer, - partReviewFaqOrg: { + organization: { connect: { organizationId } }, + isOnPartReviewPage: true, userCreated: { connect: { userId: user.userId } } @@ -3807,7 +3808,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 } }); diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 430e4ee640..2303831580 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -241,7 +241,7 @@ export const createTestFAQ = async (orgId: string, faqId: string) => { userId: user.userId } }, - regularFaqOrg: { + organization: { connect: { organizationId: orgId } @@ -326,7 +326,7 @@ export const createTestFaq = async (user: User, organizationId: string) => { data: { question: 'Who is Chief Software Engineer of NER?', answer: 'Peyton McKee!', - regularFaqOrgId: organizationId, + organizationId: organizationId, userCreatedId: user.userId } }); diff --git a/src/backend/tests/unit/part-review.test.ts b/src/backend/tests/unit/part-review.test.ts index f201c0e2c7..1e3abeaf9a 100644 --- a/src/backend/tests/unit/part-review.test.ts +++ b/src/backend/tests/unit/part-review.test.ts @@ -532,8 +532,8 @@ describe('part review tests', () => { 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 +550,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'); @@ -792,7 +792,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 +803,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 +828,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 +839,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); From 012423a735144950865a43f49cf41d4c849123d3 Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 18 Jun 2026 18:30:14 -0400 Subject: [PATCH 05/43] one more dashboard boolean default change --- .../migration.sql | 2 ++ src/backend/src/prisma/schema.prisma | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 src/backend/src/prisma/migrations/20260618222947_milestone_dashboard_default_false/migration.sql diff --git a/src/backend/src/prisma/migrations/20260618222947_milestone_dashboard_default_false/migration.sql b/src/backend/src/prisma/migrations/20260618222947_milestone_dashboard_default_false/migration.sql new file mode 100644 index 0000000000..f9c328ed35 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260618222947_milestone_dashboard_default_false/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Milestone" ALTER COLUMN "isOnRecruitingDashboard" SET DEFAULT false; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index b49d1bcb61..f0e5bc32a5 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -1504,7 +1504,7 @@ model Milestone { dateDeleted DateTime? organizationId String organization Organization @relation(fields: [organizationId], references: [organizationId]) - isOnRecruitingDashboard Boolean @default(true) + isOnRecruitingDashboard Boolean @default(false) isOnNewMemberDashboard Boolean @default(false) @@index([organizationId]) From 13c00e856f417e951462489fa121234b21f8a3ea Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 18 Jun 2026 18:33:46 -0400 Subject: [PATCH 06/43] lint --- src/backend/src/services/part-review.services.ts | 2 +- src/backend/tests/test-utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/src/services/part-review.services.ts b/src/backend/src/services/part-review.services.ts index b48408b6df..a33103a0fb 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, organizationId: organizationId, isOnPartReviewPage: true }, + where: { dateDeleted: null, organizationId, isOnPartReviewPage: true }, ...getFaqQueryArgs(organizationId) }); diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 2303831580..4b4eda5908 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -326,7 +326,7 @@ export const createTestFaq = async (user: User, organizationId: string) => { data: { question: 'Who is Chief Software Engineer of NER?', answer: 'Peyton McKee!', - organizationId: organizationId, + organizationId, userCreatedId: user.userId } }); From e36fb377fdca30cb49b4e8f817102405a521da68 Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 19 Jun 2026 15:42:33 -0400 Subject: [PATCH 07/43] merge migrations --- .../migration.sql | 59 +++++++++++++--- .../migration.sql | 67 ------------------- .../migration.sql | 3 - .../migration.sql | 2 - 4 files changed, 48 insertions(+), 83 deletions(-) delete mode 100644 src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql delete mode 100644 src/backend/src/prisma/migrations/20260618215318_faq_default_false/migration.sql delete mode 100644 src/backend/src/prisma/migrations/20260618222947_milestone_dashboard_default_false/migration.sql diff --git a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql index e4b08d4939..1f30f0d6e6 100644 --- a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql +++ b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql @@ -1,17 +1,55 @@ --- CreateEnum -CREATE TYPE "Dashboard_Target" AS ENUM ('RECRUITING', 'ONBOARDING', 'BOTH'); - -- CreateEnum CREATE TYPE "Team_Join_Request_Status" AS ENUM ('PENDING', 'APPROVED', 'DENIED'); --- AlterTable -ALTER TABLE "FrequentlyAskedQuestion" ADD COLUMN "dashboardTarget" "Dashboard_Target" NOT NULL DEFAULT 'RECRUITING'; +-- 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; --- AlterTable -ALTER TABLE "Link_Type" ADD COLUMN "isOnOnboardingDashboard" BOOLEAN NOT NULL DEFAULT false; +-- Now make organizationId non-nullable +ALTER TABLE "FrequentlyAskedQuestion" +ALTER COLUMN "organizationId" SET NOT NULL; --- AlterTable -ALTER TABLE "Milestone" ADD COLUMN "dashboardTarget" "Dashboard_Target" NOT NULL DEFAULT 'RECRUITING'; +-- 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" ( @@ -21,7 +59,6 @@ CREATE TABLE "Team_Join_Request" ( "status" "Team_Join_Request_Status" NOT NULL DEFAULT 'PENDING', "dateRequested" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "denialReason" TEXT, - CONSTRAINT "Team_Join_Request_pkey" PRIMARY KEY ("teamJoinRequestId") ); @@ -35,4 +72,4 @@ CREATE INDEX "Team_Join_Request_teamId_idx" ON "Team_Join_Request"("teamId"); 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 -ALTER TABLE "Team_Join_Request" ADD CONSTRAINT "Team_Join_Request_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("teamId") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Team_Join_Request" ADD CONSTRAINT "Team_Join_Request_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("teamId") ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql b/src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql deleted file mode 100644 index 40cdf21ce7..0000000000 --- a/src/backend/src/prisma/migrations/20260612180029_faq_boolean_pattern/migration.sql +++ /dev/null @@ -1,67 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `dashboardTarget` on the `FrequentlyAskedQuestion` table. All the data in the column will be lost. - - You are about to drop the column `partReviewFaqOrgId` on the `FrequentlyAskedQuestion` table. All the data in the column will be lost. - - You are about to drop the column `regularFaqOrgId` on the `FrequentlyAskedQuestion` table. All the data in the column will be lost. - - You are about to drop the column `isOnOnboardingDashboard` on the `Link_Type` table. All the data in the column will be lost. - - You are about to drop the column `dashboardTarget` on the `Milestone` table. All the data in the column will be lost. - - Added the required column `organizationId` to the `FrequentlyAskedQuestion` table without a default value. This is not possible if the table is not empty. - -*/ --- DropForeignKey -ALTER TABLE "FrequentlyAskedQuestion" DROP CONSTRAINT "FrequentlyAskedQuestion_partReviewFaqOrgId_fkey"; - --- DropForeignKey -ALTER TABLE "FrequentlyAskedQuestion" DROP CONSTRAINT "FrequentlyAskedQuestion_regularFaqOrgId_fkey"; - --- AlterTable --- First add new columns -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 true, -ADD COLUMN "organizationId" TEXT; - --- Populate orgId where available -UPDATE "FrequentlyAskedQuestion" -SET "organizationId" = "regularFaqOrgId" -WHERE "regularFaqOrgId" IS NOT NULL; - -UPDATE "FrequentlyAskedQuestion" -SET "organizationId" = "partReviewFaqOrgId" -WHERE "organizationId" IS NULL AND "partReviewFaqOrgId" IS NOT NULL; - --- Populate booleans -UPDATE "FrequentlyAskedQuestion" -SET "isOnPartReviewPage" = true -WHERE "partReviewFaqOrgId" IS NOT NULL; - -UPDATE "FrequentlyAskedQuestion" -SET "isOnRecruitingDashboard" = false -WHERE "regularFaqOrgId" IS NULL AND "partReviewFaqOrgId" IS NOT NULL; - -ALTER TABLE "FrequentlyAskedQuestion" -ALTER COLUMN "organizationId" SET NOT NULL; - --- Drop old columns -ALTER TABLE "FrequentlyAskedQuestion" -DROP COLUMN "dashboardTarget", -DROP COLUMN "partReviewFaqOrgId", -DROP COLUMN "regularFaqOrgId"; - --- AlterTable -ALTER TABLE "Link_Type" DROP COLUMN "isOnOnboardingDashboard", -ADD COLUMN "isOnNewMemberDashboard" BOOLEAN NOT NULL DEFAULT false; - --- AlterTable -ALTER TABLE "Milestone" -DROP COLUMN "dashboardTarget", -ADD COLUMN "isOnNewMemberDashboard" BOOLEAN NOT NULL DEFAULT false, -ADD COLUMN "isOnRecruitingDashboard" BOOLEAN NOT NULL DEFAULT true; - --- DropEnum -DROP TYPE "Dashboard_Target"; - --- AddForeignKey -ALTER TABLE "FrequentlyAskedQuestion" ADD CONSTRAINT "FrequentlyAskedQuestion_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/migrations/20260618215318_faq_default_false/migration.sql b/src/backend/src/prisma/migrations/20260618215318_faq_default_false/migration.sql deleted file mode 100644 index 3d6736ac9b..0000000000 --- a/src/backend/src/prisma/migrations/20260618215318_faq_default_false/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- This is an empty migration. - -ALTER TABLE "FrequentlyAskedQuestion" ALTER COLUMN "isOnRecruitingDashboard" SET DEFAULT false; \ No newline at end of file diff --git a/src/backend/src/prisma/migrations/20260618222947_milestone_dashboard_default_false/migration.sql b/src/backend/src/prisma/migrations/20260618222947_milestone_dashboard_default_false/migration.sql deleted file mode 100644 index f9c328ed35..0000000000 --- a/src/backend/src/prisma/migrations/20260618222947_milestone_dashboard_default_false/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Milestone" ALTER COLUMN "isOnRecruitingDashboard" SET DEFAULT false; From f98e3cc80bcf0b6dd6ddad4f904f96a4bc5981a4 Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 26 Jun 2026 11:37:34 -0400 Subject: [PATCH 08/43] add routes, update migration to add reviewer, pass org to query args --- .../src/controllers/recruitment.controllers.ts | 18 ++++++++++++++++++ .../migration.sql | 11 ++++++++--- src/backend/src/prisma/schema.prisma | 4 ++++ src/backend/src/routes/recruitment.routes.ts | 4 ++++ .../src/services/part-review.services.ts | 2 +- src/shared/src/types/team-types.ts | 2 ++ 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/backend/src/controllers/recruitment.controllers.ts b/src/backend/src/controllers/recruitment.controllers.ts index 8c501933b1..cf12d6c168 100644 --- a/src/backend/src/controllers/recruitment.controllers.ts +++ b/src/backend/src/controllers/recruitment.controllers.ts @@ -69,6 +69,24 @@ export default class RecruitmentController { } } + 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 createOrganizationFaq(req: Request, res: Response, next: NextFunction) { try { const { question, answer } = req.body; diff --git a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql index 1f30f0d6e6..d7e9a1d7b2 100644 --- a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql +++ b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql @@ -59,6 +59,8 @@ CREATE TABLE "Team_Join_Request" ( "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") ); @@ -68,8 +70,11 @@ 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 +-- 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 -ALTER TABLE "Team_Join_Request" ADD CONSTRAINT "Team_Join_Request_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("teamId") ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file +-- 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; \ No newline at end of file diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index f0e5bc32a5..3abb205171 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -214,6 +214,7 @@ model User { 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") createdProposedSolutions Proposed_Solution[] @@ -365,6 +366,9 @@ model Team_Join_Request { 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]) diff --git a/src/backend/src/routes/recruitment.routes.ts b/src/backend/src/routes/recruitment.routes.ts index ec7b8545fc..3a6ebfc10e 100644 --- a/src/backend/src/routes/recruitment.routes.ts +++ b/src/backend/src/routes/recruitment.routes.ts @@ -32,6 +32,10 @@ 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/create', nonEmptyString(body('question')), diff --git a/src/backend/src/services/part-review.services.ts b/src/backend/src/services/part-review.services.ts index a33103a0fb..9772c74827 100644 --- a/src/backend/src/services/part-review.services.ts +++ b/src/backend/src/services/part-review.services.ts @@ -712,7 +712,7 @@ 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.organizationId !== organizationId || !faq.isOnPartReviewPage) { throw new NotFoundException('Faq', faqId); diff --git a/src/shared/src/types/team-types.ts b/src/shared/src/types/team-types.ts index a41866ad7e..9f5f4a7c5f 100644 --- a/src/shared/src/types/team-types.ts +++ b/src/shared/src/types/team-types.ts @@ -38,4 +38,6 @@ export interface TeamJoinRequest { status: TeamJoinRequestStatus; dateRequested: Date; denialReason?: string; + reviewedBy?: User; + dateReviewed?: Date; } From 1d46f0c2f43048ed70eb87463cf620df1db3da2a Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 26 Jun 2026 16:27:37 -0400 Subject: [PATCH 09/43] validation endpoint --- .../src/controllers/users.controllers.ts | 10 ++++++++++ src/backend/src/integrations/slack.ts | 19 ++++++++++++++++++- src/backend/src/routes/users.routes.ts | 2 ++ src/backend/src/services/users.services.ts | 10 ++++++++++ src/frontend/src/app/AppAuthenticated.tsx | 5 +++-- src/frontend/src/app/AppPublic.tsx | 7 ++++++- 6 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index 75fe877002..00e1f637b1 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -242,4 +242,14 @@ export default class UsersController { next(error); } } + + static async validateSlackId(req: Request, res: Response, next: NextFunction) { + try { + const { slackId } = req.body; + const isValid = await UsersService.validateSlackId(slackId); + res.status(200).json({ isValid }); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index 0ac331f125..9ecde09c57 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -289,7 +289,7 @@ export const checkBotInChannel = async (channelId: string): Promise => }; /** - * Given a slack user id, prood.uces the name of the channel + * Given a slack user id, produces the name of the channel * @param userId the id of the slack user * @returns the name of the user (real name if no display name), undefined if cannot be found */ @@ -380,3 +380,20 @@ export const getReceiver = (): ExpressReceiver | null => { // Export the getters for any direct usage if needed export { getSlackClient }; export default getSlackClient; + +/** + * Validates that a given Slack user id exists in the workspace + * All slack ids start with U. If you pass a valid user id to users.info, it returns ok: true; throws error otherwise. + * @param slackId the Slack user id to validate + * @returns true if the user exists, false otherwise + */ +export const validateSlackUserId = async (slackId: string): Promise => { + const client = getSlackClient(); + if (!client) return false; + try { + const res = await client.users.info({ user: slackId }); + return res.ok === true; + } catch (error) { + return false; + } +}; diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 98a7b6b21f..11cba8d7ee 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -66,4 +66,6 @@ userRouter.post( UsersController.getManyUserTasks ); +userRouter.post('/validate-slack-id', nonEmptyString(body('slackId')), validateInputs, UsersController.validateSlackId); + export default userRouter; diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 69bf4cf9e5..bce2daf453 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -29,6 +29,7 @@ import authenticatedUserTransformer from '../transformers/auth-user.transformer. import { getTaskQueryArgs } from '../prisma-query-args/tasks.query-args.js'; import taskTransformer from '../transformers/tasks.transformer.js'; import { validateUserIsPartOfFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js'; +import { validateSlackUserId } from '../integrations/slack.js'; export default class UsersService { /** @@ -622,4 +623,13 @@ export default class UsersService { return users.map(userWithScheduleSettingsTransformer); } + + /** + * Validates a user's slack id + * @param slackId the Slack user id to validate + * @returns true if the user exists, false otherwise + */ + static async validateSlackId(slackId: string): Promise { + return validateSlackUserId(slackId); + } } diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index f4228acb34..5b322ea7a4 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -37,9 +37,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 { @@ -64,7 +65,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) { From bacb7b1e7d820705378e18da6fc8b1e6856b7c9d Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 29 Jun 2026 14:25:41 -0400 Subject: [PATCH 10/43] api and hook, validate in SetUserPreferences --- src/frontend/src/apis/users.api.ts | 10 +++ src/frontend/src/hooks/users.hooks.ts | 13 ++- .../src/pages/AcceptedPage/AcceptedPage.tsx | 86 ------------------- src/frontend/src/pages/HomePage/Home.tsx | 4 +- .../src/pages/HomePage/OnboardingHomePage.tsx | 6 +- .../components/SetUserPreferences.tsx | 9 +- src/frontend/src/utils/routes.ts | 2 - src/frontend/src/utils/urls.ts | 2 + 8 files changed, 38 insertions(+), 94 deletions(-) delete mode 100644 src/frontend/src/pages/AcceptedPage/AcceptedPage.tsx diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index 4da38b9489..bb0292e847 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -212,3 +212,13 @@ export const getManyUsersWithScheduleSettings = (userIds: string[]) => { export const logUserOut = () => { return axios.post<{ message: string }>(apiUrls.logUserOut()); }; + +/** + * Validates a user's slack id + * + * @param slackId the user's slack id + * @returns true if the slack id is valid, false otherwise + */ +export const validateSlackId = (slackId: string) => { + return axios.post<{ isValid: boolean }>(apiUrls.validateSlackId(), { slackId }); +}; diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index c890c23671..ffb3eb19bd 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -24,7 +24,8 @@ import { logUserOut, getManyUsersWithScheduleSettings, getAllOrgUsers, - getAllOrgMembers + getAllOrgMembers, + validateSlackId } from '../apis/users.api'; import { User, @@ -322,3 +323,13 @@ export const useLogUserOut = () => { return data; }); }; + +/** + * Custom react hook to determine if a user's slack id is valid + */ +export const useValidateSlackId = () => { + return useMutation<{ isValid: boolean }, Error, string>(['users', 'validate-slack-id'], async (slackId: string) => { + const { data } = await validateSlackId(slackId); + return data; + }); +}; 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/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 7439241e4a..54e5f267a4 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -7,7 +7,6 @@ import { routes } from '../../utils/routes'; import PNMHomePage from './PNMHomePage'; import OnboardingHomePage from './OnboardingHomePage'; import SelectSubteamPage from './SelectSubteamPage'; -import AcceptedPage from '../AcceptedPage/AcceptedPage'; import HomePage from './HomePage'; import { useCurrentUser } from '../../hooks/users.hooks'; import IntroGuestHomePage from './IntroGuestHomePage'; @@ -23,12 +22,11 @@ const Home: React.FC = () => { {completedOnboarding && !isAdmin(user.role) && - [routes.HOME_PNM, routes.HOME_ONBOARDING, routes.HOME_ACCEPT].map((path) => ( + [routes.HOME_PNM, routes.HOME_ONBOARDING].map((path) => ( ))} {onOnboarding && !completedOnboarding && } - diff --git a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx index 7f6b9836a9..25a23ca79f 100644 --- a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx +++ b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx @@ -13,6 +13,7 @@ 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'; const OnboardingHomePage = () => { const history = useHistory(); @@ -41,6 +42,8 @@ const OnboardingHomePage = () => { const progress = useChecklistProgress(usersChecklists || [], checkedChecklists || []); + const { mutateAsync: completeOnboarding } = useCompleteOnboarding(); + if (usersChecklistsIsError) { return ; } @@ -69,7 +72,8 @@ const OnboardingHomePage = () => { }; const handleConfirmModal = async () => { - history.push(routes.HOME_ACCEPT); + await completeOnboarding(); + history.push(routes.HOME); }; return ( diff --git a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx index 74292b5dfd..11634e4805 100644 --- a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx +++ b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx @@ -15,7 +15,7 @@ import LoadingIndicator from '../../../components/LoadingIndicator'; import NERSuccessButton from '../../../components/NERSuccessButton'; import ReactHookTextField from '../../../components/ReactHookTextField'; import { useToast } from '../../../hooks/toasts.hooks'; -import { useUpdateUserSettings } from '../../../hooks/users.hooks'; +import { useUpdateUserSettings, useValidateSlackId } from '../../../hooks/users.hooks'; import ErrorPage from '../../ErrorPage'; interface SetUserPreferencesProps { @@ -28,13 +28,20 @@ const SetUserPreferences: React.FC = ({ userSettings }) const { handleSubmit, control } = useForm<{ slackId: string }>({ defaultValues: { slackId: userSettings.slackId } }); + const { mutateAsync: validateSlackId } = useValidateSlackId(); if (isLoading) return ; if (isError) return ; const onSubmit = async ({ slackId }: { slackId: string }) => { try { + const { isValid } = await validateSlackId(slackId); + if (!isValid) { + toast.error('Invalid Slack ID! Please check it and try again.'); + return; + } await mutateAsync({ ...userSettings, slackId }); + // window.location.reload(); might not need this if it rerenders automatically } catch (error: unknown) { if (error instanceof Error) { toast.error(error.message); diff --git a/src/frontend/src/utils/routes.ts b/src/frontend/src/utils/routes.ts index ce658120bd..bd4b941769 100644 --- a/src/frontend/src/utils/routes.ts +++ b/src/frontend/src/utils/routes.ts @@ -15,7 +15,6 @@ 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`; @@ -93,7 +92,6 @@ export const routes = { HOME_PNM, HOME_SELECT_SUBTEAM, HOME_ONBOARDING, - HOME_ACCEPT, HOME_MEMBER, TEAMS, diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index ee39409179..609f913492 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -32,6 +32,7 @@ const manyUserTasks = () => `${users()}/tasks/get-many`; const currentUser = () => `${users()}/auth/current`; const logUserOut = () => `${users()}/auth/log-out`; const manyUsersWithScheduleSettings = () => `${users()}/scheduleSettings`; +const validateSlackId = () => `${users()}/validate-slack-id`; /**************** Projects Endpoints ****************/ const projects = () => `${API_URL}/projects`; @@ -532,6 +533,7 @@ export const apiUrls = { currentUser, logUserOut, manyUsersWithScheduleSettings, + validateSlackId, projects, allProjectsGantt, From ae7510d7087b82e2ae326696f409056bbea0c47a Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 29 Jun 2026 14:45:27 -0400 Subject: [PATCH 11/43] unit tests --- src/backend/tests/unit/users.test.ts | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts index 0c532f579e..62e85009e4 100644 --- a/src/backend/tests/unit/users.test.ts +++ b/src/backend/tests/unit/users.test.ts @@ -10,6 +10,8 @@ import { import UsersService from '../../src/services/users.services.js'; import { NotFoundException, AccessDeniedException } from '../../src/utils/errors.utils.js'; import { RoleEnum } from 'shared'; +import { vi, Mock } from 'vitest'; +import * as slackIntegration from '../../src/integrations/slack.js'; describe('User Tests', () => { let orgId: string; @@ -119,4 +121,35 @@ describe('User Tests', () => { ).rejects.toThrow(new AccessDeniedException('Guests and members cannot update user roles!')); }); }); + + describe('Validate slack id tests', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns true for a valid Slack ID', async () => { + vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(true); + + const result = await UsersService.validateSlackId('U06D5RURPMF'); + + expect(result).toBe(true); + expect(slackIntegration.validateSlackUserId).toHaveBeenCalledWith('U06D5RURPMF'); + }); + + it('returns false for an invalid Slack ID', async () => { + vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(false); + + const result = await UsersService.validateSlackId('NOTAVALIDID'); + + expect(result).toBe(false); + }); + + it('returns false when Slack client is not configured', async () => { + vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(false); + + const result = await UsersService.validateSlackId('U06D5RURPMF'); + + expect(result).toBe(false); + }); + }); }); From 8e83829f1412dbe8f9919b597d3aaf7352417264 Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 29 Jun 2026 14:48:54 -0400 Subject: [PATCH 12/43] prettier --- src/frontend/src/pages/HomePage/Home.tsx | 4 +--- src/frontend/src/tests/app/AppAuthenticated.test.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/pages/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 54e5f267a4..e2aa183cdb 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -22,9 +22,7 @@ const Home: React.FC = () => { {completedOnboarding && !isAdmin(user.role) && - [routes.HOME_PNM, routes.HOME_ONBOARDING].map((path) => ( - - ))} + [routes.HOME_PNM, routes.HOME_ONBOARDING].map((path) => )} {onOnboarding && !completedOnboarding && } 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( - + ); }; From 978c190d74ae0fc120c7db757bd9036cc778a3f8 Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 29 Jun 2026 15:06:36 -0400 Subject: [PATCH 13/43] different test pattern --- src/backend/tests/unit/users.test.ts | 29 +++++++++++----------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts index 62e85009e4..2896c38473 100644 --- a/src/backend/tests/unit/users.test.ts +++ b/src/backend/tests/unit/users.test.ts @@ -13,6 +13,10 @@ import { RoleEnum } from 'shared'; import { vi, Mock } from 'vitest'; import * as slackIntegration from '../../src/integrations/slack.js'; +vi.mock('../../src/integrations/slack.js', () => ({ + validateSlackUserId: vi.fn() +})); + describe('User Tests', () => { let orgId: string; let organization: Organization; @@ -122,33 +126,22 @@ describe('User Tests', () => { }); }); - describe('Validate slack id tests', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('returns true for a valid Slack ID', async () => { - vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(true); - + describe('Validate Slack id tests', () => { + it('returns true for a valid Slack id', async () => { + (slackIntegration.validateSlackUserId as Mock).mockResolvedValue(true); const result = await UsersService.validateSlackId('U06D5RURPMF'); - expect(result).toBe(true); - expect(slackIntegration.validateSlackUserId).toHaveBeenCalledWith('U06D5RURPMF'); }); - it('returns false for an invalid Slack ID', async () => { - vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(false); - - const result = await UsersService.validateSlackId('NOTAVALIDID'); - + it('returns false for an invalid Slack id', async () => { + (slackIntegration.validateSlackUserId as Mock).mockResolvedValue(false); + const result = await UsersService.validateSlackId('BLAH'); expect(result).toBe(false); }); it('returns false when Slack client is not configured', async () => { - vi.spyOn(slackIntegration, 'validateSlackUserId').mockResolvedValue(false); - + (slackIntegration.validateSlackUserId as Mock).mockResolvedValue(false); const result = await UsersService.validateSlackId('U06D5RURPMF'); - expect(result).toBe(false); }); }); From c7b64d1fbadba29962cf8e16d526fae93749bd28 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Tue, 7 Jul 2026 17:16:23 -0400 Subject: [PATCH 14/43] #4122 schema changes + widget --- .../src/controllers/calendar.controllers.ts | 15 +- .../migration.sql | 3 + src/backend/src/prisma/schema.prisma | 27 ++-- src/backend/src/prisma/seed.ts | 113 +++++++++++++ src/backend/src/routes/calendar.routes.ts | 4 + src/backend/src/services/calendar.services.ts | 86 ++++++++-- .../src/transformers/calendar.transformer.ts | 3 +- src/backend/tests/unit/calendar.test.ts | 16 +- src/frontend/src/apis/calendar.api.ts | 15 +- src/frontend/src/hooks/calendar.hooks.ts | 20 ++- .../AdminToolsScheduleConfig.tsx | 9 +- .../ScheduleConfig/Calendar/CalendarModal.tsx | 38 +++-- .../Calendar/CreateCalendarModal.tsx | 3 +- .../Calendar/EditCalendarModal.tsx | 6 +- .../pages/CalendarPage/CalendarDayCard.tsx | 11 +- .../src/pages/CalendarPage/CalendarPage.tsx | 6 +- .../src/pages/CalendarPage/CalendarTab.tsx | 20 ++- .../pages/CalendarPage/CalendarWeekView.tsx | 15 +- .../components/NewMemberEventsWidget.tsx | 151 ++++++++++++++++++ .../components/OnboardingInfoSection.tsx | 4 + src/frontend/src/utils/urls.ts | 2 + src/shared/src/types/calendar-types.ts | 1 + 22 files changed, 509 insertions(+), 59 deletions(-) create mode 100644 src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx diff --git a/src/backend/src/controllers/calendar.controllers.ts b/src/backend/src/controllers/calendar.controllers.ts index 022d0616bf..48c823356b 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/prisma/migrations/20260522172140_onboarding_improvements/migration.sql b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql index d7e9a1d7b2..550f3c3504 100644 --- a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql +++ b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql @@ -1,6 +1,9 @@ -- 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, diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index a2130c9472..de3ad6f065 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -1177,19 +1177,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 { diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 7ef3757b50..1534889baa 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -4294,6 +4294,7 @@ const performSeed: () => Promise = async () => { 'Engineering Team Calendar', 'Tracks all engineering team events, meetings, and deadlines.', '#3498db', + false, ner ); @@ -4302,6 +4303,7 @@ const performSeed: () => Promise = async () => { 'Finishline Projects Calendar', 'Tracks all ongoing projects currently being developed for Finishline', '#911111ff', + false, ner ); @@ -4310,6 +4312,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 ); @@ -4405,6 +4417,107 @@ 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 4a34817be0..c3997989df 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 ); @@ -215,6 +216,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); @@ -244,6 +247,7 @@ calendarRouter.post( nonEmptyString(body('name')), nonEmptyString(body('description')), nonEmptyString(body('colorHexCode')), + body('isNewMemberCalendar').isBoolean(), validateInputs, CalendarController.editCalendar ); diff --git a/src/backend/src/services/calendar.services.ts b/src/backend/src/services/calendar.services.ts index 6816cb9b8f..4239a6e854 100644 --- a/src/backend/src/services/calendar.services.ts +++ b/src/backend/src/services/calendar.services.ts @@ -2104,6 +2104,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 @@ -2115,6 +2116,7 @@ export default class CalendarService { name: string, description: string, colorHexCode: string, + isNewMemberCalendar: boolean, organization: Organization ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { @@ -2132,15 +2134,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); @@ -2152,6 +2164,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. @@ -2167,6 +2180,7 @@ export default class CalendarService { name: string, description: string, colorHexCode: string, + isNewMemberCalendar: boolean, organization: Organization ): Promise { const calendar = await prisma.calendar.findUnique({ @@ -2196,14 +2210,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); @@ -2607,6 +2631,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/transformers/calendar.transformer.ts b/src/backend/src/transformers/calendar.transformer.ts index d1317748a8..efd9db4136 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 { '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/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/hooks/calendar.hooks.ts b/src/frontend/src/hooks/calendar.hooks.ts index 15b8607002..e4a2918edf 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, @@ -124,7 +125,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; @@ -139,7 +144,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; @@ -392,6 +401,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/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..08c45d87fe 100644 --- a/src/frontend/src/pages/CalendarPage/CalendarTab.tsx +++ b/src/frontend/src/pages/CalendarPage/CalendarTab.tsx @@ -3,17 +3,17 @@ 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'; @@ -31,8 +31,21 @@ const CalendarTab: React.FC = () => { const [createTaskDefaultDeadline, setCreateTaskDefaultDeadline] = useState(undefined); const user = useCurrentUser(); const history = useHistory(); + const location = useLocation(); const canViewReviews = isHead(user.role) || isLead(user.role); + const selectedEventId = new URLSearchParams(location.search).get('eventId') ?? undefined; + const { data: selectedEvent } = useSingleEvent(selectedEventId); + + useEffect(() => { + if (!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)); + }, [selectedEvent]); + const handleViewModeToggle = (_: React.MouseEvent, newMode: 'month' | 'week' | null) => { if (!newMode || newMode === viewMode) return; if (newMode === 'week') { @@ -191,6 +204,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/components/NewMemberEventsWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx new file mode 100644 index 0000000000..a815a36a29 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx @@ -0,0 +1,151 @@ +import { useMemo, useState } from 'react'; +import { Box, Checkbox, FormControlLabel, FormGroup, Typography, useTheme } from '@mui/material'; +import { useHistory } from 'react-router-dom'; +import { format } from 'date-fns'; +import { Event } from 'shared'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useNewMemberEvents } from '../../../hooks/calendar.hooks'; +import { meetingStartTimePipeScheduleSlot } from '../../../utils/pipes'; +import { routes } from '../../../utils/routes'; + +const getEventDate = (event: Event): Date | undefined => { + const firstScheduledDate = event.initialDateScheduled || event.scheduledTimes[0]?.startTime; + return firstScheduledDate ? new Date(firstScheduledDate) : undefined; +}; + +const EventBlock: React.FC<{ event: Event }> = ({ event }) => { + const theme = useTheme(); + const history = useHistory(); + const eventDate = getEventDate(event); + + return ( + history.push(`${routes.CALENDAR}?eventId=${event.eventId}`)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1.5, + p: 1, + borderRadius: '8px', + cursor: 'pointer', + '&:hover': { backgroundColor: theme.palette.action.hover } + }} + > + + + {eventDate ? format(eventDate, 'MMM').toUpperCase() : '—'} + + + {eventDate ? format(eventDate, 'd') : '—'} + + + + + {event.title} + + + {meetingStartTimePipeScheduleSlot(event.scheduledTimes)} + {event.location ? ` · ${event.location}` : event.zoomLink ? ` · ${event.zoomLink}` : ''} + + + + ); +}; + +const NewMemberEventsWidget: React.FC = () => { + const theme = useTheme(); + const { data: events, isLoading, isError, error } = useNewMemberEvents(); + const [selectedTeamTypeIds, setSelectedTeamTypeIds] = useState([]); + + const teamTypeOptions = useMemo(() => { + const seen = new Map(); + (events ?? []).forEach((event) => { + if (event.teamType) seen.set(event.teamType.teamTypeId, event.teamType.name); + }); + return Array.from(seen, ([teamTypeId, name]) => ({ teamTypeId, name })); + }, [events]); + + const sortedEvents = useMemo(() => { + return [...(events ?? [])].sort((a, b) => { + const aDate = getEventDate(a); + const bDate = getEventDate(b); + if (!aDate && !bDate) return 0; + if (!aDate) return 1; + if (!bDate) return -1; + return aDate.getTime() - bDate.getTime(); + }); + }, [events]); + + const filteredEvents = + selectedTeamTypeIds.length === 0 + ? sortedEvents + : sortedEvents.filter((event) => event.teamType && selectedTeamTypeIds.includes(event.teamType.teamTypeId)); + + const toggleTeamType = (teamTypeId: string) => { + setSelectedTeamTypeIds((prev) => + prev.includes(teamTypeId) ? prev.filter((id) => id !== teamTypeId) : [...prev, teamTypeId] + ); + }; + + if (isError) return ; + if (isLoading || !events) return ; + + return ( + + + New Member Events + + + {teamTypeOptions.length > 1 && ( + + {teamTypeOptions.map((teamType) => ( + toggleTeamType(teamType.teamTypeId)} + /> + } + label={{teamType.name}} + /> + ))} + + )} + + + {filteredEvents.length === 0 ? ( + + No upcoming new member events + + ) : ( + filteredEvents.map((event) => ) + )} + + + ); +}; + +export default NewMemberEventsWidget; diff --git a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx b/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx index f3cbadc225..38aa535afc 100644 --- a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx +++ b/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx @@ -5,6 +5,7 @@ import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; import OnboardingBlock from '../../AdminToolsPage/OnboardingConfig/OnboardingBlock'; import { useAllUsefulLinks } from '../../../hooks/projects.hooks'; +import NewMemberEventsWidget from './NewMemberEventsWidget'; const OnboardingInfoSection: React.FC = () => { const theme = useTheme(); @@ -30,6 +31,9 @@ const OnboardingInfoSection: React.FC = () => { return ( + + + `${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`; @@ -862,6 +863,7 @@ export const apiUrls = { calendarGetSingleEventWithMembers, calendarGetConflictingEvent, calendarEvents, + calendarNewMemberEvents, calendarEventsPaginated, calendarEventTypes, calendarDeleteEvent, diff --git a/src/shared/src/types/calendar-types.ts b/src/shared/src/types/calendar-types.ts index eb49c76548..20e755a41e 100644 --- a/src/shared/src/types/calendar-types.ts +++ b/src/shared/src/types/calendar-types.ts @@ -100,6 +100,7 @@ export interface Calendar { userCreated: User; dateCreated: Date; eventTypes: EventType[]; + isNewMemberCalendar: boolean; } export interface ScheduleSlot { From 34286e98093358e2e3d3fb465616f011edd310ae Mon Sep 17 00:00:00 2001 From: wavehassman Date: Tue, 7 Jul 2026 17:43:22 -0400 Subject: [PATCH 15/43] #4122 fix error handling --- .../src/pages/CalendarPage/CalendarTab.tsx | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/frontend/src/pages/CalendarPage/CalendarTab.tsx b/src/frontend/src/pages/CalendarPage/CalendarTab.tsx index 08c45d87fe..a7b9c0c2fe 100644 --- a/src/frontend/src/pages/CalendarPage/CalendarTab.tsx +++ b/src/frontend/src/pages/CalendarPage/CalendarTab.tsx @@ -17,6 +17,7 @@ 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); @@ -32,19 +33,30 @@ const CalendarTab: React.FC = () => { 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 } = useSingleEvent(selectedEventId); + const { + data: selectedEvent, + isLoading: selectedEventIsLoading, + isError: selectedEventIsError, + error: selectedEventError + } = useSingleEvent(selectedEventId); useEffect(() => { - if (!selectedEvent) return; + 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)); - }, [selectedEvent]); + // 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; @@ -104,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 || @@ -115,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' }); From b862b8231e97afedebde24e22d1b614701febd7b Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 10 Jul 2026 12:02:09 -0400 Subject: [PATCH 16/43] remove endpoint and validate in updateusersettings instead --- src/backend/src/controllers/users.controllers.ts | 10 ---------- src/backend/src/routes/users.routes.ts | 2 -- src/backend/src/services/users.services.ts | 15 ++++++--------- src/frontend/src/apis/users.api.ts | 10 ---------- src/frontend/src/hooks/users.hooks.ts | 11 ----------- .../HomePage/components/SetUserPreferences.tsx | 10 ++-------- 6 files changed, 8 insertions(+), 50 deletions(-) diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index 00e1f637b1..75fe877002 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -242,14 +242,4 @@ export default class UsersController { next(error); } } - - static async validateSlackId(req: Request, res: Response, next: NextFunction) { - try { - const { slackId } = req.body; - const isValid = await UsersService.validateSlackId(slackId); - res.status(200).json({ isValid }); - } catch (error: unknown) { - next(error); - } - } } diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 11cba8d7ee..98a7b6b21f 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -66,6 +66,4 @@ userRouter.post( UsersController.getManyUserTasks ); -userRouter.post('/validate-slack-id', nonEmptyString(body('slackId')), validateInputs, UsersController.validateSlackId); - export default userRouter; diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index bce2daf453..030a9d129d 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -198,6 +198,12 @@ export default class UsersService { * @throws if the user does not exist */ static async updateUserSettings(user: User, defaultTheme: ThemeName, slackId: string): Promise { + if (slackId) { + const isValid = await validateSlackUserId(slackId); + if (!isValid) { + throw new HttpException(400, 'Invalid Slack ID'); + } + } const { userId } = user; const updatedSettings = await prisma.user_Settings.upsert({ @@ -623,13 +629,4 @@ export default class UsersService { return users.map(userWithScheduleSettingsTransformer); } - - /** - * Validates a user's slack id - * @param slackId the Slack user id to validate - * @returns true if the user exists, false otherwise - */ - static async validateSlackId(slackId: string): Promise { - return validateSlackUserId(slackId); - } } diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index bb0292e847..4da38b9489 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -212,13 +212,3 @@ export const getManyUsersWithScheduleSettings = (userIds: string[]) => { export const logUserOut = () => { return axios.post<{ message: string }>(apiUrls.logUserOut()); }; - -/** - * Validates a user's slack id - * - * @param slackId the user's slack id - * @returns true if the slack id is valid, false otherwise - */ -export const validateSlackId = (slackId: string) => { - return axios.post<{ isValid: boolean }>(apiUrls.validateSlackId(), { slackId }); -}; diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index ffb3eb19bd..dfbc40f390 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -25,7 +25,6 @@ import { getManyUsersWithScheduleSettings, getAllOrgUsers, getAllOrgMembers, - validateSlackId } from '../apis/users.api'; import { User, @@ -323,13 +322,3 @@ export const useLogUserOut = () => { return data; }); }; - -/** - * Custom react hook to determine if a user's slack id is valid - */ -export const useValidateSlackId = () => { - return useMutation<{ isValid: boolean }, Error, string>(['users', 'validate-slack-id'], async (slackId: string) => { - const { data } = await validateSlackId(slackId); - return data; - }); -}; diff --git a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx index 11634e4805..79e9b2257a 100644 --- a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx +++ b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx @@ -15,7 +15,7 @@ import LoadingIndicator from '../../../components/LoadingIndicator'; import NERSuccessButton from '../../../components/NERSuccessButton'; import ReactHookTextField from '../../../components/ReactHookTextField'; import { useToast } from '../../../hooks/toasts.hooks'; -import { useUpdateUserSettings, useValidateSlackId } from '../../../hooks/users.hooks'; +import { useUpdateUserSettings } from '../../../hooks/users.hooks'; import ErrorPage from '../../ErrorPage'; interface SetUserPreferencesProps { @@ -28,20 +28,14 @@ const SetUserPreferences: React.FC = ({ userSettings }) const { handleSubmit, control } = useForm<{ slackId: string }>({ defaultValues: { slackId: userSettings.slackId } }); - const { mutateAsync: validateSlackId } = useValidateSlackId(); if (isLoading) return ; if (isError) return ; const onSubmit = async ({ slackId }: { slackId: string }) => { try { - const { isValid } = await validateSlackId(slackId); - if (!isValid) { - toast.error('Invalid Slack ID! Please check it and try again.'); - return; - } await mutateAsync({ ...userSettings, slackId }); - // window.location.reload(); might not need this if it rerenders automatically + window.location.reload(); } catch (error: unknown) { if (error instanceof Error) { toast.error(error.message); From 3b1aac059d7bf064b4fa026ccdab81287fe8c00d Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 10 Jul 2026 12:03:54 -0400 Subject: [PATCH 17/43] remove tests --- src/backend/tests/unit/users.test.ts | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts index 2896c38473..7220c6605c 100644 --- a/src/backend/tests/unit/users.test.ts +++ b/src/backend/tests/unit/users.test.ts @@ -11,11 +11,6 @@ import UsersService from '../../src/services/users.services.js'; import { NotFoundException, AccessDeniedException } from '../../src/utils/errors.utils.js'; import { RoleEnum } from 'shared'; import { vi, Mock } from 'vitest'; -import * as slackIntegration from '../../src/integrations/slack.js'; - -vi.mock('../../src/integrations/slack.js', () => ({ - validateSlackUserId: vi.fn() -})); describe('User Tests', () => { let orgId: string; @@ -125,24 +120,4 @@ describe('User Tests', () => { ).rejects.toThrow(new AccessDeniedException('Guests and members cannot update user roles!')); }); }); - - describe('Validate Slack id tests', () => { - it('returns true for a valid Slack id', async () => { - (slackIntegration.validateSlackUserId as Mock).mockResolvedValue(true); - const result = await UsersService.validateSlackId('U06D5RURPMF'); - expect(result).toBe(true); - }); - - it('returns false for an invalid Slack id', async () => { - (slackIntegration.validateSlackUserId as Mock).mockResolvedValue(false); - const result = await UsersService.validateSlackId('BLAH'); - expect(result).toBe(false); - }); - - it('returns false when Slack client is not configured', async () => { - (slackIntegration.validateSlackUserId as Mock).mockResolvedValue(false); - const result = await UsersService.validateSlackId('U06D5RURPMF'); - expect(result).toBe(false); - }); - }); }); From 776bab202965ae2580624c4c1c6b17af80dfd5a1 Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 10 Jul 2026 14:46:42 -0400 Subject: [PATCH 18/43] tests --- src/backend/src/services/users.services.ts | 3 ++ src/backend/tests/unit/users.test.ts | 43 +++++++++++++++++++ .../components/SetUserPreferences.tsx | 5 +-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 030a9d129d..a34d93e222 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -199,6 +199,9 @@ export default class UsersService { */ static async updateUserSettings(user: User, defaultTheme: ThemeName, slackId: string): Promise { if (slackId) { + if (!process.env.SLACK_BOT_TOKEN) { + throw new HttpException(500, 'Slack integration not configured'); + } const isValid = await validateSlackUserId(slackId); if (!isValid) { throw new HttpException(400, 'Invalid Slack ID'); diff --git a/src/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts index 7220c6605c..bbb7f72d25 100644 --- a/src/backend/tests/unit/users.test.ts +++ b/src/backend/tests/unit/users.test.ts @@ -11,6 +11,11 @@ import UsersService from '../../src/services/users.services.js'; import { NotFoundException, AccessDeniedException } from '../../src/utils/errors.utils.js'; import { RoleEnum } from 'shared'; import { vi, Mock } from 'vitest'; +import { validateSlackUserId } from '../../src/integrations/slack.js'; + +vi.mock('../../src/integrations/slack.js', () => ({ + validateSlackUserId: vi.fn() +})); describe('User Tests', () => { let orgId: string; @@ -120,4 +125,42 @@ describe('User Tests', () => { ).rejects.toThrow(new AccessDeniedException('Guests and members cannot update user roles!')); }); }); + + describe('Update User Settings', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it('throws when slack bot token is not set, regardless of slackId', async () => { + vi.stubEnv('SLACK_BOT_TOKEN', ''); + + const testUser = await createTestUser(batmanAppAdmin, orgId); + + await expect(async () => await UsersService.updateUserSettings(testUser, 'DARK', 'la la la')).rejects.toThrow( + 'Slack integration is not configured' + ); + }); + + it('throws when slack bot token is set and the id is invalid', async () => { + vi.stubEnv('SLACK_BOT_TOKEN', 'fake-token'); + (validateSlackUserId as Mock).mockResolvedValue(false); + + const testUser = await createTestUser(batmanAppAdmin, orgId); + + await expect(async () => await UsersService.updateUserSettings(testUser, 'DARK', 'blahID')).rejects.toThrow( + 'Invalid Slack ID' + ); + }); + + it('saves successfully when slack bot token is set and id is valid', async () => { + vi.stubEnv('SLACK_BOT_TOKEN', 'fake-token'); + (validateSlackUserId as Mock).mockResolvedValue(true); + + const testUser = await createTestUser(batmanAppAdmin, orgId); + const result = await UsersService.updateUserSettings(testUser, 'DARK', 'UIDVALID'); + + expect(result.slackId).toBe('UIDVALID'); + }); + }); }); diff --git a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx index 79e9b2257a..6be87d627b 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,13 @@ 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 ; + //if (isError) return ; const onSubmit = async ({ slackId }: { slackId: string }) => { try { From 1bd86bc7821a03e608ba7c72c4a2a4644ca25927 Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 10 Jul 2026 14:48:04 -0400 Subject: [PATCH 19/43] remove endpoint from urls --- src/frontend/src/utils/urls.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 609f913492..ee39409179 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -32,7 +32,6 @@ const manyUserTasks = () => `${users()}/tasks/get-many`; const currentUser = () => `${users()}/auth/current`; const logUserOut = () => `${users()}/auth/log-out`; const manyUsersWithScheduleSettings = () => `${users()}/scheduleSettings`; -const validateSlackId = () => `${users()}/validate-slack-id`; /**************** Projects Endpoints ****************/ const projects = () => `${API_URL}/projects`; @@ -533,7 +532,6 @@ export const apiUrls = { currentUser, logUserOut, manyUsersWithScheduleSettings, - validateSlackId, projects, allProjectsGantt, From 06e670d7c093b051eca630a5a251e524dfbc7783 Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 10 Jul 2026 14:51:03 -0400 Subject: [PATCH 20/43] prettier --- src/frontend/src/hooks/users.hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index dfbc40f390..c890c23671 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -24,7 +24,7 @@ import { logUserOut, getManyUsersWithScheduleSettings, getAllOrgUsers, - getAllOrgMembers, + getAllOrgMembers } from '../apis/users.api'; import { User, From ab65e7c9b823f0026e786953c03591536a2d127f Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 10 Jul 2026 15:01:04 -0400 Subject: [PATCH 21/43] typo --- src/backend/tests/unit/users.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts index bbb7f72d25..2afffc6430 100644 --- a/src/backend/tests/unit/users.test.ts +++ b/src/backend/tests/unit/users.test.ts @@ -138,7 +138,7 @@ describe('User Tests', () => { const testUser = await createTestUser(batmanAppAdmin, orgId); await expect(async () => await UsersService.updateUserSettings(testUser, 'DARK', 'la la la')).rejects.toThrow( - 'Slack integration is not configured' + 'Slack integration not configured' ); }); From ef6c1feca388bc3806f0b0f341c4c9b071b17baa Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 10 Jul 2026 16:57:55 -0400 Subject: [PATCH 22/43] #4121 frontend and backend changes --- .../controllers/recruitment.controllers.ts | 15 ++++- src/backend/src/prisma/seed.ts | 42 ++++++++++-- src/backend/src/routes/recruitment.routes.ts | 5 ++ .../src/services/recruitment.services.ts | 19 ++++++ src/backend/tests/unit/recruitment.test.ts | 12 ++++ .../tests/unmocked/recruitment.test.ts | 10 +++ src/frontend/src/apis/recruitment.api.ts | 6 ++ src/frontend/src/hooks/recruitment.hooks.ts | 11 ++- .../RecruitmentConfig/MilestoneFormModal.tsx | 27 ++++++-- .../components/NewMemberMilestonesWidget.tsx | 67 +++++++++++++++++++ .../components/OnboardingInfoSection.tsx | 4 ++ src/frontend/src/utils/urls.ts | 2 + 12 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx diff --git a/src/backend/src/controllers/recruitment.controllers.ts b/src/backend/src/controllers/recruitment.controllers.ts index cf12d6c168..db30577492 100644 --- a/src/backend/src/controllers/recruitment.controllers.ts +++ b/src/backend/src/controllers/recruitment.controllers.ts @@ -11,15 +11,25 @@ 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 createMilestone(req: Request, res: Response, next: NextFunction) { try { - const { name, description, dateOfEvent } = req.body; + const { name, description, dateOfEvent, isOnNewMemberDashboard } = req.body; const milestone = await RecruitmentServices.createMilestone( req.currentUser, name, description, dateOfEvent, + isOnNewMemberDashboard, req.organization ); res.status(200).json(milestone); @@ -31,13 +41,14 @@ export default class RecruitmentController { static async editMilestone(req: Request, res: Response, next: NextFunction) { try { const { milestoneId } = req.params as Record; - const { name, description, dateOfEvent } = req.body; + const { name, description, dateOfEvent, isOnNewMemberDashboard } = req.body; const milestone = await RecruitmentServices.editMilestone( req.currentUser, name, description, dateOfEvent, + isOnNewMemberDashboard, milestoneId, req.organization ); diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 7ef3757b50..de65e27302 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -3288,10 +3288,44 @@ 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); + await RecruitmentServices.createMilestone(batman, 'Club fair!', 'Also meet us at:', daysAgo(120), false, ner); + await RecruitmentServices.createMilestone(batman, 'Applications Open', '', daysAgo(70), false, ner); + await RecruitmentServices.createMilestone(batman, 'Applications Close', '', daysAgo(56), false, ner); + await RecruitmentServices.createMilestone(batman, 'Decision Day!', '', daysAgo(49), false, ner); + + // new member onboarding milestones + await RecruitmentServices.createMilestone( + batman, + 'First Meeting', + 'Attend your first general body meeting', + daysAgo(14), + true, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'First Bay Time', + 'Get hands-on time in the bay with a team lead', + daysAgo(7), + true, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'Safety Training Deadline', + 'Complete required safety training to access the bay unsupervised', + daysFromNow(14), + true, + ner + ); + await RecruitmentServices.createMilestone( + batman, + 'Subteam Placement', + 'Officially join a subteam project', + daysFromNow(30), + true, + ner + ); await RecruitmentServices.createOrganizationFaq(batman, 'Who is the Chief Software Engineer?', 'Peyton McKee', ner); await RecruitmentServices.createOrganizationFaq( diff --git a/src/backend/src/routes/recruitment.routes.ts b/src/backend/src/routes/recruitment.routes.ts index 3a6ebfc10e..78abd1df2a 100644 --- a/src/backend/src/routes/recruitment.routes.ts +++ b/src/backend/src/routes/recruitment.routes.ts @@ -6,6 +6,9 @@ import RecruitmentController from '../controllers/recruitment.controllers.js'; const recruitmentRouter = express.Router(); /* Milestone Section */ + +recruitmentRouter.get('/milestones/new-member', RecruitmentController.getNewMemberMilestones); + recruitmentRouter.get('/milestones', RecruitmentController.getAllMilestones); recruitmentRouter.post( @@ -13,6 +16,7 @@ recruitmentRouter.post( nonEmptyString(body('name')), nonEmptyString(body('description')), isDateOnly(body('dateOfEvent')), + body('isOnNewMemberDashboard').isBoolean(), validateInputs, RecruitmentController.createMilestone ); @@ -22,6 +26,7 @@ recruitmentRouter.post( nonEmptyString(body('name')), nonEmptyString(body('description')), isDateOnly(body('dateOfEvent')), + body('isOnNewMemberDashboard').isBoolean(), validateInputs, RecruitmentController.editMilestone ); diff --git a/src/backend/src/services/recruitment.services.ts b/src/backend/src/services/recruitment.services.ts index d6ab6f5844..f1f5db99d5 100644 --- a/src/backend/src/services/recruitment.services.ts +++ b/src/backend/src/services/recruitment.services.ts @@ -20,12 +20,26 @@ export default class RecruitmentServices { return allMilestones; } + /** + * 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) { + const newMemberMilestones = await prisma.milestone.findMany({ + where: { organizationId: organization.organizationId, dateDeleted: null, isOnNewMemberDashboard: true } + }); + + return newMemberMilestones; + } + /** * 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 isOnNewMemberDashboard whether the milestone should show on the new member dashboard * @param organizationId the organization Id of the milestone * @returns A newly created milestone */ @@ -34,6 +48,7 @@ export default class RecruitmentServices { name: string, description: string, dateOfEvent: Date, + isOnNewMemberDashboard: boolean, organization: Organization ) { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) @@ -44,6 +59,7 @@ export default class RecruitmentServices { name, description, dateOfEvent, + isOnNewMemberDashboard, organizationId: organization.organizationId, userCreatedId: submitter.userId } @@ -58,6 +74,7 @@ export default class RecruitmentServices { * @param name the name of the user * @param description description of the milestone * @param dateOfEvent date of the event of the milestone + * @param isOnNewMemberDashboard whether the milestone should show on the new member dashboard * @param organizationId the organization Id of the milestone * @returns the edited milestone */ @@ -66,6 +83,7 @@ export default class RecruitmentServices { name: string, description: string, dateOfEvent: Date, + isOnNewMemberDashboard: boolean, milestoneId: string, organization: Organization ) { @@ -94,6 +112,7 @@ export default class RecruitmentServices { name, description, dateOfEvent, + isOnNewMemberDashboard, organizationId: organization.organizationId } }); diff --git a/src/backend/tests/unit/recruitment.test.ts b/src/backend/tests/unit/recruitment.test.ts index e1173dce76..d637113de6 100644 --- a/src/backend/tests/unit/recruitment.test.ts +++ b/src/backend/tests/unit/recruitment.test.ts @@ -104,6 +104,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date(), + false, organization ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a milestone')); @@ -115,6 +116,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, organization ); @@ -133,6 +135,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date(), + false, '1', organization ) @@ -147,6 +150,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, '1', organization ) @@ -159,6 +163,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, organization ); @@ -175,6 +180,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, milestone.milestoneId, organization ) @@ -187,6 +193,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, organization ); @@ -195,6 +202,7 @@ describe('Recruitment Tests', () => { 'new name', 'new description', new Date('11/14/24'), + false, milestone.milestoneId, organization ); @@ -212,6 +220,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/11/24'), + false, organization ); @@ -220,6 +229,7 @@ describe('Recruitment Tests', () => { 'name2', 'description2', new Date('1/1/1'), + false, organization ); const result = await RecruitmentServices.getAllMilestones(organization); @@ -515,6 +525,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, organization ); @@ -531,6 +542,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, milestone.milestoneId, organization ) diff --git a/src/backend/tests/unmocked/recruitment.test.ts b/src/backend/tests/unmocked/recruitment.test.ts index bbf7187bf4..b629168fe2 100644 --- a/src/backend/tests/unmocked/recruitment.test.ts +++ b/src/backend/tests/unmocked/recruitment.test.ts @@ -105,6 +105,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date(), + false, organization ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a milestone')); @@ -116,6 +117,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, organization ); @@ -134,6 +136,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date(), + false, '1', organization ) @@ -148,6 +151,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, '1', organization ) @@ -160,6 +164,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, organization ); @@ -176,6 +181,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, milestone.milestoneId, organization ) @@ -188,6 +194,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), + false, organization ); @@ -196,6 +203,7 @@ describe('Recruitment Tests', () => { 'new name', 'new description', new Date('11/14/24'), + false, milestone.milestoneId, organization ); @@ -213,6 +221,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/11/24'), + false, organization ); @@ -221,6 +230,7 @@ describe('Recruitment Tests', () => { 'name2', 'description2', new Date('1/1/1'), + false, organization ); const result = await RecruitmentServices.getAllMilestones(organization); diff --git a/src/frontend/src/apis/recruitment.api.ts b/src/frontend/src/apis/recruitment.api.ts index ad8691bc2e..9988523768 100644 --- a/src/frontend/src/apis/recruitment.api.ts +++ b/src/frontend/src/apis/recruitment.api.ts @@ -10,6 +10,12 @@ export const getAllMilestones = () => { }); }; +export const getNewMemberMilestones = () => { + return axios.get(apiUrls.newMemberMilestones(), { + transformResponse: (data) => JSON.parse(data) + }); +}; + export const createMilestone = (payload: MilestonePayload) => { return axios.post(apiUrls.milestoneCreate(), { ...payload, diff --git a/src/frontend/src/hooks/recruitment.hooks.ts b/src/frontend/src/hooks/recruitment.hooks.ts index cea7f41993..95ec5bbb3e 100644 --- a/src/frontend/src/hooks/recruitment.hooks.ts +++ b/src/frontend/src/hooks/recruitment.hooks.ts @@ -12,13 +12,15 @@ import { editMilestone, getAllFaqs, getAllGuestDefinitions, - getAllMilestones + getAllMilestones, + getNewMemberMilestones } from '../apis/recruitment.api'; export interface MilestonePayload { name: string; description: string; dateOfEvent: Date; + isOnNewMemberDashboard: boolean; } export interface FaqPayload { @@ -43,6 +45,13 @@ export const useAllMilestones = () => { }); }; +export const useNewMemberMilestones = () => { + return useQuery(['milestones', 'new-member'], async () => { + const { data } = await getNewMemberMilestones(); + return data; + }); +}; + export const useCreateMilestone = () => { const queryClient = useQueryClient(); return useMutation( diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneFormModal.tsx index d56aaf3c09..f88cf13425 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneFormModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneFormModal.tsx @@ -1,6 +1,6 @@ import { Controller, useForm } from 'react-hook-form'; import NERFormModal from '../../../components/NERFormModal'; -import { FormControl, FormLabel, FormHelperText } from '@mui/material'; +import { Checkbox, FormControl, FormControlLabel, FormLabel, FormHelperText } from '@mui/material'; import ReactHookTextField from '../../../components/ReactHookTextField'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; @@ -22,7 +22,8 @@ interface MilestoneFormModalProps { const schema = yup.object().shape({ name: yup.string().required('Milestone is Required'), description: yup.string().required('Description is Required'), - dateOfEvent: yup.date().required('Date of Event is Required') + dateOfEvent: yup.date().required('Date of Event is Required'), + isOnNewMemberDashboard: yup.boolean().required() }); const MilestoneFormModal: React.FC = ({ open, handleClose, defaultValues, onSubmit }) => { @@ -45,7 +46,8 @@ const MilestoneFormModal: React.FC = ({ open, handleClo defaultValues: { name: defaultValues?.name ?? '', description: defaultValues?.description ?? '', - dateOfEvent: defaultValues?.dateOfEvent ? new Date(defaultValues.dateOfEvent) : new Date() + dateOfEvent: defaultValues?.dateOfEvent ? new Date(defaultValues.dateOfEvent) : new Date(), + isOnNewMemberDashboard: defaultValues?.isOnNewMemberDashboard ?? false } }); @@ -60,12 +62,13 @@ const MilestoneFormModal: React.FC = ({ open, handleClo reset({ name: defaultValues?.name ?? '', description: defaultValues?.description ?? '', - dateOfEvent: defaultValues?.dateOfEvent ?? new Date() + dateOfEvent: defaultValues?.dateOfEvent ?? new Date(), + isOnNewMemberDashboard: defaultValues?.isOnNewMemberDashboard ?? false }); }, [defaultValues, reset]); const handleCancel = () => { - reset({ name: '', description: '', dateOfEvent: new Date() }); + reset({ name: '', description: '', dateOfEvent: new Date(), isOnNewMemberDashboard: false }); sessionStorage.removeItem(formStorageKey); handleClose(); }; @@ -75,7 +78,7 @@ const MilestoneFormModal: React.FC = ({ open, handleClo open={open} onHide={handleCancel} title={defaultValues ? 'Edit Milestone' : 'New Milestone'} - reset={() => reset({ name: '', description: '', dateOfEvent: new Date() })} + reset={() => reset({ name: '', description: '', dateOfEvent: new Date(), isOnNewMemberDashboard: false })} handleUseFormSubmit={handleSubmit} onFormSubmit={onFormSubmit} formId="milestone-form" @@ -128,6 +131,18 @@ const MilestoneFormModal: React.FC = ({ open, handleClo /> {errors.description?.message} + + ( + } + label="Show on new member dashboard" + /> + )} + /> + ); }; 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..29be928fa6 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx @@ -0,0 +1,67 @@ +import { useMemo } from 'react'; +import { Box, Typography, useTheme } from '@mui/material'; +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(); + + const sortedMilestones = useMemo(() => { + return [...(milestones ?? [])].sort( + (a, b) => new Date(a.dateOfEvent).getTime() - new Date(b.dateOfEvent).getTime() + ); + }, [milestones]); + + if (isError) return ; + if (isLoading || !milestones) return ; + + return ( + + + Onboarding Milestones + + + + {sortedMilestones.length === 0 ? ( + + No onboarding milestones yet + + ) : ( + sortedMilestones.map((milestone) => { + const isPast = isPastEvent(new Date(milestone.dateOfEvent), new Date()); + return ( + + + {formatDateOnly(new Date(milestone.dateOfEvent), 'MMMM D, YYYY')} + + + {milestone.name} + + {milestone.description && ( + + {milestone.description} + + )} + + ); + }) + )} + + + ); +}; + +export default NewMemberMilestonesWidget; diff --git a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx b/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx index f3cbadc225..02dc21eb44 100644 --- a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx +++ b/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx @@ -5,6 +5,7 @@ import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; import OnboardingBlock from '../../AdminToolsPage/OnboardingConfig/OnboardingBlock'; import { useAllUsefulLinks } from '../../../hooks/projects.hooks'; +import NewMemberMilestonesWidget from './NewMemberMilestonesWidget'; const OnboardingInfoSection: React.FC = () => { const theme = useTheme(); @@ -30,6 +31,9 @@ const OnboardingInfoSection: React.FC = () => { return ( + + + `${cars()}/${id}/edit`; /************** Recruitment Endpoints ***************/ const recruitment = () => `${API_URL}/recruitment`; const allMilestones = () => `${recruitment()}/milestones`; +const newMemberMilestones = () => `${recruitment()}/milestones/new-member`; const milestoneCreate = () => `${recruitment()}/milestone/create`; const milestoneEdit = (id: string) => `${recruitment()}/milestone/${id}/edit`; const milestoneDelete = (id: string) => `${recruitment()}/milestone/${id}/delete`; @@ -801,6 +802,7 @@ export const apiUrls = { recruitment, allMilestones, + newMemberMilestones, milestoneCreate, milestoneEdit, milestoneDelete, From 0c705450ac29526a60166d0c99e8ffea34b2d79a Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 10 Jul 2026 23:10:50 -0400 Subject: [PATCH 23/43] #4121 make it two tables --- .../controllers/recruitment.controllers.ts | 15 ++++++++--- src/backend/src/prisma/seed.ts | 12 ++++++--- src/backend/src/routes/recruitment.routes.ts | 4 ++- .../src/services/recruitment.services.ts | 19 ++++++++++--- src/backend/tests/unit/recruitment.test.ts | 12 +++++---- .../tests/unmocked/recruitment.test.ts | 10 ++++--- src/frontend/src/apis/recruitment.api.ts | 10 +++++-- src/frontend/src/hooks/recruitment.hooks.ts | 16 +++++++++-- .../AdminToolsOnboardingConfig.tsx | 7 +++++ .../AdminToolsRecruitmentConfig.tsx | 4 +-- .../CreateMilestoneFormModal.tsx | 9 ++++--- .../RecruitmentConfig/MilestoneFormModal.tsx | 27 +++++-------------- .../RecruitmentConfig/MilestoneTable.tsx | 21 +++++++++++---- .../NewMemberMilestoneTable.tsx | 12 +++++++++ .../RecruitingMilestoneTable.tsx | 12 +++++++++ .../components/NewMemberMilestonesWidget.tsx | 4 +-- src/frontend/src/utils/urls.ts | 2 ++ 17 files changed, 138 insertions(+), 58 deletions(-) create mode 100644 src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/NewMemberMilestoneTable.tsx create mode 100644 src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/RecruitingMilestoneTable.tsx diff --git a/src/backend/src/controllers/recruitment.controllers.ts b/src/backend/src/controllers/recruitment.controllers.ts index db30577492..4055fcae27 100644 --- a/src/backend/src/controllers/recruitment.controllers.ts +++ b/src/backend/src/controllers/recruitment.controllers.ts @@ -20,9 +20,18 @@ export default class RecruitmentController { } } + 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, isOnNewMemberDashboard } = req.body; + const { name, description, dateOfEvent, isOnNewMemberDashboard, isOnRecruitingDashboard } = req.body; const milestone = await RecruitmentServices.createMilestone( req.currentUser, @@ -30,6 +39,7 @@ export default class RecruitmentController { description, dateOfEvent, isOnNewMemberDashboard, + isOnRecruitingDashboard, req.organization ); res.status(200).json(milestone); @@ -41,14 +51,13 @@ export default class RecruitmentController { static async editMilestone(req: Request, res: Response, next: NextFunction) { try { const { milestoneId } = req.params as Record; - const { name, description, dateOfEvent, isOnNewMemberDashboard } = req.body; + const { name, description, dateOfEvent } = req.body; const milestone = await RecruitmentServices.editMilestone( req.currentUser, name, description, dateOfEvent, - isOnNewMemberDashboard, milestoneId, req.organization ); diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index de65e27302..003e9814fb 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -3288,10 +3288,10 @@ const performSeed: () => Promise = async () => { { userId: regina.userId, title: 'Chief Electrical Engineer' } ]); - await RecruitmentServices.createMilestone(batman, 'Club fair!', 'Also meet us at:', daysAgo(120), false, ner); - await RecruitmentServices.createMilestone(batman, 'Applications Open', '', daysAgo(70), false, ner); - await RecruitmentServices.createMilestone(batman, 'Applications Close', '', daysAgo(56), false, ner); - await RecruitmentServices.createMilestone(batman, 'Decision Day!', '', daysAgo(49), false, ner); + await RecruitmentServices.createMilestone(batman, 'Club fair!', 'Also meet us at:', daysAgo(120), false, true, ner); + await RecruitmentServices.createMilestone(batman, 'Applications Open', '', daysAgo(70), false, true, ner); + await RecruitmentServices.createMilestone(batman, 'Applications Close', '', daysAgo(56), false, true, ner); + await RecruitmentServices.createMilestone(batman, 'Decision Day!', '', daysAgo(49), false, true, ner); // new member onboarding milestones await RecruitmentServices.createMilestone( @@ -3300,6 +3300,7 @@ const performSeed: () => Promise = async () => { 'Attend your first general body meeting', daysAgo(14), true, + false, ner ); await RecruitmentServices.createMilestone( @@ -3308,6 +3309,7 @@ const performSeed: () => Promise = async () => { 'Get hands-on time in the bay with a team lead', daysAgo(7), true, + false, ner ); await RecruitmentServices.createMilestone( @@ -3316,6 +3318,7 @@ const performSeed: () => Promise = async () => { 'Complete required safety training to access the bay unsupervised', daysFromNow(14), true, + false, ner ); await RecruitmentServices.createMilestone( @@ -3324,6 +3327,7 @@ const performSeed: () => Promise = async () => { 'Officially join a subteam project', daysFromNow(30), true, + false, ner ); diff --git a/src/backend/src/routes/recruitment.routes.ts b/src/backend/src/routes/recruitment.routes.ts index 78abd1df2a..bc326d1820 100644 --- a/src/backend/src/routes/recruitment.routes.ts +++ b/src/backend/src/routes/recruitment.routes.ts @@ -9,6 +9,8 @@ const recruitmentRouter = express.Router(); recruitmentRouter.get('/milestones/new-member', RecruitmentController.getNewMemberMilestones); +recruitmentRouter.get('/milestones/recruiting', RecruitmentController.getRecruitingMilestones); + recruitmentRouter.get('/milestones', RecruitmentController.getAllMilestones); recruitmentRouter.post( @@ -17,6 +19,7 @@ recruitmentRouter.post( nonEmptyString(body('description')), isDateOnly(body('dateOfEvent')), body('isOnNewMemberDashboard').isBoolean(), + body('isOnRecruitingDashboard').isBoolean(), validateInputs, RecruitmentController.createMilestone ); @@ -26,7 +29,6 @@ recruitmentRouter.post( nonEmptyString(body('name')), nonEmptyString(body('description')), isDateOnly(body('dateOfEvent')), - body('isOnNewMemberDashboard').isBoolean(), validateInputs, RecruitmentController.editMilestone ); diff --git a/src/backend/src/services/recruitment.services.ts b/src/backend/src/services/recruitment.services.ts index f1f5db99d5..fdcaa60c1b 100644 --- a/src/backend/src/services/recruitment.services.ts +++ b/src/backend/src/services/recruitment.services.ts @@ -33,6 +33,19 @@ export default class RecruitmentServices { return newMemberMilestones; } + /** + * 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) { + const recruitingMilestones = await prisma.milestone.findMany({ + where: { organizationId: organization.organizationId, dateDeleted: null, isOnRecruitingDashboard: true } + }); + + return recruitingMilestones; + } + /** * Creates a new milestone in the given organization * @param submitter a user who is making this request @@ -40,6 +53,7 @@ export default class RecruitmentServices { * @param description description of the milestone * @param dateOfEvent date of the event of the milestone * @param isOnNewMemberDashboard whether the milestone should show on the new member dashboard + * @param isOnRecruitingDashboard whether the milestone should show on the recruiting dashboard * @param organizationId the organization Id of the milestone * @returns A newly created milestone */ @@ -49,6 +63,7 @@ export default class RecruitmentServices { description: string, dateOfEvent: Date, isOnNewMemberDashboard: boolean, + isOnRecruitingDashboard: boolean, organization: Organization ) { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) @@ -60,6 +75,7 @@ export default class RecruitmentServices { description, dateOfEvent, isOnNewMemberDashboard, + isOnRecruitingDashboard, organizationId: organization.organizationId, userCreatedId: submitter.userId } @@ -74,7 +90,6 @@ export default class RecruitmentServices { * @param name the name of the user * @param description description of the milestone * @param dateOfEvent date of the event of the milestone - * @param isOnNewMemberDashboard whether the milestone should show on the new member dashboard * @param organizationId the organization Id of the milestone * @returns the edited milestone */ @@ -83,7 +98,6 @@ export default class RecruitmentServices { name: string, description: string, dateOfEvent: Date, - isOnNewMemberDashboard: boolean, milestoneId: string, organization: Organization ) { @@ -112,7 +126,6 @@ export default class RecruitmentServices { name, description, dateOfEvent, - isOnNewMemberDashboard, organizationId: organization.organizationId } }); diff --git a/src/backend/tests/unit/recruitment.test.ts b/src/backend/tests/unit/recruitment.test.ts index d637113de6..4081b79e19 100644 --- a/src/backend/tests/unit/recruitment.test.ts +++ b/src/backend/tests/unit/recruitment.test.ts @@ -105,6 +105,7 @@ describe('Recruitment Tests', () => { 'description', new Date(), false, + false, organization ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a milestone')); @@ -117,6 +118,7 @@ describe('Recruitment Tests', () => { 'description', new Date('11/12/24'), false, + false, organization ); @@ -135,7 +137,6 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date(), - false, '1', organization ) @@ -150,7 +151,6 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, '1', organization ) @@ -164,6 +164,7 @@ describe('Recruitment Tests', () => { 'description', new Date('11/12/24'), false, + false, organization ); @@ -180,7 +181,6 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, milestone.milestoneId, organization ) @@ -194,6 +194,7 @@ describe('Recruitment Tests', () => { 'description', new Date('11/12/24'), false, + false, organization ); @@ -202,7 +203,6 @@ describe('Recruitment Tests', () => { 'new name', 'new description', new Date('11/14/24'), - false, milestone.milestoneId, organization ); @@ -221,6 +221,7 @@ describe('Recruitment Tests', () => { 'description', new Date('11/11/24'), false, + false, organization ); @@ -230,6 +231,7 @@ describe('Recruitment Tests', () => { 'description2', new Date('1/1/1'), false, + false, organization ); const result = await RecruitmentServices.getAllMilestones(organization); @@ -526,6 +528,7 @@ describe('Recruitment Tests', () => { 'description', new Date('11/12/24'), false, + false, organization ); @@ -542,7 +545,6 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, milestone.milestoneId, organization ) diff --git a/src/backend/tests/unmocked/recruitment.test.ts b/src/backend/tests/unmocked/recruitment.test.ts index b629168fe2..36c51ace2a 100644 --- a/src/backend/tests/unmocked/recruitment.test.ts +++ b/src/backend/tests/unmocked/recruitment.test.ts @@ -106,6 +106,7 @@ describe('Recruitment Tests', () => { 'description', new Date(), false, + false, organization ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a milestone')); @@ -118,6 +119,7 @@ describe('Recruitment Tests', () => { 'description', new Date('11/12/24'), false, + false, organization ); @@ -136,7 +138,6 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date(), - false, '1', organization ) @@ -151,7 +152,6 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, '1', organization ) @@ -165,6 +165,7 @@ describe('Recruitment Tests', () => { 'description', new Date('11/12/24'), false, + false, organization ); @@ -181,7 +182,6 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, milestone.milestoneId, organization ) @@ -195,6 +195,7 @@ describe('Recruitment Tests', () => { 'description', new Date('11/12/24'), false, + false, organization ); @@ -203,7 +204,6 @@ describe('Recruitment Tests', () => { 'new name', 'new description', new Date('11/14/24'), - false, milestone.milestoneId, organization ); @@ -222,6 +222,7 @@ describe('Recruitment Tests', () => { 'description', new Date('11/11/24'), false, + false, organization ); @@ -231,6 +232,7 @@ describe('Recruitment Tests', () => { 'description2', new Date('1/1/1'), false, + false, organization ); const result = await RecruitmentServices.getAllMilestones(organization); diff --git a/src/frontend/src/apis/recruitment.api.ts b/src/frontend/src/apis/recruitment.api.ts index 9988523768..b888523b1d 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'; @@ -16,7 +16,13 @@ export const getNewMemberMilestones = () => { }); }; -export const createMilestone = (payload: MilestonePayload) => { +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) diff --git a/src/frontend/src/hooks/recruitment.hooks.ts b/src/frontend/src/hooks/recruitment.hooks.ts index 95ec5bbb3e..07f53ff7a3 100644 --- a/src/frontend/src/hooks/recruitment.hooks.ts +++ b/src/frontend/src/hooks/recruitment.hooks.ts @@ -13,14 +13,19 @@ import { getAllFaqs, getAllGuestDefinitions, getAllMilestones, - getNewMemberMilestones + getNewMemberMilestones, + getRecruitingMilestones } from '../apis/recruitment.api'; export interface MilestonePayload { name: string; description: string; dateOfEvent: Date; +} + +export interface MilestoneCreatePayload extends MilestonePayload { isOnNewMemberDashboard: boolean; + isOnRecruitingDashboard: boolean; } export interface FaqPayload { @@ -52,9 +57,16 @@ export const useNewMemberMilestones = () => { }); }; +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); diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx index d68977b8eb..97986370a1 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx @@ -7,6 +7,7 @@ import { groupChecklists, sortGroupNames } from '../../../utils/onboarding.utils import ErrorPage from '../../ErrorPage'; import { AdminChecklist } from './Checklists/AdminChecklist'; import OnboardingInfoSection from './OnboardingInfoSection'; +import NewMemberMilestoneTable from '../RecruitmentConfig/NewMemberMilestoneTable'; import { Checklist } from 'shared'; type GroupedChecklists = Record; // Change made here @@ -71,6 +72,12 @@ const AdminToolsOnboardingConfig: React.FC = () => { + + + Milestones + + + ); diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/AdminToolsRecruitmentConfig.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/AdminToolsRecruitmentConfig.tsx index c7b76ba2c3..c84e713701 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/AdminToolsRecruitmentConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/AdminToolsRecruitmentConfig.tsx @@ -1,5 +1,5 @@ import { Box, Grid, Typography } from '@mui/material'; -import MilestoneTable from './MilestoneTable'; +import RecruitingMilestoneTable from './RecruitingMilestoneTable'; import FAQsTable from './FAQTable'; import { useCurrentOrganization } from '../../../hooks/organizations.hooks'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -33,7 +33,7 @@ const AdminToolsRecruitmentConfig: React.FC = () => { Milestones - + 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/MilestoneFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneFormModal.tsx index f88cf13425..d56aaf3c09 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneFormModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneFormModal.tsx @@ -1,6 +1,6 @@ import { Controller, useForm } from 'react-hook-form'; import NERFormModal from '../../../components/NERFormModal'; -import { Checkbox, FormControl, FormControlLabel, FormLabel, FormHelperText } from '@mui/material'; +import { FormControl, FormLabel, FormHelperText } from '@mui/material'; import ReactHookTextField from '../../../components/ReactHookTextField'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; @@ -22,8 +22,7 @@ interface MilestoneFormModalProps { const schema = yup.object().shape({ name: yup.string().required('Milestone is Required'), description: yup.string().required('Description is Required'), - dateOfEvent: yup.date().required('Date of Event is Required'), - isOnNewMemberDashboard: yup.boolean().required() + dateOfEvent: yup.date().required('Date of Event is Required') }); const MilestoneFormModal: React.FC = ({ open, handleClose, defaultValues, onSubmit }) => { @@ -46,8 +45,7 @@ const MilestoneFormModal: React.FC = ({ open, handleClo defaultValues: { name: defaultValues?.name ?? '', description: defaultValues?.description ?? '', - dateOfEvent: defaultValues?.dateOfEvent ? new Date(defaultValues.dateOfEvent) : new Date(), - isOnNewMemberDashboard: defaultValues?.isOnNewMemberDashboard ?? false + dateOfEvent: defaultValues?.dateOfEvent ? new Date(defaultValues.dateOfEvent) : new Date() } }); @@ -62,13 +60,12 @@ const MilestoneFormModal: React.FC = ({ open, handleClo reset({ name: defaultValues?.name ?? '', description: defaultValues?.description ?? '', - dateOfEvent: defaultValues?.dateOfEvent ?? new Date(), - isOnNewMemberDashboard: defaultValues?.isOnNewMemberDashboard ?? false + dateOfEvent: defaultValues?.dateOfEvent ?? new Date() }); }, [defaultValues, reset]); const handleCancel = () => { - reset({ name: '', description: '', dateOfEvent: new Date(), isOnNewMemberDashboard: false }); + reset({ name: '', description: '', dateOfEvent: new Date() }); sessionStorage.removeItem(formStorageKey); handleClose(); }; @@ -78,7 +75,7 @@ const MilestoneFormModal: React.FC = ({ open, handleClo open={open} onHide={handleCancel} title={defaultValues ? 'Edit Milestone' : 'New Milestone'} - reset={() => reset({ name: '', description: '', dateOfEvent: new Date(), isOnNewMemberDashboard: false })} + reset={() => reset({ name: '', description: '', dateOfEvent: new Date() })} handleUseFormSubmit={handleSubmit} onFormSubmit={onFormSubmit} formId="milestone-form" @@ -131,18 +128,6 @@ const MilestoneFormModal: React.FC = ({ open, handleClo /> {errors.description?.message} - - ( - } - label="Show on new member dashboard" - /> - )} - /> - ); }; diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx index 2226d409ad..3259a26653 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx @@ -2,18 +2,25 @@ import { TableRow, TableCell, Box, Table as MuiTable, TableHead, TableBody, Typo import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import { 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 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; +} + +const MilestoneTable = ({ useMilestones, createDefaults, addButtonLabel = 'Add Milestone' }: MilestoneTableProps) => { const [createModalShow, setCreateModalShow] = useHistoryState('', false); const [milestoneEditing, setMilestoneEditing] = useHistoryState('', undefined); const { @@ -21,7 +28,7 @@ const MilestoneTable = () => { isError: milestonesIsError, error: milestonesError, data: milestones - } = useAllMilestones(); + } = useMilestones(); const handleDelete = (id: string) => { setMilestoneToDelete(undefined); @@ -80,7 +87,11 @@ const MilestoneTable = () => { return ( - setCreateModalShow(false)} /> + setCreateModalShow(false)} + createDefaults={createDefaults} + /> {milestoneEditing && ( { setCreateModalShow(true); }} > - Add Milestone + {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/HomePage/components/NewMemberMilestonesWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx index 29be928fa6..9ee3360396 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx @@ -11,9 +11,7 @@ const NewMemberMilestonesWidget: React.FC = () => { const { data: milestones, isLoading, isError, error } = useNewMemberMilestones(); const sortedMilestones = useMemo(() => { - return [...(milestones ?? [])].sort( - (a, b) => new Date(a.dateOfEvent).getTime() - new Date(b.dateOfEvent).getTime() - ); + return [...(milestones ?? [])].sort((a, b) => new Date(a.dateOfEvent).getTime() - new Date(b.dateOfEvent).getTime()); }, [milestones]); if (isError) return ; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 021e8a6573..984b6af62c 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -396,6 +396,7 @@ const carEdit = (id: string) => `${cars()}/${id}/edit`; 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`; @@ -803,6 +804,7 @@ export const apiUrls = { recruitment, allMilestones, newMemberMilestones, + recruitingMilestones, milestoneCreate, milestoneEdit, milestoneDelete, From ee3f0eb08eedcc46cab40e1af2612066d73e2691 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sat, 11 Jul 2026 09:44:48 -0400 Subject: [PATCH 24/43] #4121 fix look of table in admin tools --- .../AdminToolsOnboardingConfig.tsx | 7 - .../OnboardingInfoSection.tsx | 24 +++ .../NewMemberMilestoneTable.tsx | 148 ++++++++++++++++-- 3 files changed, 162 insertions(+), 17 deletions(-) diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx index 97986370a1..d68977b8eb 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx @@ -7,7 +7,6 @@ import { groupChecklists, sortGroupNames } from '../../../utils/onboarding.utils import ErrorPage from '../../ErrorPage'; import { AdminChecklist } from './Checklists/AdminChecklist'; import OnboardingInfoSection from './OnboardingInfoSection'; -import NewMemberMilestoneTable from '../RecruitmentConfig/NewMemberMilestoneTable'; import { Checklist } from 'shared'; type GroupedChecklists = Record; // Change made here @@ -72,12 +71,6 @@ const AdminToolsOnboardingConfig: React.FC = () => { - - - Milestones - - - ); diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx index 6a641a6579..a499931b64 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx @@ -1,6 +1,7 @@ import { Grid, Typography, List, ListItem, useTheme } from '@mui/material'; import { Box } from '@mui/system'; import UsefulLinksTable from './UsefulLinks/UsefulLinksTable'; +import NewMemberMilestoneTable from '../RecruitmentConfig/NewMemberMilestoneTable'; import { useCurrentOrganization, useOrganizationNewMemberImage, @@ -148,6 +149,29 @@ const OnboardingInfoSection: React.FC = () => { + + theme.palette.background.paper, + height: '100%', + borderRadius: '10px', + padding: '16px', + width: '100%' + }} + > + + Milestones + + + + ( - -); +import { + TableRow, + TableCell, + Box, + IconButton, + Typography, + Table, + TableHead, + TableBody, + TableContainer, + Button +} from '@mui/material'; +import { Delete } from '@mui/icons-material'; +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import { useState } from 'react'; +import { isAdmin, Milestone, formatDateOnly } from 'shared'; +import { useCurrentUser } from '../../../hooks/users.hooks'; +import { useDeleteMilestone, useNewMemberMilestones } from '../../../hooks/recruitment.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import NERModal from '../../../components/NERModal'; +import CreateMilestoneFormModal from './CreateMilestoneFormModal'; +import EditMilestoneFormModal from './EditMilestoneFormModal'; + +const NewMemberMilestoneTable = () => { + const currentUser = useCurrentUser(); + const { + data: milestones, + isLoading: milestonesIsLoading, + isError: milestonesIsError, + error: milestonesError + } = useNewMemberMilestones(); + const { mutateAsync: deleteMilestone } = useDeleteMilestone(); + + const [milestoneToDelete, setMilestoneToDelete] = useState(); + const [editingMilestone, setEditingMilestone] = useState(); + const [showCreateModal, setShowCreateModal] = useState(false); + + if (!milestones || milestonesIsLoading) return ; + if (milestonesIsError) return ; + + const sortedMilestones = [...milestones].sort( + (a, b) => new Date(a.dateOfEvent).getTime() - new Date(b.dateOfEvent).getTime() + ); + + const handleDelete = (milestone: Milestone) => { + deleteMilestone(milestone.milestoneId); + setMilestoneToDelete(undefined); + }; + + return ( + + setShowCreateModal(false)} + createDefaults={{ isOnNewMemberDashboard: true, isOnRecruitingDashboard: false }} + /> + {editingMilestone && ( + setEditingMilestone(undefined)} + milestone={editingMilestone} + /> + )} + + + + + + + Date + Name + Description + + + + + {sortedMilestones.map((milestone) => ( + setEditingMilestone(milestone)} + sx={{ cursor: 'pointer' }} + > + + {formatDateOnly(new Date(milestone.dateOfEvent))} + + {milestone.name} + {milestone.description} + + { + event.stopPropagation(); + setMilestoneToDelete(milestone); + }} + > + + + + + ))} + +
+
+ + + {isAdmin(currentUser.role) && ( + + )} + +
+ + setMilestoneToDelete(undefined)} + submitText="Delete" + onSubmit={() => handleDelete(milestoneToDelete!)} + > + + Are you sure you want to delete the milestone {milestoneToDelete?.name}? + + This action cannot be undone! + +
+ ); +}; export default NewMemberMilestoneTable; From 092158592fca97ba905ae7b5bf279002a8221d59 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sat, 11 Jul 2026 10:45:50 -0400 Subject: [PATCH 25/43] #4121 silly mistakes + refactor backend --- .../controllers/recruitment.controllers.ts | 3 +- src/backend/src/prisma/seed.ts | 30 ++++--- .../src/services/recruitment.services.ts | 37 ++++---- src/backend/tests/unit/recruitment.test.ts | 89 +++++++++++++++--- .../tests/unmocked/recruitment.test.ts | 90 ++++++++++++++++--- .../NewMemberMilestoneTable.tsx | 14 ++- .../components/NewMemberMilestonesWidget.tsx | 69 ++++++-------- .../HomePage/components/TimelineSection.tsx | 6 +- 8 files changed, 235 insertions(+), 103 deletions(-) diff --git a/src/backend/src/controllers/recruitment.controllers.ts b/src/backend/src/controllers/recruitment.controllers.ts index 4055fcae27..b1613cfa35 100644 --- a/src/backend/src/controllers/recruitment.controllers.ts +++ b/src/backend/src/controllers/recruitment.controllers.ts @@ -38,8 +38,7 @@ export default class RecruitmentController { name, description, dateOfEvent, - isOnNewMemberDashboard, - isOnRecruitingDashboard, + { isOnNewMemberDashboard, isOnRecruitingDashboard }, req.organization ); res.status(200).json(milestone); diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 003e9814fb..3235440fcd 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -3288,10 +3288,20 @@ const performSeed: () => Promise = async () => { { userId: regina.userId, title: 'Chief Electrical Engineer' } ]); - await RecruitmentServices.createMilestone(batman, 'Club fair!', 'Also meet us at:', daysAgo(120), false, true, ner); - await RecruitmentServices.createMilestone(batman, 'Applications Open', '', daysAgo(70), false, true, ner); - await RecruitmentServices.createMilestone(batman, 'Applications Close', '', daysAgo(56), false, true, ner); - await RecruitmentServices.createMilestone(batman, 'Decision Day!', '', daysAgo(49), false, true, 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( @@ -3299,8 +3309,7 @@ const performSeed: () => Promise = async () => { 'First Meeting', 'Attend your first general body meeting', daysAgo(14), - true, - false, + newMemberDashboardOnly, ner ); await RecruitmentServices.createMilestone( @@ -3308,8 +3317,7 @@ const performSeed: () => Promise = async () => { 'First Bay Time', 'Get hands-on time in the bay with a team lead', daysAgo(7), - true, - false, + newMemberDashboardOnly, ner ); await RecruitmentServices.createMilestone( @@ -3317,8 +3325,7 @@ const performSeed: () => Promise = async () => { 'Safety Training Deadline', 'Complete required safety training to access the bay unsupervised', daysFromNow(14), - true, - false, + newMemberDashboardOnly, ner ); await RecruitmentServices.createMilestone( @@ -3326,8 +3333,7 @@ const performSeed: () => Promise = async () => { 'Subteam Placement', 'Officially join a subteam project', daysFromNow(30), - true, - false, + newMemberDashboardOnly, ner ); diff --git a/src/backend/src/services/recruitment.services.ts b/src/backend/src/services/recruitment.services.ts index fdcaa60c1b..02a4c05fa2 100644 --- a/src/backend/src/services/recruitment.services.ts +++ b/src/backend/src/services/recruitment.services.ts @@ -20,17 +20,28 @@ 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) { - const newMemberMilestones = await prisma.milestone.findMany({ - where: { organizationId: organization.organizationId, dateDeleted: null, isOnNewMemberDashboard: true } - }); - - return newMemberMilestones; + return this.getMilestonesByDashboardFlag(organization, 'isOnNewMemberDashboard'); } /** @@ -39,11 +50,7 @@ export default class RecruitmentServices { * @returns all recruiting-dashboard milestones from the given organization */ static async getRecruitingMilestones(organization: Organization) { - const recruitingMilestones = await prisma.milestone.findMany({ - where: { organizationId: organization.organizationId, dateDeleted: null, isOnRecruitingDashboard: true } - }); - - return recruitingMilestones; + return this.getMilestonesByDashboardFlag(organization, 'isOnRecruitingDashboard'); } /** @@ -52,8 +59,7 @@ export default class RecruitmentServices { * @param name the name of the user * @param description description of the milestone * @param dateOfEvent date of the event of the milestone - * @param isOnNewMemberDashboard whether the milestone should show on the new member dashboard - * @param isOnRecruitingDashboard whether the milestone should show on the recruiting dashboard + * @param dashboards which dashboards the milestone should show on * @param organizationId the organization Id of the milestone * @returns A newly created milestone */ @@ -62,8 +68,7 @@ export default class RecruitmentServices { name: string, description: string, dateOfEvent: Date, - isOnNewMemberDashboard: boolean, - isOnRecruitingDashboard: boolean, + dashboards: { isOnNewMemberDashboard: boolean; isOnRecruitingDashboard: boolean }, organization: Organization ) { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) @@ -74,8 +79,8 @@ export default class RecruitmentServices { name, description, dateOfEvent, - isOnNewMemberDashboard, - isOnRecruitingDashboard, + isOnNewMemberDashboard: dashboards.isOnNewMemberDashboard, + isOnRecruitingDashboard: dashboards.isOnRecruitingDashboard, organizationId: organization.organizationId, userCreatedId: submitter.userId } diff --git a/src/backend/tests/unit/recruitment.test.ts b/src/backend/tests/unit/recruitment.test.ts index 4081b79e19..b140c01f65 100644 --- a/src/backend/tests/unit/recruitment.test.ts +++ b/src/backend/tests/unit/recruitment.test.ts @@ -104,8 +104,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date(), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a milestone')); @@ -117,8 +116,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -163,8 +161,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -193,8 +190,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -220,8 +216,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/11/24'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -230,8 +225,7 @@ describe('Recruitment Tests', () => { 'name2', 'description2', new Date('1/1/1'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); const result = await RecruitmentServices.getAllMilestones(organization); @@ -239,6 +233,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( @@ -527,8 +589,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); diff --git a/src/backend/tests/unmocked/recruitment.test.ts b/src/backend/tests/unmocked/recruitment.test.ts index 36c51ace2a..ecba68d70a 100644 --- a/src/backend/tests/unmocked/recruitment.test.ts +++ b/src/backend/tests/unmocked/recruitment.test.ts @@ -105,8 +105,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date(), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a milestone')); @@ -118,8 +117,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -164,8 +162,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -194,8 +191,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/12/24'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -221,8 +217,7 @@ describe('Recruitment Tests', () => { 'name', 'description', new Date('11/11/24'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); @@ -231,8 +226,7 @@ describe('Recruitment Tests', () => { 'name2', 'description2', new Date('1/1/1'), - false, - false, + { isOnNewMemberDashboard: false, isOnRecruitingDashboard: false }, organization ); const result = await RecruitmentServices.getAllMilestones(organization); @@ -240,6 +234,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( diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/NewMemberMilestoneTable.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/NewMemberMilestoneTable.tsx index 39fabf9d79..3d994ed192 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/NewMemberMilestoneTable.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/NewMemberMilestoneTable.tsx @@ -21,6 +21,7 @@ import ErrorPage from '../../ErrorPage'; import NERModal from '../../../components/NERModal'; import CreateMilestoneFormModal from './CreateMilestoneFormModal'; import EditMilestoneFormModal from './EditMilestoneFormModal'; +import { useToast } from '../../../hooks/toasts.hooks'; const NewMemberMilestoneTable = () => { const currentUser = useCurrentUser(); @@ -36,16 +37,25 @@ const NewMemberMilestoneTable = () => { const [editingMilestone, setEditingMilestone] = useState(); const [showCreateModal, setShowCreateModal] = useState(false); - if (!milestones || milestonesIsLoading) return ; + const toast = useToast(); + if (milestonesIsError) return ; + if (!milestones || milestonesIsLoading) return ; const sortedMilestones = [...milestones].sort( (a, b) => new Date(a.dateOfEvent).getTime() - new Date(b.dateOfEvent).getTime() ); const handleDelete = (milestone: Milestone) => { - deleteMilestone(milestone.milestoneId); setMilestoneToDelete(undefined); + try { + deleteMilestone(milestone.milestoneId); + toast.success('Milestone deleted successfully'); + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message, 3000); + } + } }; return ( diff --git a/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx index 9ee3360396..0eeb495f4b 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx @@ -1,13 +1,13 @@ import { useMemo } from 'react'; -import { Box, Typography, useTheme } from '@mui/material'; +import { Box, Typography } from '@mui/material'; 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'; +import ScrollablePageBlock from './ScrollablePageBlock'; const NewMemberMilestonesWidget: React.FC = () => { - const theme = useTheme(); const { data: milestones, isLoading, isError, error } = useNewMemberMilestones(); const sortedMilestones = useMemo(() => { @@ -18,47 +18,32 @@ const NewMemberMilestonesWidget: React.FC = () => { if (isLoading || !milestones) return ; return ( - - - Onboarding Milestones - - - - {sortedMilestones.length === 0 ? ( - - No onboarding milestones yet - - ) : ( - sortedMilestones.map((milestone) => { - const isPast = isPastEvent(new Date(milestone.dateOfEvent), new Date()); - return ( - - - {formatDateOnly(new Date(milestone.dateOfEvent), 'MMMM D, YYYY')} - - - {milestone.name} + + {sortedMilestones.length === 0 ? ( + + No onboarding milestones yet + + ) : ( + sortedMilestones.map((milestone) => { + const isPast = isPastEvent(new Date(milestone.dateOfEvent), new Date()); + return ( + + + {formatDateOnly(new Date(milestone.dateOfEvent), 'MMMM D, YYYY')} + + + {milestone.name} + + {milestone.description && ( + + {milestone.description} - {milestone.description && ( - - {milestone.description} - - )} - - ); - }) - )} - - + )} + + ); + }) + )} + ); }; 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) => ({ From 2bf91ecfa53e9e6c523d41b9aa26558872a8f3c6 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sat, 11 Jul 2026 16:32:15 -0400 Subject: [PATCH 26/43] #4121 frontend code adjustments --- .../OnboardingInfoSection.tsx | 2 +- .../AdminToolsRecruitmentConfig.tsx | 2 +- .../RecruitmentConfig/MilestoneTable.tsx | 242 ++++++++++++------ .../NewMemberMilestoneTable.tsx | 159 +----------- 4 files changed, 173 insertions(+), 232 deletions(-) diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx index a499931b64..38a5a1b564 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx @@ -167,7 +167,7 @@ const OnboardingInfoSection: React.FC = () => { marginBottom: '12px' }} > - Milestones + Onboarding Milestones
diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/AdminToolsRecruitmentConfig.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/AdminToolsRecruitmentConfig.tsx index c84e713701..bced1804b5 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/AdminToolsRecruitmentConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/AdminToolsRecruitmentConfig.tsx @@ -31,7 +31,7 @@ const AdminToolsRecruitmentConfig: React.FC = () => {
- Milestones + Recruitment Milestones diff --git a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx index 3259a26653..6d81fdb8c8 100644 --- a/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx +++ b/src/frontend/src/pages/AdminToolsPage/RecruitmentConfig/MilestoneTable.tsx @@ -1,13 +1,26 @@ -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 { 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'; @@ -18,9 +31,17 @@ 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' }: MilestoneTableProps) => { +const MilestoneTable = ({ + useMilestones, + createDefaults, + addButtonLabel = 'Add Milestone', + variant = 'recruitment' +}: MilestoneTableProps) => { + const currentUser = useCurrentUser(); const [createModalShow, setCreateModalShow] = useHistoryState('', false); const [milestoneEditing, setMilestoneEditing] = useHistoryState('', undefined); const { @@ -29,6 +50,12 @@ const MilestoneTable = ({ useMilestones, createDefaults, addButtonLabel = 'Add M error: milestonesError, data: milestones } = 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); @@ -42,48 +69,12 @@ const MilestoneTable = ({ useMilestones, createDefaults, addButtonLabel = 'Add M } }; - 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 ( @@ -99,47 +90,134 @@ const MilestoneTable = ({ useMilestones, createDefaults, addButtonLabel = 'Add M 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} + + ))} { - const currentUser = useCurrentUser(); - const { - data: milestones, - isLoading: milestonesIsLoading, - isError: milestonesIsError, - error: milestonesError - } = useNewMemberMilestones(); - const { mutateAsync: deleteMilestone } = useDeleteMilestone(); - - const [milestoneToDelete, setMilestoneToDelete] = useState(); - const [editingMilestone, setEditingMilestone] = useState(); - const [showCreateModal, setShowCreateModal] = useState(false); - - const toast = useToast(); - - if (milestonesIsError) return ; - if (!milestones || milestonesIsLoading) return ; - - const sortedMilestones = [...milestones].sort( - (a, b) => new Date(a.dateOfEvent).getTime() - new Date(b.dateOfEvent).getTime() - ); - - const handleDelete = (milestone: Milestone) => { - setMilestoneToDelete(undefined); - try { - deleteMilestone(milestone.milestoneId); - toast.success('Milestone deleted successfully'); - } catch (e: unknown) { - if (e instanceof Error) { - toast.error(e.message, 3000); - } - } - }; - - return ( - - setShowCreateModal(false)} - createDefaults={{ isOnNewMemberDashboard: true, isOnRecruitingDashboard: false }} - /> - {editingMilestone && ( - setEditingMilestone(undefined)} - milestone={editingMilestone} - /> - )} - - - - - - - Date - Name - Description - - - - - {sortedMilestones.map((milestone) => ( - setEditingMilestone(milestone)} - sx={{ cursor: 'pointer' }} - > - - {formatDateOnly(new Date(milestone.dateOfEvent))} - - {milestone.name} - {milestone.description} - - { - event.stopPropagation(); - setMilestoneToDelete(milestone); - }} - > - - - - - ))} - -
-
- - - {isAdmin(currentUser.role) && ( - - )} - -
- - setMilestoneToDelete(undefined)} - submitText="Delete" - onSubmit={() => handleDelete(milestoneToDelete!)} - > - - Are you sure you want to delete the milestone {milestoneToDelete?.name}? - - This action cannot be undone! - -
- ); -}; +import MilestoneTable from './MilestoneTable'; +import { useNewMemberMilestones } from '../../../hooks/recruitment.hooks'; + +const NewMemberMilestoneTable = () => ( + +); export default NewMemberMilestoneTable; From 745f25136ac213e58e7bb736f5d285668e815f09 Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Thu, 16 Jul 2026 18:53:28 -0400 Subject: [PATCH 27/43] Abstracted out faq service, added recruitment / onboarding separation --- .../controllers/part-review.controllers.ts | 11 +- .../controllers/recruitment.controllers.ts | 30 +++- src/backend/src/prisma/seed.ts | 39 +++++- src/backend/src/routes/recruitment.routes.ts | 12 +- .../src/services/part-review.services.ts | 32 ----- .../src/services/recruitment.services.ts | 17 ++- src/backend/tests/unit/part-review.test.ts | 44 +++++- src/backend/tests/unit/recruitment.test.ts | 79 ++++++++++- .../tests/unmocked/recruitment.test.ts | 27 +++- src/frontend/src/apis/recruitment.api.ts | 22 ++- src/frontend/src/hooks/recruitment.hooks.ts | 41 +++++- .../AdminToolsOnboardingConfig.tsx | 7 + .../CreateNewMemberFaqFormModal.tsx | 21 +++ .../EditNewMemberFaqFormModal.tsx | 22 +++ .../NewMemberFAQ/NewMemberFAQTable.tsx | 132 ++++++++++++++++++ .../OnboardingConfig/UpdateContactsModal.tsx | 30 ++-- .../RecruitmentConfig/CreateFaqFormModal.tsx | 4 +- .../RecruitmentConfig/FAQTable.tsx | 4 +- .../src/pages/HomePage/OnboardingHomePage.tsx | 5 + .../pages/HomePage/components/FAQsSection.tsx | 4 +- .../components/NewMemberFAQsSection.tsx | 31 ++++ src/frontend/src/utils/urls.ts | 10 +- 22 files changed, 539 insertions(+), 85 deletions(-) create mode 100644 src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/CreateNewMemberFaqFormModal.tsx create mode 100644 src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/EditNewMemberFaqFormModal.tsx create mode 100644 src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberFAQ/NewMemberFAQTable.tsx create mode 100644 src/frontend/src/pages/HomePage/components/NewMemberFAQsSection.tsx 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/recruitment.controllers.ts b/src/backend/src/controllers/recruitment.controllers.ts index cf12d6c168..69a629cceb 100644 --- a/src/backend/src/controllers/recruitment.controllers.ts +++ b/src/backend/src/controllers/recruitment.controllers.ts @@ -87,10 +87,36 @@ export default class RecruitmentController { } } - static async createOrganizationFaq(req: Request, res: Response, next: NextFunction) { + 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/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 7ef3757b50..071220e3b6 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -3293,18 +3293,51 @@ const performSeed: () => Promise = async () => { await RecruitmentServices.createMilestone(batman, 'Applications Close', '', daysAgo(56), ner); await RecruitmentServices.createMilestone(batman, 'Decision Day!', '', daysAgo(49), 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 prisma.frequentlyAskedQuestion.create({ diff --git a/src/backend/src/routes/recruitment.routes.ts b/src/backend/src/routes/recruitment.routes.ts index 3a6ebfc10e..63f246cb58 100644 --- a/src/backend/src/routes/recruitment.routes.ts +++ b/src/backend/src/routes/recruitment.routes.ts @@ -37,11 +37,19 @@ recruitmentRouter.get('/faqs/recruiting', RecruitmentController.getRecruitingFaq recruitmentRouter.get('/faqs/new-member', RecruitmentController.getNewMemberFaqs); recruitmentRouter.post( - '/faq/create', + '/faq/recruiting/create', nonEmptyString(body('question')), nonEmptyString(body('answer')), validateInputs, - RecruitmentController.createOrganizationFaq + RecruitmentController.createRecruitingFaq +); + +recruitmentRouter.post( + '/faq/new-member/create', + nonEmptyString(body('question')), + nonEmptyString(body('answer')), + validateInputs, + RecruitmentController.createNewMemberFaq ); recruitmentRouter.post( diff --git a/src/backend/src/services/part-review.services.ts b/src/backend/src/services/part-review.services.ts index 9772c74827..70ed747d07 100644 --- a/src/backend/src/services/part-review.services.ts +++ b/src/backend/src/services/part-review.services.ts @@ -629,38 +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 } }, - organization: { connect: { organizationId } }, - isOnPartReviewPage: true - }, - ...getFaqQueryArgs(organizationId) - }); - - return faqTransformer(faq); - } - /** * updates an faq * @param faqId the faq to update diff --git a/src/backend/src/services/recruitment.services.ts b/src/backend/src/services/recruitment.services.ts index d6ab6f5844..6f3ab1bdc6 100644 --- a/src/backend/src/services/recruitment.services.ts +++ b/src/backend/src/services/recruitment.services.ts @@ -169,9 +169,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'); @@ -181,7 +192,9 @@ export default class RecruitmentServices { answer, organizationId: organization.organizationId, userCreatedId: submitter.userId, - isOnRecruitingDashboard: true + isOnRecruitingDashboard, + isOnNewMemberDashboard, + isOnPartReviewPage } }); diff --git a/src/backend/tests/unit/part-review.test.ts b/src/backend/tests/unit/part-review.test.ts index 1e3abeaf9a..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,7 +527,15 @@ 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'); @@ -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); diff --git a/src/backend/tests/unit/recruitment.test.ts b/src/backend/tests/unit/recruitment.test.ts index e1173dce76..e6023a2ed5 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( @@ -235,7 +276,10 @@ describe('Recruitment Tests', () => { await createTestUser(member, orgId), 'question', 'answer', - organization + organization, + true, + false, + false ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create an faq')); }); @@ -286,21 +330,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); }); }); }); diff --git a/src/backend/tests/unmocked/recruitment.test.ts b/src/backend/tests/unmocked/recruitment.test.ts index bbf7187bf4..710d42c792 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); @@ -236,7 +242,10 @@ describe('Recruitment Tests', () => { await createTestUser(member, orgId), 'question', 'answer', - organization + organization, + true, + false, + false ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create an faq')); }); @@ -287,7 +296,10 @@ describe('Recruitment Tests', () => { await createTestUser(member, orgId), 'question', 'answer', - organization + organization, + true, + false, + false ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create an faq')); }); @@ -297,11 +309,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/recruitment.api.ts b/src/frontend/src/apis/recruitment.api.ts index ad8691bc2e..986be6f419 100644 --- a/src/frontend/src/apis/recruitment.api.ts +++ b/src/frontend/src/apis/recruitment.api.ts @@ -34,8 +34,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/hooks/recruitment.hooks.ts b/src/frontend/src/hooks/recruitment.hooks.ts index cea7f41993..b5564b0bcc 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,6 +12,8 @@ import { editGuestDefinition, editMilestone, getAllFaqs, + getRecruitingFaqs, + getNewMemberFaqs, getAllGuestDefinitions, getAllMilestones } from '../apis/recruitment.api'; @@ -98,12 +101,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/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx index d68977b8eb..8646237148 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx @@ -7,6 +7,7 @@ import { groupChecklists, sortGroupNames } from '../../../utils/onboarding.utils import ErrorPage from '../../ErrorPage'; import { AdminChecklist } from './Checklists/AdminChecklist'; import OnboardingInfoSection from './OnboardingInfoSection'; +import NewMemberFAQTable from './NewMemberFAQ/NewMemberFAQTable'; import { Checklist } from 'shared'; type GroupedChecklists = Record; // Change made here @@ -70,6 +71,12 @@ const AdminToolsOnboardingConfig: React.FC = () => { + + + New Member FAQs + + +
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/UpdateContactsModal.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UpdateContactsModal.tsx index 4164795bc7..c7a31ef525 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UpdateContactsModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UpdateContactsModal.tsx @@ -11,7 +11,7 @@ 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 { userToAutocompleteOption } from '../../../utils/teams.utils'; const schema = yup.object().shape({ contacts: yup @@ -116,18 +116,22 @@ 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(userToAutocompleteOption); + 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 { 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/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/HomePage/OnboardingHomePage.tsx b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx index 7f6b9836a9..c055125057 100644 --- a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx +++ b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx @@ -12,6 +12,7 @@ import { useHistory } from 'react-router-dom'; import { routes } from '../../utils/routes'; import { useCurrentOrganization } from '../../hooks/organizations.hooks'; import OnboardingProgressBar from '../../components/OnboardingProgressBar'; +import NewMemberFAQsSection from './components/NewMemberFAQsSection'; import ErrorPage from '../ErrorPage'; const OnboardingHomePage = () => { @@ -133,6 +134,10 @@ const OnboardingHomePage = () => { + + FAQs + + 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/NewMemberFAQsSection.tsx b/src/frontend/src/pages/HomePage/components/NewMemberFAQsSection.tsx new file mode 100644 index 0000000000..31728f68fd --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberFAQsSection.tsx @@ -0,0 +1,31 @@ +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 (isLoading || !faqs) return ; + + if (isError) 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/utils/urls.ts b/src/frontend/src/utils/urls.ts index 2543769d79..41bce549fe 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -399,7 +399,10 @@ 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`; @@ -805,7 +808,10 @@ export const apiUrls = { milestoneEdit, milestoneDelete, allFaqs, - faqCreate, + recruitingFaqs, + newMemberFaqs, + recruitingFaqCreate, + newMemberFaqCreate, faqEdit, faqDelete, imageById, From e2d2be2658d48e436caf0211c92167b60481d53f Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sat, 25 Jul 2026 22:42:48 -0400 Subject: [PATCH 28/43] code fixes --- .../src/pages/HomePage/components/NewMemberFAQsSection.tsx | 3 ++- .../src/pages/HomePage/components/SetUserPreferences.tsx | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/pages/HomePage/components/NewMemberFAQsSection.tsx b/src/frontend/src/pages/HomePage/components/NewMemberFAQsSection.tsx index 31728f68fd..0155b8cd40 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberFAQsSection.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberFAQsSection.tsx @@ -7,10 +7,11 @@ import React from 'react'; const NewMemberFAQsSection = () => { const { isLoading, isError, error, data: faqs } = useNewMemberFaqs(); - if (isLoading || !faqs) return ; if (isError) return ; + if (isLoading || !faqs) return ; + if (faqs.length === 0) { return ( diff --git a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx index 6be87d627b..eac16ab3ff 100644 --- a/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx +++ b/src/frontend/src/pages/HomePage/components/SetUserPreferences.tsx @@ -29,12 +29,10 @@ const SetUserPreferences: React.FC = ({ userSettings }) }); if (isLoading) return ; - //if (isError) return ; const onSubmit = async ({ slackId }: { slackId: string }) => { try { await mutateAsync({ ...userSettings, slackId }); - window.location.reload(); } catch (error: unknown) { if (error instanceof Error) { toast.error(error.message); From 7cc79deadb57ca603f09d75890dba328314a7c4f Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 26 Jul 2026 21:02:53 -0400 Subject: [PATCH 29/43] fix flow from potential new member to onboarding to new member --- src/frontend/src/app/HomePageContext.tsx | 35 +++-------- src/frontend/src/pages/HomePage/Home.tsx | 23 +++++++- .../src/pages/HomePage/NewMemberHomePage.tsx | 59 +++++++++++++++++++ .../src/pages/HomePage/OnboardingHomePage.tsx | 17 ++---- .../src/tests/pages/HomePage/Home.test.tsx | 37 +++++++++++- .../src/tests/test-support/mock-hooks.ts | 3 + .../test-data/authenticated-user.stub.ts | 11 ++++ src/frontend/src/utils/routes.ts | 2 + 8 files changed, 146 insertions(+), 41 deletions(-) create mode 100644 src/frontend/src/pages/HomePage/NewMemberHomePage.tsx 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/pages/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index e2aa183cdb..929b010a33 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -6,26 +6,47 @@ 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 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 or are promoted off the Guest + // role -- either one means they've graduated out of the new member experience + const isNewMember = completedOnboarding && isGuest(user.role) && teams.length === 0; + return ( {completedOnboarding && + !isNewMember && !isAdmin(user.role) && - [routes.HOME_PNM, routes.HOME_ONBOARDING].map((path) => )} + [routes.HOME_PNM, routes.HOME_ONBOARDING, routes.HOME_NEW_MEMBER].map((path) => ( + + ))} + {isNewMember && + [routes.HOME_PNM, routes.HOME_ONBOARDING].map((path) => ( + + ))} {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..489dddef72 --- /dev/null +++ b/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx @@ -0,0 +1,59 @@ +/* + * 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 { Grid, Typography } from '@mui/material'; +import { useEffect } from 'react'; +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 OnboardingInfoSection from './components/OnboardingInfoSection'; +import NewMemberFAQsSection from './components/NewMemberFAQsSection'; + +const NewMemberHomePage = () => { + 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 the {organization.name} Team + + Here's what's coming up while you get settled in + + + + + + + + + FAQs + + + + + ); +}; + +export default NewMemberHomePage; diff --git a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx index 30eda7e2c5..7f0c0aa01b 100644 --- a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx +++ b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx @@ -4,7 +4,6 @@ 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 ConfirmOnboardingChecklistModal from './components/ConfirmOnboardingChecklistModal'; import { NERButton } from '../../components/NERButton'; import { useCheckedChecklists, useUsersChecklists, useChecklistProgress } from '../../hooks/onboarding.hook'; @@ -12,12 +11,13 @@ import { useHistory } from 'react-router-dom'; import { routes } from '../../utils/routes'; import { useCurrentOrganization } from '../../hooks/organizations.hooks'; import OnboardingProgressBar from '../../components/OnboardingProgressBar'; -import NewMemberFAQsSection from './components/NewMemberFAQsSection'; import ErrorPage from '../ErrorPage'; import { useCompleteOnboarding } from '../../hooks/team-types.hooks'; +import { useAuth } from '../../hooks/auth.hooks'; const OnboardingHomePage = () => { const history = useHistory(); + const auth = useAuth(); const [isModalOpen, setModalOpen] = useState(false); const { setCurrentHomePage } = useHomePageContext(); const { data: organization, isLoading: organizationIsLoading } = useCurrentOrganization(); @@ -74,6 +74,9 @@ const OnboardingHomePage = () => { const handleConfirmModal = async () => { 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); }; @@ -123,7 +126,6 @@ const OnboardingHomePage = () => { { > - - - - - - FAQs - - - {isModalOpen && ( 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 a83103f434..56d1e643f0 100644 --- a/src/frontend/src/tests/test-support/mock-hooks.ts +++ b/src/frontend/src/tests/test-support/mock-hooks.ts @@ -8,6 +8,7 @@ import { Task, TaskPriority, TaskStatus, + Team, UserSettings, UserWithRole, WorkPackage @@ -54,6 +55,8 @@ 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 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/utils/routes.ts b/src/frontend/src/utils/routes.ts index bd4b941769..817e953338 100644 --- a/src/frontend/src/utils/routes.ts +++ b/src/frontend/src/utils/routes.ts @@ -17,6 +17,7 @@ const HOME_PNM = HOME + `/pnm`; const HOME_SELECT_SUBTEAM = HOME + `/select-subteam`; const HOME_MEMBER = HOME + `/member`; const HOME_ONBOARDING = HOME + `/onboarding`; +const HOME_NEW_MEMBER = HOME + `/new-member`; /**************** Finance Section ****************/ const FINANCE = `/finance`; @@ -92,6 +93,7 @@ export const routes = { HOME_PNM, HOME_SELECT_SUBTEAM, HOME_ONBOARDING, + HOME_NEW_MEMBER, HOME_MEMBER, TEAMS, From 343ba1611763db89522be00df5fa345c419809ac Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 26 Jul 2026 22:42:42 -0400 Subject: [PATCH 30/43] #4120 slack feed widget --- .../controllers/organizations.controllers.ts | 24 +++++ src/backend/src/integrations/slack.ts | 101 +++++++++++++++++- .../migration.sql | 6 +- src/backend/src/prisma/schema.prisma | 2 + src/backend/src/prisma/seed.ts | 3 + .../src/routes/organizations.routes.ts | 9 ++ .../src/services/organizations.services.ts | 41 +++++++ src/backend/tests/unit/organization.test.ts | 62 +++++++++++ src/frontend/src/apis/organizations.api.ts | 21 +++- src/frontend/src/hooks/organizations.hooks.ts | 37 ++++++- .../AdminToolsPage/AdminToolsSlackIds.tsx | 34 ++++++ .../components/NewMemberSlackWidget.tsx | 84 +++++++++++++++ .../components/OnboardingInfoSection.tsx | 4 + src/frontend/src/utils/urls.ts | 4 + src/shared/src/types/announcements.types.ts | 7 ++ src/shared/src/types/user-types.ts | 2 + 16 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 src/frontend/src/pages/HomePage/components/NewMemberSlackWidget.tsx diff --git a/src/backend/src/controllers/organizations.controllers.ts b/src/backend/src/controllers/organizations.controllers.ts index 9c6a4951de..9b1a0e089e 100644 --- a/src/backend/src/controllers/organizations.controllers.ts +++ b/src/backend/src/controllers/organizations.controllers.ts @@ -251,6 +251,30 @@ export default class OrganizationsController { } } + static async setNewMemberSlackChannelId(req: Request, res: Response, next: NextFunction) { + try { + const { channelId } = req.body; + + const updatedOrg = await OrganizationsService.setNewMemberSlackChannelId( + channelId, + req.currentUser, + req.organization.organizationId + ); + res.status(200).json(updatedOrg); + } catch (error: unknown) { + next(error); + } + } + + static async getNewMemberSlackMessages(req: Request, res: Response, next: NextFunction) { + try { + const messages = await OrganizationsService.getNewMemberSlackMessages(req.organization); + res.status(200).json(messages); + } catch (error: unknown) { + next(error); + } + } + static async getFinanceDelegates(req: Request, res: Response, next: NextFunction) { try { const financeDelegates = await OrganizationsService.getFinanceDelegates(req.organization.organizationId); diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index 4356f54bbb..4857fd2a79 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -1,6 +1,7 @@ import bolt from '@slack/bolt'; import type { App, ExpressReceiver } from '@slack/bolt'; import { LRUCache } from 'lru-cache'; +import { SlackMessagePreview } from 'shared'; import { HttpException } from '../utils/errors.utils.js'; const { App: AppClass, ExpressReceiver: ExpressReceiverClass } = bolt; @@ -397,17 +398,36 @@ export const checkBotInChannel = async (channelId: string): Promise => }; /** - * Given a slack user id, produces 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; } @@ -489,6 +509,79 @@ export const getReceiver = (): ExpressReceiver | null => { export { getSlackClient }; export default getSlackClient; +/** + * Fetches the most recent real (non-system) messages posted in a Slack channel, newest first, + * with each message's author name and a permalink back to it in Slack resolved. + * @param channelId the id of the slack channel to fetch messages from + * @param limit the maximum number of recent messages to fetch + * @returns the most recent messages in the channel, newest first + */ +const fetchRecentChannelMessages = async (key: string): Promise => { + const [channelId, limitStr] = key.split(':'); + const limit = Number(limitStr); + + const client = getSlackClient(); + if (!client) { + throw new HttpException(500, 'Slack integration not configured'); + } + + try { + const historyRes = await client.conversations.history({ channel: channelId, limit }); + if (!historyRes.ok || !historyRes.messages) { + throw new Error(historyRes.error ?? 'unknown error fetching channel history'); + } + + const realMessages = historyRes.messages.filter((message: any) => !message.subtype && message.text && message.ts); + + return await Promise.all( + realMessages.map(async (message: any) => { + const [userName, permalinkRes] = await Promise.all([ + message.user ? getUserName(message.user) : undefined, + client.chat.getPermalink({ channel: channelId, message_ts: message.ts }) + ]); + + return { + text: message.text, + userName, + timestamp: new Date(Number(message.ts) * 1000).toISOString(), + permalink: permalinkRes.permalink as string + }; + }) + ); + } catch (error) { + throw new HttpException( + 500, + `Failed to fetch recent Slack messages: ${(error as any)?.data?.error ?? (error as Error).message}` + ); + } +}; + +/** + * Caches recent channel messages briefly, keyed by `${channelId}:${limit}`. This is the + * important one for widgets that poll on a timer: every viewer asking for the same channel + * shares one Slack request per TTL window instead of hitting Slack once per viewer per poll. + * TTL matches the frontend's poll interval so a single viewer's repeat polls hit cache too, + * not just concurrent polls from different viewers. + */ +const recentChannelMessagesCache = new LRUCache({ + max: 100, + ttl: 1000 * 60, // 60 seconds + fetchMethod: fetchRecentChannelMessages +}); + +/** + * Fetches the most recent real (non-system) messages posted in a Slack channel, newest first, + * with each message's author name and a permalink back to it in Slack resolved. Results are + * cached briefly, and concurrent/near-concurrent callers for the same channel share a single + * slack request rather than each hitting Slack independently. + * @param channelId the id of the slack channel to fetch messages from + * @param limit the maximum number of recent messages to fetch + * @returns the most recent messages in the channel, newest first + */ +export const getRecentChannelMessages = async (channelId: string, limit: number): Promise => { + return (await recentChannelMessagesCache.fetch(`${channelId}:${limit}`)) ?? []; +}; + /** * Validates that a given Slack user id exists in the workspace * All slack ids start with U. If you pass a valid user id to users.info, it returns ok: true; throws error otherwise. diff --git a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql index 550f3c3504..c08d6abc38 100644 --- a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql +++ b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql @@ -80,4 +80,8 @@ ALTER TABLE "Team_Join_Request" ADD CONSTRAINT "Team_Join_Request_userId_fkey" F 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; \ No newline at end of file +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 "Organization" ADD COLUMN "newMemberSlackChannelId" TEXT, +ADD COLUMN "newMemberSlackChannelName" TEXT; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 9624e4bc89..c20e34d212 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -1398,6 +1398,8 @@ model Organization { partReviewSampleImageId String? partReviewGuideLink String? sponsorshipNotificationsSlackChannelId String? + newMemberSlackChannelId String? + newMemberSlackChannelName String? platformDescription String @default("") platformLogoImageId String? diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index af069587f4..982e8bb7d5 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -4578,6 +4578,7 @@ const performSeed: () => Promise = async () => { } ], undefined, + [], electrical.teamTypeId, undefined, 'Curry Student Center', @@ -4604,6 +4605,7 @@ const performSeed: () => Promise = async () => { } ], undefined, + [], mechanical.teamTypeId, undefined, 'Richards Hall', @@ -4630,6 +4632,7 @@ const performSeed: () => Promise = async () => { } ], undefined, + [], software.teamTypeId, undefined, undefined, diff --git a/src/backend/src/routes/organizations.routes.ts b/src/backend/src/routes/organizations.routes.ts index 0be359c127..2353f043a5 100644 --- a/src/backend/src/routes/organizations.routes.ts +++ b/src/backend/src/routes/organizations.routes.ts @@ -88,6 +88,15 @@ organizationRouter.post( organizationRouter.get('/notification-channels', OrganizationsController.getNotificationChannels); +organizationRouter.post( + '/newMemberSlackChannelId/set', + nonEmptyString(body('channelId')), + validateInputs, + OrganizationsController.setNewMemberSlackChannelId +); + +organizationRouter.get('/new-member-slack-messages', OrganizationsController.getNewMemberSlackMessages); + organizationRouter.get('/finance-delegates', OrganizationsController.getFinanceDelegates); organizationRouter.post( '/finance-delegates/set', diff --git a/src/backend/src/services/organizations.services.ts b/src/backend/src/services/organizations.services.ts index d1ffa4553f..138ebeeb8e 100644 --- a/src/backend/src/services/organizations.services.ts +++ b/src/backend/src/services/organizations.services.ts @@ -5,10 +5,12 @@ import { NotificationChannelPreview, ProjectPreview, RoleEnum, + SlackMessagePreview, isAdmin, isAtLeastRank, User } from 'shared'; +import { getChannelName, getRecentChannelMessages } from '../integrations/slack.js'; import prisma from '../prisma/prisma.js'; import { AccessDeniedAdminOnlyException, @@ -530,6 +532,45 @@ export default class OrganizationsService { ); } + /** + * Sets the organization's designated new member Slack channel, shown on the new member dashboard. + * The channel's display name is resolved and stored alongside its id at set-time, since it rarely + * changes -- this avoids re-resolving it from Slack on every dashboard load/poll. + * @param channelId the slack id of the channel + * @param submitter the user making the change + * @param organizationId the organization to update + * @returns the updated organization + */ + static async setNewMemberSlackChannelId( + channelId: string, + submitter: User, + organizationId: string + ): Promise { + if (!(await userHasPermission(submitter.userId, organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('set new member slack channel id'); + } + + const channelName = await getChannelName(channelId); + + const updatedOrg = await prisma.organization.update({ + where: { organizationId }, + data: { newMemberSlackChannelId: channelId, newMemberSlackChannelName: channelName } + }); + + return updatedOrg; + } + + /** + * Gets the 3 most recent messages from the organization's designated new member Slack channel + * @param organization the organization to get new member slack messages for + * @returns the most recent messages in the channel, or an empty array if no channel is configured + */ + static async getNewMemberSlackMessages(organization: Organization): Promise { + if (!organization.newMemberSlackChannelId) return []; + + return getRecentChannelMessages(organization.newMemberSlackChannelId, 3); + } + /** * Gets the finance delegates for the given organization * @param organizationId the organization to get the finance delegates for diff --git a/src/backend/tests/unit/organization.test.ts b/src/backend/tests/unit/organization.test.ts index 9fca867b26..4f112e5ab4 100644 --- a/src/backend/tests/unit/organization.test.ts +++ b/src/backend/tests/unit/organization.test.ts @@ -8,11 +8,20 @@ import { uploadFile } from '../../src/utils/google-integration.utils.js'; import { Mock, vi } from 'vitest'; import OrganizationsService from '../../src/services/organizations.services.js'; import { Organization } from '@prisma/client'; +import * as slackIntegration from '../../src/integrations/slack.js'; vi.mock('../../src/utils/google-integration.utils', () => ({ uploadFile: vi.fn() })); +vi.mock('../../src/integrations/slack.js', async (importOriginal) => { + return { + ...(await importOriginal()), + getRecentChannelMessages: vi.fn(), + getChannelName: vi.fn() + }; +}); + describe('Organization Tests', () => { let orgId: string; let organization: Organization; @@ -334,4 +343,57 @@ describe('Organization Tests', () => { expect(updatedOrganization?.platformLogoImageId).toBe('uploaded-image3.png'); }); }); + + describe('Set New Member Slack Channel Id', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('Fails if user is not an admin', async () => { + const testWonderwoman = await createTestUser(wonderwomanGuest, orgId); + await expect(OrganizationsService.setNewMemberSlackChannelId('channel-id', testWonderwoman, orgId)).rejects.toThrow( + new AccessDeniedAdminOnlyException('set new member slack channel id') + ); + }); + + it('Succeeds and updates the new member slack channel id and its resolved name', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + (slackIntegration.getChannelName as Mock).mockResolvedValue('new-members'); + + const updatedOrganization = await OrganizationsService.setNewMemberSlackChannelId('channel-id', testBatman, orgId); + + expect(slackIntegration.getChannelName).toHaveBeenCalledWith('channel-id'); + expect(updatedOrganization).not.toBeNull(); + expect(updatedOrganization.newMemberSlackChannelId).toBe('channel-id'); + expect(updatedOrganization.newMemberSlackChannelName).toBe('new-members'); + }); + }); + + describe('Get New Member Slack Messages', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('Returns an empty array when no channel is configured', async () => { + const messages = await OrganizationsService.getNewMemberSlackMessages(organization); + + expect(messages).toEqual([]); + expect(slackIntegration.getRecentChannelMessages).not.toHaveBeenCalled(); + }); + + it('Fetches the 3 most recent messages from the configured channel', async () => { + (slackIntegration.getRecentChannelMessages as Mock).mockResolvedValue([ + { text: 'hi', userName: 'Bruce', timestamp: '2026-01-01T00:00:00.000Z', permalink: 'https://slack.com/1' } + ]); + + const messages = await OrganizationsService.getNewMemberSlackMessages({ + ...organization, + newMemberSlackChannelId: 'channel-id' + }); + + expect(slackIntegration.getRecentChannelMessages).toHaveBeenCalledWith('channel-id', 3); + expect(messages).toHaveLength(1); + expect(messages[0].text).toBe('hi'); + }); + }); }); diff --git a/src/frontend/src/apis/organizations.api.ts b/src/frontend/src/apis/organizations.api.ts index 0ea7e48b30..d89e5e5942 100644 --- a/src/frontend/src/apis/organizations.api.ts +++ b/src/frontend/src/apis/organizations.api.ts @@ -1,5 +1,5 @@ import axios from '../utils/axios'; -import { NotificationChannelPreview, Organization, ProjectPreview } from 'shared'; +import { NotificationChannelPreview, Organization, ProjectPreview, SlackMessagePreview } from 'shared'; import { apiUrls } from '../utils/urls'; import { ApplicationLinkPayload, @@ -145,6 +145,25 @@ export const setSlackSponsorshipNotificationSlackChannelId = (payload: ChannelId }); }; +/** + * Sets the organization's designated new member Slack channel + * @param payload contains the channel id + */ +export const setNewMemberSlackChannelId = (payload: ChannelIdPayload) => { + return axios.post(apiUrls.organizationsSetNewMemberSlackChannelId(), { + ...payload + }); +}; + +/** + * Gets the 3 most recent messages from the organization's designated new member Slack channel + */ +export const getNewMemberSlackMessages = () => { + return axios.get(apiUrls.organizationsNewMemberSlackMessages(), { + transformResponse: (data) => JSON.parse(data) + }); +}; + /** * Gets the finance delegates for an organization */ diff --git a/src/frontend/src/hooks/organizations.hooks.ts b/src/frontend/src/hooks/organizations.hooks.ts index 5ff47bd075..08d3d7a0b6 100644 --- a/src/frontend/src/hooks/organizations.hooks.ts +++ b/src/frontend/src/hooks/organizations.hooks.ts @@ -1,7 +1,7 @@ import { useContext, useState } from 'react'; import { OrganizationContext } from '../app/AppOrganizationContext'; import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { NotificationChannelPreview, Organization, ProjectPreview, User } from 'shared'; +import { NotificationChannelPreview, Organization, ProjectPreview, SlackMessagePreview, User } from 'shared'; import { getFeaturedProjects, getCurrentOrganization, @@ -17,6 +17,8 @@ import { getPartReviewGuideLink, setPartReviewGuideLink, setSlackSponsorshipNotificationSlackChannelId, + setNewMemberSlackChannelId, + getNewMemberSlackMessages, getFinanceDelegates, setFinanceDelegates, setOrganizationNewMemberImage, @@ -302,6 +304,39 @@ export const useNotificationChannels = () => { }); }; +export const useSetNewMemberSlackChannelId = () => { + const queryClient = useQueryClient(); + return useMutation( + ['organizations', 'new-member-slack-channel'], + async (channelId: string) => { + const { data } = await setNewMemberSlackChannelId({ channelId }); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['organizations']); + } + } + ); +}; + +/** + * Custom React Hook to get the 3 most recent messages from the new member Slack channel. + * Polls periodically so the widget reflects new messages without a page reload. + */ +export const useNewMemberSlackMessages = () => { + return useQuery( + ['organizations', 'new-member-slack-messages'], + async () => { + const { data } = await getNewMemberSlackMessages(); + return data; + }, + { + refetchInterval: 60000 + } + ); +}; + export const useGetFinanceDelegates = () => { return useQuery(['organizations', 'finance-delegates'], async () => { const { data } = await getFinanceDelegates(); diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx index db4994f6cb..b9201e2010 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx @@ -10,6 +10,7 @@ import { useToast } from '../../hooks/toasts.hooks'; import { useCurrentOrganization, useSetSlackSponsorshipNotificationChannelId, + useSetNewMemberSlackChannelId, useSetWorkspaceId } from '../../hooks/organizations.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; @@ -36,10 +37,12 @@ const AdminToolsSlackIdsView: React.FC = ({ orga const toast = useToast(); const { mutateAsync: setWorkspaceIdMutateAsync, isLoading } = useSetWorkspaceId(); const { mutateAsync: setSponsorshipChannelIdMutateAsync } = useSetSlackSponsorshipNotificationChannelId(); + const { mutateAsync: setNewMemberChannelIdMutateAsync } = useSetNewMemberSlackChannelId(); const [workspaceId, setWorkspaceId] = useState(organization.slackWorkspaceId ?? ''); const [sponsorshipChannelId, setSponsorshipChannelId] = useState( organization.sponsorshipNotificationsSlackChannelId ?? '' ); + const [newMemberChannelId, setNewMemberChannelId] = useState(organization.newMemberSlackChannelId ?? ''); const { data: allTeams, isLoading: allTeamsIsLoading, @@ -89,6 +92,17 @@ const AdminToolsSlackIdsView: React.FC = ({ orga } }; + const handleSubmitNewMemberChannelId = async () => { + try { + await setNewMemberChannelIdMutateAsync(newMemberChannelId); + toast.success('Successfully updated the new member channel ID.'); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + return ( @@ -139,6 +153,26 @@ const AdminToolsSlackIdsView: React.FC = ({ orga Update + + + + + + setNewMemberChannelId(e.target.value)} + sx={{ mr: 2 }} + /> + + Update + +
diff --git a/src/frontend/src/pages/HomePage/components/NewMemberSlackWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberSlackWidget.tsx new file mode 100644 index 0000000000..109ca2cb6e --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberSlackWidget.tsx @@ -0,0 +1,84 @@ +import { Box, Typography, useTheme } from '@mui/material'; +import { SlackMessagePreview } from 'shared'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useCurrentOrganization, useNewMemberSlackMessages } from '../../../hooks/organizations.hooks'; + +const MessageBlock: React.FC<{ message: SlackMessagePreview }> = ({ message }) => { + const theme = useTheme(); + + return ( + + + {message.userName || 'Someone'} + + + {message.text} + + + ); +}; + +const NewMemberSlackWidget: React.FC = () => { + const theme = useTheme(); + const { data: messages, isLoading, isError, error } = useNewMemberSlackMessages(); + // decorative only -- if this hasn't loaded yet, just fall back to a generic title + const { data: organization } = useCurrentOrganization(); + + const widgetTitle = organization?.newMemberSlackChannelName + ? `#${organization.newMemberSlackChannelName} on Slack` + : 'New Member Slack'; + + const cardSx = { + backgroundColor: theme.palette.background.paper, + borderRadius: '10px', + width: '100%', + overflow: 'hidden', + paddingBottom: 2, + minHeight: '150px' + }; + + const fallback = (text: string, errorDetail?: string) => ( + + + {widgetTitle} + + + {text} + + + ); + + if (isError) return fallback("Couldn't load Slack messages right now", error?.message); + + if (isLoading || !messages) return ; + + if (messages.length === 0) return fallback('No messages yet'); + + return ( + + + {widgetTitle} + + + {messages.map((message) => ( + + ))} + + + ); +}; + +export default NewMemberSlackWidget; diff --git a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx b/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx index b50e199997..549cef803c 100644 --- a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx +++ b/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx @@ -7,6 +7,7 @@ import OnboardingBlock from '../../AdminToolsPage/OnboardingConfig/OnboardingBlo import { useAllUsefulLinks } from '../../../hooks/projects.hooks'; import NewMemberMilestonesWidget from './NewMemberMilestonesWidget'; import NewMemberEventsWidget from './NewMemberEventsWidget'; +import NewMemberSlackWidget from './NewMemberSlackWidget'; const OnboardingInfoSection: React.FC = () => { const theme = useTheme(); @@ -38,6 +39,9 @@ const OnboardingInfoSection: React.FC = () => { + + + `${organizations()}/workspaceId/set`; const organizationsGetPartReviewGuideLink = () => `${organizations()}/part-review-guide-link/get`; const organizationsSetPartReviewGuideLink = () => `${organizations()}/part-review-guide-link/set`; const organizationsSetSlackSponsorshipNotificationChannelId = () => `${organizations()}/sponsorshipChannelId/set`; +const organizationsSetNewMemberSlackChannelId = () => `${organizations()}/newMemberSlackChannelId/set`; +const organizationsNewMemberSlackMessages = () => `${organizations()}/new-member-slack-messages`; const organizationsFinanceDelegates = () => `${organizations()}/finance-delegates`; const organizationsSetFinanceDelegates = () => `${organizationsFinanceDelegates()}/set`; const organizationsNotificationChannels = () => `${organizations()}/notification-channels`; @@ -818,6 +820,8 @@ export const apiUrls = { organizationsGetPartReviewGuideLink, organizationsSetPartReviewGuideLink, organizationsSetSlackSponsorshipNotificationChannelId, + organizationsSetNewMemberSlackChannelId, + organizationsNewMemberSlackMessages, organizationsFinanceDelegates, organizationsSetFinanceDelegates, organizationsNotificationChannels, diff --git a/src/shared/src/types/announcements.types.ts b/src/shared/src/types/announcements.types.ts index 55a40b086a..97fd923559 100644 --- a/src/shared/src/types/announcements.types.ts +++ b/src/shared/src/types/announcements.types.ts @@ -10,3 +10,10 @@ export interface Announcement { slackChannelName: string; dateDeleted?: Date; } + +export interface SlackMessagePreview { + text: string; + userName?: string; + timestamp: string; + permalink: string; +} diff --git a/src/shared/src/types/user-types.ts b/src/shared/src/types/user-types.ts index a6119c0eb1..4da9133ecf 100644 --- a/src/shared/src/types/user-types.ts +++ b/src/shared/src/types/user-types.ts @@ -72,6 +72,8 @@ export interface Organization { slackWorkspaceId?: string; partReviewGuideLink?: string; sponsorshipNotificationsSlackChannelId?: string; + newMemberSlackChannelId?: string; + newMemberSlackChannelName?: string; platformDescription: string; platformLogoImageId?: string; } From 2cd849b4189bb697b5a84d1c275376080f53834f Mon Sep 17 00:00:00 2001 From: wavehassman Date: Mon, 27 Jul 2026 16:50:48 -0400 Subject: [PATCH 31/43] #4134 and #4133 usefule links --- .../src/controllers/projects.controllers.ts | 17 +++- .../change-requests.query-args.ts | 22 ++++- .../migration.sql | 4 + src/backend/src/prisma/schema.prisma | 5 +- src/backend/src/prisma/seed.ts | 39 +++++++-- src/backend/src/services/projects.services.ts | 21 ++++- .../OnboardingInfoSection.tsx | 30 ++++++- .../UsefulLinks/UsefulLinksTable.tsx | 27 +++--- .../LinkTypes/CreateLinkTypeModal.tsx | 13 ++- .../LinkTypes/EditLinkTypeModal.tsx | 2 + .../LinkTypes/LinkTypeFormModal.tsx | 16 +++- .../LinkTypes/LinkTypeTable.tsx | 15 +++- .../src/pages/HomePage/OnboardingHomePage.tsx | 7 ++ .../components/GuestOrganizationInfo.tsx | 2 +- .../components/NewMemberUsefulLinksWidget.tsx | 67 +++++++++++++++ .../components/OnboardingInfoSection.tsx | 83 ++++++------------- .../test-support/test-data/projects.stub.ts | 12 ++- src/shared/src/types/project-types.ts | 4 + 18 files changed, 289 insertions(+), 97 deletions(-) create mode 100644 src/frontend/src/pages/HomePage/components/NewMemberUsefulLinksWidget.tsx 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/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/migrations/20260522172140_onboarding_improvements/migration.sql b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql index c08d6abc38..e6f3253fb3 100644 --- a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql +++ b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql @@ -85,3 +85,7 @@ ALTER TABLE "Team_Join_Request" ADD CONSTRAINT "Team_Join_Request_reviewedByUser -- AlterTable ALTER TABLE "Organization" ADD COLUMN "newMemberSlackChannelId" TEXT, ADD COLUMN "newMemberSlackChannelName" TEXT; + +-- AlterTable +ALTER TABLE "Link_Type" ADD COLUMN "isOnOnboardingDashboard" BOOLEAN NOT NULL DEFAULT false; + diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index c20e34d212..2a2d0c75f8 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -588,8 +588,9 @@ model Link_Type { links Link[] @relation(name: "linkTypes") organizationId String organization Organization @relation(fields: [organizationId], references: [organizationId]) - isOnGuestHomePage Boolean @default(false) - isOnNewMemberDashboard Boolean @default(false) + isOnGuestHomePage Boolean @default(false) + isOnNewMemberDashboard Boolean @default(false) + isOnOnboardingDashboard Boolean @default(false) @@unique([name, organizationId], name: "uniqueLinkType") @@index([organizationId]) diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 982e8bb7d5..d2eda278df 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -590,11 +590,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, @@ -602,10 +629,12 @@ 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); /** * Projects 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/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx index 38a5a1b564..b50840f7f8 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx @@ -1,6 +1,7 @@ import { Grid, Typography, List, ListItem, useTheme } from '@mui/material'; import { Box } from '@mui/system'; import UsefulLinksTable from './UsefulLinks/UsefulLinksTable'; +import LinkTypeTable from '../ProjectsConfig/LinkTypes/LinkTypeTable'; import NewMemberMilestoneTable from '../RecruitmentConfig/NewMemberMilestoneTable'; import { useCurrentOrganization, @@ -144,9 +145,34 @@ const OnboardingInfoSection: React.FC = () => { marginBottom: '12px' }} > - Useful Links + Onboarding Page Useful Links - + + + + + + theme.palette.background.paper, + height: '100%', + borderRadius: '10px', + padding: '16px', + width: '100%' + }} + > + + New Member Dashboard Useful Links + + + diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinksTable.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinksTable.tsx index 7bfd4bdf7c..780d6c7221 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinksTable.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinksTable.tsx @@ -28,9 +28,11 @@ import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; interface UsefulLinksTableProps { isOnGuestHomePage?: boolean; + isOnNewMemberDashboard?: boolean; + isOnOnboardingDashboard?: boolean; } -const UsefulLinksTable = ({ isOnGuestHomePage }: UsefulLinksTableProps) => { +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 && ( { { > + + + + + {isModalOpen && ( 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 ( = ({ + 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 index 549cef803c..cdb59149dc 100644 --- a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx +++ b/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx @@ -1,15 +1,21 @@ -import { Grid, Typography, ListItem, List, useTheme, Button } from '@mui/material'; +import { Grid, Typography, ListItem, List, useTheme } 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'; import NewMemberMilestonesWidget from './NewMemberMilestonesWidget'; import NewMemberEventsWidget from './NewMemberEventsWidget'; import NewMemberSlackWidget from './NewMemberSlackWidget'; +import NewMemberUsefulLinksWidget from './NewMemberUsefulLinksWidget'; -const OnboardingInfoSection: React.FC = () => { +interface OnboardingInfoSectionProps { + /** 'full' (default) shows every widget, for the new member dashboard. 'checklist' shows only + * the onboarding block, useful links, and contacts, for the onboarding checklist page. */ + variant?: 'full' | 'checklist'; +} + +const OnboardingInfoSection: React.FC = ({ variant = 'full' }) => { const theme = useTheme(); const { data: organization, @@ -18,69 +24,32 @@ const OnboardingInfoSection: React.FC = () => { 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); + if (!organization || organizationIsLoading) return ; return ( - - - - - - - - - - - - - Useful Links - - - {links.map((link) => { - return ( - - - - ); - })} + {variant === 'full' && ( + <> + + - + + + + + + + + )} + + Date: Mon, 27 Jul 2026 19:30:40 -0400 Subject: [PATCH 32/43] #4135 readonly checklist --- .../src/pages/HomePage/NewMemberHomePage.tsx | 6 ++ .../NewMemberChecklistSummaryWidget.tsx | 83 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/frontend/src/pages/HomePage/components/NewMemberChecklistSummaryWidget.tsx diff --git a/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx b/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx index 489dddef72..7ca3bfd0e2 100644 --- a/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx +++ b/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx @@ -11,6 +11,7 @@ import { useHomePageContext } from '../../app/HomePageContext'; import { useCurrentOrganization } from '../../hooks/organizations.hooks'; import OnboardingInfoSection from './components/OnboardingInfoSection'; import NewMemberFAQsSection from './components/NewMemberFAQsSection'; +import NewMemberChecklistSummaryWidget from './components/NewMemberChecklistSummaryWidget'; const NewMemberHomePage = () => { const { setCurrentHomePage } = useHomePageContext(); @@ -52,6 +53,11 @@ const NewMemberHomePage = () => { + + + + + ); }; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberChecklistSummaryWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberChecklistSummaryWidget.tsx new file mode 100644 index 0000000000..e499b87a19 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberChecklistSummaryWidget.tsx @@ -0,0 +1,83 @@ +import { Box, Typography, useTheme } from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { ChecklistItemType } from 'shared'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import NERMarkdown from '../../../components/NERMarkdown'; +import { useCheckedChecklists } from '../../../hooks/onboarding.hook'; +import { groupChecklists } from '../../../utils/onboarding.utils'; + +/** + * Read-only reference view of the onboarding checklist items a new member already completed. + * Reuses useCheckedChecklists() (the same source of truth the interactive checklist reads from) + * and never imports useToggleChecklist, so it can't affect checklist state. + */ +const NewMemberChecklistSummaryWidget: React.FC = () => { + const theme = useTheme(); + const { data: checkedChecklists, isLoading, isError, error } = useCheckedChecklists(); + + if (isError) return ; + if (isLoading || !checkedChecklists) return ; + + const checkedIds = new Set(checkedChecklists.map((checklist) => checklist.checklistId)); + const completedParents = checkedChecklists.filter((checklist) => !checklist.parentChecklistId); + const groupedCompleted = groupChecklists(completedParents); + + return ( + + + What You Completed + + {completedParents.length === 0 ? ( + + Nothing completed yet + + ) : ( + Object.entries(groupedCompleted).map(([groupName, parents]) => ( + + + {groupName} + + {parents.map((parent) => { + const referenceItems = [...parent.subtasks] + .filter((subtask) => subtask.itemType === ChecklistItemType.INFO || checkedIds.has(subtask.checklistId)) + .sort((a, b) => (a.displayIndex ?? 999) - (b.displayIndex ?? 999)); + + return ( + + {parent.content} + + {referenceItems.map((item) => + item.itemType === ChecklistItemType.INFO ? ( + + + + ) : ( + + + {item.content} + + ) + )} + + + ); + })} + + )) + )} + + ); +}; + +export default NewMemberChecklistSummaryWidget; From cb31a246529396d54bb920984138da392ffc5c9b Mon Sep 17 00:00:00 2001 From: wavehassman Date: Tue, 28 Jul 2026 18:34:49 -0400 Subject: [PATCH 33/43] #4123 + #4124 team join requests --- .../src/controllers/teams.controllers.ts | 49 +++++ .../src/prisma-query-args/teams.query-args.ts | 10 + src/backend/src/routes/teams.routes.ts | 9 + src/backend/src/services/teams.services.ts | 181 +++++++++++++++- .../src/transformers/teams.transformer.ts | 24 ++- src/backend/src/utils/errors.utils.ts | 3 +- src/backend/tests/test-utils.ts | 1 + .../tests/unit/team-join-requests.test.ts | 200 ++++++++++++++++++ src/frontend/src/apis/teams.api.ts | 30 ++- .../apis/transformers/teams.transformers.ts | 11 +- src/frontend/src/hooks/teams.hooks.ts | 60 +++++- src/frontend/src/pages/HomePage/Home.tsx | 4 +- .../pages/TeamsPage/RequestToJoinButton.tsx | 77 +++++++ .../TeamsPage/TeamJoinRequestsPageBlock.tsx | 135 ++++++++++++ .../src/pages/TeamsPage/TeamSpecificPage.tsx | 12 +- src/frontend/src/pages/TeamsPage/Teams.tsx | 18 +- .../src/tests/test-support/mock-hooks.ts | 4 + src/frontend/src/utils/teams.utils.ts | 3 +- src/frontend/src/utils/urls.ts | 8 + 19 files changed, 813 insertions(+), 26 deletions(-) create mode 100644 src/backend/tests/unit/team-join-requests.test.ts create mode 100644 src/frontend/src/pages/TeamsPage/RequestToJoinButton.tsx create mode 100644 src/frontend/src/pages/TeamsPage/TeamJoinRequestsPageBlock.tsx 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/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/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/teams.services.ts b/src/backend/src/services/teams.services.ts index 23d5c1658a..d21f0fbea5 100644 --- a/src/backend/src/services/teams.services.ts +++ b/src/backend/src/services/teams.services.ts @@ -1,10 +1,24 @@ -import { isAdmin, isHead, TeamDropdownItem, Team, TeamPreview, TeamType, User, WbsElementStatus } from 'shared'; +import { + isAdmin, + isHead, + TeamDropdownItem, + Team, + TeamPreview, + TeamType, + TeamJoinRequest, + User, + WbsElementStatus +} 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, @@ -16,7 +30,12 @@ import { import { getPrismaQueryUserIds, getUsers, userHasPermission } from '../utils/users.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 +422,162 @@ 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) + }); + + return teamJoinRequestTransformer(created); + } + + /** + * 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, the team head, or a team lead + * @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, the team head, or a team lead + * @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 } } } + }); + } + + return updatedRequest; + }); + + return teamJoinRequestTransformer(updated); + } + + /** + * Validates that the given user is allowed to review join requests for the given team + * @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, the team head, or a team lead + */ + private static async validateJoinRequestReviewer( + reviewer: User, + team: { head: { userId: string }; leads: { userId: string }[] }, + organization: Organization + ): Promise { + const isTeamLead = team.leads.some((lead) => lead.userId === reviewer.userId); + + if ( + !(await userHasPermission(reviewer.userId, organization.organizationId, isAdmin)) && + reviewer.userId !== team.head.userId && + !isTeamLead + ) { + throw new AccessDeniedException( + 'you must be an admin, the team head, or a team lead 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/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/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 12218d3b5f..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(); 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..a92b5e9538 --- /dev/null +++ b/src/backend/tests/unit/team-join-requests.test.ts @@ -0,0 +1,200 @@ +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, the head, or a lead', async () => { + await expect( + async () => await TeamsService.getPendingTeamJoinRequests(team.teamId, outsider, organization) + ).rejects.toThrow( + new AccessDeniedException( + 'you must be an admin, the team head, or a team lead 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('succeeds for a team lead', async () => { + await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); + + const result = await TeamsService.getPendingTeamJoinRequests(team.teamId, lead, organization); + + expect(result).toHaveLength(1); + }); + + 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, the head, or a lead', 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, the team head, or a team lead 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/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/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/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 929b010a33..74e8fd1255 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -26,8 +26,8 @@ const Home: React.FC = () => { if (teamsIsError) return ; if (teamsIsLoading || !teams) return ; - // a new member stays on their own dashboard until they join a team or are promoted off the Guest - // role -- either one means they've graduated out of the new member experience + // 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 ( diff --git a/src/frontend/src/pages/TeamsPage/RequestToJoinButton.tsx b/src/frontend/src/pages/TeamsPage/RequestToJoinButton.tsx new file mode 100644 index 0000000000..ac153bfa0e --- /dev/null +++ b/src/frontend/src/pages/TeamsPage/RequestToJoinButton.tsx @@ -0,0 +1,77 @@ +/* + * 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, Chip, Typography } 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 ; + } + + if (latestRequest?.status === 'DENIED') { + return ( + + + Request to Join + + + Previous request denied{latestRequest.denialReason ? `: ${latestRequest.denialReason}` : ''} + + + ); + } + + return ( + + Request to Join + + ); +}; + +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..e635f0be0d --- /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(); + + const hasPerms = isAdmin(user.role) || user.userId === team.head.userId; + const editMembersPerms = hasPerms || team.leads.map((lead) => lead.userId).includes(user.userId); + + if (!editMembersPerms) 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..194bf665ff 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'; @@ -180,7 +182,8 @@ const TeamSpecificPage: React.FC = () => { return ( + + {TeamActionsDropdown} @@ -194,15 +197,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/test-support/mock-hooks.ts b/src/frontend/src/tests/test-support/mock-hooks.ts index 0d81e7afff..36d466a41a 100644 --- a/src/frontend/src/tests/test-support/mock-hooks.ts +++ b/src/frontend/src/tests/test-support/mock-hooks.ts @@ -9,6 +9,7 @@ import { TaskPriority, TaskStatus, Team, + TeamJoinRequest, UserSettings, UserWithRole, WorkPackage @@ -57,6 +58,9 @@ export const mockUseUsersFavoriteProjects = (projects?: Project[]) => 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/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 fcfd33055d..52ddc10e9a 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`; @@ -680,6 +684,10 @@ export const apiUrls = { teamTypeEdit, teamTypeSetImage, myTeamAsHead, + myTeamJoinRequests, + teamsPendingJoinRequests, + teamsCreateJoinRequest, + teamsReviewJoinRequest, descriptionBulletsCheck, descriptionBulletTypes, From 8c63b46c57bcbe72db7ce85d8487988b72cfd0dd Mon Sep 17 00:00:00 2001 From: wavehassman Date: Tue, 28 Jul 2026 18:47:54 -0400 Subject: [PATCH 34/43] remove slack id from guest so page is hit --- src/backend/src/prisma/seed-data/users.seed.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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: '' } } }; From c1b1c29df66b2d25a6f554b9c8f6e4275a47a2d1 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Tue, 28 Jul 2026 19:16:56 -0400 Subject: [PATCH 35/43] rename file names, update contact widget to have slack --- src/backend/src/prisma/seed.ts | 3 ++- .../AdminToolsOnboardingConfig.tsx | 4 ++-- ...ection.tsx => OnboardingConfigSection.tsx} | 22 +++++++++++++---- .../OnboardingConfig/UpdateContactsModal.tsx | 10 ++++---- .../src/pages/HomePage/NewMemberHomePage.tsx | 4 ++-- .../src/pages/HomePage/OnboardingHomePage.tsx | 4 ++-- ...tsx => NewMemberOnboardingInfoSection.tsx} | 24 ++++++++++++++----- 7 files changed, 49 insertions(+), 22 deletions(-) rename src/frontend/src/pages/AdminToolsPage/OnboardingConfig/{OnboardingInfoSection.tsx => OnboardingConfigSection.tsx} (90%) rename src/frontend/src/pages/HomePage/components/{OnboardingInfoSection.tsx => NewMemberOnboardingInfoSection.tsx} (74%) diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index d2eda278df..e0548c404f 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -138,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' } }); diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx index 8646237148..068ef44057 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx @@ -6,7 +6,7 @@ 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 { Checklist } from 'shared'; @@ -70,7 +70,7 @@ const AdminToolsOnboardingConfig: React.FC = () => { - + New Member FAQs diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx similarity index 90% rename from src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx rename to src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx index b50840f7f8..e625b1076a 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingInfoSection.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx @@ -1,4 +1,4 @@ -import { Grid, Typography, List, ListItem, useTheme } from '@mui/material'; +import { Grid, Typography, List, ListItem, Link, useTheme } from '@mui/material'; import { Box } from '@mui/system'; import UsefulLinksTable from './UsefulLinks/UsefulLinksTable'; import LinkTypeTable from '../ProjectsConfig/LinkTypes/LinkTypeTable'; @@ -18,7 +18,7 @@ import NERUploadButton from '../../../components/NERUploadButton'; import { useToast } from '../../../hooks/toasts.hooks'; import { MAX_FILE_SIZE } from 'shared'; -const OnboardingInfoSection: React.FC = () => { +const OnboardingConfigSection: React.FC = () => { const theme = useTheme(); const [showModal, setShowModal] = useState(false); const [addedImage, setAddedImage] = useState(undefined); @@ -218,12 +218,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 c7a31ef525..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 { userToAutocompleteOption } from '../../../utils/teams.utils'; +import { useMembersDropdown } from '../../../hooks/dropdowns.hooks'; const schema = yup.object().shape({ contacts: yup @@ -48,7 +47,7 @@ const UpdateOnboardingContactsModal: React.FC @@ -117,7 +116,10 @@ const UpdateOnboardingContactsModal: React.FC { - const memberOptions = users.map(userToAutocompleteOption); + const memberOptions = users.map((user) => ({ + id: user.userId, + label: `${user.firstName} ${user.lastName}` + })); return ( { - + FAQs diff --git a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx index b20dbe6d92..521ba0864b 100644 --- a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx +++ b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx @@ -4,7 +4,7 @@ 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 { NERButton } from '../../components/NERButton'; import { useCheckedChecklists, useUsersChecklists, useChecklistProgress } from '../../hooks/onboarding.hook'; @@ -140,7 +140,7 @@ const OnboardingHomePage = () => { - + diff --git a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx b/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx similarity index 74% rename from src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx rename to src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx index cdb59149dc..dd275c7204 100644 --- a/src/frontend/src/pages/HomePage/components/OnboardingInfoSection.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx @@ -1,4 +1,4 @@ -import { Grid, Typography, ListItem, List, useTheme } from '@mui/material'; +import { Grid, Typography, ListItem, List, Link, useTheme } from '@mui/material'; import { Box } from '@mui/system'; import { useCurrentOrganization } from '../../../hooks/organizations.hooks'; import ErrorPage from '../../ErrorPage'; @@ -9,13 +9,13 @@ import NewMemberEventsWidget from './NewMemberEventsWidget'; import NewMemberSlackWidget from './NewMemberSlackWidget'; import NewMemberUsefulLinksWidget from './NewMemberUsefulLinksWidget'; -interface OnboardingInfoSectionProps { +interface NewMemberOnboardingInfoSectionProps { /** 'full' (default) shows every widget, for the new member dashboard. 'checklist' shows only * the onboarding block, useful links, and contacts, for the onboarding checklist page. */ variant?: 'full' | 'checklist'; } -const OnboardingInfoSection: React.FC = ({ variant = 'full' }) => { +const NewMemberOnboardingInfoSection: React.FC = ({ variant = 'full' }) => { const theme = useTheme(); const { data: organization, @@ -67,16 +67,28 @@ const OnboardingInfoSection: React.FC = ({ variant = {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 NewMemberOnboardingInfoSection; From 426c10ff7b81a56d2a9a458847e6b3407ab22bc7 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 29 Jul 2026 19:26:55 -0400 Subject: [PATCH 36/43] #4126 slack integration --- .../src/controllers/slack.controllers.ts | 48 +++++++++- src/backend/src/integrations/slack.ts | 6 +- src/backend/src/routes/slack.routes.ts | 16 ++++ src/backend/src/services/slack.services.ts | 88 +++++++++++++++++++ src/backend/src/services/teams.services.ts | 13 ++- src/backend/src/utils/slack.utils.ts | 74 +++++++++++++++- 6 files changed, 238 insertions(+), 7 deletions(-) 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/integrations/slack.ts b/src/backend/src/integrations/slack.ts index 4857fd2a79..1da5d66bc2 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -457,14 +457,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[] @@ -476,7 +476,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/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/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 d21f0fbea5..505cb43e68 100644 --- a/src/backend/src/services/teams.services.ts +++ b/src/backend/src/services/teams.services.ts @@ -28,6 +28,7 @@ import { InvalidOrganizationException } from '../utils/errors.utils.js'; import { getPrismaQueryUserIds, getUsers, userHasPermission } from '../utils/users.utils.js'; +import { sendTeamJoinRequestNotification } from '../utils/slack.utils.js'; import { isUnderWordCount } from 'shared'; import { removeUsersFromList } from '../utils/teams.utils.js'; import { @@ -451,7 +452,17 @@ export default class TeamsService { ...getTeamJoinRequestQueryArgs(organization.organizationId) }); - return teamJoinRequestTransformer(created); + 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; } /** diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index 8036985646..becad85b19 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,74 @@ export const sendStandardCRCreatedNotification = async ( ); }; +/** + * Sends an ephemeral "Approve this join request?" Slack message with an approve button to each + * team join request reviewer (team head, team leads, and org admins) who is a member of the + * team's Slack channel. 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; + + const headSlackId = await getUserSlackId(team.head.userId); + const leadSlackIds = (await Promise.all(team.leads.map((lead) => getUserSlackId(lead.userId)))).filter( + (id): id is string => !!id + ); + + const admins = await prisma.user.findMany({ + where: { + roles: { + some: { + roleType: { in: ['ADMIN', 'APP_ADMIN'] }, + organizationId: organization.organizationId + } + } + }, + include: { userSettings: true } + }); + const adminSlackIds = admins.map((admin) => admin.userSettings?.slackId).filter((id): id is string => !!id); + + const allSlackIds = new Set([...(headSlackId ? [headSlackId] : []), ...leadSlackIds, ...adminSlackIds]); + if (allSlackIds.size === 0) return; + + 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)) + ); +}; + /** * Adds the relevant slack notifications for a change request to the change request * From e7b400a4b9693984f9eebd3a912e208e6033fbd7 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 29 Jul 2026 20:14:57 -0400 Subject: [PATCH 37/43] more seed data --- .../src/prisma/seed-data/teams.seed.ts | 24 ++++-- src/backend/src/prisma/seed.ts | 79 +++++++++++++++++-- 2 files changed, 87 insertions(+), 16 deletions(-) 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.ts b/src/backend/src/prisma/seed.ts index e0548c404f..b3d355292f 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -410,22 +410,45 @@ 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, @@ -637,6 +660,11 @@ const performSeed: () => Promise = async () => { 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 */ @@ -3345,6 +3373,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' } ]); @@ -3354,6 +3397,8 @@ const performSeed: () => Promise = async () => { 'Thank you for applying to Northeastern Electric Racing! After reviewing your application, we are very excited to officially welcome you to our team.' ); + await OrganizationsService.setNewMemberSlackChannelId('C06HR7WTTKM', batman, organizationId); + await OrganizationsService.updateOrganizationContacts(batman, ner, [ { userId: batman.userId, title: 'Chief Software Engineer' }, { userId: thomasEmrax.userId, title: 'Chief Mechanical Engineer' }, @@ -3455,6 +3500,24 @@ const performSeed: () => Promise = async () => { 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({ data: { From 813a969013b88e4fe1fcdf94797b0a907b407fe9 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 29 Jul 2026 21:16:08 -0400 Subject: [PATCH 38/43] UI fixes --- src/backend/src/prisma/seed.ts | 8 +-- .../AdminToolsOnboardingConfig.tsx | 10 ++- .../NewMemberDashboardUsefulLinksSection.tsx | 34 ++++++++++ .../OnboardingConfig/OnboardingBlock.tsx | 2 +- .../OnboardingConfigSection.tsx | 28 +------- .../src/pages/HomePage/NewMemberHomePage.tsx | 24 ++++--- .../HomePage/components/ChecklistSection.tsx | 4 +- .../components/NewMemberContactsWidget.tsx | 56 ++++++++++++++++ .../NewMemberOnboardingInfoSection.tsx | 64 +++++-------------- .../components/ScrollablePageBlock.tsx | 2 +- .../pages/TeamsPage/RequestToJoinButton.tsx | 31 +++++---- .../src/pages/TeamsPage/TeamSpecificPage.tsx | 26 +++++++- 12 files changed, 176 insertions(+), 113 deletions(-) create mode 100644 src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberDashboardUsefulLinksSection.tsx create mode 100644 src/frontend/src/pages/HomePage/components/NewMemberContactsWidget.tsx diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index b3d355292f..1c4525f29a 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -410,13 +410,7 @@ const performSeed: () => Promise = async () => { 'This is the electrical team', ner ); - const business = await TeamsService.createTeamType( - batman, - 'Business', - 'AttachMoney', - 'This is the business 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( diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx index 068ef44057..8a483303e4 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/AdminToolsOnboardingConfig.tsx @@ -8,6 +8,7 @@ import ErrorPage from '../../ErrorPage'; import { AdminChecklist } from './Checklists/AdminChecklist'; import OnboardingConfigSection from './OnboardingConfigSection'; import NewMemberFAQTable from './NewMemberFAQ/NewMemberFAQTable'; +import NewMemberDashboardUsefulLinksSection from './NewMemberDashboardUsefulLinksSection'; import { Checklist } from 'shared'; type GroupedChecklists = Record; // Change made here @@ -68,9 +69,9 @@ const AdminToolsOnboardingConfig: React.FC = () => { ); })} - - - + + + New Member FAQs @@ -78,6 +79,9 @@ const AdminToolsOnboardingConfig: React.FC = () => { + + + ); 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/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 ( - + { marginBottom: '12px' }} > - New Member Events Image + Onboarding Image {isUploading || imageIsLoading ? ( @@ -108,7 +108,7 @@ const OnboardingConfigSection: React.FC = () => { )} @@ -151,30 +151,6 @@ const OnboardingConfigSection: React.FC = () => { - - theme.palette.background.paper, - height: '100%', - borderRadius: '10px', - padding: '16px', - width: '100%' - }} - > - - New Member Dashboard Useful Links - - - - - { const { setCurrentHomePage } = useHomePageContext(); @@ -48,14 +50,20 @@ const NewMemberHomePage = () => { - - FAQs - - - - - - + + + FAQs + + + + + + + + + + + diff --git a/src/frontend/src/pages/HomePage/components/ChecklistSection.tsx b/src/frontend/src/pages/HomePage/components/ChecklistSection.tsx index ac4b35ac2c..51bc5a1d10 100644 --- a/src/frontend/src/pages/HomePage/components/ChecklistSection.tsx +++ b/src/frontend/src/pages/HomePage/components/ChecklistSection.tsx @@ -55,7 +55,7 @@ const ChecklistSection: React.FC = ({ usersChecklists, ch }} > - New Member Events + Onboarding Image = ({ usersChecklists, ch objectFit: 'contain', borderRadius: '8px' }} - alt="New Member Events" + alt="Onboarding" src={newMemberImageUrl} /> diff --git a/src/frontend/src/pages/HomePage/components/NewMemberContactsWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberContactsWidget.tsx new file mode 100644 index 0000000000..d2cb0a8deb --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/NewMemberContactsWidget.tsx @@ -0,0 +1,56 @@ +import { Box, Link, List, ListItem, Typography, useTheme } from '@mui/material'; +import { useCurrentOrganization } from '../../../hooks/organizations.hooks'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; + +const NewMemberContactsWidget: React.FC = () => { + 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/NewMemberOnboardingInfoSection.tsx b/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx index dd275c7204..fe1843af8c 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx @@ -1,5 +1,4 @@ -import { Grid, Typography, ListItem, List, Link, useTheme } from '@mui/material'; -import { Box } from '@mui/system'; +import { Grid } from '@mui/material'; import { useCurrentOrganization } from '../../../hooks/organizations.hooks'; import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -8,6 +7,7 @@ import NewMemberMilestonesWidget from './NewMemberMilestonesWidget'; import NewMemberEventsWidget from './NewMemberEventsWidget'; import NewMemberSlackWidget from './NewMemberSlackWidget'; import NewMemberUsefulLinksWidget from './NewMemberUsefulLinksWidget'; +import NewMemberContactsWidget from './NewMemberContactsWidget'; interface NewMemberOnboardingInfoSectionProps { /** 'full' (default) shows every widget, for the new member dashboard. 'checklist' shows only @@ -16,7 +16,6 @@ interface NewMemberOnboardingInfoSectionProps { } const NewMemberOnboardingInfoSection: React.FC = ({ variant = 'full' }) => { - const theme = useTheme(); const { data: organization, isLoading: organizationIsLoading, @@ -31,62 +30,31 @@ const NewMemberOnboardingInfoSection: React.FC; return ( - + {variant === 'full' && ( <> - + - + - + )} - - - - - - - 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 - - - )} - - + {variant === 'checklist' && ( + <> + + + + + + + + )} ); }; 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/TeamsPage/RequestToJoinButton.tsx b/src/frontend/src/pages/TeamsPage/RequestToJoinButton.tsx index ac153bfa0e..103f500c5a 100644 --- a/src/frontend/src/pages/TeamsPage/RequestToJoinButton.tsx +++ b/src/frontend/src/pages/TeamsPage/RequestToJoinButton.tsx @@ -3,7 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ -import { Box, Chip, Typography } from '@mui/material'; +import { Chip, Tooltip } from '@mui/material'; import { isGuest, TeamPreview } from 'shared'; import { NERButton } from '../../components/NERButton'; import { useCurrentUser } from '../../hooks/users.hooks'; @@ -54,24 +54,27 @@ const RequestToJoinButton: React.FC = ({ team }) => { return ; } + const button = ( + + Request to Join + + ); + if (latestRequest?.status === 'DENIED') { return ( - - - Request to Join - - - Previous request denied{latestRequest.denialReason ? `: ${latestRequest.denialReason}` : ''} - - + + {button} + ); } - return ( - - Request to Join - - ); + return button; }; export default RequestToJoinButton; diff --git a/src/frontend/src/pages/TeamsPage/TeamSpecificPage.tsx b/src/frontend/src/pages/TeamsPage/TeamSpecificPage.tsx index 194bf665ff..19e0c887c7 100644 --- a/src/frontend/src/pages/TeamsPage/TeamSpecificPage.tsx +++ b/src/frontend/src/pages/TeamsPage/TeamSpecificPage.tsx @@ -89,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 ); @@ -110,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 ); @@ -147,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 From 2447e70f61eec9165962945c3df0b0f133080926 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 29 Jul 2026 21:22:40 -0400 Subject: [PATCH 39/43] approving a request makes guest a member --- src/backend/src/services/teams.services.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/backend/src/services/teams.services.ts b/src/backend/src/services/teams.services.ts index 505cb43e68..2a6c746074 100644 --- a/src/backend/src/services/teams.services.ts +++ b/src/backend/src/services/teams.services.ts @@ -7,7 +7,8 @@ import { TeamType, TeamJoinRequest, User, - WbsElementStatus + WbsElementStatus, + RoleEnum } from 'shared'; import { Organization } from '@prisma/client'; import prisma from '../prisma/prisma.js'; @@ -556,6 +557,18 @@ export default class TeamsService { 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; From 2ec5651840ad656676f9f3a364ce52b1c937147e Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 2 Aug 2026 00:19:45 -0400 Subject: [PATCH 40/43] requested changes from meeting --- .../controllers/organizations.controllers.ts | 49 --- src/backend/src/integrations/slack.ts | 91 ------ src/backend/src/prisma/manual.ts | 282 ++++++++++++++++++ .../migration.sql | 4 - .../migration.sql | 2 + src/backend/src/prisma/schema.prisma | 3 - src/backend/src/prisma/seed.ts | 2 - .../src/routes/organizations.routes.ts | 15 - .../src/services/organizations.services.ts | 92 ------ src/backend/src/services/teams.services.ts | 32 +- src/backend/src/services/users.services.ts | 14 +- .../transformers/organizationTransformer.ts | 1 - src/backend/src/utils/slack.utils.ts | 53 ++-- src/backend/tests/unit/organization.test.ts | 62 ---- .../tests/unit/team-join-requests.test.ts | 32 +- src/backend/tests/unit/users.test.ts | 35 +-- src/frontend/src/apis/organizations.api.ts | 33 +- src/frontend/src/hooks/organizations.hooks.ts | 59 +--- .../AdminToolsPage/AdminToolsSlackIds.tsx | 34 --- .../OnboardingConfigSection.tsx | 88 +----- src/frontend/src/pages/HomePage/Home.tsx | 6 +- .../src/pages/HomePage/NewMemberHomePage.tsx | 24 +- .../src/pages/HomePage/OnboardingHomePage.tsx | 62 +++- .../src/pages/HomePage/SlackIdGateContext.tsx | 33 ++ .../HomePage/components/ChecklistSection.tsx | 41 +-- .../ConfirmOnboardingChecklistModal.tsx | 6 +- .../NewMemberChecklistSummaryWidget.tsx | 83 ------ .../components/NewMemberEventsWidget.tsx | 156 ++++------ .../components/NewMemberMilestonesWidget.tsx | 15 +- .../NewMemberOnboardingInfoSection.tsx | 24 +- .../components/NewMemberSlackWidget.tsx | 84 ------ .../components/NewMemberUsefulLinksWidget.tsx | 4 +- .../HomePage/components/SetSlackIdModal.tsx | 74 +++++ .../HomePage/components/SubtaskSection.tsx | 8 +- .../TeamsPage/TeamJoinRequestsPageBlock.tsx | 6 +- src/frontend/src/utils/urls.ts | 8 - src/shared/index.ts | 1 + src/shared/src/types/announcements.types.ts | 7 - src/shared/src/types/user-types.ts | 4 - src/shared/src/validate-slack-id.ts | 15 + 40 files changed, 641 insertions(+), 1003 deletions(-) create mode 100644 src/backend/src/prisma/migrations/20260801000001_remove_new_member_image/migration.sql create mode 100644 src/frontend/src/pages/HomePage/SlackIdGateContext.tsx delete mode 100644 src/frontend/src/pages/HomePage/components/NewMemberChecklistSummaryWidget.tsx delete mode 100644 src/frontend/src/pages/HomePage/components/NewMemberSlackWidget.tsx create mode 100644 src/frontend/src/pages/HomePage/components/SetSlackIdModal.tsx create mode 100644 src/shared/src/validate-slack-id.ts diff --git a/src/backend/src/controllers/organizations.controllers.ts b/src/backend/src/controllers/organizations.controllers.ts index 9b1a0e089e..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( @@ -251,30 +226,6 @@ export default class OrganizationsController { } } - static async setNewMemberSlackChannelId(req: Request, res: Response, next: NextFunction) { - try { - const { channelId } = req.body; - - const updatedOrg = await OrganizationsService.setNewMemberSlackChannelId( - channelId, - req.currentUser, - req.organization.organizationId - ); - res.status(200).json(updatedOrg); - } catch (error: unknown) { - next(error); - } - } - - static async getNewMemberSlackMessages(req: Request, res: Response, next: NextFunction) { - try { - const messages = await OrganizationsService.getNewMemberSlackMessages(req.organization); - res.status(200).json(messages); - } catch (error: unknown) { - next(error); - } - } - static async getFinanceDelegates(req: Request, res: Response, next: NextFunction) { try { const financeDelegates = await OrganizationsService.getFinanceDelegates(req.organization.organizationId); diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index 1da5d66bc2..589f5f08ea 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -1,7 +1,6 @@ import bolt from '@slack/bolt'; import type { App, ExpressReceiver } from '@slack/bolt'; import { LRUCache } from 'lru-cache'; -import { SlackMessagePreview } from 'shared'; import { HttpException } from '../utils/errors.utils.js'; const { App: AppClass, ExpressReceiver: ExpressReceiverClass } = bolt; @@ -508,93 +507,3 @@ export const getReceiver = (): ExpressReceiver | null => { // Export the getters for any direct usage if needed export { getSlackClient }; export default getSlackClient; - -/** - * Fetches the most recent real (non-system) messages posted in a Slack channel, newest first, - * with each message's author name and a permalink back to it in Slack resolved. - * @param channelId the id of the slack channel to fetch messages from - * @param limit the maximum number of recent messages to fetch - * @returns the most recent messages in the channel, newest first - */ -const fetchRecentChannelMessages = async (key: string): Promise => { - const [channelId, limitStr] = key.split(':'); - const limit = Number(limitStr); - - const client = getSlackClient(); - if (!client) { - throw new HttpException(500, 'Slack integration not configured'); - } - - try { - const historyRes = await client.conversations.history({ channel: channelId, limit }); - if (!historyRes.ok || !historyRes.messages) { - throw new Error(historyRes.error ?? 'unknown error fetching channel history'); - } - - const realMessages = historyRes.messages.filter((message: any) => !message.subtype && message.text && message.ts); - - return await Promise.all( - realMessages.map(async (message: any) => { - const [userName, permalinkRes] = await Promise.all([ - message.user ? getUserName(message.user) : undefined, - client.chat.getPermalink({ channel: channelId, message_ts: message.ts }) - ]); - - return { - text: message.text, - userName, - timestamp: new Date(Number(message.ts) * 1000).toISOString(), - permalink: permalinkRes.permalink as string - }; - }) - ); - } catch (error) { - throw new HttpException( - 500, - `Failed to fetch recent Slack messages: ${(error as any)?.data?.error ?? (error as Error).message}` - ); - } -}; - -/** - * Caches recent channel messages briefly, keyed by `${channelId}:${limit}`. This is the - * important one for widgets that poll on a timer: every viewer asking for the same channel - * shares one Slack request per TTL window instead of hitting Slack once per viewer per poll. - * TTL matches the frontend's poll interval so a single viewer's repeat polls hit cache too, - * not just concurrent polls from different viewers. - */ -const recentChannelMessagesCache = new LRUCache({ - max: 100, - ttl: 1000 * 60, // 60 seconds - fetchMethod: fetchRecentChannelMessages -}); - -/** - * Fetches the most recent real (non-system) messages posted in a Slack channel, newest first, - * with each message's author name and a permalink back to it in Slack resolved. Results are - * cached briefly, and concurrent/near-concurrent callers for the same channel share a single - * slack request rather than each hitting Slack independently. - * @param channelId the id of the slack channel to fetch messages from - * @param limit the maximum number of recent messages to fetch - * @returns the most recent messages in the channel, newest first - */ -export const getRecentChannelMessages = async (channelId: string, limit: number): Promise => { - return (await recentChannelMessagesCache.fetch(`${channelId}:${limit}`)) ?? []; -}; - -/** - * Validates that a given Slack user id exists in the workspace - * All slack ids start with U. If you pass a valid user id to users.info, it returns ok: true; throws error otherwise. - * @param slackId the Slack user id to validate - * @returns true if the user exists, false otherwise - */ -export const validateSlackUserId = async (slackId: string): Promise => { - const client = getSlackClient(); - if (!client) return false; - try { - const res = await client.users.info({ user: slackId }); - return res.ok === true; - } catch (error) { - return false; - } -}; diff --git a/src/backend/src/prisma/manual.ts b/src/backend/src/prisma/manual.ts index db6e6ca8f2..43fcbc2c25 100644 --- a/src/backend/src/prisma/manual.ts +++ b/src/backend/src/prisma/manual.ts @@ -8,6 +8,9 @@ import { Reimbursement_Status_Type, WBS_Element_Status } from '@prisma/client'; import { calculateEndDate } from 'shared'; import { writeFileSync } from 'fs'; import { getUserFullName } from '../utils/users.utils.js'; +import ProjectsService from '../services/projects.services.js'; +import RecruitmentServices from '../services/recruitment.services.js'; +import CalendarService from '../services/calendar.services.js'; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -16,6 +19,285 @@ import { getUserFullName } from '../utils/users.utils.js'; * @see {@link https://github.com/Northeastern-Electric-Racing/FinishLine/blob/develop/docs/Deployment.md docs/Deployment.md} for details */ +/** + * One-off backfill for an existing dev DB that's missing the recruitment milestones, FAQs, and + * onboarding/new-member-dashboard useful link types + links that seed.ts creates on a fresh DB. + * Safe to re-run -- everything is checked for existence first. + */ +export const seedMissingOnboardingRecruitmentContent = async () => { + const ner = await prisma.organization.findFirstOrThrow({ where: { name: 'Northeastern Electric Racing' } }); + const submitter = await prisma.user.findFirstOrThrow({ where: { email: 'pyle.c@northeastern.edu' } }); + + const daysAgo = (days: number): Date => new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const daysFromNow = (days: number): Date => new Date(Date.now() + days * 24 * 60 * 60 * 1000); + + const recruitingDashboardOnly = { isOnNewMemberDashboard: false, isOnRecruitingDashboard: true }; + const newMemberDashboardOnly = { isOnNewMemberDashboard: true, isOnRecruitingDashboard: false }; + + /** Milestones */ + const milestones: [string, string, Date, { isOnNewMemberDashboard: boolean; isOnRecruitingDashboard: boolean }][] = [ + ['Club fair!', 'Also meet us at:', daysAgo(120), recruitingDashboardOnly], + ['Applications Open', '', daysAgo(70), recruitingDashboardOnly], + ['Applications Close', '', daysAgo(56), recruitingDashboardOnly], + ['Decision Day!', '', daysAgo(49), recruitingDashboardOnly], + ['First Meeting', 'Attend your first general body meeting', daysAgo(14), newMemberDashboardOnly], + ['First Bay Time', 'Get hands-on time in the bay with a team lead', daysAgo(7), newMemberDashboardOnly], + [ + 'Safety Training Deadline', + 'Complete required safety training to access the bay unsupervised', + daysFromNow(14), + newMemberDashboardOnly + ], + ['Subteam Placement', 'Officially join a subteam project', daysFromNow(30), newMemberDashboardOnly] + ]; + + for (const [name, description, dateOfEvent, dashboards] of milestones) { + const exists = await prisma.milestone.findFirst({ where: { name, organizationId: ner.organizationId } }); + if (exists) continue; + await RecruitmentServices.createMilestone(submitter, name, description, dateOfEvent, dashboards, ner); + console.log(`Created milestone: ${name}`); + } + + /** FAQs */ + const faqs: [string, string, boolean, boolean, boolean][] = [ + ['Who is the Chief Software Engineer?', 'Peyton McKee', true, false, false], + ['When was FinishLine created?', 'FinishLine was created in 2019', true, false, false], + ['How many developers are working on FinishLine?', '178 as of 2024', true, false, false], + [ + 'Where do I go if I have a question during onboarding?', + 'Ask in the #new-members Slack channel — no question is too small!', + false, + true, + false + ], + [ + 'How do I get access to the shop?', + 'Complete the safety training checklist item and a lead will grant you access.', + false, + true, + false + ], + [ + '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.', + false, + true, + false + ], + [ + '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.", + false, + true, + false + ] + ]; + + for (const [question, answer, isOnRecruitingDashboard, isOnNewMemberDashboard, isOnPartReviewPage] of faqs) { + const exists = await prisma.frequentlyAskedQuestion.findFirst({ + where: { question, organizationId: ner.organizationId } + }); + if (exists) continue; + await RecruitmentServices.createOrganizationFaq( + submitter, + question, + answer, + ner, + isOnRecruitingDashboard, + isOnNewMemberDashboard, + isOnPartReviewPage + ); + console.log(`Created FAQ: ${question}`); + } + + /** Onboarding-page + new-member-dashboard useful link types */ + const linkTypes: [string, string, boolean, boolean][] = [ + // isOnNewMemberDashboard, isOnOnboardingDashboard + ['Confluence', 'description', false, true], + ['Bill of Materials', 'bar_chart', false, true], + ['NER Website', 'bar_chart', false, true], + ['NER Instagram', 'bar_chart', false, true], + ['Google Drive', 'folder', false, true], + ['NER Handbook', 'menu_book', true, false], + ['Team Directory', 'groups', true, false], + ['NER Merch Store', 'storefront', true, false] + ]; + + for (const [name, iconName, isOnNewMemberDashboard, isOnOnboardingDashboard] of linkTypes) { + const exists = await prisma.link_Type.findUnique({ + where: { uniqueLinkType: { name, organizationId: ner.organizationId } } + }); + if (exists) continue; + await ProjectsService.createLinkType( + submitter, + name, + iconName, + true, + ner, + false, + isOnNewMemberDashboard, + isOnOnboardingDashboard + ); + console.log(`Created link type: ${name}`); + } + + /** Useful links (URLs) -- added one at a time, only if the org doesn't already have a useful + * link of that link type, so this never touches/replaces any existing links */ + const usefulLinks: [string, string][] = [ + ['Confluence', 'https://confluence.com'], + ['Bill of Materials', 'https://docs.google.com'], + ['NER Website', 'https://electricracing.northeastern.edu/'], + ['NER Instagram', 'https://www.instagram.com/nuelectricracing/'], + ['NER Handbook', 'https://electricracing.northeastern.edu/handbook'], + ['Team Directory', 'https://electricracing.northeastern.edu/teams'], + ['NER Merch Store', 'https://electricracing.northeastern.edu/store'] + ]; + + const orgWithLinks = await prisma.organization.findUniqueOrThrow({ + where: { organizationId: ner.organizationId }, + include: { usefulLinks: { include: { linkType: true } } } + }); + const existingLinkTypeNames = new Set(orgWithLinks.usefulLinks.map((link) => link.linkType.name)); + + for (const [linkTypeName, url] of usefulLinks) { + if (existingLinkTypeNames.has(linkTypeName)) continue; + + const linkType = await prisma.link_Type.findUniqueOrThrow({ + where: { uniqueLinkType: { name: linkTypeName, organizationId: ner.organizationId } } + }); + + const newLink = await prisma.link.create({ + data: { + url, + linkType: { connect: { id: linkType.id } }, + creator: { connect: { userId: submitter.userId } } + } + }); + + await prisma.organization.update({ + where: { organizationId: ner.organizationId }, + data: { usefulLinks: { connect: { linkId: newLink.linkId } } } + }); + + console.log(`Added useful link: ${linkTypeName}`); + } +}; + +/** + * One-off fix for an existing dev DB: the "New Member Events" calendar already exists with the + * "Educational" event type attached and real events on it, but the calendar's isNewMemberCalendar + * flag was never set, so the new member dashboard's events widget (which only looks at the + * calendar flagged isNewMemberCalendar: true) always came up empty. All of that calendar's + * existing events are also in the past, so a few new upcoming ones are added too. + * Safe to re-run -- the calendar flip is idempotent, and events are only added if missing by title. + */ +export const fixNewMemberEventsCalendar = async () => { + const ner = await prisma.organization.findFirstOrThrow({ where: { name: 'Northeastern Electric Racing' } }); + const submitter = await prisma.user.findFirstOrThrow({ where: { email: 'pyle.c@northeastern.edu' } }); + + const newMemberCalendar = await prisma.calendar.findFirstOrThrow({ + where: { organizationId: ner.organizationId, name: 'New Member Events', dateDeleted: null } + }); + + if (!newMemberCalendar.isNewMemberCalendar) { + await CalendarService.editCalendar( + submitter, + newMemberCalendar.calendarId, + newMemberCalendar.name, + newMemberCalendar.description, + newMemberCalendar.colorHexCode, + true, + ner + ); + console.log('Flagged "New Member Events" as the new member calendar'); + } + + const educationalEventType = await prisma.event_Type.findFirstOrThrow({ + where: { organizationId: ner.organizationId, name: 'Educational', dateDeleted: null } + }); + + const teamTypes = await prisma.team_Type.findMany({ where: { organizationId: ner.organizationId } }); + const teamTypeIdByName = new Map(teamTypes.map((teamType) => [teamType.name, teamType.teamTypeId])); + + const daysFromNow = (days: number): Date => new Date(Date.now() + days * 24 * 60 * 60 * 1000); + + const events: { + title: string; + teamTypeName: string; + start: Date; + durationMinutes: number; + location?: string; + zoomLink?: string; + description: string; + }[] = [ + { + title: 'New Member Mixer', + teamTypeName: 'Electrical', + start: daysFromNow(7), + durationMinutes: 60, + location: 'Curry Student Center', + description: 'Come meet the team!' + }, + { + title: 'New Member Bay Time', + teamTypeName: 'Mechanical', + start: daysFromNow(14), + durationMinutes: 60, + location: 'Richards Hall', + description: 'Hands-on time in the bay with the mechanical team' + }, + { + title: 'New Member Software Onboarding', + teamTypeName: 'Software', + start: daysFromNow(21), + durationMinutes: 90, + zoomLink: 'https://zoom.us/j/123456789', + description: 'Intro to the FinishLine codebase' + } + ]; + + for (const event of events) { + const exists = await prisma.event.findFirst({ + where: { title: event.title, eventTypeId: educationalEventType.eventTypeId, dateDeleted: null } + }); + if (exists) continue; + + const teamTypeId = teamTypeIdByName.get(event.teamTypeName); + if (!teamTypeId) { + console.log(`Skipping "${event.title}" -- no "${event.teamTypeName}" team type found`); + continue; + } + + await CalendarService.createEvent( + submitter, + event.title, + educationalEventType.eventTypeId, + ner, + [], + [], + [], + [], + [], + [], + [ + { + startTime: event.start, + endTime: new Date(event.start.getTime() + event.durationMinutes * 60 * 1000), + allDay: false + } + ], + undefined, + [], + teamTypeId, + undefined, + event.location, + event.zoomLink, + event.description + ); + console.log(`Created event: ${event.title}`); + } +}; + /** Execute all given prisma database interaction scripts written in this function */ const executeScripts = async () => {}; diff --git a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql index e6f3253fb3..f33822addc 100644 --- a/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql +++ b/src/backend/src/prisma/migrations/20260522172140_onboarding_improvements/migration.sql @@ -82,10 +82,6 @@ ALTER TABLE "Team_Join_Request" ADD CONSTRAINT "Team_Join_Request_teamId_fkey" F -- 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 "Organization" ADD COLUMN "newMemberSlackChannelId" TEXT, -ADD COLUMN "newMemberSlackChannelName" TEXT; - -- 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 2a2d0c75f8..cfc701cbb8 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -1391,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? @@ -1399,8 +1398,6 @@ model Organization { partReviewSampleImageId String? partReviewGuideLink String? sponsorshipNotificationsSlackChannelId String? - newMemberSlackChannelId String? - newMemberSlackChannelName String? platformDescription String @default("") platformLogoImageId String? diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 1c4525f29a..9ef2f6b1d1 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -3391,8 +3391,6 @@ const performSeed: () => Promise = async () => { 'Thank you for applying to Northeastern Electric Racing! After reviewing your application, we are very excited to officially welcome you to our team.' ); - await OrganizationsService.setNewMemberSlackChannelId('C06HR7WTTKM', batman, organizationId); - await OrganizationsService.updateOrganizationContacts(batman, ner, [ { userId: batman.userId, title: 'Chief Software Engineer' }, { userId: thomasEmrax.userId, title: 'Chief Mechanical Engineer' }, diff --git a/src/backend/src/routes/organizations.routes.ts b/src/backend/src/routes/organizations.routes.ts index 2353f043a5..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(), @@ -88,15 +82,6 @@ organizationRouter.post( organizationRouter.get('/notification-channels', OrganizationsController.getNotificationChannels); -organizationRouter.post( - '/newMemberSlackChannelId/set', - nonEmptyString(body('channelId')), - validateInputs, - OrganizationsController.setNewMemberSlackChannelId -); - -organizationRouter.get('/new-member-slack-messages', OrganizationsController.getNewMemberSlackMessages); - organizationRouter.get('/finance-delegates', OrganizationsController.getFinanceDelegates); organizationRouter.post( '/finance-delegates/set', diff --git a/src/backend/src/services/organizations.services.ts b/src/backend/src/services/organizations.services.ts index 138ebeeb8e..b11daf7e92 100644 --- a/src/backend/src/services/organizations.services.ts +++ b/src/backend/src/services/organizations.services.ts @@ -5,12 +5,10 @@ import { NotificationChannelPreview, ProjectPreview, RoleEnum, - SlackMessagePreview, isAdmin, isAtLeastRank, User } from 'shared'; -import { getChannelName, getRecentChannelMessages } from '../integrations/slack.js'; import prisma from '../prisma/prisma.js'; import { AccessDeniedAdminOnlyException, @@ -314,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 @@ -532,45 +479,6 @@ export default class OrganizationsService { ); } - /** - * Sets the organization's designated new member Slack channel, shown on the new member dashboard. - * The channel's display name is resolved and stored alongside its id at set-time, since it rarely - * changes -- this avoids re-resolving it from Slack on every dashboard load/poll. - * @param channelId the slack id of the channel - * @param submitter the user making the change - * @param organizationId the organization to update - * @returns the updated organization - */ - static async setNewMemberSlackChannelId( - channelId: string, - submitter: User, - organizationId: string - ): Promise { - if (!(await userHasPermission(submitter.userId, organizationId, isAdmin))) { - throw new AccessDeniedAdminOnlyException('set new member slack channel id'); - } - - const channelName = await getChannelName(channelId); - - const updatedOrg = await prisma.organization.update({ - where: { organizationId }, - data: { newMemberSlackChannelId: channelId, newMemberSlackChannelName: channelName } - }); - - return updatedOrg; - } - - /** - * Gets the 3 most recent messages from the organization's designated new member Slack channel - * @param organization the organization to get new member slack messages for - * @returns the most recent messages in the channel, or an empty array if no channel is configured - */ - static async getNewMemberSlackMessages(organization: Organization): Promise { - if (!organization.newMemberSlackChannelId) return []; - - return getRecentChannelMessages(organization.newMemberSlackChannelId, 3); - } - /** * Gets the finance delegates for the given organization * @param organizationId the organization to get the finance delegates for diff --git a/src/backend/src/services/teams.services.ts b/src/backend/src/services/teams.services.ts index 2a6c746074..2da7394b8f 100644 --- a/src/backend/src/services/teams.services.ts +++ b/src/backend/src/services/teams.services.ts @@ -29,7 +29,7 @@ import { InvalidOrganizationException } from '../utils/errors.utils.js'; import { getPrismaQueryUserIds, getUsers, userHasPermission } from '../utils/users.utils.js'; -import { sendTeamJoinRequestNotification } from '../utils/slack.utils.js'; +import { sendTeamJoinRequestNotification, sendTeamJoinRequestReviewedNotification } from '../utils/slack.utils.js'; import { isUnderWordCount } from 'shared'; import { removeUsersFromList } from '../utils/teams.utils.js'; import { @@ -487,7 +487,7 @@ export default class TeamsService { * @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, the team head, or a team lead + * @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( @@ -517,7 +517,7 @@ export default class TeamsService { * @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, the team head, or a team lead + * @throws AccessDeniedException if the reviewer isn't an admin or the team head * @returns the updated team join request */ static async reviewTeamJoinRequest( @@ -574,31 +574,35 @@ export default class TeamsService { return updatedRequest; }); - return teamJoinRequestTransformer(updated); + 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 + * 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, the team head, or a team lead + * @throws AccessDeniedException if the reviewer isn't an admin or the team head */ private static async validateJoinRequestReviewer( reviewer: User, - team: { head: { userId: string }; leads: { userId: string }[] }, + team: { head: { userId: string } }, organization: Organization ): Promise { - const isTeamLead = team.leads.some((lead) => lead.userId === reviewer.userId); - if ( !(await userHasPermission(reviewer.userId, organization.organizationId, isAdmin)) && - reviewer.userId !== team.head.userId && - !isTeamLead + reviewer.userId !== team.head.userId ) { - throw new AccessDeniedException( - 'you must be an admin, the team head, or a team lead to review join requests for this team' - ); + throw new AccessDeniedException('you must be an admin or the team head to review join requests for this team'); } } diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 895fba7a1f..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'; @@ -36,7 +37,6 @@ import authenticatedUserTransformer from '../transformers/auth-user.transformer. import { getTaskQueryArgs } from '../prisma-query-args/tasks.query-args.js'; import taskTransformer from '../transformers/tasks.transformer.js'; import { validateUserIsPartOfFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js'; -import { validateSlackUserId } from '../integrations/slack.js'; import { encrypt, decrypt } from '../utils/encryption.utils.js'; export default class UsersService { @@ -222,14 +222,8 @@ export default class UsersService { * @throws if the user does not exist */ static async updateUserSettings(user: User, defaultTheme: ThemeName, slackId: string): Promise { - if (slackId) { - if (!process.env.SLACK_BOT_TOKEN) { - throw new HttpException(500, 'Slack integration not configured'); - } - const isValid = await validateSlackUserId(slackId); - if (!isValid) { - throw new HttpException(400, 'Invalid Slack ID'); - } + if (slackId && !isValidSlackUserIdFormat(slackId)) { + throw new HttpException(400, 'Invalid Slack ID'); } const { userId } = user; diff --git a/src/backend/src/transformers/organizationTransformer.ts b/src/backend/src/transformers/organizationTransformer.ts index 08b5e62492..85c2f96eca 100644 --- a/src/backend/src/transformers/organizationTransformer.ts +++ b/src/backend/src/transformers/organizationTransformer.ts @@ -5,7 +5,6 @@ export const organizationTransformer = (organization: Organization): Organizatio return { ...organization, applicationLink: organization.applicationLink ?? undefined, - newMemberImageId: organization.newMemberImageId ?? undefined, platformDescription: organization.platformDescription, platformLogoImageId: organization.platformLogoImageId ?? undefined }; diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index becad85b19..d7622dae6a 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -727,11 +727,11 @@ export const sendStandardCRCreatedNotification = async ( }; /** - * Sends an ephemeral "Approve this join request?" Slack message with an approve button to each - * team join request reviewer (team head, team leads, and org admins) who is a member of the - * team's Slack channel. 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. + * 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, @@ -741,26 +741,12 @@ export const sendTeamJoinRequestNotification = async ( 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); - const leadSlackIds = (await Promise.all(team.leads.map((lead) => getUserSlackId(lead.userId)))).filter( - (id): id is string => !!id - ); - - const admins = await prisma.user.findMany({ - where: { - roles: { - some: { - roleType: { in: ['ADMIN', 'APP_ADMIN'] }, - organizationId: organization.organizationId - } - } - }, - include: { userSettings: true } - }); - const adminSlackIds = admins.map((admin) => admin.userSettings?.slackId).filter((id): id is string => !!id); + if (!headSlackId) return; - const allSlackIds = new Set([...(headSlackId ? [headSlackId] : []), ...leadSlackIds, ...adminSlackIds]); - if (allSlackIds.size === 0) return; + const allSlackIds = new Set([headSlackId]); const membersInChannel = new Set(await getUsersInChannel(team.slackId)); @@ -794,6 +780,27 @@ export const sendTeamJoinRequestNotification = async ( ); }; +/** + * 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/unit/organization.test.ts b/src/backend/tests/unit/organization.test.ts index 4f112e5ab4..9fca867b26 100644 --- a/src/backend/tests/unit/organization.test.ts +++ b/src/backend/tests/unit/organization.test.ts @@ -8,20 +8,11 @@ import { uploadFile } from '../../src/utils/google-integration.utils.js'; import { Mock, vi } from 'vitest'; import OrganizationsService from '../../src/services/organizations.services.js'; import { Organization } from '@prisma/client'; -import * as slackIntegration from '../../src/integrations/slack.js'; vi.mock('../../src/utils/google-integration.utils', () => ({ uploadFile: vi.fn() })); -vi.mock('../../src/integrations/slack.js', async (importOriginal) => { - return { - ...(await importOriginal()), - getRecentChannelMessages: vi.fn(), - getChannelName: vi.fn() - }; -}); - describe('Organization Tests', () => { let orgId: string; let organization: Organization; @@ -343,57 +334,4 @@ describe('Organization Tests', () => { expect(updatedOrganization?.platformLogoImageId).toBe('uploaded-image3.png'); }); }); - - describe('Set New Member Slack Channel Id', () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it('Fails if user is not an admin', async () => { - const testWonderwoman = await createTestUser(wonderwomanGuest, orgId); - await expect(OrganizationsService.setNewMemberSlackChannelId('channel-id', testWonderwoman, orgId)).rejects.toThrow( - new AccessDeniedAdminOnlyException('set new member slack channel id') - ); - }); - - it('Succeeds and updates the new member slack channel id and its resolved name', async () => { - const testBatman = await createTestUser(batmanAppAdmin, orgId); - (slackIntegration.getChannelName as Mock).mockResolvedValue('new-members'); - - const updatedOrganization = await OrganizationsService.setNewMemberSlackChannelId('channel-id', testBatman, orgId); - - expect(slackIntegration.getChannelName).toHaveBeenCalledWith('channel-id'); - expect(updatedOrganization).not.toBeNull(); - expect(updatedOrganization.newMemberSlackChannelId).toBe('channel-id'); - expect(updatedOrganization.newMemberSlackChannelName).toBe('new-members'); - }); - }); - - describe('Get New Member Slack Messages', () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it('Returns an empty array when no channel is configured', async () => { - const messages = await OrganizationsService.getNewMemberSlackMessages(organization); - - expect(messages).toEqual([]); - expect(slackIntegration.getRecentChannelMessages).not.toHaveBeenCalled(); - }); - - it('Fetches the 3 most recent messages from the configured channel', async () => { - (slackIntegration.getRecentChannelMessages as Mock).mockResolvedValue([ - { text: 'hi', userName: 'Bruce', timestamp: '2026-01-01T00:00:00.000Z', permalink: 'https://slack.com/1' } - ]); - - const messages = await OrganizationsService.getNewMemberSlackMessages({ - ...organization, - newMemberSlackChannelId: 'channel-id' - }); - - expect(slackIntegration.getRecentChannelMessages).toHaveBeenCalledWith('channel-id', 3); - expect(messages).toHaveLength(1); - expect(messages[0].text).toBe('hi'); - }); - }); }); diff --git a/src/backend/tests/unit/team-join-requests.test.ts b/src/backend/tests/unit/team-join-requests.test.ts index a92b5e9538..e3615adbe9 100644 --- a/src/backend/tests/unit/team-join-requests.test.ts +++ b/src/backend/tests/unit/team-join-requests.test.ts @@ -99,13 +99,11 @@ describe('Team Join Request Tests', () => { }); describe('Get Pending Team Join Requests', () => { - it('fails if the reviewer is not an admin, the head, or a lead', async () => { + 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, the team head, or a team lead to review join requests for this team' - ) + new AccessDeniedException('you must be an admin or the team head to review join requests for this team') ); }); @@ -117,12 +115,14 @@ describe('Team Join Request Tests', () => { expect(result).toHaveLength(1); }); - it('succeeds for a team lead', async () => { + it('fails for a team lead', async () => { await TeamsService.createTeamJoinRequest(requester, team.teamId, organization); - const result = await TeamsService.getPendingTeamJoinRequests(team.teamId, lead, organization); - - expect(result).toHaveLength(1); + 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 () => { @@ -151,16 +151,24 @@ describe('Team Join Request Tests', () => { ).rejects.toThrow(new HttpException(400, 'This request has already been reviewed')); }); - it('fails if the reviewer is not an admin, the head, or a lead', async () => { + 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, the team head, or a team lead to review join requests for this team' - ) + 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') ); }); diff --git a/src/backend/tests/unit/users.test.ts b/src/backend/tests/unit/users.test.ts index 2afffc6430..13da709343 100644 --- a/src/backend/tests/unit/users.test.ts +++ b/src/backend/tests/unit/users.test.ts @@ -10,12 +10,6 @@ import { import UsersService from '../../src/services/users.services.js'; import { NotFoundException, AccessDeniedException } from '../../src/utils/errors.utils.js'; import { RoleEnum } from 'shared'; -import { vi, Mock } from 'vitest'; -import { validateSlackUserId } from '../../src/integrations/slack.js'; - -vi.mock('../../src/integrations/slack.js', () => ({ - validateSlackUserId: vi.fn() -})); describe('User Tests', () => { let orgId: string; @@ -127,40 +121,19 @@ describe('User Tests', () => { }); describe('Update User Settings', () => { - afterEach(() => { - vi.unstubAllEnvs(); - vi.clearAllMocks(); - }); - - it('throws when slack bot token is not set, regardless of slackId', async () => { - vi.stubEnv('SLACK_BOT_TOKEN', ''); - + 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( - 'Slack integration not configured' - ); - }); - - it('throws when slack bot token is set and the id is invalid', async () => { - vi.stubEnv('SLACK_BOT_TOKEN', 'fake-token'); - (validateSlackUserId as Mock).mockResolvedValue(false); - - const testUser = await createTestUser(batmanAppAdmin, orgId); - - await expect(async () => await UsersService.updateUserSettings(testUser, 'DARK', 'blahID')).rejects.toThrow( 'Invalid Slack ID' ); }); - it('saves successfully when slack bot token is set and id is valid', async () => { - vi.stubEnv('SLACK_BOT_TOKEN', 'fake-token'); - (validateSlackUserId as Mock).mockResolvedValue(true); - + 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', 'UIDVALID'); + const result = await UsersService.updateUserSettings(testUser, 'DARK', 'U1234ABCD'); - expect(result.slackId).toBe('UIDVALID'); + expect(result.slackId).toBe('U1234ABCD'); }); }); }); diff --git a/src/frontend/src/apis/organizations.api.ts b/src/frontend/src/apis/organizations.api.ts index d89e5e5942..ba50983a45 100644 --- a/src/frontend/src/apis/organizations.api.ts +++ b/src/frontend/src/apis/organizations.api.ts @@ -1,5 +1,5 @@ import axios from '../utils/axios'; -import { NotificationChannelPreview, Organization, ProjectPreview, SlackMessagePreview } from 'shared'; +import { NotificationChannelPreview, Organization, ProjectPreview } from 'shared'; import { apiUrls } from '../utils/urls'; import { ApplicationLinkPayload, @@ -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); @@ -145,25 +133,6 @@ export const setSlackSponsorshipNotificationSlackChannelId = (payload: ChannelId }); }; -/** - * Sets the organization's designated new member Slack channel - * @param payload contains the channel id - */ -export const setNewMemberSlackChannelId = (payload: ChannelIdPayload) => { - return axios.post(apiUrls.organizationsSetNewMemberSlackChannelId(), { - ...payload - }); -}; - -/** - * Gets the 3 most recent messages from the organization's designated new member Slack channel - */ -export const getNewMemberSlackMessages = () => { - return axios.get(apiUrls.organizationsNewMemberSlackMessages(), { - transformResponse: (data) => JSON.parse(data) - }); -}; - /** * Gets the finance delegates for an organization */ diff --git a/src/frontend/src/hooks/organizations.hooks.ts b/src/frontend/src/hooks/organizations.hooks.ts index 08d3d7a0b6..637f9fae35 100644 --- a/src/frontend/src/hooks/organizations.hooks.ts +++ b/src/frontend/src/hooks/organizations.hooks.ts @@ -1,7 +1,7 @@ import { useContext, useState } from 'react'; import { OrganizationContext } from '../app/AppOrganizationContext'; import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { NotificationChannelPreview, Organization, ProjectPreview, SlackMessagePreview, User } from 'shared'; +import { NotificationChannelPreview, Organization, ProjectPreview, User } from 'shared'; import { getFeaturedProjects, getCurrentOrganization, @@ -17,12 +17,8 @@ import { getPartReviewGuideLink, setPartReviewGuideLink, setSlackSponsorshipNotificationSlackChannelId, - setNewMemberSlackChannelId, - getNewMemberSlackMessages, getFinanceDelegates, setFinanceDelegates, - setOrganizationNewMemberImage, - getOrganizationNewMemberImage, setOrganizationPlatformLogoImage, getNotificationChannels } from '../apis/organizations.api'; @@ -219,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) => { @@ -304,39 +280,6 @@ export const useNotificationChannels = () => { }); }; -export const useSetNewMemberSlackChannelId = () => { - const queryClient = useQueryClient(); - return useMutation( - ['organizations', 'new-member-slack-channel'], - async (channelId: string) => { - const { data } = await setNewMemberSlackChannelId({ channelId }); - return data; - }, - { - onSuccess: () => { - queryClient.invalidateQueries(['organizations']); - } - } - ); -}; - -/** - * Custom React Hook to get the 3 most recent messages from the new member Slack channel. - * Polls periodically so the widget reflects new messages without a page reload. - */ -export const useNewMemberSlackMessages = () => { - return useQuery( - ['organizations', 'new-member-slack-messages'], - async () => { - const { data } = await getNewMemberSlackMessages(); - return data; - }, - { - refetchInterval: 60000 - } - ); -}; - export const useGetFinanceDelegates = () => { return useQuery(['organizations', 'finance-delegates'], async () => { const { data } = await getFinanceDelegates(); diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx index b9201e2010..db4994f6cb 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx @@ -10,7 +10,6 @@ import { useToast } from '../../hooks/toasts.hooks'; import { useCurrentOrganization, useSetSlackSponsorshipNotificationChannelId, - useSetNewMemberSlackChannelId, useSetWorkspaceId } from '../../hooks/organizations.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; @@ -37,12 +36,10 @@ const AdminToolsSlackIdsView: React.FC = ({ orga const toast = useToast(); const { mutateAsync: setWorkspaceIdMutateAsync, isLoading } = useSetWorkspaceId(); const { mutateAsync: setSponsorshipChannelIdMutateAsync } = useSetSlackSponsorshipNotificationChannelId(); - const { mutateAsync: setNewMemberChannelIdMutateAsync } = useSetNewMemberSlackChannelId(); const [workspaceId, setWorkspaceId] = useState(organization.slackWorkspaceId ?? ''); const [sponsorshipChannelId, setSponsorshipChannelId] = useState( organization.sponsorshipNotificationsSlackChannelId ?? '' ); - const [newMemberChannelId, setNewMemberChannelId] = useState(organization.newMemberSlackChannelId ?? ''); const { data: allTeams, isLoading: allTeamsIsLoading, @@ -92,17 +89,6 @@ const AdminToolsSlackIdsView: React.FC = ({ orga } }; - const handleSubmitNewMemberChannelId = async () => { - try { - await setNewMemberChannelIdMutateAsync(newMemberChannelId); - toast.success('Successfully updated the new member channel ID.'); - } catch (error: unknown) { - if (error instanceof Error) { - toast.error(error.message); - } - } - }; - return ( @@ -153,26 +139,6 @@ const AdminToolsSlackIdsView: React.FC = ({ orga Update - - - - - - setNewMemberChannelId(e.target.value)} - sx={{ mr: 2 }} - /> - - Update - - diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx index fc6f9445fa..d583dd4fd7 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx @@ -3,26 +3,17 @@ import { Box } from '@mui/system'; import UsefulLinksTable from './UsefulLinks/UsefulLinksTable'; import LinkTypeTable from '../ProjectsConfig/LinkTypes/LinkTypeTable'; import NewMemberMilestoneTable from '../RecruitmentConfig/NewMemberMilestoneTable'; -import { - useCurrentOrganization, - useOrganizationNewMemberImage, - useSetOrganizationNewMemberImage -} from '../../../hooks/organizations.hooks'; +import { useCurrentOrganization } from '../../../hooks/organizations.hooks'; import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; import EditIcon from '@mui/icons-material/Edit'; import { useState } from 'react'; import UpdateOnboardingContactsModal from './UpdateContactsModal'; import OnboardingBlock from './OnboardingBlock'; -import NERUploadButton from '../../../components/NERUploadButton'; -import { useToast } from '../../../hooks/toasts.hooks'; -import { MAX_FILE_SIZE } from 'shared'; const OnboardingConfigSection: React.FC = () => { const theme = useTheme(); const [showModal, setShowModal] = useState(false); - const [addedImage, setAddedImage] = useState(undefined); - const toast = useToast(); const { data: organization, @@ -31,39 +22,10 @@ const OnboardingConfigSection: 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 ( @@ -79,54 +41,6 @@ const OnboardingConfigSection: React.FC = () => { }} > - - - - Onboarding Image - - {isUploading || imageIsLoading ? ( - - - - ) : ( - <> - {!addedImage && newMemberImageBlob && ( - - )} - { - if (e.target.files) { - setAddedImage(e.target.files[0]); - } - }} - onSubmit={handleImageUpload} - addedImage={addedImage} - setAddedImage={setAddedImage} - /> - - )} - - { [routes.HOME_PNM, routes.HOME_ONBOARDING, routes.HOME_NEW_MEMBER].map((path) => ( ))} - {isNewMember && - [routes.HOME_PNM, routes.HOME_ONBOARDING].map((path) => ( - - ))} + {/* new members can still visit HOME_ONBOARDING to look back at what they completed */} + {isNewMember && } {onOnboarding && !completedOnboarding && } {isNewMember && } diff --git a/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx b/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx index e5d5ae4f95..93d9360f93 100644 --- a/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx +++ b/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx @@ -2,20 +2,22 @@ * 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 { Grid, Typography } from '@mui/material'; +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 NewMemberFAQsSection from './components/NewMemberFAQsSection'; -import NewMemberChecklistSummaryWidget from './components/NewMemberChecklistSummaryWidget'; import NewMemberUsefulLinksWidget from './components/NewMemberUsefulLinksWidget'; -import NewMemberContactsWidget from './components/NewMemberContactsWidget'; const NewMemberHomePage = () => { + const history = useHistory(); const { setCurrentHomePage } = useHomePageContext(); const { data: organization, @@ -39,12 +41,15 @@ const NewMemberHomePage = () => { return ( - - Welcome to the {organization.name} Team + + Welcome to {organization.name} New Member Dashboard - Here's what's coming up while you get settled in + You're ready to become a member! Check out the resources below to get started. + + + @@ -55,14 +60,13 @@ const NewMemberHomePage = () => { FAQs - - - - + history.push(routes.HOME_ONBOARDING)}> + View My Completed Onboarding Checklist + diff --git a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx index 521ba0864b..1ac7c4c55d 100644 --- a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx +++ b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx @@ -6,6 +6,7 @@ import { useHomePageContext } from '../../app/HomePageContext'; import ChecklistSection from './components/ChecklistSection'; 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'; @@ -15,15 +16,31 @@ 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]); @@ -65,7 +82,11 @@ const OnboardingHomePage = () => { return ; } - const handleOpenModal = () => { + const handleFinishedClick = () => { + if (!hasSlackId) { + setSlackIdModalOpen(true); + return; + } setModalOpen(true); }; @@ -73,6 +94,13 @@ 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 () => { await completeOnboarding(); // the logged-in user object is plain client state, not refetched automatically, @@ -84,13 +112,24 @@ const OnboardingHomePage = () => { 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? + + )} { 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/ChecklistSection.tsx b/src/frontend/src/pages/HomePage/components/ChecklistSection.tsx index 51bc5a1d10..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 && ( - - - - Onboarding Image - - - - - )} {!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/NewMemberChecklistSummaryWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberChecklistSummaryWidget.tsx deleted file mode 100644 index e499b87a19..0000000000 --- a/src/frontend/src/pages/HomePage/components/NewMemberChecklistSummaryWidget.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Box, Typography, useTheme } from '@mui/material'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import { ChecklistItemType } from 'shared'; -import ErrorPage from '../../ErrorPage'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import NERMarkdown from '../../../components/NERMarkdown'; -import { useCheckedChecklists } from '../../../hooks/onboarding.hook'; -import { groupChecklists } from '../../../utils/onboarding.utils'; - -/** - * Read-only reference view of the onboarding checklist items a new member already completed. - * Reuses useCheckedChecklists() (the same source of truth the interactive checklist reads from) - * and never imports useToggleChecklist, so it can't affect checklist state. - */ -const NewMemberChecklistSummaryWidget: React.FC = () => { - const theme = useTheme(); - const { data: checkedChecklists, isLoading, isError, error } = useCheckedChecklists(); - - if (isError) return ; - if (isLoading || !checkedChecklists) return ; - - const checkedIds = new Set(checkedChecklists.map((checklist) => checklist.checklistId)); - const completedParents = checkedChecklists.filter((checklist) => !checklist.parentChecklistId); - const groupedCompleted = groupChecklists(completedParents); - - return ( - - - What You Completed - - {completedParents.length === 0 ? ( - - Nothing completed yet - - ) : ( - Object.entries(groupedCompleted).map(([groupName, parents]) => ( - - - {groupName} - - {parents.map((parent) => { - const referenceItems = [...parent.subtasks] - .filter((subtask) => subtask.itemType === ChecklistItemType.INFO || checkedIds.has(subtask.checklistId)) - .sort((a, b) => (a.displayIndex ?? 999) - (b.displayIndex ?? 999)); - - return ( - - {parent.content} - - {referenceItems.map((item) => - item.itemType === ChecklistItemType.INFO ? ( - - - - ) : ( - - - {item.content} - - ) - )} - - - ); - })} - - )) - )} - - ); -}; - -export default NewMemberChecklistSummaryWidget; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx index a815a36a29..d08326824b 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx @@ -1,97 +1,38 @@ +/* + * 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 { useHistory } from 'react-router-dom'; -import { format } from 'date-fns'; -import { Event } from 'shared'; +import { formatEventTime } from 'shared'; import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; import { useNewMemberEvents } from '../../../hooks/calendar.hooks'; -import { meetingStartTimePipeScheduleSlot } from '../../../utils/pipes'; -import { routes } from '../../../utils/routes'; - -const getEventDate = (event: Event): Date | undefined => { - const firstScheduledDate = event.initialDateScheduled || event.scheduledTimes[0]?.startTime; - return firstScheduledDate ? new Date(firstScheduledDate) : undefined; -}; - -const EventBlock: React.FC<{ event: Event }> = ({ event }) => { - const theme = useTheme(); - const history = useHistory(); - const eventDate = getEventDate(event); - - return ( - history.push(`${routes.CALENDAR}?eventId=${event.eventId}`)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1.5, - p: 1, - borderRadius: '8px', - cursor: 'pointer', - '&:hover': { backgroundColor: theme.palette.action.hover } - }} - > - - - {eventDate ? format(eventDate, 'MMM').toUpperCase() : '—'} - - - {eventDate ? format(eventDate, 'd') : '—'} - - - - - {event.title} - - - {meetingStartTimePipeScheduleSlot(event.scheduledTimes)} - {event.location ? ` · ${event.location}` : event.zoomLink ? ` · ${event.zoomLink}` : ''} - - - - ); -}; +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, isError, error } = useNewMemberEvents(); + 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 teamTypeOptions = useMemo(() => { - const seen = new Map(); - (events ?? []).forEach((event) => { - if (event.teamType) seen.set(event.teamType.teamTypeId, event.teamType.name); - }); - return Array.from(seen, ([teamTypeId, name]) => ({ teamTypeId, name })); - }, [events]); + const upcomingOccurrences = useMemo(() => { + const filteredEvents = + selectedTeamTypeIds.length === 0 + ? (events ?? []) + : (events ?? []).filter((event) => event.teamType && selectedTeamTypeIds.includes(event.teamType.teamTypeId)); - const sortedEvents = useMemo(() => { - return [...(events ?? [])].sort((a, b) => { - const aDate = getEventDate(a); - const bDate = getEventDate(b); - if (!aDate && !bDate) return 0; - if (!aDate) return 1; - if (!bDate) return -1; - return aDate.getTime() - bDate.getTime(); - }); - }, [events]); - - const filteredEvents = - selectedTeamTypeIds.length === 0 - ? sortedEvents - : sortedEvents.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() + ); + }, [events, selectedTeamTypeIds]); const toggleTeamType = (teamTypeId: string) => { setSelectedTeamTypeIds((prev) => @@ -99,8 +40,9 @@ const NewMemberEventsWidget: React.FC = () => { ); }; - if (isError) return ; - if (isLoading || !events) return ; + if (eventsIsError) return ; + if (teamTypesIsError) return ; + if (eventsIsLoading || !events || teamTypesIsLoading || !teamTypes) return ; return ( { New Member Events - {teamTypeOptions.length > 1 && ( + {teamTypes.length > 1 && ( - {teamTypeOptions.map((teamType) => ( + {teamTypes.map((teamType) => ( { )} - - {filteredEvents.length === 0 ? ( - - No upcoming new member events - - ) : ( - filteredEvents.map((event) => ) - )} - + {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'} + + + ))} + + )} ); }; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx index 0eeb495f4b..eced0a014c 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx @@ -27,8 +27,19 @@ const NewMemberMilestonesWidget: React.FC = () => { sortedMilestones.map((milestone) => { const isPast = isPastEvent(new Date(milestone.dateOfEvent), new Date()); return ( - - + + {formatDateOnly(new Date(milestone.dateOfEvent), 'MMMM D, YYYY')} diff --git a/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx b/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx index fe1843af8c..12e9487a99 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx @@ -1,37 +1,18 @@ import { Grid } from '@mui/material'; -import { useCurrentOrganization } from '../../../hooks/organizations.hooks'; -import ErrorPage from '../../ErrorPage'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import OnboardingBlock from '../../AdminToolsPage/OnboardingConfig/OnboardingBlock'; import NewMemberMilestonesWidget from './NewMemberMilestonesWidget'; import NewMemberEventsWidget from './NewMemberEventsWidget'; -import NewMemberSlackWidget from './NewMemberSlackWidget'; import NewMemberUsefulLinksWidget from './NewMemberUsefulLinksWidget'; import NewMemberContactsWidget from './NewMemberContactsWidget'; interface NewMemberOnboardingInfoSectionProps { /** 'full' (default) shows every widget, for the new member dashboard. 'checklist' shows only - * the onboarding block, useful links, and contacts, for the onboarding checklist page. */ + * useful links and contacts, for the onboarding checklist page. */ variant?: 'full' | 'checklist'; } const NewMemberOnboardingInfoSection: React.FC = ({ variant = 'full' }) => { - const { - data: organization, - isLoading: organizationIsLoading, - isError: organizationIsError, - error: organizationError - } = useCurrentOrganization(); - - if (organizationIsError) { - return ; - } - - if (!organization || organizationIsLoading) return ; - return ( - {variant === 'full' && ( <> @@ -40,9 +21,6 @@ const NewMemberOnboardingInfoSection: React.FC - - - )} {variant === 'checklist' && ( diff --git a/src/frontend/src/pages/HomePage/components/NewMemberSlackWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberSlackWidget.tsx deleted file mode 100644 index 109ca2cb6e..0000000000 --- a/src/frontend/src/pages/HomePage/components/NewMemberSlackWidget.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Box, Typography, useTheme } from '@mui/material'; -import { SlackMessagePreview } from 'shared'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import { useCurrentOrganization, useNewMemberSlackMessages } from '../../../hooks/organizations.hooks'; - -const MessageBlock: React.FC<{ message: SlackMessagePreview }> = ({ message }) => { - const theme = useTheme(); - - return ( - - - {message.userName || 'Someone'} - - - {message.text} - - - ); -}; - -const NewMemberSlackWidget: React.FC = () => { - const theme = useTheme(); - const { data: messages, isLoading, isError, error } = useNewMemberSlackMessages(); - // decorative only -- if this hasn't loaded yet, just fall back to a generic title - const { data: organization } = useCurrentOrganization(); - - const widgetTitle = organization?.newMemberSlackChannelName - ? `#${organization.newMemberSlackChannelName} on Slack` - : 'New Member Slack'; - - const cardSx = { - backgroundColor: theme.palette.background.paper, - borderRadius: '10px', - width: '100%', - overflow: 'hidden', - paddingBottom: 2, - minHeight: '150px' - }; - - const fallback = (text: string, errorDetail?: string) => ( - - - {widgetTitle} - - - {text} - - - ); - - if (isError) return fallback("Couldn't load Slack messages right now", error?.message); - - if (isLoading || !messages) return ; - - if (messages.length === 0) return fallback('No messages yet'); - - return ( - - - {widgetTitle} - - - {messages.map((message) => ( - - ))} - - - ); -}; - -export default NewMemberSlackWidget; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberUsefulLinksWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberUsefulLinksWidget.tsx index cf8cf3898c..56dbf130d5 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberUsefulLinksWidget.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberUsefulLinksWidget.tsx @@ -45,11 +45,11 @@ const NewMemberUsefulLinksWidget: React.FC = ({ variant="contained" fullWidth sx={{ - backgroundColor: '#616161', + backgroundColor: '#ef4345', color: 'white', borderRadius: '10px', padding: 2.5, - '&:hover': { backgroundColor: '#ef4345' } + '&:hover': { backgroundColor: '#b0191a' } }} href={link.url} target="_blank" 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/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/TeamsPage/TeamJoinRequestsPageBlock.tsx b/src/frontend/src/pages/TeamsPage/TeamJoinRequestsPageBlock.tsx index e635f0be0d..12fd85c828 100644 --- a/src/frontend/src/pages/TeamsPage/TeamJoinRequestsPageBlock.tsx +++ b/src/frontend/src/pages/TeamsPage/TeamJoinRequestsPageBlock.tsx @@ -34,10 +34,10 @@ const TeamJoinRequestsPageBlock: React.FC = ({ t } = usePendingTeamJoinRequests(team.teamId); const { mutateAsync: reviewRequest, isLoading: reviewIsLoading } = useReviewTeamJoinRequest(); - const hasPerms = isAdmin(user.role) || user.userId === team.head.userId; - const editMembersPerms = hasPerms || team.leads.map((lead) => lead.userId).includes(user.userId); + // only admins and the team head can review join requests -- team leads cannot + const canReviewJoinRequests = isAdmin(user.role) || user.userId === team.head.userId; - if (!editMembersPerms) return null; + if (!canReviewJoinRequests) return null; if (joinRequestsIsError) return ; if (joinRequestsIsLoading || !joinRequests) return ; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 52ddc10e9a..e0092f60c9 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -380,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`; @@ -389,8 +387,6 @@ const organizationsSetWorkspaceId = () => `${organizations()}/workspaceId/set`; const organizationsGetPartReviewGuideLink = () => `${organizations()}/part-review-guide-link/get`; const organizationsSetPartReviewGuideLink = () => `${organizations()}/part-review-guide-link/set`; const organizationsSetSlackSponsorshipNotificationChannelId = () => `${organizations()}/sponsorshipChannelId/set`; -const organizationsSetNewMemberSlackChannelId = () => `${organizations()}/newMemberSlackChannelId/set`; -const organizationsNewMemberSlackMessages = () => `${organizations()}/new-member-slack-messages`; const organizationsFinanceDelegates = () => `${organizations()}/finance-delegates`; const organizationsSetFinanceDelegates = () => `${organizationsFinanceDelegates()}/set`; const organizationsNotificationChannels = () => `${organizations()}/notification-channels`; @@ -819,8 +815,6 @@ export const apiUrls = { organizationsSetPlatformDescription, organizationsLogoImage, organizationsSetLogoImage, - organizationsNewMemberImage, - organizationsSetNewMemberImage, organizationsPlatformLogoImage, organizationsSetPlatformLogoImage, organizationsSetFeaturedProjects, @@ -828,8 +822,6 @@ export const apiUrls = { organizationsGetPartReviewGuideLink, organizationsSetPartReviewGuideLink, organizationsSetSlackSponsorshipNotificationChannelId, - organizationsSetNewMemberSlackChannelId, - organizationsNewMemberSlackMessages, organizationsFinanceDelegates, organizationsSetFinanceDelegates, organizationsNotificationChannels, 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/announcements.types.ts b/src/shared/src/types/announcements.types.ts index 97fd923559..55a40b086a 100644 --- a/src/shared/src/types/announcements.types.ts +++ b/src/shared/src/types/announcements.types.ts @@ -10,10 +10,3 @@ export interface Announcement { slackChannelName: string; dateDeleted?: Date; } - -export interface SlackMessagePreview { - text: string; - userName?: string; - timestamp: string; - permalink: string; -} diff --git a/src/shared/src/types/user-types.ts b/src/shared/src/types/user-types.ts index 4da9133ecf..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,15 +64,12 @@ export interface Organization { treasurer?: User; advisor?: User; description: string; - newMemberImageId?: string; applicationLink?: string; onboardingText?: string; contacts: Contact[]; slackWorkspaceId?: string; partReviewGuideLink?: string; sponsorshipNotificationsSlackChannelId?: string; - newMemberSlackChannelId?: string; - newMemberSlackChannelName?: string; platformDescription: string; platformLogoImageId?: string; } 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); From 3594fcdc11fb9aef8b9df98715c6e45a1ddb13f7 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Tue, 4 Aug 2026 21:09:46 -0400 Subject: [PATCH 41/43] more UI fixes --- .../src/pages/HomePage/NewMemberHomePage.tsx | 21 ++-- .../src/pages/HomePage/OnboardingHomePage.tsx | 2 +- .../pages/HomePage/components/Checklist.tsx | 2 +- .../pages/HomePage/components/Dropdown.tsx | 11 +- .../components/NewMemberEventsWidget.tsx | 6 +- .../NewMemberMilestonesAndFAQsSection.tsx | 21 ++++ .../components/NewMemberMilestonesWidget.tsx | 117 +++++++++++------- .../NewMemberOnboardingInfoSection.tsx | 12 +- .../pages/HomePage/components/ParentTask.tsx | 7 +- 9 files changed, 128 insertions(+), 71 deletions(-) create mode 100644 src/frontend/src/pages/HomePage/components/NewMemberMilestonesAndFAQsSection.tsx diff --git a/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx b/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx index 93d9360f93..3c330d89c1 100644 --- a/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx +++ b/src/frontend/src/pages/HomePage/NewMemberHomePage.tsx @@ -13,7 +13,7 @@ import { useCurrentOrganization } from '../../hooks/organizations.hooks'; import { routes } from '../../utils/routes'; import { NERButton } from '../../components/NERButton'; import NewMemberOnboardingInfoSection from './components/NewMemberOnboardingInfoSection'; -import NewMemberFAQsSection from './components/NewMemberFAQsSection'; +import NewMemberMilestonesAndFAQsSection from './components/NewMemberMilestonesAndFAQsSection'; import NewMemberUsefulLinksWidget from './components/NewMemberUsefulLinksWidget'; const NewMemberHomePage = () => { @@ -42,7 +42,7 @@ const NewMemberHomePage = () => { - Welcome to {organization.name} New Member Dashboard + Welcome to {organization.name} New Member Dashboard You're ready to become a member! Check out the resources below to get started. @@ -52,23 +52,24 @@ const NewMemberHomePage = () => { - + - - - - FAQs - - history.push(routes.HOME_ONBOARDING)}> - View My Completed Onboarding Checklist + Click Me to View Your Completed Onboarding Checklist + + + + + + + ); diff --git a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx index 1ac7c4c55d..5a6e0fd855 100644 --- a/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx +++ b/src/frontend/src/pages/HomePage/OnboardingHomePage.tsx @@ -113,7 +113,7 @@ const OnboardingHomePageContent = () => { - Welcome to {organization.name} Onboarding + Welcome to {organization.name} Onboarding {organization.onboardingText && ( {organization.onboardingText} 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/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/NewMemberEventsWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx index d08326824b..a11ebbfb55 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberEventsWidget.tsx @@ -29,9 +29,9 @@ const NewMemberEventsWidget: React.FC = () => { ? (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() - ); + 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) => { 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 index eced0a014c..802a80602e 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx @@ -1,60 +1,93 @@ -import { useMemo } from 'react'; -import { Box, Typography } from '@mui/material'; +import { Grid, Typography } 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'; -import ScrollablePageBlock from './ScrollablePageBlock'; const NewMemberMilestonesWidget: React.FC = () => { const { data: milestones, isLoading, isError, error } = useNewMemberMilestones(); - const sortedMilestones = useMemo(() => { - return [...(milestones ?? [])].sort((a, b) => new Date(a.dateOfEvent).getTime() - new Date(b.dateOfEvent).getTime()); - }, [milestones]); - 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 + }); + + // shrink the text as there are more milestones to fit, so the timeline doesn't overflow -- + // stays at the max size for a handful of milestones, then scales down with a floor so it never + // becomes unreadable + const milestoneCount = sortedMilestones.length; + const nameFontSize = Math.max(12, Math.min(20, 20 - (milestoneCount - 3) * 1.5)); + const bodyFontSize = Math.max(10, Math.min(18, 18 - (milestoneCount - 3) * 1.5)); + return ( - - {sortedMilestones.length === 0 ? ( - - No onboarding milestones yet - - ) : ( - sortedMilestones.map((milestone) => { - const isPast = isPastEvent(new Date(milestone.dateOfEvent), new Date()); - return ( - - - {formatDateOnly(new Date(milestone.dateOfEvent), 'MMMM D, YYYY')} - - + + + {sortedMilestones.map((milestone, index) => ( + + + + {index < milestones.length - 1 && } + + + {milestone.name} - {milestone.description && ( - - {milestone.description} - - )} - - ); - }) - )} - + + {formatDateOnly(milestone.dateOfEvent, 'MMMM D, YYYY')} + + + {milestone.description} + + + + ))} + + ); }; diff --git a/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx b/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx index 12e9487a99..189fafd8b7 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberOnboardingInfoSection.tsx @@ -1,5 +1,4 @@ import { Grid } from '@mui/material'; -import NewMemberMilestonesWidget from './NewMemberMilestonesWidget'; import NewMemberEventsWidget from './NewMemberEventsWidget'; import NewMemberUsefulLinksWidget from './NewMemberUsefulLinksWidget'; import NewMemberContactsWidget from './NewMemberContactsWidget'; @@ -14,14 +13,9 @@ const NewMemberOnboardingInfoSection: React.FC {variant === 'full' && ( - <> - - - - - - - + + + )} {variant === 'checklist' && ( <> 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 ? : } From 2c9a7026fa1cde40bdb6e6a42667abc96c9138bc Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 5 Aug 2026 20:06:11 -0400 Subject: [PATCH 42/43] make timeline scrollable --- src/backend/src/prisma/seed.ts | 48 +++++++++++++++++++ .../components/NewMemberMilestonesWidget.tsx | 39 ++++++++------- 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 9ef2f6b1d1..3834b55f55 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -3445,6 +3445,54 @@ const performSeed: () => Promise = async () => { 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, diff --git a/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx index 802a80602e..35a92f47c9 100644 --- a/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx +++ b/src/frontend/src/pages/HomePage/components/NewMemberMilestonesWidget.tsx @@ -1,4 +1,4 @@ -import { Grid, Typography } from '@mui/material'; +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'; @@ -12,6 +12,7 @@ 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 ; @@ -43,28 +44,34 @@ const NewMemberMilestonesWidget: React.FC = () => { flexGrow: 1 }); - // shrink the text as there are more milestones to fit, so the timeline doesn't overflow -- - // stays at the max size for a handful of milestones, then scales down with a floor so it never - // becomes unreadable - const milestoneCount = sortedMilestones.length; - const nameFontSize = Math.max(12, Math.min(20, 20 - (milestoneCount - 3) * 1.5)); - const bodyFontSize = Math.max(10, Math.min(18, 18 - (milestoneCount - 3) * 1.5)); - return ( {sortedMilestones.map((milestone, index) => ( @@ -74,13 +81,13 @@ const NewMemberMilestonesWidget: React.FC = () => { {index < milestones.length - 1 && } - + {milestone.name} - + {formatDateOnly(milestone.dateOfEvent, 'MMMM D, YYYY')} - + {milestone.description} From 3583d54a60e9ca2a704e30a698bcd5324c38a274 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 5 Aug 2026 20:09:25 -0400 Subject: [PATCH 43/43] revert manual.ts --- src/backend/src/prisma/manual.ts | 282 ------------------------------- 1 file changed, 282 deletions(-) diff --git a/src/backend/src/prisma/manual.ts b/src/backend/src/prisma/manual.ts index 43fcbc2c25..db6e6ca8f2 100644 --- a/src/backend/src/prisma/manual.ts +++ b/src/backend/src/prisma/manual.ts @@ -8,9 +8,6 @@ import { Reimbursement_Status_Type, WBS_Element_Status } from '@prisma/client'; import { calculateEndDate } from 'shared'; import { writeFileSync } from 'fs'; import { getUserFullName } from '../utils/users.utils.js'; -import ProjectsService from '../services/projects.services.js'; -import RecruitmentServices from '../services/recruitment.services.js'; -import CalendarService from '../services/calendar.services.js'; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -19,285 +16,6 @@ import CalendarService from '../services/calendar.services.js'; * @see {@link https://github.com/Northeastern-Electric-Racing/FinishLine/blob/develop/docs/Deployment.md docs/Deployment.md} for details */ -/** - * One-off backfill for an existing dev DB that's missing the recruitment milestones, FAQs, and - * onboarding/new-member-dashboard useful link types + links that seed.ts creates on a fresh DB. - * Safe to re-run -- everything is checked for existence first. - */ -export const seedMissingOnboardingRecruitmentContent = async () => { - const ner = await prisma.organization.findFirstOrThrow({ where: { name: 'Northeastern Electric Racing' } }); - const submitter = await prisma.user.findFirstOrThrow({ where: { email: 'pyle.c@northeastern.edu' } }); - - const daysAgo = (days: number): Date => new Date(Date.now() - days * 24 * 60 * 60 * 1000); - const daysFromNow = (days: number): Date => new Date(Date.now() + days * 24 * 60 * 60 * 1000); - - const recruitingDashboardOnly = { isOnNewMemberDashboard: false, isOnRecruitingDashboard: true }; - const newMemberDashboardOnly = { isOnNewMemberDashboard: true, isOnRecruitingDashboard: false }; - - /** Milestones */ - const milestones: [string, string, Date, { isOnNewMemberDashboard: boolean; isOnRecruitingDashboard: boolean }][] = [ - ['Club fair!', 'Also meet us at:', daysAgo(120), recruitingDashboardOnly], - ['Applications Open', '', daysAgo(70), recruitingDashboardOnly], - ['Applications Close', '', daysAgo(56), recruitingDashboardOnly], - ['Decision Day!', '', daysAgo(49), recruitingDashboardOnly], - ['First Meeting', 'Attend your first general body meeting', daysAgo(14), newMemberDashboardOnly], - ['First Bay Time', 'Get hands-on time in the bay with a team lead', daysAgo(7), newMemberDashboardOnly], - [ - 'Safety Training Deadline', - 'Complete required safety training to access the bay unsupervised', - daysFromNow(14), - newMemberDashboardOnly - ], - ['Subteam Placement', 'Officially join a subteam project', daysFromNow(30), newMemberDashboardOnly] - ]; - - for (const [name, description, dateOfEvent, dashboards] of milestones) { - const exists = await prisma.milestone.findFirst({ where: { name, organizationId: ner.organizationId } }); - if (exists) continue; - await RecruitmentServices.createMilestone(submitter, name, description, dateOfEvent, dashboards, ner); - console.log(`Created milestone: ${name}`); - } - - /** FAQs */ - const faqs: [string, string, boolean, boolean, boolean][] = [ - ['Who is the Chief Software Engineer?', 'Peyton McKee', true, false, false], - ['When was FinishLine created?', 'FinishLine was created in 2019', true, false, false], - ['How many developers are working on FinishLine?', '178 as of 2024', true, false, false], - [ - 'Where do I go if I have a question during onboarding?', - 'Ask in the #new-members Slack channel — no question is too small!', - false, - true, - false - ], - [ - 'How do I get access to the shop?', - 'Complete the safety training checklist item and a lead will grant you access.', - false, - true, - false - ], - [ - '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.', - false, - true, - false - ], - [ - '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.", - false, - true, - false - ] - ]; - - for (const [question, answer, isOnRecruitingDashboard, isOnNewMemberDashboard, isOnPartReviewPage] of faqs) { - const exists = await prisma.frequentlyAskedQuestion.findFirst({ - where: { question, organizationId: ner.organizationId } - }); - if (exists) continue; - await RecruitmentServices.createOrganizationFaq( - submitter, - question, - answer, - ner, - isOnRecruitingDashboard, - isOnNewMemberDashboard, - isOnPartReviewPage - ); - console.log(`Created FAQ: ${question}`); - } - - /** Onboarding-page + new-member-dashboard useful link types */ - const linkTypes: [string, string, boolean, boolean][] = [ - // isOnNewMemberDashboard, isOnOnboardingDashboard - ['Confluence', 'description', false, true], - ['Bill of Materials', 'bar_chart', false, true], - ['NER Website', 'bar_chart', false, true], - ['NER Instagram', 'bar_chart', false, true], - ['Google Drive', 'folder', false, true], - ['NER Handbook', 'menu_book', true, false], - ['Team Directory', 'groups', true, false], - ['NER Merch Store', 'storefront', true, false] - ]; - - for (const [name, iconName, isOnNewMemberDashboard, isOnOnboardingDashboard] of linkTypes) { - const exists = await prisma.link_Type.findUnique({ - where: { uniqueLinkType: { name, organizationId: ner.organizationId } } - }); - if (exists) continue; - await ProjectsService.createLinkType( - submitter, - name, - iconName, - true, - ner, - false, - isOnNewMemberDashboard, - isOnOnboardingDashboard - ); - console.log(`Created link type: ${name}`); - } - - /** Useful links (URLs) -- added one at a time, only if the org doesn't already have a useful - * link of that link type, so this never touches/replaces any existing links */ - const usefulLinks: [string, string][] = [ - ['Confluence', 'https://confluence.com'], - ['Bill of Materials', 'https://docs.google.com'], - ['NER Website', 'https://electricracing.northeastern.edu/'], - ['NER Instagram', 'https://www.instagram.com/nuelectricracing/'], - ['NER Handbook', 'https://electricracing.northeastern.edu/handbook'], - ['Team Directory', 'https://electricracing.northeastern.edu/teams'], - ['NER Merch Store', 'https://electricracing.northeastern.edu/store'] - ]; - - const orgWithLinks = await prisma.organization.findUniqueOrThrow({ - where: { organizationId: ner.organizationId }, - include: { usefulLinks: { include: { linkType: true } } } - }); - const existingLinkTypeNames = new Set(orgWithLinks.usefulLinks.map((link) => link.linkType.name)); - - for (const [linkTypeName, url] of usefulLinks) { - if (existingLinkTypeNames.has(linkTypeName)) continue; - - const linkType = await prisma.link_Type.findUniqueOrThrow({ - where: { uniqueLinkType: { name: linkTypeName, organizationId: ner.organizationId } } - }); - - const newLink = await prisma.link.create({ - data: { - url, - linkType: { connect: { id: linkType.id } }, - creator: { connect: { userId: submitter.userId } } - } - }); - - await prisma.organization.update({ - where: { organizationId: ner.organizationId }, - data: { usefulLinks: { connect: { linkId: newLink.linkId } } } - }); - - console.log(`Added useful link: ${linkTypeName}`); - } -}; - -/** - * One-off fix for an existing dev DB: the "New Member Events" calendar already exists with the - * "Educational" event type attached and real events on it, but the calendar's isNewMemberCalendar - * flag was never set, so the new member dashboard's events widget (which only looks at the - * calendar flagged isNewMemberCalendar: true) always came up empty. All of that calendar's - * existing events are also in the past, so a few new upcoming ones are added too. - * Safe to re-run -- the calendar flip is idempotent, and events are only added if missing by title. - */ -export const fixNewMemberEventsCalendar = async () => { - const ner = await prisma.organization.findFirstOrThrow({ where: { name: 'Northeastern Electric Racing' } }); - const submitter = await prisma.user.findFirstOrThrow({ where: { email: 'pyle.c@northeastern.edu' } }); - - const newMemberCalendar = await prisma.calendar.findFirstOrThrow({ - where: { organizationId: ner.organizationId, name: 'New Member Events', dateDeleted: null } - }); - - if (!newMemberCalendar.isNewMemberCalendar) { - await CalendarService.editCalendar( - submitter, - newMemberCalendar.calendarId, - newMemberCalendar.name, - newMemberCalendar.description, - newMemberCalendar.colorHexCode, - true, - ner - ); - console.log('Flagged "New Member Events" as the new member calendar'); - } - - const educationalEventType = await prisma.event_Type.findFirstOrThrow({ - where: { organizationId: ner.organizationId, name: 'Educational', dateDeleted: null } - }); - - const teamTypes = await prisma.team_Type.findMany({ where: { organizationId: ner.organizationId } }); - const teamTypeIdByName = new Map(teamTypes.map((teamType) => [teamType.name, teamType.teamTypeId])); - - const daysFromNow = (days: number): Date => new Date(Date.now() + days * 24 * 60 * 60 * 1000); - - const events: { - title: string; - teamTypeName: string; - start: Date; - durationMinutes: number; - location?: string; - zoomLink?: string; - description: string; - }[] = [ - { - title: 'New Member Mixer', - teamTypeName: 'Electrical', - start: daysFromNow(7), - durationMinutes: 60, - location: 'Curry Student Center', - description: 'Come meet the team!' - }, - { - title: 'New Member Bay Time', - teamTypeName: 'Mechanical', - start: daysFromNow(14), - durationMinutes: 60, - location: 'Richards Hall', - description: 'Hands-on time in the bay with the mechanical team' - }, - { - title: 'New Member Software Onboarding', - teamTypeName: 'Software', - start: daysFromNow(21), - durationMinutes: 90, - zoomLink: 'https://zoom.us/j/123456789', - description: 'Intro to the FinishLine codebase' - } - ]; - - for (const event of events) { - const exists = await prisma.event.findFirst({ - where: { title: event.title, eventTypeId: educationalEventType.eventTypeId, dateDeleted: null } - }); - if (exists) continue; - - const teamTypeId = teamTypeIdByName.get(event.teamTypeName); - if (!teamTypeId) { - console.log(`Skipping "${event.title}" -- no "${event.teamTypeName}" team type found`); - continue; - } - - await CalendarService.createEvent( - submitter, - event.title, - educationalEventType.eventTypeId, - ner, - [], - [], - [], - [], - [], - [], - [ - { - startTime: event.start, - endTime: new Date(event.start.getTime() + event.durationMinutes * 60 * 1000), - allDay: false - } - ], - undefined, - [], - teamTypeId, - undefined, - event.location, - event.zoomLink, - event.description - ); - console.log(`Created event: ${event.title}`); - } -}; - /** Execute all given prisma database interaction scripts written in this function */ const executeScripts = async () => {};