diff --git a/apps/api/package.json b/apps/api/package.json index fe09966f..5ff4804e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,7 @@ "dev:default": "./run-docker.sh", "test": "pytest", "lint": "flake8 src tests && mypy src tests", - "build": "mkdir -p lib && pip install --target lib -r requirements.txt", + "build": "mkdir -p lib && pip3 install --target lib -r requirements.txt", "format:write": "black src tests", "format:check": "black --check src tests" }, diff --git a/apps/api/src/admin/applicant_review_processor.py b/apps/api/src/admin/applicant_review_processor.py index fff5293a..0f9568f9 100644 --- a/apps/api/src/admin/applicant_review_processor.py +++ b/apps/api/src/admin/applicant_review_processor.py @@ -1,7 +1,13 @@ -from typing import Any +from typing import Any, Optional from models.ApplicationData import Decision +scores_to_decisions: dict[Optional[int], Decision] = { + 100: Decision.ACCEPTED, + -2: Decision.WAITLISTED, + 0: Decision.REJECTED, +} + def include_hacker_app_fields( applicant_record: dict[str, Any], accept_threshold: float, waitlist_threshold: float @@ -16,7 +22,8 @@ def include_hacker_app_fields( def include_review_decision(applicant_record: dict[str, Any]) -> None: """Sets the applicant's decision as the last submitted review decision or None.""" reviews = applicant_record["application_data"]["reviews"] - applicant_record["decision"] = reviews[-1][2] if reviews else None + score: Optional[int] = reviews[-1][2] if reviews else None + applicant_record["decision"] = scores_to_decisions.get(score) def get_unique_reviewers(applicant_record: dict[str, Any]) -> set[str]: diff --git a/apps/api/src/admin/summary_handler.py b/apps/api/src/admin/summary_handler.py index 4f4f9ce0..d5fe0469 100644 --- a/apps/api/src/admin/summary_handler.py +++ b/apps/api/src/admin/summary_handler.py @@ -1,11 +1,16 @@ -from collections import Counter +from collections import Counter, defaultdict +from datetime import date, datetime +from typing import Iterable +from zoneinfo import ZoneInfo -from pydantic import BaseModel, TypeAdapter +from pydantic import BaseModel, TypeAdapter, ValidationError from models.user_record import ApplicantStatus, Role from services import mongodb_handler from services.mongodb_handler import Collection +LOCAL_TIMEZONE = ZoneInfo("America/Los_Angeles") + class ApplicantSummaryRecord(BaseModel): status: ApplicantStatus @@ -21,3 +26,85 @@ async def applicant_summary() -> Counter[ApplicantStatus]: applicants = TypeAdapter(list[ApplicantSummaryRecord]).validate_python(records) return Counter(applicant.status for applicant in applicants) + + +class ApplicationSubmissionTime(BaseModel): + submission_time: datetime + + +class ApplicationSchoolAndTime(ApplicationSubmissionTime): + school: str + + +class ApplicantSchoolStats(BaseModel): + application_data: ApplicationSchoolAndTime + + +async def applications_by_school() -> dict[str, dict[date, int]]: + """Get daily number of applications by school.""" + records = await mongodb_handler.retrieve( + Collection.USERS, + {"roles": Role.APPLICANT}, + ["application_data.school", "application_data.submission_time"], + ) + + try: + applicant_stats_adapter = TypeAdapter(list[ApplicantSchoolStats]) + applicants = applicant_stats_adapter.validate_python(records) + except ValidationError: + raise RuntimeError("Could not parse applicant data.") + + grouped_applications: dict[str, dict[date, int]] = defaultdict( + lambda: defaultdict(int) + ) + + for applicant in applicants: + school = applicant.application_data.school + day = applicant.application_data.submission_time.astimezone( + LOCAL_TIMEZONE + ).date() + grouped_applications[school][day] += 1 + + return grouped_applications + + +class ApplicantRoleStats(BaseModel): + roles: tuple[Role, ...] + application_data: ApplicationSubmissionTime + + +async def applications_by_role() -> dict[str, dict[date, int]]: + """Get daily number of applications by role.""" + records: list[dict[str, object]] = await mongodb_handler.retrieve( + Collection.USERS, + {"roles": Role.APPLICANT}, + ["roles", "application_data.submission_time"], + ) + + try: + applicant_stats_adapter = TypeAdapter(list[ApplicantRoleStats]) + applicants = applicant_stats_adapter.validate_python(records) + except ValidationError: + raise RuntimeError("Could not parse applicant data.") + + return { + role.value: _count_applications_by_day( + applicant.application_data + for applicant in applicants + if role in applicant.roles + ) + for role in [Role.HACKER, Role.MENTOR, Role.VOLUNTEER] + } + + +def _count_applications_by_day( + application_data: Iterable[ApplicationSubmissionTime], +) -> dict[date, int]: + """Group the applications by the date of submission.""" + daily_applications = defaultdict[date, int](int) + + for data in application_data: + day = data.submission_time.astimezone(LOCAL_TIMEZONE).date() + daily_applications[day] += 1 + + return daily_applications diff --git a/apps/api/src/app.py b/apps/api/src/app.py index f51d2766..f84a171d 100644 --- a/apps/api/src/app.py +++ b/apps/api/src/app.py @@ -3,7 +3,7 @@ from fastapi import FastAPI -from routers import admin, guest, saml, user +from routers import admin, director, guest, saml, user logging.basicConfig(level=logging.INFO) @@ -23,6 +23,7 @@ app.include_router(guest.router, prefix="/guest", tags=["guest"]) app.include_router(user.router, prefix="/user", tags=["user"]) app.include_router(admin.router, prefix="/admin", tags=["admin"]) +app.include_router(director.router, prefix="/director", tags=["director"]) @app.get("/") diff --git a/apps/api/src/auth/authorization.py b/apps/api/src/auth/authorization.py index 53a25ab8..4bcdaa10 100644 --- a/apps/api/src/auth/authorization.py +++ b/apps/api/src/auth/authorization.py @@ -53,6 +53,7 @@ async def require_accepted_applicant( Decision.ACCEPTED, Status.WAIVER_SIGNED, Status.CONFIRMED, + Status.ATTENDING, ): raise HTTPException(status.HTTP_403_FORBIDDEN, "User was not accepted.") diff --git a/apps/api/src/models/ApplicationData.py b/apps/api/src/models/ApplicationData.py index 051c1cc0..6a9eb256 100644 --- a/apps/api/src/models/ApplicationData.py +++ b/apps/api/src/models/ApplicationData.py @@ -97,7 +97,6 @@ class BaseVolunteerApplicationData(BaseModel): school: str education_level: str major: str - applied_before: bool frq_volunteer: str = Field(max_length=2048) frq_utensil: str = Field(max_length=2048) allergies: Union[str, None] = Field(None, max_length=2048) diff --git a/apps/api/src/models/user_record.py b/apps/api/src/models/user_record.py index f0d88c84..423c835e 100644 --- a/apps/api/src/models/user_record.py +++ b/apps/api/src/models/user_record.py @@ -18,10 +18,16 @@ class Role(str, Enum): DIRECTOR = "Director" HACKER = "Hacker" MENTOR = "Mentor" - REVIEWER = "Reviewer" + REVIEWER = ( + "Reviewer" # leaving this role in for now, but might be removed down the line + ) + HACKER_REVIEWER = "Hacker Reviewer" + MENTOR_REVIEWER = "Mentor Reviewer" + VOLUNTEER_REVIEWER = "Volunteer Reviewer" ORGANIZER = "Organizer" VOLUNTEER = "Volunteer" CHECKIN_LEAD = "Check-in Lead" + LEAD = "Lead" # Applications/Mentors/Volunteer Committee Leads SPONSOR = "Sponsor" JUDGE = "Judge" WORKSHOP_LEAD = "Workshop Lead" diff --git a/apps/api/src/routers/admin.py b/apps/api/src/routers/admin.py index 0625e160..68b22a2f 100644 --- a/apps/api/src/routers/admin.py +++ b/apps/api/src/routers/admin.py @@ -1,32 +1,41 @@ -import asyncio -from datetime import datetime +from datetime import date, datetime from logging import getLogger -from typing import Annotated, Any, Mapping, Optional, Sequence +from typing import Annotated, Any, Literal, Mapping, Optional from fastapi import APIRouter, Body, Depends, HTTPException, status -from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError +from typing_extensions import assert_never -from admin import participant_manager, summary_handler -from admin import applicant_review_processor +from admin import applicant_review_processor, participant_manager, summary_handler from admin.participant_manager import Participant from auth.authorization import require_role from auth.user_identity import User, utc_now from models.ApplicationData import Decision, Review -from models.user_record import Applicant, ApplicantStatus, Role, Status -from services import mongodb_handler, sendgrid_handler +from models.user_record import Applicant, ApplicantStatus, Role +from services import mongodb_handler from services.mongodb_handler import BaseRecord, Collection -from services.sendgrid_handler import ApplicationUpdatePersonalization, Template from utils import email_handler -from utils.batched import batched -from utils.email_handler import IH_SENDER log = getLogger(__name__) router = APIRouter() -require_manager = require_role({Role.DIRECTOR, Role.REVIEWER, Role.CHECKIN_LEAD}) +require_manager = require_role( + { + Role.DIRECTOR, + Role.HACKER_REVIEWER, + Role.MENTOR_REVIEWER, + Role.VOLUNTEER_REVIEWER, + Role.CHECKIN_LEAD, + } +) +require_reviewer = require_role( + {Role.DIRECTOR, Role.HACKER_REVIEWER, Role.MENTOR_REVIEWER, Role.VOLUNTEER_REVIEWER} +) +require_hacker_reviewer = require_role({Role.DIRECTOR, Role.HACKER_REVIEWER}) +require_mentor_reviewer = require_role({Role.DIRECTOR, Role.MENTOR_REVIEWER}) +require_volunteer_reviewer = require_role({Role.DIRECTOR, Role.VOLUNTEER_REVIEWER}) require_checkin_lead = require_role({Role.DIRECTOR, Role.CHECKIN_LEAD}) -require_director = require_role({Role.DIRECTOR}) require_organizer = require_role({Role.ORGANIZER}) @@ -58,16 +67,13 @@ class ReviewRequest(BaseModel): score: float -@router.get("/applicants") -async def applicants( - user: Annotated[User, Depends(require_manager)] +async def mentor_volunteer_applicants( + application_type: Literal["Mentor", "Volunteer"] ) -> list[ApplicantSummary]: - """Get records of all applicants.""" - log.info("%s requested applicants", user) - + """Get records of all mentor and volunteer applicants.""" records: list[dict[str, object]] = await mongodb_handler.retrieve( Collection.USERS, - {"roles": Role.APPLICANT}, + {"roles": [Role.APPLICANT, Role(application_type)]}, [ "_id", "status", @@ -88,9 +94,29 @@ async def applicants( raise RuntimeError("Could not parse applicant data.") +@router.get("/applicants/mentors") +async def mentor_applicants( + user: Annotated[User, Depends(require_mentor_reviewer)] +) -> list[ApplicantSummary]: + """Get records of all mentor applicants.""" + log.info("%s requested mentor applicants", user) + + return await mentor_volunteer_applicants("Mentor") + + +@router.get("/applicants/volunteers") +async def volunteer_applicants( + user: Annotated[User, Depends(require_volunteer_reviewer)] +) -> list[ApplicantSummary]: + """Get records of all volunteer applicants.""" + log.info("%s requested volunteer applicants", user) + + return await mentor_volunteer_applicants("Volunteer") + + @router.get("/applicants/hackers") async def hacker_applicants( - user: Annotated[User, Depends(require_manager)] + user: Annotated[User, Depends(require_hacker_reviewer)] ) -> list[HackerApplicantSummary]: """Get records of all hacker applicants.""" log.info("%s requested hacker applicants", user) @@ -109,7 +135,7 @@ async def hacker_applicants( ], ) - thresholds: Optional[dict[str, float]] = await _retrieve_thresholds() + thresholds: Optional[dict[str, float]] = await retrieve_thresholds() if not thresholds: log.error("Could not retrieve thresholds") @@ -126,13 +152,13 @@ async def hacker_applicants( raise RuntimeError("Could not parse applicant data.") -@router.get("/applicant/{uid}", dependencies=[Depends(require_manager)]) async def applicant( - uid: str, + uid: str, application_type: Literal["Hacker", "Mentor", "Volunteer"] ) -> Applicant: """Get record of an applicant by uid.""" record: Optional[dict[str, object]] = await mongodb_handler.retrieve_one( - Collection.USERS, {"_id": uid, "roles": Role.APPLICANT} + Collection.USERS, + {"_id": uid, "roles": [Role.APPLICANT, Role(application_type)]}, ) if not record: @@ -144,18 +170,64 @@ async def applicant( raise RuntimeError("Could not parse applicant data.") +@router.get("/applicant/hacker/{uid}", dependencies=[Depends(require_hacker_reviewer)]) +async def hacker_applicant( + uid: str, +) -> Applicant: + """Get record of a hacker applicant by uid.""" + return await applicant(uid, "Hacker") + + +@router.get("/applicant/mentor/{uid}", dependencies=[Depends(require_mentor_reviewer)]) +async def mentor_applicant( + uid: str, +) -> Applicant: + """Get record of a mentor applicant by uid.""" + return await applicant(uid, "Mentor") + + +@router.get( + "/applicant/volunteer/{uid}", dependencies=[Depends(require_volunteer_reviewer)] +) +async def volunteer_applicant( + uid: str, +) -> Applicant: + """Get record of a volunteer applicant by uid.""" + return await applicant(uid, "Volunteer") + + @router.get("/summary/applicants", dependencies=[Depends(require_manager)]) async def applicant_summary() -> dict[ApplicantStatus, int]: """Provide summary of statuses of applicants.""" return await summary_handler.applicant_summary() +@router.get( + "/summary/applications", + response_model=dict[str, object], + dependencies=[Depends(require_manager)], +) +async def applications( + group_by: Literal["school", "role"] +) -> dict[str, dict[date, int]]: + if group_by == "school": + return await summary_handler.applications_by_school() + elif group_by == "role": + return await summary_handler.applications_by_role() + assert_never(group_by) + + @router.post("/review") async def submit_review( applicant_review: ReviewRequest, - reviewer: User = Depends(require_role({Role.REVIEWER})), + reviewer: User = Depends(require_reviewer), ) -> None: """Submit a review decision from the reviewer for the given hacker applicant.""" + + if applicant_review.score < -2 or applicant_review.score > 100: + log.error("Invalid review score submitted.") + raise HTTPException(status.HTTP_400_BAD_REQUEST) + log.info("%s reviewed hacker %s", reviewer, applicant_review.applicant) review: Review = (utc_now(), reviewer.uid, applicant_review.score) @@ -171,6 +243,11 @@ async def submit_review( raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) if Role.HACKER in applicant_record["roles"]: + + if applicant_review.score < 0 or applicant_review.score > 10: + log.error("Invalid review score submitted.") + raise HTTPException(status.HTTP_400_BAD_REQUEST) + unique_reviewers = applicant_review_processor.get_unique_reviewers( applicant_record ) @@ -210,110 +287,24 @@ async def submit_review( ) -@router.post("/release", dependencies=[Depends(require_director)]) -async def release_decisions() -> None: - """Update applicant status based on decision and send decision emails.""" - records = await mongodb_handler.retrieve( - Collection.USERS, - {"status": Status.REVIEWED}, - ["_id", "application_data.reviews", "first_name"], - ) - - for record in records: - applicant_review_processor.include_review_decision(record) - - await _process_records_in_batches(records) - - -# TODO: need to make release hackers check roles as part of query -@router.post("/release/hackers", dependencies=[Depends(require_director)]) -async def release_hacker_decisions() -> None: - """Update hacker applicant status based on decision and send decision emails.""" - records = await mongodb_handler.retrieve( - Collection.USERS, - {"status": Status.REVIEWED}, - ["_id", "application_data.reviews", "first_name"], - ) - - thresholds: Optional[dict[str, float]] = await _retrieve_thresholds() - - if not thresholds: - log.error("Could not retrieve thresholds") - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) - - for record in records: - applicant_review_processor.include_hacker_app_fields( - record, thresholds["accept"], thresholds["waitlist"] - ) - - await _process_records_in_batches(records) - - -@router.post("/rsvp-reminder", dependencies=[Depends(require_director)]) -async def rsvp_reminder() -> None: - """Send email to applicants who have a status of ACCEPTED or WAIVER_SIGNED - reminding them to RSVP.""" - # TODO: Consider using Pydantic model validation instead of type annotations - not_yet_rsvpd: list[dict[str, Any]] = await mongodb_handler.retrieve( - Collection.USERS, - { - "roles": Role.APPLICANT, - "status": {"$in": [Decision.ACCEPTED, Status.WAIVER_SIGNED]}, - }, - ["_id", "first_name"], - ) - - personalizations = [] - for record in not_yet_rsvpd: - personalizations.append( - ApplicationUpdatePersonalization( - email=_recover_email_from_uid(record["_id"]), - first_name=record["first_name"], - ) - ) - - log.info(f"Sending RSVP reminder emails to {len(not_yet_rsvpd)} applicants") - - await sendgrid_handler.send_email( - Template.RSVP_REMINDER, - IH_SENDER, - personalizations, - True, - ) - - -@router.post("/confirm-attendance", dependencies=[Depends(require_director)]) -async def confirm_attendance() -> None: - """Update applicant status to void or attending based on their current status.""" - # TODO: consider using Pydantic model, maybe BareApplicant - records = await mongodb_handler.retrieve( - Collection.USERS, {"roles": Role.APPLICANT}, ["_id", "status"] - ) - - statuses = { - Status.CONFIRMED: Status.ATTENDING, - Decision.ACCEPTED: Status.VOID, - Status.WAIVER_SIGNED: Status.VOID, - } - - for status_from, status_to in statuses.items(): - curr_records = [record for record in records if record["status"] == status_from] - - for record in curr_records: - record["status"] = status_to - - log.info( - f"Changing status of {len(curr_records)} from {status_from} to {status_to}" - ) +@router.get("/get-thresholds") +async def get_hacker_score_thresholds( + user: Annotated[User, Depends(require_manager)] +) -> Optional[dict[str, Any]]: + """ + Gets accepted and waitlisted thresholds + """ + log.info("%s requested thresholds", user) - await asyncio.gather( - *( - _process_status(batch, status_to) - for batch in batched( - [str(record["_id"]) for record in curr_records], 100 - ) - ) + try: + record = await mongodb_handler.retrieve_one( + Collection.SETTINGS, + {"_id": "hacker_score_thresholds"}, ) + except RuntimeError: + log.error("%s could not retrieve thresholds", user) + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + return record @router.post("/waitlist-release/{uid}") @@ -338,7 +329,7 @@ async def waitlist_release( log.info("%s accepted %s off the waitlist. Sending email.", associate, uid) await email_handler.send_waitlist_release_email( - record["first_name"], _recover_email_from_uid(uid) + record["first_name"], recover_email_from_uid(uid) ) @@ -401,47 +392,13 @@ async def subevent_checkin( await participant_manager.subevent_checkin(event, uid, organizer) -async def _process_records_in_batches(records: list[dict[str, object]]) -> None: - for decision in (Decision.ACCEPTED, Decision.WAITLISTED, Decision.REJECTED): - group = [record for record in records if record["decision"] == decision] - if not group: - continue - await asyncio.gather( - *(_process_batch(batch, decision) for batch in batched(group, 100)) - ) - - -async def _process_status(uids: Sequence[str], status: Status) -> None: - ok = await mongodb_handler.update( - Collection.USERS, {"_id": {"$in": uids}}, {"status": status} - ) - if not ok: - raise RuntimeError("gg wp") - - -async def _process_batch(batch: tuple[dict[str, Any], ...], decision: Decision) -> None: - uids: list[str] = [record["_id"] for record in batch] - log.info(f"Setting {','.join(uids)} as {decision}") - ok = await mongodb_handler.update( - Collection.USERS, {"_id": {"$in": uids}}, {"status": decision} - ) - if not ok: - raise RuntimeError("gg wp") - - # Send emails - log.info(f"Sending {decision} emails for {len(batch)} applicants") - await email_handler.send_decision_email( - map(_extract_personalizations, batch), decision +async def retrieve_thresholds() -> Optional[dict[str, Any]]: + return await mongodb_handler.retrieve_one( + Collection.SETTINGS, {"_id": "hacker_score_thresholds"}, ["accept", "waitlist"] ) -def _extract_personalizations(decision_data: dict[str, Any]) -> tuple[str, EmailStr]: - name = decision_data["first_name"] - email = _recover_email_from_uid(decision_data["_id"]) - return name, email - - -def _recover_email_from_uid(uid: str) -> str: +def recover_email_from_uid(uid: str) -> str: """For NativeUsers, the email should still delivery properly.""" uid = uid.replace("..", "\n") *reversed_domain, local = uid.split(".") @@ -450,12 +407,6 @@ def _recover_email_from_uid(uid: str) -> str: return f"{local}@{domain}" -async def _retrieve_thresholds() -> Optional[dict[str, Any]]: - return await mongodb_handler.retrieve_one( - Collection.SETTINGS, {"_id": "hacker_score_thresholds"}, ["accept", "waitlist"] - ) - - async def _try_update_applicant_with_query( applicant_review: ReviewRequest, *, diff --git a/apps/api/src/routers/director.py b/apps/api/src/routers/director.py new file mode 100644 index 00000000..a1397cc5 --- /dev/null +++ b/apps/api/src/routers/director.py @@ -0,0 +1,444 @@ +import asyncio + +from datetime import datetime +from logging import getLogger +from typing import Annotated, Any, Literal, Optional, Sequence + +from fastapi import APIRouter, Body, Depends, HTTPException, status +from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError + +from admin import applicant_review_processor +from auth.authorization import require_role +from auth.user_identity import User, uci_email, utc_now +from models.ApplicationData import Decision +from models.user_record import Role, Status +from services import mongodb_handler, sendgrid_handler +from services.mongodb_handler import BaseRecord, Collection +from services.sendgrid_handler import ( + ApplicationUpdatePersonalization, + PersonalizationData, + Template, +) +from routers.admin import recover_email_from_uid, retrieve_thresholds +from utils import email_handler +from utils.email_handler import IH_SENDER +from utils.batched import batched + +log = getLogger(__name__) + +router = APIRouter() + +require_director = require_role({Role.DIRECTOR}) + + +class ApplyReminderSenders(BaseModel): + _id: str + senders: list[tuple[datetime, str, int]] + + +class ApplyReminderRecipients(BaseModel): + _id: str + recipients: list[str] + + +class OrganizerSummary(BaseRecord): + first_name: str + last_name: str + roles: list[Role] + + +class RawOrganizerData(BaseModel): + email: str + first_name: str + last_name: str + roles: list[Role] + + +def uci_scoped_uid(email: EmailStr) -> str: + """Provide a scoped unique identifier based on the UCI email""" + local, domain = email.split("@") + reversed_domains = ".".join(reversed(domain.split("."))) + cleaned_local = local.replace(".", "..") + return f"{reversed_domains}.{cleaned_local}" + + +def roles_includes_organizer(roles: list[Role]) -> bool: + return Role.ORGANIZER in roles + + +def roles_includes_applicant(roles: list[Role]) -> bool: + return Role.APPLICANT in roles + + +async def _get_apply_reminder_email_recipients() -> Optional[dict[str, Any]]: + try: + apply_reminder_recipients = await mongodb_handler.retrieve_one( + Collection.EMAILS, {"_id": "apply_reminder"}, ["recipients"] + ) + except RuntimeError: + log.error("Could not get apply reminder email recipients") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + + return apply_reminder_recipients + + +@router.get("/organizers") +async def organizers( + user: Annotated[User, Depends(require_director)] +) -> list[OrganizerSummary]: + """Get records of all organizers""" + log.info("%s requested organizer", user) + + records: list[dict[str, object]] = await mongodb_handler.retrieve( + Collection.USERS, {"roles": Role.ORGANIZER} + ) + + try: + return TypeAdapter(list[OrganizerSummary]).validate_python(records) + except ValidationError: + raise RuntimeError("Could not parse applicant data.") + + +@router.post("/organizers", status_code=status.HTTP_201_CREATED) +async def add_organizer( + user: Annotated[User, Depends(require_director)], + email: EmailStr = Body(), + first_name: str = Body(), + last_name: str = Body(), + roles: list[Role] = Body(), +) -> None: + """Adds an organizer record""" + log.info("%s adding organizer", user) + + if not uci_email(email): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "User doesn't have a UCI email." + ) + + if not roles_includes_organizer(roles): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "User doesn't have organizer role." + ) + + if roles_includes_applicant(roles): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "User has submitted an application." + ) + + uid = uci_scoped_uid(email) + await mongodb_handler.update_one( + Collection.USERS, + {"_id": uid}, + { + "_id": uid, + "first_name": first_name, + "last_name": last_name, + "roles": roles, + }, + upsert=True, + ) + + +@router.get("/apply-reminder", dependencies=[Depends(require_director)]) +async def get_apply_reminder_senders() -> list[tuple[datetime, str, int]]: + """Get data about every sender that sent out apply reminder emails""" + records = await mongodb_handler.retrieve_one( + Collection.EMAILS, {"_id": "apply_reminder"}, ["senders"] + ) + + if not records: + log.error("Could not retrieve apply reminder email senders") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + + senders = ApplyReminderSenders.model_validate(records) + + try: + return TypeAdapter(list[tuple[datetime, str, int]]).validate_python( + senders.senders + ) + except ValidationError: + raise RuntimeError("Could not parse apply reminder email sender data") + + +@router.post("/apply-reminder") +async def apply_reminder(user: Annotated[User, Depends(require_director)]) -> None: + """Send email to users who haven't submitted an app""" + not_yet_applied: list[dict[str, Any]] = await mongodb_handler.retrieve( + Collection.USERS, + {"last_login": {"$exists": True}, "roles": {"$exists": False}}, + ["_id"], + ) + + apply_reminder_recipients: Optional[dict[str, Any]] = ( + await _get_apply_reminder_email_recipients() + ) + + if not apply_reminder_recipients: + log.error("Could not retrieve apply reminder email recipients") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + + validated_recipients = ApplyReminderRecipients.model_validate( + apply_reminder_recipients + ) + + recipients = set(validated_recipients.recipients) + + personalizations = [] + new_recipients = [] + for record in not_yet_applied: + if record["_id"] not in recipients: + new_recipients.append(record["_id"]) + personalizations.append( + PersonalizationData( + email=recover_email_from_uid(record["_id"]), + ) + ) + + log.info(f"{user} sending apply reminder emails to {len(new_recipients)} users") + + try: + await mongodb_handler.raw_update_one( + Collection.EMAILS, + {"_id": "apply_reminder"}, + { + "$push": { + "senders": (utc_now(), user.uid, len(new_recipients)), + "recipients": {"$each": new_recipients}, + }, + }, + upsert=True, + ) + except RuntimeError: + log.error("Error when attempting to update list of senders and recipients") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + + if len(new_recipients) > 0: + await sendgrid_handler.send_email( + Template.APPLY_REMINDER, + IH_SENDER, + personalizations, + True, + ) + + +@router.post("/rsvp-reminder", dependencies=[Depends(require_director)]) +async def rsvp_reminder() -> None: + """Send email to applicants who have a status of ACCEPTED or WAIVER_SIGNED + reminding them to RSVP.""" + # TODO: Consider using Pydantic model validation instead of type annotations + not_yet_rsvpd: list[dict[str, Any]] = await mongodb_handler.retrieve( + Collection.USERS, + { + "roles": Role.APPLICANT, + "status": {"$in": [Decision.ACCEPTED, Status.WAIVER_SIGNED]}, + }, + ["_id", "first_name"], + ) + + personalizations = [] + for record in not_yet_rsvpd: + personalizations.append( + ApplicationUpdatePersonalization( + email=recover_email_from_uid(record["_id"]), + first_name=record["first_name"], + ) + ) + + log.info(f"Sending RSVP reminder emails to {len(not_yet_rsvpd)} applicants") + + await sendgrid_handler.send_email( + Template.RSVP_REMINDER, + IH_SENDER, + personalizations, + True, + ) + + +@router.post("/confirm-attendance", dependencies=[Depends(require_director)]) +async def confirm_attendance() -> None: + """Update applicant status to void or attending based on their current status.""" + # TODO: consider using Pydantic model, maybe BareApplicant + records = await mongodb_handler.retrieve( + Collection.USERS, {"roles": Role.APPLICANT}, ["_id", "status"] + ) + + statuses = { + Status.CONFIRMED: Status.ATTENDING, + Decision.ACCEPTED: Status.VOID, + Status.WAIVER_SIGNED: Status.VOID, + } + + for status_from, status_to in statuses.items(): + curr_records = [record for record in records if record["status"] == status_from] + + for record in curr_records: + record["status"] = status_to + + log.info( + f"Changing status of {len(curr_records)} from {status_from} to {status_to}" + ) + + await asyncio.gather( + *( + _process_status(batch, status_to) + for batch in batched( + [str(record["_id"]) for record in curr_records], 100 + ) + ) + ) + + +@router.post("/set-thresholds") +async def set_hacker_score_thresholds( + user: Annotated[User, Depends(require_director)], + accept: float = Body(), + waitlist: float = Body(), +) -> None: + """ + Sets accepted and waitlisted score thresholds. + Any score under waitlisted is considered rejected. + """ + + thresholds: Optional[dict[str, float]] = await retrieve_thresholds() + + if accept != -1 and thresholds is not None: + thresholds["accept"] = accept + if waitlist != -1 and thresholds is not None: + thresholds["waitlist"] = waitlist + + if ( + accept < -1 + or accept > 10 + or waitlist < -1 + or waitlist > 10 + or (accept != -1 and waitlist != -1 and waitlist > accept) + or (thresholds and thresholds["waitlist"] > thresholds["accept"]) + ): + log.error("Invalid threshold score submitted.") + raise HTTPException(status.HTTP_400_BAD_REQUEST) + + log.info("%s changed thresholds: Accept-%f | Waitlist-%f", user, accept, waitlist) + + # negative numbers should not be received, but -1 in this case + # means there is no update to the respective threshold + update_query = {} + if accept != -1: + update_query["accept"] = accept + if waitlist != -1: + update_query["waitlist"] = waitlist + + try: + await mongodb_handler.raw_update_one( + Collection.SETTINGS, + {"_id": "hacker_score_thresholds"}, + {"$set": update_query}, + upsert=True, + ) + except RuntimeError: + log.error( + "%s could not change thresholds: Accept-%f | Waitlist-%f", + user, + accept, + waitlist, + ) + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@router.post("/release/mentor-volunteer", dependencies=[Depends(require_director)]) +async def release_mentor_volunteer_decisions() -> None: + """Update applicant status based on decision and send decision emails.""" + mentor_records = await mongodb_handler.retrieve( + Collection.USERS, + {"status": Status.REVIEWED, "roles": {"$in": [Role.MENTOR]}}, + ["_id", "application_data.reviews", "first_name"], + ) + + for record in mentor_records: + applicant_review_processor.include_review_decision(record) + + volunteer_records = await mongodb_handler.retrieve( + Collection.USERS, + {"status": Status.REVIEWED, "roles": {"$in": [Role.VOLUNTEER]}}, + ["_id", "application_data.reviews", "first_name"], + ) + + for record in volunteer_records: + applicant_review_processor.include_review_decision(record) + + await _process_records_in_batches(mentor_records, Role.MENTOR) + await _process_records_in_batches(volunteer_records, Role.VOLUNTEER) + + +@router.post("/release/hackers", dependencies=[Depends(require_director)]) +async def release_hacker_decisions() -> None: + """Update hacker applicant status based on decision and send decision emails.""" + records = await mongodb_handler.retrieve( + Collection.USERS, + {"status": Status.REVIEWED, "roles": {"$in": [Role.HACKER]}}, + ["_id", "application_data.reviews", "first_name"], + ) + + thresholds: Optional[dict[str, float]] = await retrieve_thresholds() + + if not thresholds: + log.error("Could not retrieve thresholds") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + + for record in records: + applicant_review_processor.include_hacker_app_fields( + record, thresholds["accept"], thresholds["waitlist"] + ) + + await _process_records_in_batches(records, Role.HACKER) + + +async def _process_status(uids: Sequence[str], status: Status) -> None: + ok = await mongodb_handler.update( + Collection.USERS, {"_id": {"$in": uids}}, {"status": status} + ) + if not ok: + raise RuntimeError("gg wp") + + +async def _process_records_in_batches( + records: list[dict[str, object]], + application_type: Literal[Role.HACKER, Role.MENTOR, Role.VOLUNTEER], +) -> None: + for decision in (Decision.ACCEPTED, Decision.WAITLISTED, Decision.REJECTED): + group = [record for record in records if record["decision"] == decision] + if not group: + continue + await asyncio.gather( + *( + _process_batch(batch, decision, application_type) + for batch in batched(group, 100) + ) + ) + + +async def _process_batch( + batch: tuple[dict[str, Any], ...], + decision: Decision, + application_type: Literal[Role.HACKER, Role.MENTOR, Role.VOLUNTEER], +) -> None: + uids: list[str] = [record["_id"] for record in batch] + log.info(f"Setting {application_type}s {','.join(uids)} as {decision}") + ok = await mongodb_handler.update( + Collection.USERS, {"_id": {"$in": uids}}, {"status": decision} + ) + if not ok: + raise RuntimeError("gg wp") + + # Send emails + log.info( + f"Sending {application_type} {decision} emails for {len(batch)} applicants" + ) + await email_handler.send_decision_email( + map(_extract_personalizations, batch), decision, application_type + ) + + +def _extract_personalizations(decision_data: dict[str, Any]) -> tuple[str, EmailStr]: + name = decision_data["first_name"] + email = recover_email_from_uid(decision_data["_id"]) + return name, email diff --git a/apps/api/src/routers/user.py b/apps/api/src/routers/user.py index d2d2b803..1830fc8e 100644 --- a/apps/api/src/routers/user.py +++ b/apps/api/src/routers/user.py @@ -28,7 +28,7 @@ router = APIRouter() -DEADLINE = datetime(2025, 1, 11, 8, 1, tzinfo=timezone.utc) +DEADLINE = datetime(2025, 1, 13, 8, 1, tzinfo=timezone.utc) class IdentityResponse(BaseModel): @@ -225,7 +225,7 @@ async def _apply_flow( log.info("%s submitted an application", user.uid) return ( - "Thank you for submitting an application to IrvineHacks 2024! Please " + "Thank you for submitting an application to IrvineHacks 2025! Please " + "visit https://irvinehacks.com/portal to see your application status." ) @@ -238,7 +238,7 @@ async def request_waiver( # TODO: non-applicants might also want to request a waiver user_data, applicant = user - if applicant.status in (Status.WAIVER_SIGNED, Status.CONFIRMED): + if applicant.status in (Status.WAIVER_SIGNED, Status.CONFIRMED, Status.ATTENDING): raise HTTPException(status.HTTP_403_FORBIDDEN, "Already submitted a waiver.") user_name = f"{applicant.first_name} {applicant.last_name}" diff --git a/apps/api/src/services/docusign_handler.py b/apps/api/src/services/docusign_handler.py index a1cf88ed..c58407e2 100644 --- a/apps/api/src/services/docusign_handler.py +++ b/apps/api/src/services/docusign_handler.py @@ -52,11 +52,12 @@ class WebhookPayload(BaseModel): STAGING_ENV = os.getenv("DEPLOYMENT") == "STAGING" if STAGING_ENV: - POWERFORM_ID = UUID("d5120219-dec1-41c5-b579-5e6b45c886e8") # temporary + POWERFORM_ID = UUID("d5120219-dec1-41c5-b579-5e6b45c886e8") ACCOUNT_ID = UUID("cc0e3157-358d-4e10-acb0-ef39db7e3071") DOCUSIGN_ENV = "demo" else: - POWERFORM_ID = UUID("f2a69fce-a986-4ad5-9ce9-53f9b544816d") + # If POWERFORM_ID changed here, also update PowerFormId in next.config.js + POWERFORM_ID = UUID("155a2cee-437f-4aa4-bc58-bd1cb01cde20") ACCOUNT_ID = UUID("e6262c0d-c7c1-444b-99b1-e5c6ceaa4b40") DOCUSIGN_ENV = "na3" diff --git a/apps/api/src/services/mongodb_handler.py b/apps/api/src/services/mongodb_handler.py index 5d9b1b65..53a88d11 100644 --- a/apps/api/src/services/mongodb_handler.py +++ b/apps/api/src/services/mongodb_handler.py @@ -46,6 +46,7 @@ class Collection(str, Enum): TESTING = "testing" SETTINGS = "settings" EVENTS = "events" + EMAILS = "emails" async def insert( diff --git a/apps/api/src/services/sendgrid_handler.py b/apps/api/src/services/sendgrid_handler.py index 223a62fc..56cbaf33 100644 --- a/apps/api/src/services/sendgrid_handler.py +++ b/apps/api/src/services/sendgrid_handler.py @@ -18,9 +18,14 @@ class Template(str, Enum): CONFIRMATION_EMAIL = "d-e2cf3f31521f4b938f584e9c48811a92" GUEST_TOKEN = "d-5820106c78fb4d35a0d5d71947a25821" - ACCEPTED_EMAIL = "d-07fa796cf6c34518a7124a68d4790d82" - WAITLISTED_EMAIL = "d-0e0cde2bfcc14dbfa069422801b6cf58" - REJECTED_EMAIL = "d-4edf53090e42417ea9c065645d8c55c2" + HACKER_ACCEPTED_EMAIL = "d-07fa796cf6c34518a7124a68d4790d82" + HACKER_WAITLISTED_EMAIL = "d-0e0cde2bfcc14dbfa069422801b6cf58" + HACKER_REJECTED_EMAIL = "d-4edf53090e42417ea9c065645d8c55c2" + MENTOR_ACCEPTED_EMAIL = "d-b98f516aedc24b83a1ad913610d6994a" + MENTOR_REJECTED_EMAIL = "d-d7edf0ed4acd4fd084d619f9dab181fc" + VOLUNTEER_ACCEPTED_EMAIL = "d-32ec9c6b7b00474a833778e276f65e50" + VOLUNTEER_REJECTED_EMAIL = "d-29c4bbb0fedb48cb8869a0f43d058b80" + APPLY_REMINDER = "d-9fe9988991b9420c86ba7bf2b5cd7357" RSVP_REMINDER = "d-50090289b60947198def96e5bbc9e8c4" WAITLIST_RELEASE_EMAIL = "d-467b8de41d214f33ad9b6cc98cbb6c05" @@ -44,9 +49,13 @@ class ApplicationUpdatePersonalization(PersonalizationData): ApplicationUpdateTemplates: TypeAlias = Literal[ - Template.ACCEPTED_EMAIL, - Template.WAITLISTED_EMAIL, - Template.REJECTED_EMAIL, + Template.HACKER_ACCEPTED_EMAIL, + Template.HACKER_WAITLISTED_EMAIL, + Template.HACKER_REJECTED_EMAIL, + Template.MENTOR_ACCEPTED_EMAIL, + Template.MENTOR_REJECTED_EMAIL, + Template.VOLUNTEER_ACCEPTED_EMAIL, + Template.VOLUNTEER_REJECTED_EMAIL, Template.RSVP_REMINDER, Template.WAITLIST_RELEASE_EMAIL, ] @@ -102,6 +111,26 @@ async def send_email( ) -> None: ... +@overload +async def send_email( + template_id: Literal[Template.APPLY_REMINDER], + sender_email: Tuple[str, str], + receiver_data: PersonalizationData, + send_to_multiple: Literal[False], + reply_to: Union[Tuple[str, str], None] = None, +) -> None: ... + + +@overload +async def send_email( + template_id: Literal[Template.APPLY_REMINDER], + sender_email: Tuple[str, str], + receiver_data: Iterable[PersonalizationData], + send_to_multiple: Literal[True], + reply_to: Union[Tuple[str, str], None] = None, +) -> None: ... + + async def send_email( template_id: Template, sender_email: Tuple[str, str], diff --git a/apps/api/src/utils/email_handler.py b/apps/api/src/utils/email_handler.py index 9b49f661..59c9cb7f 100644 --- a/apps/api/src/utils/email_handler.py +++ b/apps/api/src/utils/email_handler.py @@ -1,8 +1,9 @@ -from typing import Iterable, Protocol +from typing import Iterable, Literal, Protocol from pydantic import EmailStr from models.ApplicationData import Decision +from models.user_record import Role from services import sendgrid_handler from services.sendgrid_handler import ( ApplicationUpdatePersonalization, @@ -12,10 +13,20 @@ IH_SENDER = ("apply@irvinehacks.com", "IrvineHacks 2025 Applications") -DECISION_TEMPLATES: dict[Decision, ApplicationUpdateTemplates] = { - Decision.ACCEPTED: Template.ACCEPTED_EMAIL, - Decision.REJECTED: Template.REJECTED_EMAIL, - Decision.WAITLISTED: Template.WAITLISTED_EMAIL, +DECISION_TEMPLATES: dict[Role, dict[Decision, ApplicationUpdateTemplates]] = { + Role.HACKER: { + Decision.ACCEPTED: Template.HACKER_ACCEPTED_EMAIL, + Decision.REJECTED: Template.HACKER_REJECTED_EMAIL, + Decision.WAITLISTED: Template.HACKER_WAITLISTED_EMAIL, + }, + Role.MENTOR: { + Decision.ACCEPTED: Template.MENTOR_ACCEPTED_EMAIL, + Decision.REJECTED: Template.MENTOR_REJECTED_EMAIL, + }, + Role.VOLUNTEER: { + Decision.ACCEPTED: Template.VOLUNTEER_ACCEPTED_EMAIL, + Decision.REJECTED: Template.VOLUNTEER_REJECTED_EMAIL, + }, } @@ -54,7 +65,9 @@ async def send_guest_login_email(email: EmailStr, passphrase: str) -> None: async def send_decision_email( - applicant_batch: Iterable[tuple[str, EmailStr]], decision: Decision + applicant_batch: Iterable[tuple[str, EmailStr]], + decision: Decision, + application_type: Literal[Role.HACKER, Role.MENTOR, Role.VOLUNTEER], ) -> None: """Send a specific decision email to a group of applicants.""" personalizations = [ @@ -62,7 +75,7 @@ async def send_decision_email( for first_name, email in applicant_batch ] - template = DECISION_TEMPLATES[decision] + template = DECISION_TEMPLATES[application_type][decision] await sendgrid_handler.send_email(template, IH_SENDER, personalizations, True) diff --git a/apps/api/src/utils/waiver_handler.py b/apps/api/src/utils/waiver_handler.py index dbf83cff..0f64d00e 100644 --- a/apps/api/src/utils/waiver_handler.py +++ b/apps/api/src/utils/waiver_handler.py @@ -38,6 +38,9 @@ async def process_waiver_completion(uid: str, email: EmailStr) -> None: f"User {uid} attempted to sign waiver but already signed it previously." ) return + elif applicant_record.status == Status.ATTENDING: + log.warning(f"User {uid} has already signed the waiver and is attending.") + return elif applicant_record.status != Decision.ACCEPTED: log.warning(f"User {uid} attempted to sign waiver but was not accepted.") return diff --git a/apps/api/tests/test_admin.py b/apps/api/tests/test_admin.py index b6c219fe..bbeeffa5 100644 --- a/apps/api/tests/test_admin.py +++ b/apps/api/tests/test_admin.py @@ -1,13 +1,12 @@ from datetime import datetime from typing import Any -from unittest.mock import ANY, AsyncMock, call, patch +from unittest.mock import ANY, AsyncMock, patch from fastapi import FastAPI from auth import user_identity from auth.user_identity import NativeUser, UserTestClient from models.ApplicationData import Decision -from models.user_record import Status from routers import admin from services.mongodb_handler import Collection from services.sendgrid_handler import Template @@ -28,9 +27,9 @@ affiliations=["student"], ) -REVIEWER_IDENTITY = { +HACKER_REVIEWER_IDENTITY = { "_id": "edu.uci.alicia", - "roles": ["Organizer", "Reviewer"], + "roles": ["Organizer", "Hacker Reviewer"], } USER_DIRECTOR = NativeUser( @@ -61,7 +60,7 @@ def test_restricted_admin_route_is_forbidden( "_id": "edu.uci.icssc", "roles": ["Mentor"], } - res = unauthorized_client.get("/applicants") + res = unauthorized_client.get("/applicants/hackers") mock_mongodb_handler_retrieve_one.assert_awaited_once() assert res.status_code == 403 @@ -75,7 +74,10 @@ def test_can_retrieve_applicants( ) -> None: """Test that the applicants summary can be processed.""" - mock_mongodb_handler_retrieve_one.return_value = REVIEWER_IDENTITY + mock_mongodb_handler_retrieve_one.side_effect = [ + HACKER_REVIEWER_IDENTITY, + {"accept": 8, "waitlist": 5}, + ] mock_mongodb_handler_retrieve.return_value = [ { "_id": "edu.uci.petr", @@ -85,12 +87,15 @@ def test_can_retrieve_applicants( "application_data": { "school": "UC Irvine", "submission_time": datetime(2023, 1, 12, 9, 0, 0), - "reviews": [[datetime(2023, 1, 18), "edu.uci.alicia", "ACCEPTED"]], + "reviews": [ + [datetime(2023, 1, 18), "edu.uci.alicia", 8], + [datetime(2023, 1, 18), "edu.uci.albert", 9], + ], }, }, ] - res = reviewer_client.get("/applicants") + res = reviewer_client.get("/applicants/hackers") assert res.status_code == 200 mock_mongodb_handler_retrieve.assert_awaited_once() @@ -100,6 +105,8 @@ def test_can_retrieve_applicants( "_id": "edu.uci.petr", "first_name": "Peter", "last_name": "Anteater", + "avg_score": 8.5, + "reviewers": ["edu.uci.albert", "edu.uci.alicia"], "status": "REVIEWED", "decision": "ACCEPTED", "application_data": { @@ -110,6 +117,22 @@ def test_can_retrieve_applicants( ] +@patch("services.mongodb_handler.retrieve", autospec=True) +@patch("services.mongodb_handler.retrieve_one", autospec=True) +def test_cannot_retrieve_applicants_without_role( + mock_mongodb_handler_retrieve_one: AsyncMock, + mock_mongodb_handler_retrieve: AsyncMock, +) -> None: + """Test that the applicants cannot be processed without correct reviewer role.""" + + mock_mongodb_handler_retrieve_one.return_value = HACKER_REVIEWER_IDENTITY + + res = reviewer_client.get("/applicants/mentors") + + assert res.status_code == 403 + mock_mongodb_handler_retrieve.assert_not_awaited() + + @patch("services.mongodb_handler.raw_update_one", autospec=True) @patch("services.mongodb_handler.retrieve_one", autospec=True) def test_can_submit_nonhacker_review( @@ -130,7 +153,7 @@ def test_can_submit_nonhacker_review( } mock_mongodb_handler_retrieve_one.side_effect = [ - REVIEWER_IDENTITY, + HACKER_REVIEWER_IDENTITY, returned_record, ] mock_mongodb_handler_raw_update_one.return_value = True @@ -167,7 +190,10 @@ def test_submit_hacker_review_with_one_reviewer_works( }, } - mock_mongodb_handler_retrieve_one.side_effect = [REVIEWER_IDENTITY, returned_record] + mock_mongodb_handler_retrieve_one.side_effect = [ + HACKER_REVIEWER_IDENTITY, + returned_record, + ] mock_mongodb_handler_raw_update_one.return_value = True res = reviewer_client.post("/review", json=post_data) @@ -202,7 +228,7 @@ def test_submit_hacker_review_with_two_reviewers_works( } mock_mongodb_handler_retrieve_one.side_effect = [ - REVIEWER_IDENTITY, + HACKER_REVIEWER_IDENTITY, returned_record, ] mock_mongodb_handler_raw_update_one.return_value = True @@ -241,7 +267,7 @@ def test_submit_hacker_review_with_three_reviewers_fails( } mock_mongodb_handler_retrieve_one.side_effect = [ - REVIEWER_IDENTITY, + HACKER_REVIEWER_IDENTITY, returned_record, ] mock_mongodb_handler_raw_update_one.return_value = True @@ -253,65 +279,6 @@ def test_submit_hacker_review_with_three_reviewers_fails( assert res.status_code == 403 -@patch("services.mongodb_handler.update", autospec=True) -@patch("services.mongodb_handler.retrieve_one", autospec=True) -@patch("services.mongodb_handler.retrieve", autospec=True) -def test_confirm_attendance_route( - mock_mongodb_handler_retrieve: AsyncMock, - mock_mongodb_handler_retrieve_one: AsyncMock, - mock_mognodb_handler_update: AsyncMock, -) -> None: - """Test that confirmed status changes to void with accepted.""" - - mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY - mock_mongodb_handler_retrieve.return_value = [ - { - "_id": "edu.uc.tester", - "status": Decision.ACCEPTED, - }, - { - "_id": "edu.uc.tester2", - "status": Status.WAIVER_SIGNED, - }, - { - "_id": "edu.uc.tester3", - "status": Status.CONFIRMED, - }, - { - "_id": "edu.uc.tester4", - "status": Decision.WAITLISTED, - }, - ] - - # Not doing this will result in the return value acting as a mock, which would be - # tracked in the assert_has_calls below. - mock_mognodb_handler_update.side_effect = [True, True, True] - - res = director_client.post("/confirm-attendance") - - assert res.status_code == 200 - mock_mongodb_handler_retrieve.assert_awaited_once() - mock_mognodb_handler_update.assert_has_calls( - [ - call( - Collection.USERS, - {"_id": {"$in": ("edu.uc.tester3",)}}, - {"status": Status.ATTENDING}, - ), - call( - Collection.USERS, - {"_id": {"$in": ("edu.uc.tester",)}}, - {"status": Status.VOID}, - ), - call( - Collection.USERS, - {"_id": {"$in": ("edu.uc.tester2",)}}, - {"status": Status.VOID}, - ), - ] - ) - - @patch("services.sendgrid_handler.send_email", autospec=True) @patch("services.mongodb_handler.update_one", autospec=True) @patch("services.mongodb_handler.retrieve_one", autospec=True) @@ -403,7 +370,7 @@ def test_hacker_applicants_returns_correct_applicants( mock_mongodb_handler_retrieve.return_value = returned_records mock_mongodb_handler_retrieve_one.side_effect = [ - REVIEWER_IDENTITY, + HACKER_REVIEWER_IDENTITY, returned_thresholds, ] @@ -415,38 +382,61 @@ def test_hacker_applicants_returns_correct_applicants( assert data == expected_records -@patch("routers.admin._process_records_in_batches", autospec=True) -@patch("services.mongodb_handler.retrieve", autospec=True) +@patch("services.mongodb_handler.raw_update_one", autospec=True) @patch("services.mongodb_handler.retrieve_one", autospec=True) -def test_release_hacker_decisions_works( +def test_review_on_invalid_value( mock_mongodb_handler_retrieve_one: AsyncMock, - mock_mongodb_handler_retrieve: AsyncMock, - mock_admin_process_records_in_batches: AsyncMock, + mock_mongodb_handler_raw_update_one: AsyncMock, ) -> None: - """Test that the /release/hackers route works""" - returned_records: list[dict[str, Any]] = [ - { - "_id": "edu.uci.sydnee", - "first_name": "sydnee", - "application_data": { - "reviews": [ - [datetime(2023, 1, 19), "edu.uci.alicia", 100], - [datetime(2023, 1, 19), "edu.uci.alicia2", 300], - ] - }, - } + """Test that a reviewer cannot submit an invalid value.""" + post_data = {"applicant": "edu.uci.sydnee", "score": -100} + + returned_record: dict[str, Any] = { + "_id": "edu.uci.sydnee", + "roles": ["Applicant", "Mentor"], + "application_data": { + "reviews": [ + [datetime(2023, 1, 19), "edu.uci.alicia", 100], + ] + }, + } + + mock_mongodb_handler_retrieve_one.side_effect = [ + HACKER_REVIEWER_IDENTITY, + returned_record, ] + mock_mongodb_handler_raw_update_one.return_value = True - threshold_record: dict[str, Any] = {"accept": 10, "waitlist": 5} + res = reviewer_client.post("/review", json=post_data) + + assert res.status_code == 400 + + +@patch("services.mongodb_handler.raw_update_one", autospec=True) +@patch("services.mongodb_handler.retrieve_one", autospec=True) +def test_error_on_hacker_invalid_value( + mock_mongodb_handler_retrieve_one: AsyncMock, + mock_mongodb_handler_raw_update_one: AsyncMock, +) -> None: + """Test for error on hacker with invalid value.""" + post_data = {"applicant": "edu.uci.sydnee", "score": 100} + + returned_record: dict[str, Any] = { + "_id": "edu.uci.sydnee", + "roles": ["Applicant", "Hacker"], + "application_data": { + "reviews": [ + [datetime(2023, 1, 19), "edu.uci.alicia", 0], + ] + }, + } mock_mongodb_handler_retrieve_one.side_effect = [ - DIRECTOR_IDENTITY, - threshold_record, + HACKER_REVIEWER_IDENTITY, + returned_record, ] - mock_mongodb_handler_retrieve.return_value = returned_records - mock_admin_process_records_in_batches.return_value = None + mock_mongodb_handler_raw_update_one.return_value = True - res = reviewer_client.post("/release/hackers") + res = reviewer_client.post("/review", json=post_data) - assert res.status_code == 200 - assert returned_records[0]["decision"] == Decision.ACCEPTED + assert res.status_code == 400 diff --git a/apps/api/tests/test_applicant_review_processor.py b/apps/api/tests/test_applicant_review_processor.py index c8eea849..062b581a 100644 --- a/apps/api/tests/test_applicant_review_processor.py +++ b/apps/api/tests/test_applicant_review_processor.py @@ -25,7 +25,7 @@ def test_can_include_decision_from_reviews() -> None: "_id": "edu.uci.sydnee", "status": "REVIEWED", "application_data": { - "reviews": [[datetime(2023, 1, 19), "edu.uci.alicia", "ACCEPTED"]], + "reviews": [[datetime(2023, 1, 19), "edu.uci.alicia", 100]], }, } diff --git a/apps/api/tests/test_director.py b/apps/api/tests/test_director.py new file mode 100644 index 00000000..6d2aa14b --- /dev/null +++ b/apps/api/tests/test_director.py @@ -0,0 +1,330 @@ +from datetime import datetime +from typing import Any +from unittest.mock import ANY, AsyncMock, call, patch + +from fastapi import FastAPI + +from auth.user_identity import NativeUser, UserTestClient +from models.ApplicationData import Decision +from models.user_record import Role, Status +from routers import director +from services.mongodb_handler import Collection +from services.sendgrid_handler import Template +from utils.email_handler import IH_SENDER + + +USER_REVIEWER = NativeUser( + ucinetid="alicia", + display_name="Alicia", + email="alicia@uci.edu", + affiliations=["student"], +) + +USER_DIRECTOR = NativeUser( + ucinetid="dir", + display_name="Dir", + email="dir@uci.edu", + affiliations=["student"], +) + +HACKER_REVIEWER_IDENTITY = { + "_id": "edu.uci.alicia", + "roles": ["Organizer", "Hacker Reviewer"], +} + +DIRECTOR_IDENTITY = {"_id": "edu.uci.dir", "roles": [Role.ORGANIZER, Role.DIRECTOR]} + +app = FastAPI() +app.include_router(director.router) + +reviewer_client = UserTestClient(USER_REVIEWER, app) + +director_client = UserTestClient(USER_DIRECTOR, app) + +SAMPLE_ORGANIZER = { + "email": "albert@uci.edu", + "first_name": "Albert", + "last_name": "Wang", + "roles": [Role.ORGANIZER], +} + +EXPECTED_ORGANIZER = director.OrganizerSummary( + uid="edu.uci.albert", first_name="Albert", last_name="Wang", roles=[Role.ORGANIZER] +) + + +@patch("services.mongodb_handler.retrieve", autospec=True) +@patch("services.mongodb_handler.retrieve_one", autospec=True) +def test_can_retrieve_organizers( + mock_mongodb_handler_retrieve_one: AsyncMock, + mock_mongodb_handler_retrieve: AsyncMock, +) -> None: + """Test that the organizers can be processed.""" + + mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY + mock_mongodb_handler_retrieve.return_value = [ + { + "_id": "edu.uci.petr", + "first_name": "Peter", + "last_name": "Anteater", + "roles": ["Organizer"], + }, + ] + + res = director_client.get("/organizers") + + assert res.status_code == 200 + mock_mongodb_handler_retrieve.assert_awaited_once() + data = res.json() + assert data == [ + { + "_id": "edu.uci.petr", + "first_name": "Peter", + "last_name": "Anteater", + "roles": ["Organizer"], + }, + ] + + +@patch("services.mongodb_handler.retrieve_one", autospec=True) +@patch("services.mongodb_handler.update_one", autospec=True) +def test_can_add_organizer( + mock_mongodb_handler_update_one: AsyncMock, + mock_mongodb_handler_retrieve_one: AsyncMock, +) -> None: + """Test that organizers can be added""" + mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY + + res = director_client.post("/organizers", json=SAMPLE_ORGANIZER) + + mock_mongodb_handler_update_one.assert_awaited_once_with( + Collection.USERS, + {"_id": EXPECTED_ORGANIZER.uid}, + EXPECTED_ORGANIZER.model_dump(), + upsert=True, + ) + + assert res.status_code == 201 + + +@patch("services.sendgrid_handler.send_email", autospec=True) +@patch("services.mongodb_handler.retrieve_one", autospec=True) +@patch("services.mongodb_handler.retrieve", autospec=True) +@patch("services.mongodb_handler.raw_update_one", autospec=True) +def test_apply_reminder_emails( + mock_mongodb_handler_raw_update_one: AsyncMock, + mock_mongodb_handler_retrieve: AsyncMock, + mock_mongodb_handler_retrieve_one: AsyncMock, + mock_sendgrid_handler_send_email: AsyncMock, +) -> None: + """Test that users that haven't submitted an application will be sent an email""" + mock_mongodb_handler_retrieve_one.side_effect = [ + DIRECTOR_IDENTITY, + {"recipients": ["edu.uci.emailsent"]}, + ] + mock_mongodb_handler_retrieve.return_value = [ + {"_id": "edu.uci.emailsent"}, + {"_id": "edu.uci.petr"}, + {"_id": "edu.uci.albert"}, + ] + + res = director_client.post("/apply-reminder") + assert res.status_code == 200 + mock_mongodb_handler_raw_update_one.return_value = True + mock_mongodb_handler_raw_update_one.assert_awaited_once_with( + Collection.EMAILS, + {"_id": "apply_reminder"}, + { + "$push": { + "senders": (ANY, "edu.uci.dir", 2), + "recipients": {"$each": ["edu.uci.petr", "edu.uci.albert"]}, + }, + }, + upsert=True, + ) + + mock_sendgrid_handler_send_email.assert_awaited_once_with( + Template.APPLY_REMINDER, + IH_SENDER, + [ + {"email": "petr@uci.edu"}, + {"email": "albert@uci.edu"}, + ], + True, + ) + + +@patch("services.mongodb_handler.retrieve_one", autospec=True) +def test_get_apply_reminder_senders( + mock_mongodb_handler_retrieve_one: AsyncMock, +) -> None: + """Test getting all senders of apply reminder emails""" + mock_mongodb_handler_retrieve_one.side_effect = [ + DIRECTOR_IDENTITY, + { + "_id": "apply_reminder", + "senders": [(datetime(2025, 1, 10), "edu.uci.dir", 2)], + }, + ] + + res = director_client.get("/apply-reminder") + assert res.status_code == 200 + mock_mongodb_handler_retrieve_one.assert_awaited_with( + Collection.EMAILS, + {"_id": "apply_reminder"}, + ["senders"], + ) + + +@patch("services.mongodb_handler.update", autospec=True) +@patch("services.mongodb_handler.retrieve_one", autospec=True) +@patch("services.mongodb_handler.retrieve", autospec=True) +def test_confirm_attendance_route( + mock_mongodb_handler_retrieve: AsyncMock, + mock_mongodb_handler_retrieve_one: AsyncMock, + mock_mognodb_handler_update: AsyncMock, +) -> None: + """Test that confirmed status changes to void with accepted.""" + + mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY + mock_mongodb_handler_retrieve.return_value = [ + { + "_id": "edu.uc.tester", + "status": Decision.ACCEPTED, + }, + { + "_id": "edu.uc.tester2", + "status": Status.WAIVER_SIGNED, + }, + { + "_id": "edu.uc.tester3", + "status": Status.CONFIRMED, + }, + { + "_id": "edu.uc.tester4", + "status": Decision.WAITLISTED, + }, + ] + + # Not doing this will result in the return value acting as a mock, which would be + # tracked in the assert_has_calls below. + mock_mognodb_handler_update.side_effect = [True, True, True] + + res = director_client.post("/confirm-attendance") + + assert res.status_code == 200 + mock_mongodb_handler_retrieve.assert_awaited_once() + mock_mognodb_handler_update.assert_has_calls( + [ + call( + Collection.USERS, + {"_id": {"$in": ("edu.uc.tester3",)}}, + {"status": Status.ATTENDING}, + ), + call( + Collection.USERS, + {"_id": {"$in": ("edu.uc.tester",)}}, + {"status": Status.VOID}, + ), + call( + Collection.USERS, + {"_id": {"$in": ("edu.uc.tester2",)}}, + {"status": Status.VOID}, + ), + ] + ) + + +@patch("services.mongodb_handler.retrieve_one", autospec=True) +@patch("services.mongodb_handler.raw_update_one", autospec=True) +def test_set_thresholds_correctly( + mock_mongodb_handler_raw_update_one: AsyncMock, + mock_mongodb_handler_retrieve_one: AsyncMock, +) -> None: + """Test that the /set-thresholds route returns correctly""" + mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY + + res = director_client.post( + "/set-thresholds", json={"accept": "10", "waitlist": "5"} + ) + + assert res.status_code == 200 + mock_mongodb_handler_raw_update_one.assert_awaited_once_with( + Collection.SETTINGS, + {"_id": "hacker_score_thresholds"}, + {"$set": {"accept": 10, "waitlist": 5}}, + upsert=True, + ) + + +@patch("services.mongodb_handler.retrieve_one", autospec=True) +@patch("services.mongodb_handler.raw_update_one", autospec=True) +def test_set_thresholds_with_empty_string_correctly( + mock_mongodb_handler_raw_update_one: AsyncMock, + mock_mongodb_handler_retrieve_one: AsyncMock, +) -> None: + """Test that the /set-thresholds route returns correctly with -1""" + mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY + + res = director_client.post( + "/set-thresholds", json={"accept": "10", "waitlist": "-1"} + ) + + assert res.status_code == 200 + mock_mongodb_handler_raw_update_one.assert_awaited_once_with( + Collection.SETTINGS, + {"_id": "hacker_score_thresholds"}, + {"$set": {"accept": 10}}, + upsert=True, + ) + + +@patch("services.mongodb_handler.retrieve_one", autospec=True) +def test_organizer_set_thresholds_forbidden( + mock_mongodb_handler_retrieve_one: AsyncMock, +) -> None: + """Test whether anyone below a director can change threshold.""" + mock_mongodb_handler_retrieve_one.return_value = HACKER_REVIEWER_IDENTITY + + res = reviewer_client.post( + "/set-thresholds", json={"accept": "12", "waitlist": "5"} + ) + + assert res.status_code == 403 + + +@patch("routers.director._process_records_in_batches", autospec=True) +@patch("services.mongodb_handler.retrieve", autospec=True) +@patch("services.mongodb_handler.retrieve_one", autospec=True) +def test_release_hacker_decisions_works( + mock_mongodb_handler_retrieve_one: AsyncMock, + mock_mongodb_handler_retrieve: AsyncMock, + mock_admin_process_records_in_batches: AsyncMock, +) -> None: + """Test that the /release/hackers route works""" + returned_records: list[dict[str, Any]] = [ + { + "_id": "edu.uci.sydnee", + "first_name": "sydnee", + "application_data": { + "reviews": [ + [datetime(2023, 1, 19), "edu.uci.alicia", 100], + [datetime(2023, 1, 19), "edu.uci.alicia2", 300], + ] + }, + } + ] + + threshold_record: dict[str, Any] = {"accept": 10, "waitlist": 5} + + mock_mongodb_handler_retrieve_one.side_effect = [ + DIRECTOR_IDENTITY, + threshold_record, + ] + mock_mongodb_handler_retrieve.return_value = returned_records + mock_admin_process_records_in_batches.return_value = None + + res = director_client.post("/release/hackers") + + assert res.status_code == 200 + assert returned_records[0]["decision"] == Decision.ACCEPTED diff --git a/apps/api/tests/test_email_handler.py b/apps/api/tests/test_email_handler.py index 64868e76..7ae0cfac 100644 --- a/apps/api/tests/test_email_handler.py +++ b/apps/api/tests/test_email_handler.py @@ -1,13 +1,16 @@ from unittest.mock import AsyncMock, patch from models.ApplicationData import Decision +from models.user_record import Role from services.sendgrid_handler import ApplicationUpdatePersonalization, Template from utils import email_handler from utils.email_handler import IH_SENDER @patch("services.sendgrid_handler.send_email") -async def test_send_decision_email(mock_sendgrid_handler_send_email: AsyncMock) -> None: +async def test_send_hacker_decision_email( + mock_sendgrid_handler_send_email: AsyncMock, +) -> None: users = [ ("test1", "test1@uci.edu"), ("test2", "test2@uci.edu"), @@ -19,8 +22,52 @@ async def test_send_decision_email(mock_sendgrid_handler_send_email: AsyncMock) for name, email in users ] - await email_handler.send_decision_email(users, Decision.ACCEPTED) + await email_handler.send_decision_email(users, Decision.ACCEPTED, Role.HACKER) mock_sendgrid_handler_send_email.assert_called_once_with( - Template.ACCEPTED_EMAIL, IH_SENDER, expected_personalizations, True + Template.HACKER_ACCEPTED_EMAIL, IH_SENDER, expected_personalizations, True + ) + + +@patch("services.sendgrid_handler.send_email") +async def test_send_mentor_decision_email( + mock_sendgrid_handler_send_email: AsyncMock, +) -> None: + users = [ + ("mentor1", "mentor1@uci.edu"), + ("mentor2", "mentor2@uci.edu"), + ("mentor3", "mentor3@uci.edu"), + ] + + expected_personalizations = [ + ApplicationUpdatePersonalization(first_name=name, email=email) + for name, email in users + ] + + await email_handler.send_decision_email(users, Decision.REJECTED, Role.MENTOR) + + mock_sendgrid_handler_send_email.assert_called_once_with( + Template.MENTOR_REJECTED_EMAIL, IH_SENDER, expected_personalizations, True + ) + + +@patch("services.sendgrid_handler.send_email") +async def test_send_volunteer_decision_email( + mock_sendgrid_handler_send_email: AsyncMock, +) -> None: + users = [ + ("volunteer1", "volunteer1@uci.edu"), + ("volunteer2", "volunteer2@uci.edu"), + ("volunteer3", "volunteer3@uci.edu"), + ] + + expected_personalizations = [ + ApplicationUpdatePersonalization(first_name=name, email=email) + for name, email in users + ] + + await email_handler.send_decision_email(users, Decision.REJECTED, Role.VOLUNTEER) + + mock_sendgrid_handler_send_email.assert_called_once_with( + Template.VOLUNTEER_REJECTED_EMAIL, IH_SENDER, expected_personalizations, True ) diff --git a/apps/api/tests/test_summary_handler.py b/apps/api/tests/test_summary_handler.py index c1066e71..9df107b3 100644 --- a/apps/api/tests/test_summary_handler.py +++ b/apps/api/tests/test_summary_handler.py @@ -1,6 +1,7 @@ +from datetime import date, datetime, timezone from unittest.mock import AsyncMock, patch -from admin.summary_handler import applicant_summary +from admin import summary_handler @patch("services.mongodb_handler.retrieve", autospec=True) @@ -12,7 +13,8 @@ async def test_applicant_summary(mock_mongodb_handler_retrieve: AsyncMock) -> No + [{"status": "WAITLISTED"}, {"status": "WAIVER_SIGNED"}] * 3 ) - summary = await applicant_summary() + summary = await summary_handler.applicant_summary() + mock_mongodb_handler_retrieve.assert_awaited_once() assert dict(summary) == { "REJECTED": 20, @@ -21,3 +23,80 @@ async def test_applicant_summary(mock_mongodb_handler_retrieve: AsyncMock) -> No "WAIVER_SIGNED": 3, "CONFIRMED": 24, } + + +@patch("services.mongodb_handler.retrieve", autospec=True) +async def test_applications_by_school(mock_mongodb_handler_retrieve: AsyncMock) -> None: + """Daily number of applications are grouped by school.""" + mock_mongodb_handler_retrieve.return_value = [ + { + "application_data": { + "school": "UC Irvine", + "submission_time": datetime(1965, 10, 4, 20, 2, 4, tzinfo=timezone.utc), + }, + }, + { + "application_data": { + "school": "UC Irvine", + "submission_time": datetime( + 1965, 10, 4, 20, 15, 26, tzinfo=timezone.utc + ), + }, + }, + { + "application_data": { + "school": "Cal State Long Beach", + "submission_time": datetime( + 2024, 12, 17, 18, 4, 11, tzinfo=timezone.utc + ), + }, + }, + ] + + applications = await summary_handler.applications_by_school() + + mock_mongodb_handler_retrieve.assert_awaited_once() + assert applications == { + "UC Irvine": {date(1965, 10, 4): 2}, + "Cal State Long Beach": {date(2024, 12, 17): 1}, + } + + +@patch("services.mongodb_handler.retrieve", autospec=True) +async def test_applications_by_role(mock_mongodb_handler_retrieve: AsyncMock) -> None: + """Daily number of applications are grouped by role.""" + mock_mongodb_handler_retrieve.return_value = [ + { + "roles": ["Applicant", "Hacker"], + "application_data": { + "submission_time": datetime( + 2024, 12, 12, 17, 0, 0, tzinfo=timezone.utc + ), + }, + }, + { + "roles": ["Applicant", "Hacker"], + "application_data": { + "submission_time": datetime( + 2024, 12, 12, 19, 0, 0, tzinfo=timezone.utc + ), + }, + }, + { + "roles": ["Applicant", "Mentor"], + "application_data": { + "submission_time": datetime( + 2024, 12, 14, 18, 0, 0, tzinfo=timezone.utc + ), + }, + }, + ] + + applications = await summary_handler.applications_by_role() + + mock_mongodb_handler_retrieve.assert_awaited_once() + assert applications == { + "Hacker": {date(2024, 12, 12): 2}, + "Mentor": {date(2024, 12, 14): 1}, + "Volunteer": {}, + } diff --git a/apps/api/tests/test_user_volunteer_apply.py b/apps/api/tests/test_user_volunteer_apply.py index 9fb45168..2f6ebcec 100644 --- a/apps/api/tests/test_user_volunteer_apply.py +++ b/apps/api/tests/test_user_volunteer_apply.py @@ -31,7 +31,6 @@ "school": "UC Irvine", "education_level": "Fifth+ Year Undergraduate", "major": "Computer Science", - "applied_before": "false", "frq_volunteer": "", "frq_utensil": "", "other_questions": "", @@ -171,7 +170,7 @@ def test_volunteer_apply_with_confirmation_email_issue_causes_500( def test_volunteer_application_data_is_bson_encodable() -> None: """Test that application data model can be encoded into BSON to store in MongoDB.""" encoded = bson.encode(EXPECTED_APPLICATION_DATA.model_dump()) - assert len(encoded) == 437 + assert len(encoded) == 420 def test_volunteer_past_deadline_causes_403() -> None: diff --git a/apps/api/tests/test_user_waiver.py b/apps/api/tests/test_user_waiver.py index 3d87ea91..414af88b 100644 --- a/apps/api/tests/test_user_waiver.py +++ b/apps/api/tests/test_user_waiver.py @@ -100,7 +100,7 @@ def test_valid_webhook_payload_is_fine(mock_process_webhook_event: AsyncMock) -> "/waiver", json=SAMPLE_WEBHOOK_PAYLOAD, headers={ - "x-docusign-signature-1": "r3eOIE4RkCGA1w2rBX9uprQuL+dV1BCUw3UI4qvQcI4=" + "x-docusign-signature-1": "eMMrlnAK8rmJc9Avou2slkKGxMVLbN3OE3nIDQ8mh18=" }, ) diff --git a/apps/site/next.config.js b/apps/site/next.config.js index 4f487130..b827933d 100644 --- a/apps/site/next.config.js +++ b/apps/site/next.config.js @@ -9,7 +9,7 @@ const VERCEL_API_PATH = "/api/"; const DOCUSIGN_FORM_URL = "https://na3.docusign.net/Member/PowerFormSigning.aspx?" + - "PowerFormId=f2a69fce-a986-4ad5-9ce9-53f9b544816d" + + "PowerFormId=155a2cee-437f-4aa4-bc58-bd1cb01cde20" + "&env=na3" + "&acct=e6262c0d-c7c1-444b-99b1-e5c6ceaa4b40" + "&v=2"; @@ -39,11 +39,11 @@ const nextConfig = { // destination: "https://forms.gle/erpJjErKLJkEZMw48", // permanent: true, // }, - // { - // source: "/waiver", - // destination: DOCUSIGN_FORM_URL, - // permanent: true, - // }, + { + source: "/waiver", + destination: DOCUSIGN_FORM_URL, + permanent: true, + }, // { // source: "/incident", // destination: "https://forms.gle/A6BdsSzYSiyeTP8Y6", diff --git a/apps/site/package.json b/apps/site/package.json index 1a0f8b53..5e877b69 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -9,9 +9,9 @@ "lint": "next lint" }, "dependencies": { - "@cloudscape-design/collection-hooks": "^1.0.34", - "@cloudscape-design/components": "^3.0.475", - "@cloudscape-design/global-styles": "^1.0.20", + "@cloudscape-design/collection-hooks": "^1.0.56", + "@cloudscape-design/components": "^3.0.856", + "@cloudscape-design/global-styles": "^1.0.33", "@fireworks-js/react": "^2.10.7", "@portabletext/react": "^3.0.11", "@radix-ui/react-accordion": "^1.1.2", diff --git a/apps/site/src/app/(main)/(home)/sections/FAQ/FAQAccordionDesktop.tsx b/apps/site/src/app/(main)/(home)/sections/FAQ/FAQAccordionDesktop.tsx index a221b090..60572bb0 100644 --- a/apps/site/src/app/(main)/(home)/sections/FAQ/FAQAccordionDesktop.tsx +++ b/apps/site/src/app/(main)/(home)/sections/FAQ/FAQAccordionDesktop.tsx @@ -18,118 +18,112 @@ const FAQAccordionDesktop: React.FC = ({ faq }) => { const [focusedQuestion, setFocusedQuestion] = useState(null); return ( -
- {/* Desktop View */} +
Dialogue box background -
- {/* Page 1 focused */} -
- {faqGroup1.map((F) => ( - setFocusedQuestion(F)} - text={F.question} - inverted={false} - className="mb-0 py-[2px]" - /> - ))} -
- -
-
- {/* Page 2 focused */} -
- {faqGroup2.map((F) => ( - setFocusedQuestion(F)} - text={F.question} - inverted={false} - className="mb-0 py-[2px]" - /> - ))} -
-
+
+ +
+ setFocusedQuestion(null)} + className="py-[2px] min-w-[175px] xl:min-w-[200px]" + text="Ask another question" + rotate="rotate-180" + inverted={false} /> - Page 2/2 - +
-
- - {/* Question is focused. */} -
-
- setFocusedQuestion(null)} - className="mb-2 py-[2px]" - text={focusedQuestion?.question} - rotate="rotate-90" - inverted - /> - -

{focusedQuestion?.answer}

+ ) : page1Selected ? ( +
+ {faqGroup1.map((F) => ( + setFocusedQuestion(F)} + text={F.question} + inverted={false} + className="mb-0 py-[2px]" + /> + ))} +
+ +
- -
- setFocusedQuestion(null)} - className="py-[2px] min-w-[175px] xl:min-w-[200px]" - text="Ask another question" - rotate="rotate-180" - inverted={false} - /> + ) : ( +
+ {faqGroup2.map((F) => ( + setFocusedQuestion(F)} + text={F.question} + inverted={false} + className="mb-0 py-[2px]" + /> + ))} +
+ +
-
+ )}
); diff --git a/apps/site/src/app/(main)/(home)/sections/FAQ/FAQAccordionMobile.tsx b/apps/site/src/app/(main)/(home)/sections/FAQ/FAQAccordionMobile.tsx index ee149a98..e565b5e2 100644 --- a/apps/site/src/app/(main)/(home)/sections/FAQ/FAQAccordionMobile.tsx +++ b/apps/site/src/app/(main)/(home)/sections/FAQ/FAQAccordionMobile.tsx @@ -12,49 +12,25 @@ const FAQAccordionMobile: React.FC = ({ faq }) => { const [focusedQuestion, setFocusedQuestion] = useState(null); return ( -
- {/* Changes size of parent component */} -
+ Dialogue box background -
- Dialogue box background - - {/* No question is focused. */} -
-
- {faq.map((F) => ( - setFocusedQuestion(F)} - text={F.question} - className="md:p-2" - inverted={false} - /> - ))} -
-
- - {/* Question is focused. */} + {focusedQuestion ? (
setFocusedQuestion(null)} @@ -64,9 +40,9 @@ const FAQAccordionMobile: React.FC = ({ faq }) => { inverted /> -

{focusedQuestion?.answer}

+ {focusedQuestion?.answer} -
+
setFocusedQuestion(null)} text="Ask another question" @@ -75,7 +51,25 @@ const FAQAccordionMobile: React.FC = ({ faq }) => { />
-
+ ) : ( +
+
+ {faq.map((F) => ( + setFocusedQuestion(F)} + text={F.question} + className="md:p-2" + inverted={false} + /> + ))} +
+
+ )}
); }; diff --git a/apps/site/src/app/(main)/(home)/sections/FAQ/assets/speech-mobile.svg b/apps/site/src/app/(main)/(home)/sections/FAQ/assets/speech-mobile.svg index 423cea1c..57e5e436 100644 --- a/apps/site/src/app/(main)/(home)/sections/FAQ/assets/speech-mobile.svg +++ b/apps/site/src/app/(main)/(home)/sections/FAQ/assets/speech-mobile.svg @@ -1,10 +1,3 @@ - - Source: openclipart.org/detail/209545 - - - - - - - + + \ No newline at end of file diff --git a/apps/site/src/app/(main)/(home)/sections/FAQ/components/ListItemButton.tsx b/apps/site/src/app/(main)/(home)/sections/FAQ/components/ListItemButton.tsx index c9843fc0..9d4998e5 100644 --- a/apps/site/src/app/(main)/(home)/sections/FAQ/components/ListItemButton.tsx +++ b/apps/site/src/app/(main)/(home)/sections/FAQ/components/ListItemButton.tsx @@ -17,7 +17,7 @@ export default function ListItemButton({ {`${name} ))} diff --git a/apps/site/src/app/(main)/layout.tsx b/apps/site/src/app/(main)/layout.tsx index f8fed0f5..9740200d 100644 --- a/apps/site/src/app/(main)/layout.tsx +++ b/apps/site/src/app/(main)/layout.tsx @@ -1,8 +1,9 @@ -import { PropsWithChildren } from "react"; +import { PropsWithChildren, Suspense } from "react"; import type { Metadata } from "next"; import Footer from "@/lib/components/Footer/Footer"; +import BaseNavbar from "@/lib/components/Navbar/BaseNavbar"; import NavbarParent from "@/lib/components/Navbar/NavbarParent"; import stars from "@/assets/backgrounds/starry_repeatable.png"; @@ -20,7 +21,9 @@ export default function Layout({ children }: PropsWithChildren) { style={{ backgroundImage: `url(${stars.src})` }} className="overflow-x-hidden bg-top bg-repeat-y bg-[length:100%]" > - + }> + + {children}
diff --git a/apps/site/src/app/(main)/login/Login.tsx b/apps/site/src/app/(main)/login/Login.tsx index b17ecd10..4659571b 100644 --- a/apps/site/src/app/(main)/login/Login.tsx +++ b/apps/site/src/app/(main)/login/Login.tsx @@ -18,9 +18,7 @@ async function Login({ return (
-

- Login to Portal -

+

Log In

); diff --git a/apps/site/src/app/(main)/portal/@applicant/ApplicantPortal.tsx b/apps/site/src/app/(main)/portal/@applicant/ApplicantPortal.tsx index 143fcc5d..0aa99e4c 100644 --- a/apps/site/src/app/(main)/portal/@applicant/ApplicantPortal.tsx +++ b/apps/site/src/app/(main)/portal/@applicant/ApplicantPortal.tsx @@ -1,6 +1,8 @@ +"use client"; + import { redirect } from "next/navigation"; -import getUserIdentity from "@/lib/utils/getUserIdentity"; +import useUserIdentity from "@/lib/utils/useUserIdentity"; import ConfirmAttendance from "./components/ConfirmAttendance"; import Message from "./components/Message"; @@ -25,8 +27,13 @@ export const enum PortalStatus { const rolesArray = ["Mentor", "Hacker", "Volunteer"]; -async function Portal() { - const identity = await getUserIdentity(); +function Portal() { + const identity = useUserIdentity(); + + if (!identity) { + return
Loading...
; + } + const status = identity.status; if (status === null) { diff --git a/apps/site/src/app/(main)/portal/@applicant/components/SignWaiver.tsx b/apps/site/src/app/(main)/portal/@applicant/components/SignWaiver.tsx index a8549363..5c2b8cb3 100644 --- a/apps/site/src/app/(main)/portal/@applicant/components/SignWaiver.tsx +++ b/apps/site/src/app/(main)/portal/@applicant/components/SignWaiver.tsx @@ -16,10 +16,17 @@ function SignWaiver() {
+ +

+ It may take up to a minute for this site to update after waiver is + signed +

); } diff --git a/apps/site/src/app/(main)/volunteer/components/VolunteerFRQ.tsx b/apps/site/src/app/(main)/volunteer/components/VolunteerFRQ.tsx index 655d372f..ccd9369b 100644 --- a/apps/site/src/app/(main)/volunteer/components/VolunteerFRQ.tsx +++ b/apps/site/src/app/(main)/volunteer/components/VolunteerFRQ.tsx @@ -1,22 +1,9 @@ -import MultipleSelect from "@/lib/components/forms/MultipleSelect"; import Textfield from "@/lib/components/forms/Textfield"; export default function VolunteerFRQ() { return (
Volunteer Information
- Applicants} + > + + + Applications Submitted + + } + > + + + + Cumulative Applications Submitted + + } + > + + + + + ); +} + +export default ApplicantsSummary; diff --git a/apps/site/src/app/admin/applicants/(summary)/ApplicationsByRoleChart.tsx b/apps/site/src/app/admin/applicants/(summary)/ApplicationsByRoleChart.tsx new file mode 100644 index 00000000..d2e69c15 --- /dev/null +++ b/apps/site/src/app/admin/applicants/(summary)/ApplicationsByRoleChart.tsx @@ -0,0 +1,88 @@ +import AreaChart from "@cloudscape-design/components/area-chart"; +import Box from "@cloudscape-design/components/box"; + +import { ParticipantRole } from "@/lib/userRecord"; + +import useApplicationsSummary from "./useApplicationsSummary"; + +const TIME_SPEC = "T00:00:00-08:00"; +const START_DAY = new Date("2024-12-10" + TIME_SPEC); +const END_DAY = new Date("2025-01-11" + TIME_SPEC); + +const ROLES = [ + ParticipantRole.Hacker, + ParticipantRole.Mentor, + ParticipantRole.Volunteer, +] as const; + +function ApplicationsByRoleChart() { + const { loading, applications, error } = useApplicationsSummary("role"); + + const today = new Date(); + const end = today < END_DAY ? today : END_DAY; + + const cumulativeApplications = Object.fromEntries( + ROLES.map((role) => [ + role, + getCumulativeApplications(applications[role] ?? {}, end), + ]), + ); + + return ( + ({ + title: role, + type: "area", + data: cumulativeApplications[role].map(([d, count]) => { + return { x: d, y: count }; + }), + }))} + xDomain={[START_DAY, end]} + i18nStrings={{ + filterLabel: "Filter displayed data", + filterPlaceholder: "Filter data", + filterSelectedAriaLabel: "selected", + xTickFormatter: (e) => + e.toLocaleDateString("en-US", { month: "short", day: "numeric" }), + }} + ariaLabel="Stacked area chart." + errorText="Error loading data." + statusType={loading ? "loading" : error ? "error" : "finished"} + fitHeight + height={300} + loadingText="Loading chart" + xScaleType="time" + xTitle="Date (Pacific Time)" + yTitle="Cumulative total applications submitted" + empty={ + + No data available + + There is no data available + + + } + /> + ); +} + +function getCumulativeApplications( + applications: Record, + end: Date, +): [Date, number][] { + const cumulativeApplications: [Date, number][] = []; + + let prev = 0; + for (let d = new Date(START_DAY); d <= end; d.setDate(d.getDate() + 1)) { + cumulativeApplications.push([ + new Date(d), + // Index as YYYY-MM-DD and accumulate with previous value + prev + (applications[d.toISOString().substring(0, 10)] ?? 0), + ]); + prev = cumulativeApplications.at(-1)![1]; + } + + return cumulativeApplications; +} + +export default ApplicationsByRoleChart; diff --git a/apps/site/src/app/admin/applicants/(summary)/ApplicationsBySchoolChart.tsx b/apps/site/src/app/admin/applicants/(summary)/ApplicationsBySchoolChart.tsx new file mode 100644 index 00000000..f93d318e --- /dev/null +++ b/apps/site/src/app/admin/applicants/(summary)/ApplicationsBySchoolChart.tsx @@ -0,0 +1,84 @@ +import BarChart from "@cloudscape-design/components/bar-chart"; +import Box from "@cloudscape-design/components/box"; + +import useApplicationsSummary from "./useApplicationsSummary"; + +const TIME_SPEC = "T00:00:00-08:00"; +const START_DAY = new Date("2024-12-11" + TIME_SPEC); +const END_DAY = new Date("2025-01-11" + TIME_SPEC); + +// In reverse of desired order +const KNOWN_SCHOOLS = [ + "UC San Diego", + "UCLA", + "UC Riverside", + "Cal State Fullerton", + "Cal State Long Beach", + "Orange Coast College", + "UC Irvine", +]; + +function ApplicationsBySchoolChart() { + const { loading, applications, error } = useApplicationsSummary("school"); + + const sortedBySchool = Object.entries(applications).sort((a, b) => { + const schoolA = a[0]; + const schoolB = b[0]; + if (KNOWN_SCHOOLS.includes(schoolA) || KNOWN_SCHOOLS.includes(schoolB)) { + return KNOWN_SCHOOLS.indexOf(schoolB) - KNOWN_SCHOOLS.indexOf(schoolA); + } + if (schoolA < schoolB) { + return -1; + } + if (schoolB > schoolA) { + return 1; + } + return 0; + }); + + const today = new Date(); + const end = today < END_DAY ? today : END_DAY; + + const xDomain = []; + for (let d = new Date(START_DAY); d <= end; d.setDate(d.getDate() + 1)) { + xDomain.push(new Date(d)); + } + + return ( + ({ + title: school, + type: "bar", + data: Object.entries(events).map(([d, count]) => { + return { x: new Date(d + TIME_SPEC), y: count }; + }), + }))} + xDomain={xDomain} + i18nStrings={{ + xTickFormatter: (e) => + e.toLocaleDateString("en-US", { month: "short", day: "numeric" }), + }} + ariaLabel="Stacked bar chart." + errorText="Error loading data." + statusType={loading ? "loading" : error ? "error" : "finished"} + fitHeight + height={300} + loadingText="Loading chart" + stackedBars + hideFilter + xScaleType="categorical" + xTitle="Date (Pacific Time)" + yTitle="Total applications submitted" + empty={ + + No data available + + There is no data available + + + } + /> + ); +} + +export default ApplicationsBySchoolChart; diff --git a/apps/site/src/app/admin/applicants/page.tsx b/apps/site/src/app/admin/applicants/(summary)/page.tsx similarity index 100% rename from apps/site/src/app/admin/applicants/page.tsx rename to apps/site/src/app/admin/applicants/(summary)/page.tsx diff --git a/apps/site/src/app/admin/applicants/(summary)/useApplicationsSummary.ts b/apps/site/src/app/admin/applicants/(summary)/useApplicationsSummary.ts new file mode 100644 index 00000000..8295749f --- /dev/null +++ b/apps/site/src/app/admin/applicants/(summary)/useApplicationsSummary.ts @@ -0,0 +1,24 @@ +import axios from "axios"; +import useSWR from "swr"; + +interface ApplicationStats { + [key: string]: { + [key: string]: number; + }; +} + +const fetcher = async (url: string) => { + const res = await axios.get(url); + return res.data; +}; + +function useApplicationsSummary(groupBy: "school" | "role") { + const { data, error, isLoading } = useSWR( + `/api/admin/summary/applications?group_by=${groupBy}`, + fetcher, + ); + + return { applications: data ?? {}, loading: isLoading, error }; +} + +export default useApplicationsSummary; diff --git a/apps/site/src/app/admin/applicants/ApplicantsSummary.tsx b/apps/site/src/app/admin/applicants/ApplicantsSummary.tsx deleted file mode 100644 index eff5f099..00000000 --- a/apps/site/src/app/admin/applicants/ApplicantsSummary.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ApplicantsSummary() { - return

PLACEHOLDER: Various summary charts

; -} diff --git a/apps/site/src/app/admin/applicants/[uid]/components/Application.tsx b/apps/site/src/app/admin/applicants/[uid]/components/Application.tsx deleted file mode 100644 index e01f6ccd..00000000 --- a/apps/site/src/app/admin/applicants/[uid]/components/Application.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import Container from "@cloudscape-design/components/container"; -import Header from "@cloudscape-design/components/header"; -import SpaceBetween from "@cloudscape-design/components/space-between"; - -import { - Applicant, - ApplicationQuestion, -} from "@/app/admin/applicants/hackers/useApplicant"; - -import ApplicationSection from "./ApplicationSection"; - -interface ApplicationSections { - [key: string]: ApplicationQuestion[]; -} - -const APPLICATION_SECTIONS: ApplicationSections = { - "Personal Information": ["pronouns", "ethnicity", "is_18_older"], - Education: ["school", "education_level", "major", "is_first_hackathon"], - Experience: ["portfolio", "linkedin", "resume_url"], - "Free Response Questions": ["frq_collaboration", "frq_dream_job"], -}; - -interface ApplicationProps { - applicant: Applicant; -} - -function Application({ applicant }: ApplicationProps) { - const { application_data } = applicant; - - return ( - Application}> - - {Object.entries(APPLICATION_SECTIONS).map(([section, questions]) => ( - - ))} - - - ); -} - -export default Application; diff --git a/apps/site/src/app/admin/applicants/[uid]/page.tsx b/apps/site/src/app/admin/applicants/[uid]/page.tsx deleted file mode 100644 index 249362ac..00000000 --- a/apps/site/src/app/admin/applicants/[uid]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default as default } from "./Applicant"; diff --git a/apps/site/src/app/admin/applicants/[uid]/Applicant.tsx b/apps/site/src/app/admin/applicants/components/Applicant.tsx similarity index 50% rename from apps/site/src/app/admin/applicants/[uid]/Applicant.tsx rename to apps/site/src/app/admin/applicants/components/Applicant.tsx index aa4a6754..c1191636 100644 --- a/apps/site/src/app/admin/applicants/[uid]/Applicant.tsx +++ b/apps/site/src/app/admin/applicants/components/Applicant.tsx @@ -5,22 +5,31 @@ import Header from "@cloudscape-design/components/header"; import SpaceBetween from "@cloudscape-design/components/space-between"; import Spinner from "@cloudscape-design/components/spinner"; -import useApplicant from "@/app/admin/applicants/hackers/useApplicant"; +import useApplicant, { + HackerApplicationData, + MentorApplicationData, + VolunteerApplicationData, +} from "@/lib/admin/useApplicant"; -import ApplicantActions from "./components/ApplicantActions"; -import ApplicantOverview from "./components/ApplicantOverview"; -import Application from "./components/Application"; -import HackerApplicantActions from "./components/HackerApplicantActions"; +import HackerApplication from "@/app/admin/applicants/hackers/components/HackerApplication"; +import MentorApplication from "@/app/admin/applicants/mentors/components/MentorApplication"; +import VolunteerApplication from "@/app/admin/applicants/volunteers/components/VolunteerApplication"; + +import ApplicantActions from "./ApplicantActions"; +import ApplicantOverview from "./ApplicantOverview"; +import HackerApplicantActions from "./HackerApplicantActions"; import { ParticipantRole } from "@/lib/userRecord"; interface ApplicantProps { - params: { uid: string }; + uid: string; + applicationType: "hacker" | "mentor" | "volunteer"; } -function Applicant({ params }: ApplicantProps) { - const { uid } = params; - - const { applicant, loading, submitReview } = useApplicant(uid); +function Applicant({ uid, applicationType }: ApplicantProps) { + const { applicant, loading, submitReview } = useApplicant( + uid, + applicationType, + ); if (loading || !applicant) { return ( @@ -59,7 +68,19 @@ function Applicant({ params }: ApplicantProps) { > - + {applicant.roles.includes(ParticipantRole.Hacker) ? ( + + ) : applicant.roles.includes(ParticipantRole.Mentor) ? ( + + ) : ( + + )} ); diff --git a/apps/site/src/app/admin/applicants/[uid]/components/ApplicantActions.tsx b/apps/site/src/app/admin/applicants/components/ApplicantActions.tsx similarity index 95% rename from apps/site/src/app/admin/applicants/[uid]/components/ApplicantActions.tsx rename to apps/site/src/app/admin/applicants/components/ApplicantActions.tsx index effad948..a370b0e9 100644 --- a/apps/site/src/app/admin/applicants/[uid]/components/ApplicantActions.tsx +++ b/apps/site/src/app/admin/applicants/components/ApplicantActions.tsx @@ -5,7 +5,7 @@ import ButtonDropdown, { } from "@cloudscape-design/components/button-dropdown"; import { isReviewer } from "@/lib/admin/authorization"; -import { submitReview } from "@/app/admin/applicants/hackers/useApplicant"; +import { submitReview } from "@/lib/admin/useApplicant"; import UserContext from "@/lib/admin/UserContext"; import { Decision, Uid } from "@/lib/userRecord"; import { decisionsToScores } from "@/lib/decisionScores"; diff --git a/apps/site/src/app/admin/applicants/components/ApplicantFilters.tsx b/apps/site/src/app/admin/applicants/components/ApplicantFilters.tsx index 4c02b860..5f5c2714 100644 --- a/apps/site/src/app/admin/applicants/components/ApplicantFilters.tsx +++ b/apps/site/src/app/admin/applicants/components/ApplicantFilters.tsx @@ -12,9 +12,12 @@ import { Status, PostAcceptedStatus, } from "@/lib/userRecord"; +import { ParticipantRole } from "@/lib/userRecord"; import { StatusLabels } from "./ApplicantStatus"; +import useHackerApplicants from "@/lib/admin/useHackerApplicants"; + export type Options = ReadonlyArray; interface ApplicantFiltersProps { @@ -22,6 +25,9 @@ interface ApplicantFiltersProps { setSelectedStatuses: Dispatch>; selectedDecisions: Options; setSelectedDecisions: Dispatch>; + uciNetIDFilter?: Options; + setUCINetIDFilter?: Dispatch>; + applicantType: ParticipantRole; } const StatusIcons: Record = { @@ -52,9 +58,28 @@ function ApplicantFilters({ setSelectedStatuses, selectedDecisions, setSelectedDecisions, + uciNetIDFilter, + setUCINetIDFilter, + applicantType, }: ApplicantFiltersProps) { + const { applicantList, loading } = useHackerApplicants(); + + let reviewerOptions: Options = []; + if (!loading && applicantList.length > 0) { + const reviewerIdsSet = new Set( + applicantList.flatMap((applicant) => applicant.reviewers || []), + ); + + const reviewerIds = Array.from(reviewerIdsSet); + + reviewerOptions = reviewerIds.map((id) => ({ + label: id.split(".")[2], + value: id, + })); + } + return ( - + setSelectedStatuses(detail.selectedOptions)} @@ -71,6 +96,16 @@ function ApplicantFilters({ placeholder="Choose reviews" selectedAriaLabel="Selected" /> + {applicantType === ParticipantRole.Hacker && ( + setUCINetIDFilter?.(detail.selectedOptions)} + deselectAriaLabel={(e) => `Remove ${e.label}`} + options={reviewerOptions} + placeholder="Search by Reviewer's UCINetID" + selectedAriaLabel="Selected" + /> + )} ); } diff --git a/apps/site/src/app/admin/applicants/[uid]/components/ApplicantOverview.tsx b/apps/site/src/app/admin/applicants/components/ApplicantOverview.tsx similarity index 94% rename from apps/site/src/app/admin/applicants/[uid]/components/ApplicantOverview.tsx rename to apps/site/src/app/admin/applicants/components/ApplicantOverview.tsx index 97eae362..08ce5beb 100644 --- a/apps/site/src/app/admin/applicants/[uid]/components/ApplicantOverview.tsx +++ b/apps/site/src/app/admin/applicants/components/ApplicantOverview.tsx @@ -4,7 +4,7 @@ import Container from "@cloudscape-design/components/container"; import Header from "@cloudscape-design/components/header"; import ApplicantStatus from "@/app/admin/applicants/components/ApplicantStatus"; -import { Applicant } from "@/app/admin/applicants/hackers/useApplicant"; +import { Applicant } from "@/lib/admin/useApplicant"; import ApplicationReviews from "./ApplicationReviews"; import { ParticipantRole } from "@/lib/userRecord"; diff --git a/apps/site/src/app/admin/applicants/[uid]/components/ApplicationReviews.tsx b/apps/site/src/app/admin/applicants/components/ApplicationReviews.tsx similarity index 94% rename from apps/site/src/app/admin/applicants/[uid]/components/ApplicationReviews.tsx rename to apps/site/src/app/admin/applicants/components/ApplicationReviews.tsx index e9488971..d2686593 100644 --- a/apps/site/src/app/admin/applicants/[uid]/components/ApplicationReviews.tsx +++ b/apps/site/src/app/admin/applicants/components/ApplicationReviews.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; import ApplicantStatus from "@/app/admin/applicants/components/ApplicantStatus"; -import { Review } from "@/app/admin/applicants/hackers/useApplicant"; +import { Review } from "@/lib/admin/useApplicant"; import UserContext from "@/lib/admin/UserContext"; import { Status, Uid } from "@/lib/userRecord"; import { scoresToDecisions } from "@/lib/decisionScores"; diff --git a/apps/site/src/app/admin/applicants/[uid]/components/HackerApplicantActions.tsx b/apps/site/src/app/admin/applicants/components/HackerApplicantActions.tsx similarity index 88% rename from apps/site/src/app/admin/applicants/[uid]/components/HackerApplicantActions.tsx rename to apps/site/src/app/admin/applicants/components/HackerApplicantActions.tsx index 8c3accec..7ec07e60 100644 --- a/apps/site/src/app/admin/applicants/[uid]/components/HackerApplicantActions.tsx +++ b/apps/site/src/app/admin/applicants/components/HackerApplicantActions.tsx @@ -5,10 +5,7 @@ import Button from "@cloudscape-design/components/button"; import Input from "@cloudscape-design/components/input"; import SpaceBetween from "@cloudscape-design/components/space-between"; -import { - Review, - submitReview, -} from "@/app/admin/applicants/hackers/useApplicant"; +import { Review, submitReview } from "@/lib/admin/useApplicant"; import { Uid } from "@/lib/userRecord"; import UserContext from "@/lib/admin/UserContext"; import { isReviewer } from "@/lib/admin/authorization"; @@ -54,8 +51,11 @@ function HackerApplicantActions({ const handleClick = () => { // TODO: use flashbar or modal for submit status - submitReview(applicant, parseFloat(value)); - setValue(""); + const val = parseFloat(value); + if (val >= 0 && val <= 10) { + submitReview(applicant, parseFloat(value)); + setValue(""); + } }; return canReview ? ( @@ -68,6 +68,9 @@ function HackerApplicantActions({ placeholder="Applicant score" step={0.5} disabled={!canReview} + invalid={ + value !== "" && (parseFloat(value) < 0 || parseFloat(value) > 10) + } /> + {status ? ( + + {status} + + ) : null} + + ); +} + +export default HackerThresholdInputs; diff --git a/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx b/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx index 0d909485..11f92600 100644 --- a/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx +++ b/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx @@ -1,54 +1,112 @@ "use client"; import { useRouter } from "next/navigation"; - -import { useContext, useState } from "react"; - +import { useContext, useState, useEffect } from "react"; import Box from "@cloudscape-design/components/box"; import Cards from "@cloudscape-design/components/cards"; import Header from "@cloudscape-design/components/header"; import Link from "@cloudscape-design/components/link"; +import { ParticipantRole } from "@/lib/userRecord"; +import Checkbox from "@cloudscape-design/components/checkbox"; import { useFollowWithNextLink } from "@/app/admin/layout/common"; import useHackerApplicants, { HackerApplicantSummary, } from "@/lib/admin/useHackerApplicants"; -import ApplicantFilters, { Options } from "../components/ApplicantFilters"; -import ApplicantStatus from "../components/ApplicantStatus"; +import ApplicantFilters, { + Options, +} from "@/app/admin/applicants/components/ApplicantFilters"; +import ApplicantStatus from "@/app/admin/applicants/components/ApplicantStatus"; import UserContext from "@/lib/admin/UserContext"; -import { isApplicationManager } from "@/lib/admin/authorization"; +import { isHackerReviewer, isDirector } from "@/lib/admin/authorization"; +import HackerThresholdInputs from "../components/HackerThresholdInputs"; import ApplicantReviewerIndicator from "../components/ApplicantReviewerIndicator"; +import useHackerThresholds from "@/lib/admin/useHackerThresholds"; + function HackerApplicants() { const router = useRouter(); - const { roles } = useContext(UserContext); - if (!isApplicationManager(roles)) { + if (!isHackerReviewer(roles)) { router.push("/admin/dashboard"); } + const isUserDirector = isDirector(roles); + const [selectedStatuses, setSelectedStatuses] = useState([]); const [selectedDecisions, setSelectedDecisions] = useState([]); + const [uciNetIDFilter, setUCINetIDFilter] = useState([]); + const { applicantList, loading } = useHackerApplicants(); const selectedStatusValues = selectedStatuses.map(({ value }) => value); const selectedDecisionValues = selectedDecisions.map(({ value }) => value); + const uciNetIDFilterValues = uciNetIDFilter.map(({ value }) => value); + + const [acceptedCount, setAcceptedCount] = useState(0); + const [waitlistedCount, setWaitlistedCount] = useState(0); + const [rejectedCount, setRejectCount] = useState(0); + + const { thresholds } = useHackerThresholds(); + const acceptThreshold = thresholds?.accept; + const waitlistThreshold = thresholds?.waitlist; + + const [top400, setTop400] = useState(false); + + useEffect(() => { + if (top400) { + setSelectedStatuses([]); + setSelectedDecisions([]); + } + }, [top400]); const filteredApplicants = applicantList.filter( (applicant) => (selectedStatuses.length === 0 || selectedStatusValues.includes(applicant.status)) && (selectedDecisions.length === 0 || - selectedDecisionValues.includes(applicant.decision || "-")), + selectedDecisionValues.includes(applicant.decision || "-")) && + (uciNetIDFilter.length === 0 || + applicant.reviewers.some((reviewer) => + uciNetIDFilterValues.includes(reviewer), + )), ); - const items = filteredApplicants; + const filteredApplicants400 = [...applicantList] + .filter((applicant) => applicant.avg_score !== -1) + .sort((a, b) => b.avg_score - a.avg_score) + .slice(0, 400); + + useEffect(() => { + const accepted = acceptThreshold ? acceptThreshold : 0; + const waitlisted = waitlistThreshold ? waitlistThreshold : 0; + + const acceptedCount = applicantList.filter( + (applicant) => applicant.avg_score >= accepted, + ).length; + setAcceptedCount(acceptedCount); + + const waitlistedCount = applicantList.filter( + (applicant) => + applicant.avg_score >= waitlisted && applicant.avg_score < accepted, + ).length; + setWaitlistedCount(waitlistedCount); + + const rejectedCount = applicantList.filter( + (applicant) => applicant.avg_score < waitlisted, + ).length; + setRejectCount(rejectedCount); + }, [applicantList, acceptThreshold, waitlistThreshold]); + + const items = top400 ? filteredApplicants400 : filteredApplicants; const counter = - selectedStatuses.length > 0 || selectedDecisions.length > 0 + selectedStatuses.length > 0 || + selectedDecisions.length > 0 || + uciNetIDFilter.length > 0 ? `(${items.length}/${applicantList.length})` : `(${applicantList.length})`; @@ -101,7 +159,6 @@ function HackerApplicants() { }, ], }} - // visibleSections={preferences.visibleContent} loading={loading} loadingText="Loading applicants" items={items} @@ -113,10 +170,60 @@ function HackerApplicants() { setSelectedStatuses={setSelectedStatuses} selectedDecisions={selectedDecisions} setSelectedDecisions={setSelectedDecisions} + uciNetIDFilter={uciNetIDFilter} + setUCINetIDFilter={setUCINetIDFilter} + applicantType={ParticipantRole.Hacker} /> } empty={emptyContent} - header={
Applicants
} + header={ +
+
}> + Hacker Applicants {counter} +
+ {acceptedCount} applicants with "accepted" status +
+
+ {waitlistedCount} applicants with "waitlisted" status +
+
+ {rejectedCount} applicants with "rejected" status +
+
+ setTop400(detail.checked)} + > + Show Top 400 Scores + + + {top400 && "Highest score: " + filteredApplicants400[0]?.avg_score} +
+ {top400 && + "Lowest score: " + + filteredApplicants400[filteredApplicants400.length - 1] + ?.avg_score} +
+
+ } /> ); } @@ -125,7 +232,7 @@ const CardHeader = ({ _id, first_name, last_name }: HackerApplicantSummary) => { const followWithNextLink = useFollowWithNextLink(); return ( diff --git a/apps/site/src/app/admin/applicants/hackers/[uid]/HackerApplicant.tsx b/apps/site/src/app/admin/applicants/hackers/[uid]/HackerApplicant.tsx new file mode 100644 index 00000000..3f615423 --- /dev/null +++ b/apps/site/src/app/admin/applicants/hackers/[uid]/HackerApplicant.tsx @@ -0,0 +1,13 @@ +import Applicant from "@/app/admin/applicants/components/Applicant"; + +interface ApplicantProps { + params: { uid: string }; +} + +function HackerApplicant({ params }: ApplicantProps) { + const { uid } = params; + + return ; +} + +export default HackerApplicant; diff --git a/apps/site/src/app/admin/applicants/hackers/[uid]/page.tsx b/apps/site/src/app/admin/applicants/hackers/[uid]/page.tsx new file mode 100644 index 00000000..23b7a8db --- /dev/null +++ b/apps/site/src/app/admin/applicants/hackers/[uid]/page.tsx @@ -0,0 +1 @@ +export { default as default } from "./HackerApplicant"; diff --git a/apps/site/src/app/admin/applicants/hackers/components/HackerApplication.tsx b/apps/site/src/app/admin/applicants/hackers/components/HackerApplication.tsx new file mode 100644 index 00000000..f3fa364b --- /dev/null +++ b/apps/site/src/app/admin/applicants/hackers/components/HackerApplication.tsx @@ -0,0 +1,44 @@ +import Container from "@cloudscape-design/components/container"; +import Header from "@cloudscape-design/components/header"; +import SpaceBetween from "@cloudscape-design/components/space-between"; + +import { HackerApplicationQuestion } from "@/lib/admin/useApplicant"; +import HackerApplicationSection from "@/app/admin/applicants/hackers/components/HackerApplicationSection"; + +import { HackerApplicationData } from "@/lib/admin/useApplicant"; + +interface HackerApplicationSections { + [key: string]: HackerApplicationQuestion[]; +} + +const HACKER_APPLICATION_SECTIONS: HackerApplicationSections = { + "Personal Information": ["pronouns", "ethnicity", "is_18_older"], + Education: ["school", "education_level", "major", "is_first_hackathon"], + Experience: ["portfolio", "linkedin", "resume_url"], + "Free Response Questions": ["frq_change", "frq_video_game"], +}; + +function HackerApplication({ + application_data, +}: { + application_data: HackerApplicationData; +}) { + return ( + Hacker Application}> + + {Object.entries(HACKER_APPLICATION_SECTIONS).map( + ([section, questions]) => ( + + ), + )} + + + ); +} + +export default HackerApplication; diff --git a/apps/site/src/app/admin/applicants/[uid]/components/ApplicationSection.tsx b/apps/site/src/app/admin/applicants/hackers/components/HackerApplicationSection.tsx similarity index 84% rename from apps/site/src/app/admin/applicants/[uid]/components/ApplicationSection.tsx rename to apps/site/src/app/admin/applicants/hackers/components/HackerApplicationSection.tsx index 094417d8..67ff513f 100644 --- a/apps/site/src/app/admin/applicants/[uid]/components/ApplicationSection.tsx +++ b/apps/site/src/app/admin/applicants/hackers/components/HackerApplicationSection.tsx @@ -2,9 +2,9 @@ import ColumnLayout from "@cloudscape-design/components/column-layout"; import TextContent from "@cloudscape-design/components/text-content"; import { - ApplicationData, - ApplicationQuestion, -} from "@/app/admin/applicants/hackers/useApplicant"; + HackerApplicationData, + HackerApplicationQuestion, +} from "@/lib/admin/useApplicant"; interface ApplicationResponseProps { value: string | boolean | string[] | null; @@ -49,11 +49,11 @@ function ApplicationResponse({ value }: ApplicationResponseProps) { interface ApplicationSectionProps { title: string; - data: Omit; - propsToShow: ApplicationQuestion[]; + data: Omit; + propsToShow: HackerApplicationQuestion[]; } -function ApplicationSection({ +function HackerApplicationSection({ title, data, propsToShow, @@ -76,4 +76,4 @@ function ApplicationSection({ ); } -export default ApplicationSection; +export default HackerApplicationSection; diff --git a/apps/site/src/app/admin/applicants/hackers/useApplicant.ts b/apps/site/src/app/admin/applicants/hackers/useApplicant.ts deleted file mode 100644 index 4ee1646b..00000000 --- a/apps/site/src/app/admin/applicants/hackers/useApplicant.ts +++ /dev/null @@ -1,69 +0,0 @@ -import axios from "axios"; -import useSWR from "swr"; - -import { ParticipantRole, Status, Uid, Score } from "@/lib/userRecord"; - -export type Review = [string, Uid, Score]; - -// The application responses submitted by an applicant -export interface ApplicationData { - email: string; - pronouns: string[]; - ethnicity: string; - is_18_older: boolean; - school: string; - education_level: string; - major: string; - is_first_hackathon: boolean; - portfolio: string | null; - linkedin: string | null; - frq_collaboration: string; - frq_dream_job: string; - resume_url: string; - submission_time: string; - reviews: Review[]; -} - -export type ApplicationQuestion = Exclude; - -export interface Applicant { - _id: Uid; - first_name: string; - last_name: string; - roles: ReadonlyArray; - status: Status; - application_data: ApplicationData; -} - -const fetcher = async ([api, uid]: [string, Uid]) => { - if (!uid) { - return null; - } - const res = await axios.get(api + uid); - return res.data; -}; - -function useApplicant(uid: Uid) { - const { data, error, isLoading, mutate } = useSWR< - Applicant | null, - unknown, - [string, Uid] - >(["/api/admin/applicant/", uid], fetcher); - - async function submitReview(uid: Uid, score: number) { - await axios.post("/api/admin/review", { applicant: uid, score: score }); - // TODO: provide success status to display in alert - mutate(); - } - - return { - applicant: data, - loading: isLoading, - error, - submitReview, - }; -} - -export type submitReview = (uid: Uid, score: number) => Promise; - -export default useApplicant; diff --git a/apps/site/src/app/admin/applicants/mentors/MentorApplicants.tsx b/apps/site/src/app/admin/applicants/mentors/MentorApplicants.tsx new file mode 100644 index 00000000..0cfe9057 --- /dev/null +++ b/apps/site/src/app/admin/applicants/mentors/MentorApplicants.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { useContext, useState } from "react"; + +import Box from "@cloudscape-design/components/box"; +import Cards from "@cloudscape-design/components/cards"; +import Header from "@cloudscape-design/components/header"; +import Link from "@cloudscape-design/components/link"; + +import { useFollowWithNextLink } from "@/app/admin/layout/common"; +import useMentorVolunteerApplicants, { + ApplicantSummary, +} from "@/lib/admin/useMentorVolunteerApplicants"; + +import ApplicantFilters, { + Options, +} from "@/app/admin/applicants/components/ApplicantFilters"; +import ApplicantStatus from "@/app/admin/applicants/components/ApplicantStatus"; + +import UserContext from "@/lib/admin/UserContext"; +import { isMentorReviewer } from "@/lib/admin/authorization"; +import { ParticipantRole } from "@/lib/userRecord"; + +function MentorApplicants() { + const router = useRouter(); + + const { roles } = useContext(UserContext); + + if (!isMentorReviewer(roles)) { + router.push("/admin/dashboard"); + } + + const [selectedStatuses, setSelectedStatuses] = useState([]); + const [selectedDecisions, setSelectedDecisions] = useState([]); + const { applicantList, loading } = useMentorVolunteerApplicants("mentors"); + + const selectedStatusValues = selectedStatuses.map(({ value }) => value); + const selectedDecisionValues = selectedDecisions.map(({ value }) => value); + + const filteredApplicants = applicantList.filter( + (applicant) => + (selectedStatuses.length === 0 || + selectedStatusValues.includes(applicant.status)) && + (selectedDecisions.length === 0 || + selectedDecisionValues.includes(applicant.decision || "-")), + ); + + const items = filteredApplicants; + + const counter = + selectedStatuses.length > 0 || selectedDecisions.length > 0 + ? `(${items.length}/${applicantList.length})` + : `(${applicantList.length})`; + + const emptyContent = ( + + No applicants + + ); + + return ( + _id, + }, + { + id: "school", + header: "School", + content: ({ application_data }) => application_data.school, + }, + { + id: "status", + header: "Status", + content: ApplicantStatus, + }, + { + id: "submission_time", + header: "Applied", + content: ({ application_data }) => + new Date(application_data.submission_time).toLocaleDateString(), + }, + { + id: "decision", + header: "Decision", + content: DecisionStatus, + }, + ], + }} + // visibleSections={preferences.visibleContent} + loading={loading} + loadingText="Loading applicants" + items={items} + trackBy="_id" + variant="full-page" + filter={ + + } + empty={emptyContent} + header={
Mentor Applicants
} + /> + ); +} + +const CardHeader = ({ _id, first_name, last_name }: ApplicantSummary) => { + const followWithNextLink = useFollowWithNextLink(); + return ( + + {first_name} {last_name} + + ); +}; + +const DecisionStatus = ({ decision }: ApplicantSummary) => + decision ? : "-"; + +export default MentorApplicants; diff --git a/apps/site/src/app/admin/applicants/mentors/[uid]/MentorApplicant.tsx b/apps/site/src/app/admin/applicants/mentors/[uid]/MentorApplicant.tsx new file mode 100644 index 00000000..5bcb62f6 --- /dev/null +++ b/apps/site/src/app/admin/applicants/mentors/[uid]/MentorApplicant.tsx @@ -0,0 +1,13 @@ +import Applicant from "@/app/admin/applicants/components/Applicant"; + +interface ApplicantProps { + params: { uid: string }; +} + +function MentorApplicant({ params }: ApplicantProps) { + const { uid } = params; + + return ; +} + +export default MentorApplicant; diff --git a/apps/site/src/app/admin/applicants/mentors/[uid]/page.tsx b/apps/site/src/app/admin/applicants/mentors/[uid]/page.tsx new file mode 100644 index 00000000..1b6089a0 --- /dev/null +++ b/apps/site/src/app/admin/applicants/mentors/[uid]/page.tsx @@ -0,0 +1 @@ +export { default as default } from "./MentorApplicant"; diff --git a/apps/site/src/app/admin/applicants/mentors/components/MentorApplication.tsx b/apps/site/src/app/admin/applicants/mentors/components/MentorApplication.tsx new file mode 100644 index 00000000..e7a2032d --- /dev/null +++ b/apps/site/src/app/admin/applicants/mentors/components/MentorApplication.tsx @@ -0,0 +1,56 @@ +import Container from "@cloudscape-design/components/container"; +import Header from "@cloudscape-design/components/header"; +import SpaceBetween from "@cloudscape-design/components/space-between"; + +import { MentorApplicationQuestion } from "@/lib/admin/useApplicant"; +import MentorApplicationSection from "@/app/admin/applicants/mentors/components/MentorApplicationSection"; + +import { MentorApplicationData } from "@/lib/admin/useApplicant"; + +interface MentorApplicationSections { + [key: string]: MentorApplicationQuestion[]; +} + +const MENTOR_APPLICATION_SECTIONS: MentorApplicationSections = { + "Personal Information": ["pronouns", "ethnicity", "is_18_older"], + Education: ["school", "education_level", "major"], + Experience: [ + "git_experience", + "portfolio", + "linkedin", + "resume_url", + "resume_share_to_sponsors", + ], + "Free Response Questions": [ + "mentor_prev_experience_saq1", + "mentor_interest_saq2", + "mentor_team_help_saq3", + "mentor_team_help_saq4", + "other_questions", + ], +}; + +function MentorApplication({ + application_data, +}: { + application_data: MentorApplicationData; +}) { + return ( + Mentor Application}> + + {Object.entries(MENTOR_APPLICATION_SECTIONS).map( + ([section, questions]) => ( + + ), + )} + + + ); +} + +export default MentorApplication; diff --git a/apps/site/src/app/admin/applicants/mentors/components/MentorApplicationSection.tsx b/apps/site/src/app/admin/applicants/mentors/components/MentorApplicationSection.tsx new file mode 100644 index 00000000..f098205b --- /dev/null +++ b/apps/site/src/app/admin/applicants/mentors/components/MentorApplicationSection.tsx @@ -0,0 +1,79 @@ +import ColumnLayout from "@cloudscape-design/components/column-layout"; +import TextContent from "@cloudscape-design/components/text-content"; + +import { + MentorApplicationData, + MentorApplicationQuestion, +} from "@/lib/admin/useApplicant"; + +interface ApplicationResponseProps { + value: string | boolean | string[] | null; +} + +const titleCase = (str: string) => + str.charAt(0).toUpperCase() + str.substring(1); + +const formatQuestion = (q: string) => q.split("_").map(titleCase).join(" "); + +function ApplicationResponse({ value }: ApplicationResponseProps) { + if (value === null) { + return

Not provided

; + } + + switch (typeof value) { + case "boolean": + return

{value ? "Yes" : "No"}

; + case "string": + if (value.startsWith("http")) { + return ( +

+ + {value} + +

+ ); + } + return

{value}

; + case "object": + return ( +
    + {value.map((v) => ( +
  • {v}
  • + ))} +
+ ); + default: + return

; + } +} + +interface ApplicationSectionProps { + title: string; + data: Omit; + propsToShow: MentorApplicationQuestion[]; +} + +function MentorApplicationSection({ + title, + data, + propsToShow, +}: ApplicationSectionProps) { + return ( + +

{title}

+ + {propsToShow.map((prop) => ( +
+

{formatQuestion(prop)}

+ +
+ ))} +
+ + ); +} + +export default MentorApplicationSection; diff --git a/apps/site/src/app/admin/applicants/mentors/page.tsx b/apps/site/src/app/admin/applicants/mentors/page.tsx new file mode 100644 index 00000000..4f0f539f --- /dev/null +++ b/apps/site/src/app/admin/applicants/mentors/page.tsx @@ -0,0 +1 @@ +export { default as default } from "./MentorApplicants"; diff --git a/apps/site/src/app/admin/applicants/volunteers/VolunteerApplicants.tsx b/apps/site/src/app/admin/applicants/volunteers/VolunteerApplicants.tsx new file mode 100644 index 00000000..e3328154 --- /dev/null +++ b/apps/site/src/app/admin/applicants/volunteers/VolunteerApplicants.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { useContext, useState } from "react"; + +import Box from "@cloudscape-design/components/box"; +import Cards from "@cloudscape-design/components/cards"; +import Header from "@cloudscape-design/components/header"; +import Link from "@cloudscape-design/components/link"; + +import { useFollowWithNextLink } from "@/app/admin/layout/common"; +import useMentorVolunteerApplicants, { + ApplicantSummary, +} from "@/lib/admin/useMentorVolunteerApplicants"; + +import ApplicantFilters, { + Options, +} from "@/app/admin/applicants/components/ApplicantFilters"; +import ApplicantStatus from "@/app/admin/applicants/components/ApplicantStatus"; + +import UserContext from "@/lib/admin/UserContext"; +import { isVolunteerReviewer } from "@/lib/admin/authorization"; +import { ParticipantRole } from "@/lib/userRecord"; + +function VolunteerApplicants() { + const router = useRouter(); + + const { roles } = useContext(UserContext); + + if (!isVolunteerReviewer(roles)) { + router.push("/admin/dashboard"); + } + + const [selectedStatuses, setSelectedStatuses] = useState([]); + const [selectedDecisions, setSelectedDecisions] = useState([]); + const { applicantList, loading } = useMentorVolunteerApplicants("volunteers"); + + const selectedStatusValues = selectedStatuses.map(({ value }) => value); + const selectedDecisionValues = selectedDecisions.map(({ value }) => value); + + const filteredApplicants = applicantList.filter( + (applicant) => + (selectedStatuses.length === 0 || + selectedStatusValues.includes(applicant.status)) && + (selectedDecisions.length === 0 || + selectedDecisionValues.includes(applicant.decision || "-")), + ); + + const items = filteredApplicants; + + const counter = + selectedStatuses.length > 0 || selectedDecisions.length > 0 + ? `(${items.length}/${applicantList.length})` + : `(${applicantList.length})`; + + const emptyContent = ( + + No applicants + + ); + + return ( + _id, + }, + { + id: "school", + header: "School", + content: ({ application_data }) => application_data.school, + }, + { + id: "status", + header: "Status", + content: ApplicantStatus, + }, + { + id: "submission_time", + header: "Applied", + content: ({ application_data }) => + new Date(application_data.submission_time).toLocaleDateString(), + }, + { + id: "decision", + header: "Decision", + content: DecisionStatus, + }, + ], + }} + // visibleSections={preferences.visibleContent} + loading={loading} + loadingText="Loading applicants" + items={items} + trackBy="_id" + variant="full-page" + filter={ + + } + empty={emptyContent} + header={
Volunteer Applicants
} + /> + ); +} + +const CardHeader = ({ _id, first_name, last_name }: ApplicantSummary) => { + const followWithNextLink = useFollowWithNextLink(); + return ( + + {first_name} {last_name} + + ); +}; + +const DecisionStatus = ({ decision }: ApplicantSummary) => + decision ? : "-"; + +export default VolunteerApplicants; diff --git a/apps/site/src/app/admin/applicants/volunteers/[uid]/VolunteerApplicant.tsx b/apps/site/src/app/admin/applicants/volunteers/[uid]/VolunteerApplicant.tsx new file mode 100644 index 00000000..ea9e7b1f --- /dev/null +++ b/apps/site/src/app/admin/applicants/volunteers/[uid]/VolunteerApplicant.tsx @@ -0,0 +1,13 @@ +import Applicant from "@/app/admin/applicants/components/Applicant"; + +interface ApplicantProps { + params: { uid: string }; +} + +function VolunteerApplicant({ params }: ApplicantProps) { + const { uid } = params; + + return ; +} + +export default VolunteerApplicant; diff --git a/apps/site/src/app/admin/applicants/volunteers/[uid]/page.tsx b/apps/site/src/app/admin/applicants/volunteers/[uid]/page.tsx new file mode 100644 index 00000000..c310db43 --- /dev/null +++ b/apps/site/src/app/admin/applicants/volunteers/[uid]/page.tsx @@ -0,0 +1 @@ +export { default as default } from "./VolunteerApplicant"; diff --git a/apps/site/src/app/admin/applicants/volunteers/components/VolunteerApplication.tsx b/apps/site/src/app/admin/applicants/volunteers/components/VolunteerApplication.tsx new file mode 100644 index 00000000..d510dfa6 --- /dev/null +++ b/apps/site/src/app/admin/applicants/volunteers/components/VolunteerApplication.tsx @@ -0,0 +1,53 @@ +import Container from "@cloudscape-design/components/container"; +import Header from "@cloudscape-design/components/header"; +import SpaceBetween from "@cloudscape-design/components/space-between"; + +import { VolunteerApplicationQuestion } from "@/lib/admin/useApplicant"; +import VolunteerApplicationSection from "@/app/admin/applicants/volunteers/components/VolunteerApplicationSection"; + +import { VolunteerApplicationData } from "@/lib/admin/useApplicant"; + +interface VolunteerApplicationSections { + [key: string]: VolunteerApplicationQuestion[]; +} + +const VOLUNTEER_APPLICATION_SECTIONS: VolunteerApplicationSections = { + "Personal Information": ["pronouns", "ethnicity", "is_18_older"], + Education: ["school", "education_level", "major"], + "Free Response Questions": [ + "frq_volunteer", + "frq_utensil", + "allergies", + "extra_questions", + ], + Availability: [ + "friday_availability", + "saturday_availability", + "sunday_availability", + ], +}; + +function VolunteerApplication({ + application_data, +}: { + application_data: VolunteerApplicationData; +}) { + return ( + Volunteer Application}> + + {Object.entries(VOLUNTEER_APPLICATION_SECTIONS).map( + ([section, questions]) => ( + + ), + )} + + + ); +} + +export default VolunteerApplication; diff --git a/apps/site/src/app/admin/applicants/volunteers/components/VolunteerApplicationSection.tsx b/apps/site/src/app/admin/applicants/volunteers/components/VolunteerApplicationSection.tsx new file mode 100644 index 00000000..78239202 --- /dev/null +++ b/apps/site/src/app/admin/applicants/volunteers/components/VolunteerApplicationSection.tsx @@ -0,0 +1,79 @@ +import ColumnLayout from "@cloudscape-design/components/column-layout"; +import TextContent from "@cloudscape-design/components/text-content"; + +import { + VolunteerApplicationData, + VolunteerApplicationQuestion, +} from "@/lib/admin/useApplicant"; + +interface ApplicationResponseProps { + value: string | boolean | string[] | ReadonlyArray | null; +} + +const titleCase = (str: string) => + str.charAt(0).toUpperCase() + str.substring(1); + +const formatQuestion = (q: string) => q.split("_").map(titleCase).join(" "); + +function ApplicationResponse({ value }: ApplicationResponseProps) { + if (value === null) { + return

Not provided

; + } + + switch (typeof value) { + case "boolean": + return

{value ? "Yes" : "No"}

; + case "string": + if (value.startsWith("http")) { + return ( +

+ + {value} + +

+ ); + } + return

{value}

; + case "object": + return ( +
    + {value.map((v) => ( +
  • {v}
  • + ))} +
+ ); + default: + return

; + } +} + +interface ApplicationSectionProps { + title: string; + data: Omit; + propsToShow: VolunteerApplicationQuestion[]; +} + +function VolunteerApplicationSection({ + title, + data, + propsToShow, +}: ApplicationSectionProps) { + return ( + +

{title}

+ + {propsToShow.map((prop) => ( +
+

{formatQuestion(prop)}

+ +
+ ))} +
+ + ); +} + +export default VolunteerApplicationSection; diff --git a/apps/site/src/app/admin/applicants/volunteers/page.tsx b/apps/site/src/app/admin/applicants/volunteers/page.tsx new file mode 100644 index 00000000..4cdae01a --- /dev/null +++ b/apps/site/src/app/admin/applicants/volunteers/page.tsx @@ -0,0 +1 @@ +export { default as default } from "./VolunteerApplicants"; diff --git a/apps/site/src/app/admin/directors/Directors.tsx b/apps/site/src/app/admin/directors/Directors.tsx new file mode 100644 index 00000000..ee34a91a --- /dev/null +++ b/apps/site/src/app/admin/directors/Directors.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { useContext } from "react"; + +import UserContext from "@/lib/admin/UserContext"; +import { isDirector } from "@/lib/admin/authorization"; + +function Directors() { + const router = useRouter(); + + const { roles } = useContext(UserContext); + + if (!isDirector(roles)) { + router.push("/admin/dashboard"); + } + + return

Director page

; +} + +export default Directors; diff --git a/apps/site/src/app/admin/directors/email-sender/EmailSender.tsx b/apps/site/src/app/admin/directors/email-sender/EmailSender.tsx new file mode 100644 index 00000000..88208aca --- /dev/null +++ b/apps/site/src/app/admin/directors/email-sender/EmailSender.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { useContext } from "react"; + +import SpaceBetween from "@cloudscape-design/components/space-between"; +import Header from "@cloudscape-design/components/header"; + +import UserContext from "@/lib/admin/UserContext"; +import { isDirector } from "@/lib/admin/authorization"; +import ApplyReminder from "./components/ApplyReminder"; +import ReleaseNonHackerDecisions from "./components/ReleaseDecisions"; +import ReleaseHackerDecisions from "./components/ReleaseHackerDecisions"; + +function EmailSender() { + const router = useRouter(); + + const { roles } = useContext(UserContext); + + if (!isDirector(roles)) { + router.push("/admin/dashboard"); + } + + return ( + +
Email Sender
+ + + +
+ ); +} + +export default EmailSender; diff --git a/apps/site/src/app/admin/directors/email-sender/components/ApplyReminder.tsx b/apps/site/src/app/admin/directors/email-sender/components/ApplyReminder.tsx new file mode 100644 index 00000000..ac5aff9c --- /dev/null +++ b/apps/site/src/app/admin/directors/email-sender/components/ApplyReminder.tsx @@ -0,0 +1,25 @@ +import Box from "@cloudscape-design/components/box"; + +import useEmailSenders from "@/lib/admin/useEmailSenders"; +import Senders from "./Senders"; +import SendGroup from "./SendGroup"; + +function ApplyReminder() { + const { senders, mutate } = useEmailSenders(); + + return ( + <> + + + Emails Sent + + + ); +} + +export default ApplyReminder; diff --git a/apps/site/src/app/admin/directors/email-sender/components/ConfirmationModal.tsx b/apps/site/src/app/admin/directors/email-sender/components/ConfirmationModal.tsx new file mode 100644 index 00000000..a0d06866 --- /dev/null +++ b/apps/site/src/app/admin/directors/email-sender/components/ConfirmationModal.tsx @@ -0,0 +1,51 @@ +import { AxiosResponse } from "axios"; + +import Modal from "@cloudscape-design/components/modal"; +import Box from "@cloudscape-design/components/box"; +import Button from "@cloudscape-design/components/button"; +import SpaceBetween from "@cloudscape-design/components/space-between"; +import TextContent from "@cloudscape-design/components/text-content"; + +interface ConfirmationModalProps { + onConfirm: () => Promise; + visible: boolean; + setVisible: (newVisible: boolean) => void; +} + +function ConfirmationModal({ + onConfirm, + visible, + setVisible, +}: ConfirmationModalProps) { + return ( + setVisible(false)} + visible={visible} + footer={ + + + + + + + } + header="Send Emails" + > + +

You are about to send out emails

+
+
+ ); +} + +export default ConfirmationModal; diff --git a/apps/site/src/app/admin/directors/email-sender/components/ReleaseDecisions.tsx b/apps/site/src/app/admin/directors/email-sender/components/ReleaseDecisions.tsx new file mode 100644 index 00000000..3f276897 --- /dev/null +++ b/apps/site/src/app/admin/directors/email-sender/components/ReleaseDecisions.tsx @@ -0,0 +1,13 @@ +import SendGroup from "./SendGroup"; + +function ReleaseNonHackerDecisions() { + return ( + + ); +} + +export default ReleaseNonHackerDecisions; diff --git a/apps/site/src/app/admin/directors/email-sender/components/ReleaseHackerDecisions.tsx b/apps/site/src/app/admin/directors/email-sender/components/ReleaseHackerDecisions.tsx new file mode 100644 index 00000000..2d32d2fd --- /dev/null +++ b/apps/site/src/app/admin/directors/email-sender/components/ReleaseHackerDecisions.tsx @@ -0,0 +1,13 @@ +import SendGroup from "./SendGroup"; + +function ReleaseHackerDecisions() { + return ( + + ); +} + +export default ReleaseHackerDecisions; diff --git a/apps/site/src/app/admin/directors/email-sender/components/SendGroup.tsx b/apps/site/src/app/admin/directors/email-sender/components/SendGroup.tsx new file mode 100644 index 00000000..0cc7fa0b --- /dev/null +++ b/apps/site/src/app/admin/directors/email-sender/components/SendGroup.tsx @@ -0,0 +1,77 @@ +import axios from "axios"; + +import { useState } from "react"; + +import { KeyedMutator } from "swr"; + +import Button from "@cloudscape-design/components/button"; +import SpaceBetween from "@cloudscape-design/components/space-between"; +import TextContent from "@cloudscape-design/components/text-content"; +import Flashbar, { + FlashbarProps, +} from "@cloudscape-design/components/flashbar"; + +import ConfirmationModal from "./ConfirmationModal"; +import { Sender } from "@/lib/admin/useEmailSenders"; + +interface SendGroupProps { + description: string; + buttonText: string; + route: string; + mutate?: KeyedMutator; +} + +function SendGroup({ description, buttonText, route, mutate }: SendGroupProps) { + const [visible, setVisible] = useState(false); + const [flashBarItems, setFlashBarItems] = useState< + ReadonlyArray + >([]); + + const handleClick = async () => { + await axios + .post(route) + .then(() => { + setFlashBarItems([ + { + type: "success", + content: "Successfully sent emails.", + dismissible: true, + dismissLabel: "Dismiss message", + onDismiss: () => setFlashBarItems([]), + }, + ]); + mutate?.(); + }) + .catch(() => { + console.error("Unable to send out emails"); + setFlashBarItems([ + { + type: "error", + content: "Emails failed to send.", + dismissible: true, + dismissLabel: "Dismiss message", + onDismiss: () => setFlashBarItems([]), + }, + ]); + }); + }; + + return ( + + + {description} + + + + + + ); +} + +export default SendGroup; diff --git a/apps/site/src/app/admin/directors/email-sender/components/Senders.tsx b/apps/site/src/app/admin/directors/email-sender/components/Senders.tsx new file mode 100644 index 00000000..3cdf30ce --- /dev/null +++ b/apps/site/src/app/admin/directors/email-sender/components/Senders.tsx @@ -0,0 +1,38 @@ +import TextContent from "@cloudscape-design/components/text-content"; + +import { Sender } from "@/lib/admin/useEmailSenders"; +import { Uid } from "@/lib/userRecord"; + +interface SendersProps { + senders: Sender[]; +} + +function Senders({ senders }: SendersProps) { + if (senders.length === 0) { + return

-

; + } + + const formatUid = (uid: Uid) => uid.split(".").at(-1); + const formatDate = (timestamp: string) => { + const formatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "short", + timeStyle: "short", + }); + return formatter.format(new Date(timestamp)); + }; + + return ( + +
    + {senders.map(([date, sender, numEmailsSent]) => ( +
  • + {formatUid(sender)} sent {numEmailsSent} email(s) on{" "} + {formatDate(date)} +
  • + ))} +
+
+ ); +} + +export default Senders; diff --git a/apps/site/src/app/admin/directors/email-sender/page.tsx b/apps/site/src/app/admin/directors/email-sender/page.tsx new file mode 100644 index 00000000..d04ccd60 --- /dev/null +++ b/apps/site/src/app/admin/directors/email-sender/page.tsx @@ -0,0 +1 @@ +export { default as default } from "./EmailSender"; diff --git a/apps/site/src/app/admin/directors/organizers/AddOrganizer.tsx b/apps/site/src/app/admin/directors/organizers/AddOrganizer.tsx new file mode 100644 index 00000000..4584c206 --- /dev/null +++ b/apps/site/src/app/admin/directors/organizers/AddOrganizer.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import axios from "axios"; + +import SpaceBetween from "@cloudscape-design/components/space-between"; +import Button from "@cloudscape-design/components/button"; +import Input from "@cloudscape-design/components/input"; +import FormField from "@cloudscape-design/components/form-field"; +import Checkbox from "@cloudscape-design/components/checkbox"; + +import AddOrganizerModal from "./AddOrganizerModal"; + +// eslint-disable-next-line no-useless-escape +const EMAIL_REGEX = /^\w+([\.\-]?\w+)*@uci.edu/; + +export interface RawOrganizer { + email: string; + first_name: string; + last_name: string; + roles: ReadonlyArray; +} + +function AddOrganizer() { + const [email, setEmail] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [isHackerReviewer, setHackerReviewer] = useState(true); + const [isMentorReviewer, setMentorReviewer] = useState(false); + const [isVolunteerReviewer, setVolunteerReviewer] = useState(false); + const [organizer, setOrganizer] = useState(null); + + const [invalidEmailError, setInvalidEmailError] = useState(""); + + function updateOrganizer() { + if (!EMAIL_REGEX.test(email)) { + setInvalidEmailError("Not a valid UCI email"); + return; + } else { + setInvalidEmailError(""); + } + + const roles = ["Organizer"]; + if (isHackerReviewer) { + roles.push("Hacker Reviewer"); + } + if (isMentorReviewer) { + roles.push("Mentor Reviewer"); + } + if (isVolunteerReviewer) { + roles.push("Volunteer Reviewer"); + } + + setOrganizer({ + email: email, + first_name: firstName, + last_name: lastName, + roles: roles, + }); + } + + async function submitOrganizer() { + await axios + .post("/api/director/organizers", organizer) + .then(() => { + window.location.reload(); + }) + .catch((error) => console.error(error)); + } + + return ( + <> + + + setEmail(detail.value)} + value={email} + inputMode="email" + placeholder="Organizer UCI Email" + /> + + + setFirstName(detail.value)} + value={firstName} + inputMode="text" + placeholder="First Name" + /> + + + setLastName(detail.value)} + value={lastName} + inputMode="text" + placeholder="Last Name" + /> + + + setHackerReviewer(detail.checked)} + checked={isHackerReviewer} + > + Hacker Reviewer + + setMentorReviewer(detail.checked)} + checked={isMentorReviewer} + > + Mentor Reviewer + + setVolunteerReviewer(detail.checked)} + checked={isVolunteerReviewer} + > + Volunteer Reviewer + + + + + setOrganizer(null)} + onConfirm={submitOrganizer} + organizer={organizer} + /> + + ); +} + +export default AddOrganizer; diff --git a/apps/site/src/app/admin/directors/organizers/AddOrganizerModal.tsx b/apps/site/src/app/admin/directors/organizers/AddOrganizerModal.tsx new file mode 100644 index 00000000..a3703e1e --- /dev/null +++ b/apps/site/src/app/admin/directors/organizers/AddOrganizerModal.tsx @@ -0,0 +1,57 @@ +import Modal from "@cloudscape-design/components/modal"; +import Box from "@cloudscape-design/components/box"; +import Button from "@cloudscape-design/components/button"; +import SpaceBetween from "@cloudscape-design/components/space-between"; +import TextContent from "@cloudscape-design/components/text-content"; +import { RawOrganizer } from "./AddOrganizer"; + +interface AddOrganizerModalProps { + onDismiss: () => void; + onConfirm: (organizer: RawOrganizer | null) => void; + organizer: RawOrganizer | null; +} + +function AddOrganizerModal({ + onDismiss, + onConfirm, + organizer, +}: AddOrganizerModalProps) { + if (organizer === null) { + return ; + } + + return ( + + + + + + + } + header={`Add Organizer ${organizer.first_name} ${organizer.last_name} (${organizer.email})`} + > + +

+ Double check that you have entered this organizer's info + correctly +

+
    +
  • Email: {organizer.email}
  • +
  • First Name: {organizer.first_name}
  • +
  • Last Name: {organizer.last_name}
  • +
  • Newly Assigned Roles: {organizer.roles.join(", ")}
  • +
+
+
+ ); +} + +export default AddOrganizerModal; diff --git a/apps/site/src/app/admin/directors/organizers/Organizers.tsx b/apps/site/src/app/admin/directors/organizers/Organizers.tsx new file mode 100644 index 00000000..09db2c20 --- /dev/null +++ b/apps/site/src/app/admin/directors/organizers/Organizers.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { useContext } from "react"; + +import Cards from "@cloudscape-design/components/cards"; +import Box from "@cloudscape-design/components/box"; +import Header from "@cloudscape-design/components/header"; + +import UserContext from "@/lib/admin/UserContext"; +import { isDirector } from "@/lib/admin/authorization"; + +import AddOrganizer from "./AddOrganizer"; +import useOrganizers, { Organizer } from "@/lib/admin/useOrganizers"; + +function Organizers() { + const router = useRouter(); + + const { roles } = useContext(UserContext); + + if (!isDirector(roles)) { + router.push("/admin/dashboard"); + } + + const { organizerList, loading } = useOrganizers(); + + const counter = `(${organizerList.length})`; + + const emptyContent = ( + + No Organizers + + ); + + return ( + _id, + }, + { + id: "roles", + header: "Roles", + content: ({ roles }) => roles.join(", "), + }, + ], + }} + loading={loading} + loadingText="Loading applicants" + items={organizerList} + trackBy="_id" + variant="full-page" + empty={emptyContent} + header={ +
}> + Organizers +
+ } + /> + ); +} + +const CardHeader = ({ first_name, last_name }: Organizer) => { + return ( + + {first_name} {last_name} + + ); +}; + +export default Organizers; diff --git a/apps/site/src/app/admin/directors/organizers/page.tsx b/apps/site/src/app/admin/directors/organizers/page.tsx new file mode 100644 index 00000000..f2fdf98e --- /dev/null +++ b/apps/site/src/app/admin/directors/organizers/page.tsx @@ -0,0 +1 @@ +export { default as default } from "./Organizers"; diff --git a/apps/site/src/app/admin/directors/page.tsx b/apps/site/src/app/admin/directors/page.tsx new file mode 100644 index 00000000..8ca42572 --- /dev/null +++ b/apps/site/src/app/admin/directors/page.tsx @@ -0,0 +1 @@ +export { default as default } from "./Directors"; diff --git a/apps/site/src/app/admin/layout/AdminLayout.tsx b/apps/site/src/app/admin/layout/AdminLayout.tsx index 2532eb09..344835a8 100644 --- a/apps/site/src/app/admin/layout/AdminLayout.tsx +++ b/apps/site/src/app/admin/layout/AdminLayout.tsx @@ -10,13 +10,13 @@ import { SWRConfig } from "swr"; import { hasAdminRole } from "@/lib/admin/authorization"; import UserContext from "@/lib/admin/UserContext"; -import useUserIdentity from "@/lib/admin/useUserIdentity"; +import useUserIdentityStatic from "@/lib/admin/useUserIdentityStatic"; import AdminSidebar from "./AdminSidebar"; import Breadcrumbs from "./Breadcrumbs"; function AdminLayout({ children }: PropsWithChildren) { - const identity = useUserIdentity(); + const identity = useUserIdentityStatic(); const router = useRouter(); if (!identity) { diff --git a/apps/site/src/app/admin/layout/AdminSidebar.tsx b/apps/site/src/app/admin/layout/AdminSidebar.tsx index cadab42a..4446fad4 100644 --- a/apps/site/src/app/admin/layout/AdminSidebar.tsx +++ b/apps/site/src/app/admin/layout/AdminSidebar.tsx @@ -6,7 +6,14 @@ import SideNavigation, { SideNavigationProps, } from "@cloudscape-design/components/side-navigation"; -import { isApplicationManager } from "@/lib/admin/authorization"; +import { + isApplicationManager, + isHackerReviewer, + isMentorReviewer, + isVolunteerReviewer, + isDirector, +} from "@/lib/admin/authorization"; + import UserContext from "@/lib/admin/UserContext"; import { BASE_PATH, useFollowWithNextLink } from "./common"; @@ -25,18 +32,65 @@ function AdminSidebar() { { type: "link", text: "Back to main site", href: "/" }, ]; + const applicationLinks: SideNavigationProps.Link[] = []; + + if (isHackerReviewer(roles)) { + applicationLinks.push({ + type: "link", + text: "Hacker Applications", + href: "/admin/applicants/hackers", + }); + } + + if (isMentorReviewer(roles)) { + applicationLinks.push({ + type: "link", + text: "Mentor Applications", + href: "/admin/applicants/mentors", + }); + } + + if (isVolunteerReviewer(roles)) { + applicationLinks.push({ + type: "link", + text: "Volunteer Applications", + href: "/admin/applicants/volunteers", + }); + } + if (isApplicationManager(roles)) { navigationItems.splice(1, 0, { - type: "link", + type: "link-group", text: "Applicants", href: "/admin/applicants", + items: applicationLinks, + }); + } + + if (isDirector(roles)) { + navigationItems.splice(4, 0, { + type: "link-group", + text: "Directors", + href: "/admin/directors", + items: [ + { + type: "link", + text: "Organizers", + href: "/admin/directors/organizers", + }, + { + type: "link", + text: "Email Sender", + href: "/admin/directors/email-sender", + }, + ], }); } return ( diff --git a/apps/site/src/app/admin/layout/Breadcrumbs.tsx b/apps/site/src/app/admin/layout/Breadcrumbs.tsx index e02d75a0..fd6e3caa 100644 --- a/apps/site/src/app/admin/layout/Breadcrumbs.tsx +++ b/apps/site/src/app/admin/layout/Breadcrumbs.tsx @@ -12,11 +12,17 @@ interface PathTitles { const pathTitles: PathTitles = { applicants: "Applicants", + hackers: "Hacker Applications", + mentors: "Mentor Applications", + volunteers: "Volunteer Applications", participants: "Participants", events: "Events", + directors: "Directors", + organizers: "Organizers", + "email-sender": "Email Sender", }; -const DEFAULT_ITEMS = [{ text: "IrvineHacks 2024", href: BASE_PATH }]; +const DEFAULT_ITEMS = [{ text: "IrvineHacks 2025", href: BASE_PATH }]; function Breadcrumbs() { const pathname = usePathname(); @@ -41,7 +47,7 @@ function getBreadcrumbItems(pathname: string): BreadcrumbGroupProps.Item[] { .slice("/admin/".length) .split("/") .reduce((partial, path) => { - partial += path; + partial += `${path}/`; items.push({ text: pathTitles[path] || path, href: partial, diff --git a/apps/site/src/lib/admin/authorization.ts b/apps/site/src/lib/admin/authorization.ts index 0fcb48bb..27b32596 100644 --- a/apps/site/src/lib/admin/authorization.ts +++ b/apps/site/src/lib/admin/authorization.ts @@ -3,10 +3,18 @@ import { AdminRole, Role } from "@/lib/userRecord"; // TODO: reexamine waitlist release procedure: do check-in leads really need to be managers? const MANAGER_ROLES = [ AdminRole.Director, - AdminRole.Reviewer, + AdminRole.HackerReviewer, + AdminRole.MentorReviewer, + AdminRole.VolunteerReviewer, AdminRole.CheckInLead, ]; +const REVIEWER_ROLES = [ + AdminRole.HackerReviewer, + AdminRole.MentorReviewer, + AdminRole.VolunteerReviewer, +]; + export function isApplicationManager(roles: ReadonlyArray): boolean { return MANAGER_ROLES.some((managerRole) => roles.includes(managerRole)); } @@ -16,6 +24,10 @@ export function hasAdminRole(role: ReadonlyArray): boolean { return role.includes(AdminRole.Organizer); } +export function isDirector(roles: ReadonlyArray): boolean { + return roles.includes(AdminRole.Director); +} + export function isCheckInLead(roles: ReadonlyArray): boolean { return ( roles.includes(AdminRole.Director) || roles.includes(AdminRole.CheckInLead) @@ -23,5 +35,26 @@ export function isCheckInLead(roles: ReadonlyArray): boolean { } export function isReviewer(roles: ReadonlyArray): boolean { - return roles.includes(AdminRole.Reviewer); + return REVIEWER_ROLES.some((reviewerRole) => roles.includes(reviewerRole)); +} + +export function isHackerReviewer(roles: ReadonlyArray): boolean { + return ( + roles.includes(AdminRole.Director) || + roles.includes(AdminRole.HackerReviewer) + ); +} + +export function isMentorReviewer(roles: ReadonlyArray): boolean { + return ( + roles.includes(AdminRole.Director) || + roles.includes(AdminRole.MentorReviewer) + ); +} + +export function isVolunteerReviewer(roles: ReadonlyArray): boolean { + return ( + roles.includes(AdminRole.Director) || + roles.includes(AdminRole.VolunteerReviewer) + ); } diff --git a/apps/site/src/lib/admin/useApplicant.ts b/apps/site/src/lib/admin/useApplicant.ts new file mode 100644 index 00000000..c84a4c2f --- /dev/null +++ b/apps/site/src/lib/admin/useApplicant.ts @@ -0,0 +1,122 @@ +import axios from "axios"; +import useSWR from "swr"; + +import { ParticipantRole, Status, Uid, Score } from "@/lib/userRecord"; + +export type Review = [string, Uid, Score]; + +// The application responses submitted by an applicant +interface BaseApplicationData { + email: string; + pronouns: string[]; + ethnicity: string; + is_18_older: boolean; + school: string; + education_level: string; + major: string; +} + +export interface HackerApplicationData extends BaseApplicationData { + is_first_hackathon: boolean; + portfolio: string | null; + linkedin: string | null; + frq_change: string; + frq_video_game: string; + resume_url: string; + submission_time: string; + reviews: Review[]; +} + +export interface MentorApplicationData extends BaseApplicationData { + git_experience: string; + github: string | null; + portfolio: string | null; + linkedin: string | null; + mentor_prev_experience_saq1: string | null; + mentor_interest_saq2: string; + mentor_team_help_saq3: string; + mentor_team_help_saq4: string; + resume_share_to_sponsors: boolean; + other_questions: string | null; + resume_url: string; + submission_time: string; + reviews: Review[]; +} + +export interface VolunteerApplicationData extends BaseApplicationData { + frq_volunteer: string; + frq_utensil: string; + allergies: string | null; + extra_questions: string | null; + other_questions: string | null; + friday_availability: ReadonlyArray; + saturday_availability: ReadonlyArray; + sunday_availability: ReadonlyArray; + submission_time: string; + reviews: Review[]; +} + +export type HackerApplicationQuestion = Exclude< + keyof HackerApplicationData, + "reviews" +>; + +export type MentorApplicationQuestion = Exclude< + keyof MentorApplicationData, + "reviews" +>; + +export type VolunteerApplicationQuestion = Exclude< + keyof VolunteerApplicationData, + "reviews" +>; + +type ApplicationData = + | HackerApplicationData + | MentorApplicationData + | VolunteerApplicationData; + +export interface Applicant { + _id: Uid; + first_name: string; + last_name: string; + roles: ReadonlyArray; + status: Status; + application_data: ApplicationData; +} + +const fetcher = async ([api, applicationType, uid]: [string, string, Uid]) => { + if (!uid) { + return null; + } + const res = await axios.get(api + `${applicationType}/${uid}`); + return res.data; +}; + +function useApplicant( + uid: Uid, + applicationType: "hacker" | "mentor" | "volunteer", +) { + const { data, error, isLoading, mutate } = useSWR< + Applicant | null, + unknown, + [string, string, Uid] + >(["/api/admin/applicant/", applicationType, uid], fetcher); + + async function submitReview(uid: Uid, score: number) { + await axios.post("/api/admin/review", { applicant: uid, score: score }); + // TODO: provide success status to display in alert + mutate(); + } + + return { + applicant: data, + loading: isLoading, + error, + submitReview, + }; +} + +export type submitReview = (uid: Uid, score: number) => Promise; + +export default useApplicant; diff --git a/apps/site/src/lib/admin/useApplicants.ts b/apps/site/src/lib/admin/useApplicants.ts deleted file mode 100644 index 7ad34f52..00000000 --- a/apps/site/src/lib/admin/useApplicants.ts +++ /dev/null @@ -1,32 +0,0 @@ -import axios from "axios"; -import useSWR from "swr"; - -import { Decision, Status } from "@/lib/userRecord"; - -export interface ApplicantSummary { - _id: string; - first_name: string; - last_name: string; - status: Status; - decision: Decision | null; - application_data: { - school: string; - submission_time: string; - }; -} - -const fetcher = async (url: string) => { - const res = await axios.get(url); - return res.data; -}; - -function useApplicants() { - const { data, error, isLoading } = useSWR( - "/api/admin/applicants", - fetcher, - ); - - return { applicantList: data || [], loading: isLoading, error }; -} - -export default useApplicants; diff --git a/apps/site/src/lib/admin/useEmailSenders.ts b/apps/site/src/lib/admin/useEmailSenders.ts new file mode 100644 index 00000000..f83b56b6 --- /dev/null +++ b/apps/site/src/lib/admin/useEmailSenders.ts @@ -0,0 +1,26 @@ +import axios from "axios"; +import useSWR from "swr"; +import { Uid } from "../userRecord"; + +export type Sender = [string, Uid, number]; + +const fetcher = async (url: string) => { + const res = await axios.get(url); + return res.data; +}; + +function useEmailSenders() { + const { data, error, isLoading, mutate } = useSWR( + "/api/director/apply-reminder", + fetcher, + ); + + return { + senders: data || [], + loading: isLoading, + error, + mutate, + }; +} + +export default useEmailSenders; diff --git a/apps/site/src/lib/admin/useHackerThresholds.ts b/apps/site/src/lib/admin/useHackerThresholds.ts new file mode 100644 index 00000000..1d1f25d2 --- /dev/null +++ b/apps/site/src/lib/admin/useHackerThresholds.ts @@ -0,0 +1,24 @@ +import axios from "axios"; +import useSWR from "swr"; + +export interface ScoreThresholds { + _id: string; + accept: number; + waitlist: number; +} + +const fetcher = async (url: string) => { + const res = await axios.get(url); + return res.data; +}; + +function useHackerThresholds() { + const { data, error, isLoading } = useSWR( + "/api/admin/get-thresholds", + fetcher, + ); + + return { thresholds: data, loading: isLoading, error }; +} + +export default useHackerThresholds; diff --git a/apps/site/src/lib/admin/useMentorVolunteerApplicants.ts b/apps/site/src/lib/admin/useMentorVolunteerApplicants.ts new file mode 100644 index 00000000..d7a3435f --- /dev/null +++ b/apps/site/src/lib/admin/useMentorVolunteerApplicants.ts @@ -0,0 +1,35 @@ +import axios from "axios"; +import useSWR from "swr"; + +import { Decision, Status } from "@/lib/userRecord"; + +export interface ApplicantSummary { + _id: string; + first_name: string; + last_name: string; + status: Status; + decision: Decision | null; + application_data: { + school: string; + submission_time: string; + }; +} + +const fetcher = async ([url, applicationType]: [string, string]) => { + const res = await axios.get(url + applicationType); + return res.data; +}; + +function useMentorVolunteerApplicants( + applicationType: "mentors" | "volunteers", +) { + const { data, error, isLoading } = useSWR< + ApplicantSummary[], + unknown, + [string, string] + >(["/api/admin/applicants/", applicationType], fetcher); + + return { applicantList: data || [], loading: isLoading, error }; +} + +export default useMentorVolunteerApplicants; diff --git a/apps/site/src/lib/admin/useOrganizers.ts b/apps/site/src/lib/admin/useOrganizers.ts new file mode 100644 index 00000000..ff5d0873 --- /dev/null +++ b/apps/site/src/lib/admin/useOrganizers.ts @@ -0,0 +1,25 @@ +import axios from "axios"; +import useSWR from "swr"; + +export interface Organizer { + _id: string; + first_name: string; + last_name: string; + roles: ReadonlyArray; +} + +const fetcher = async (url: string) => { + const res = await axios.get(url); + return res.data; +}; + +function useOrganizers() { + const { data, error, isLoading } = useSWR( + "/api/director/organizers", + fetcher, + ); + + return { organizerList: data || [], loading: isLoading, error }; +} + +export default useOrganizers; diff --git a/apps/site/src/lib/admin/useUserIdentity.ts b/apps/site/src/lib/admin/useUserIdentityStatic.ts similarity index 81% rename from apps/site/src/lib/admin/useUserIdentity.ts rename to apps/site/src/lib/admin/useUserIdentityStatic.ts index b173962b..3e956daf 100644 --- a/apps/site/src/lib/admin/useUserIdentity.ts +++ b/apps/site/src/lib/admin/useUserIdentityStatic.ts @@ -4,7 +4,7 @@ import axios from "axios"; import { Identity } from "@/lib/utils/getUserIdentity"; -function useUserIdentity(): Identity | undefined { +function useUserIdentityStatic(): Identity | undefined { const [identity, setIdentity] = useState(); useEffect(() => { @@ -20,4 +20,4 @@ function useUserIdentity(): Identity | undefined { return identity; } -export default useUserIdentity; +export default useUserIdentityStatic; diff --git a/apps/site/src/lib/components/Button/Button.tsx b/apps/site/src/lib/components/Button/Button.tsx index f8f0843d..471a84ad 100644 --- a/apps/site/src/lib/components/Button/Button.tsx +++ b/apps/site/src/lib/components/Button/Button.tsx @@ -11,6 +11,7 @@ interface ButtonProps { isLightVersion?: boolean; isNavButton?: boolean; usePrefetch?: boolean; + newWindow?: boolean; disabled?: boolean; style?: CSSProperties; } @@ -24,12 +25,15 @@ const Button: React.FC = ({ isNavButton, disabled, usePrefetch = true, + newWindow = false, }) => { if (href) { return (
{ + const scrollHandler = () => + window.scrollY !== 0 ? setHasScrolled(true) : setHasScrolled(false); + + window.addEventListener("scroll", scrollHandler); + }, []); + + // const goToChooseChar = (e: React.MouseEvent) => { + // e.preventDefault(); + + // if (window.location.pathname !== "/") { + // window.location.href = "/#apply"; + // } else { + // const target = document.getElementById("apply"); + // if (target) { + // target.scrollIntoView({ behavior: "smooth" }); + // } + // } + // }; + + // const deadlinePassed = hasDeadlinePassed(); + + return ( + + ); +} diff --git a/apps/site/src/lib/components/Navbar/Navbar.tsx b/apps/site/src/lib/components/Navbar/Navbar.tsx index b5b2da45..f0d6e3a6 100644 --- a/apps/site/src/lib/components/Navbar/Navbar.tsx +++ b/apps/site/src/lib/components/Navbar/Navbar.tsx @@ -1,123 +1,36 @@ "use client"; -import * as NavMenu from "@radix-ui/react-navigation-menu"; import clsx from "clsx"; -import Image from "next/image"; -import { useEffect, useState } from "react"; import Button from "@/lib/components/Button/Button"; -import HackLogo from "@/lib/components/HackLogo/HackLogo"; import NavLinkItem from "./NavbarHelpers"; -import hamburger from "@/assets/icons/navigation-icon.svg"; // import hasDeadlinePassed from "@/lib/utils/hasDeadlinePassed"; - import buttonStyles from "@/lib/components/Button/Button.module.scss"; -import styles from "./Navbar.module.scss"; import { Identity } from "@/lib/utils/getUserIdentity"; +import BaseNavbar from "./BaseNavbar"; interface NavbarProps { identity: Identity; } -function Navbar({ identity }: NavbarProps) { +export default function Navbar({ identity }: NavbarProps) { const { uid, status } = identity; - const isLoggedIn = uid === null; - - const [listShown, setListShown] = useState(false); - const [hasScrolled, setHasScrolled] = useState(false); - const [hidden, setHidden] = useState(true); - - useEffect(() => { - const scrollHandler = () => - window.scrollY !== 0 ? setHasScrolled(true) : setHasScrolled(false); - - window.addEventListener("scroll", scrollHandler); - }, []); - - // const goToChooseChar = (e: React.MouseEvent) => { - // e.preventDefault(); - - // if (window.location.pathname !== "/") { - // window.location.href = "/#apply"; - // } else { - // const target = document.getElementById("apply"); - // if (target) { - // target.scrollIntoView({ behavior: "smooth" }); - // } - // } - // }; - - // const deadlinePassed = hasDeadlinePassed(); + const isLoggedIn = uid !== null; return ( - + Log Out + + ) : ( +