diff --git a/.github/workflows/dev-build-deploy-bat-worker.yml b/.github/workflows/dev-build-deploy-bat-worker.yml new file mode 100644 index 00000000..4d4f9fbe --- /dev/null +++ b/.github/workflows/dev-build-deploy-bat-worker.yml @@ -0,0 +1,93 @@ +name: Deploy BAT Worker to Development + +on: + push: + branches: + - dev + paths: + - 'apps/api/**' + - 'infra/docker-compose.api.yml' + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + name: Build and Push Docker Image to GHCR + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + run: | + docker build \ + --target prod \ + -f ./apps/api/cmd/BAT_worker/Dockerfile \ + -t ghcr.io/${{ github.repository_owner }}/core-bat-worker:dev \ + ./apps/api + docker push ghcr.io/${{ github.repository_owner }}/core-bat-worker:dev + + run-migrations: + name: Run Goose Migrations + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Goose + run: | + curl -fsSL https://raw.githubusercontent.com/pressly/goose/master/install.sh | sh + + - name: Run migrations + run: | + goose -dir ./apps/api/internal/db/migrations postgres "${{ secrets.DEV_DB_URL }}" up + + deploy: + name: Deploy to Development Server + runs-on: ubuntu-latest + needs: [build-and-push, run-migrations] + + steps: + - name: SSH proxy command + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.API_HOST }} + username: root + password: ${{ secrets.API_PASSWORD }} + script: | + cd /root/core/infra + git fetch + git checkout dev + git reset --hard origin/dev + git pull + + export INFISICAL_TOKEN=$(infisical login \ + --method=universal-auth \ + --client-id='${{ secrets.INFISICAL_CLIENT_ID }}' \ + --client-secret='${{ secrets.INFISICAL_CLIENT_SECRET }}' \ + --silent \ + --plain) + + infisical export \ + --token=$INFISICAL_TOKEN \ + --env=dev \ + --format=dotenv \ + --path="/api" \ + --projectId='${{ secrets.INFISICAL_PROJECT_ID }}' \ + > ./secrets/.env.dev.api + + docker compose -f docker-compose.api.yml pull bat-worker-dev + docker compose -f docker-compose.api.yml up -d --no-deps --force-recreate bat-worker-dev diff --git a/.github/workflows/prod-build-deploy-bat-worker.yml b/.github/workflows/prod-build-deploy-bat-worker.yml new file mode 100644 index 00000000..10bcfb9a --- /dev/null +++ b/.github/workflows/prod-build-deploy-bat-worker.yml @@ -0,0 +1,93 @@ +name: Deploy BAT Worker to Production + +on: + push: + branches: + - dev + paths: + - 'apps/api/**' + - 'infra/docker-compose.api.yml' + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + name: Build and Push Docker Image to GHCR + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + run: | + docker build \ + --target prod \ + -f ./apps/api/cmd/BAT_worker/Dockerfile \ + -t ghcr.io/${{ github.repository_owner }}/core-bat-worker:latest \ + ./apps/api + docker push ghcr.io/${{ github.repository_owner }}/core-bat-worker:latest + + run-migrations: + name: Run Goose Migrations + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Goose + run: | + curl -fsSL https://raw.githubusercontent.com/pressly/goose/master/install.sh | sh + + - name: Run migrations + run: | + goose -dir ./apps/api/internal/db/migrations postgres "${{ secrets.DEV_DB_URL }}" up + + deploy: + name: Deploy to Development Server + runs-on: ubuntu-latest + needs: [build-and-push, run-migrations] + + steps: + - name: SSH proxy command + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.API_HOST }} + username: root + password: ${{ secrets.API_PASSWORD }} + script: | + cd /root/core/infra + git fetch + git checkout dev + git reset --hard origin/dev + git pull + + export INFISICAL_TOKEN=$(infisical login \ + --method=universal-auth \ + --client-id='${{ secrets.INFISICAL_CLIENT_ID }}' \ + --client-secret='${{ secrets.INFISICAL_CLIENT_SECRET }}' \ + --silent \ + --plain) + + infisical export \ + --token=$INFISICAL_TOKEN \ + --env=dev \ + --format=dotenv \ + --path="/api" \ + --projectId='${{ secrets.INFISICAL_PROJECT_ID }}' \ + > ./secrets/.env.dev.api + + docker compose -f docker-compose.api.yml pull bat-worker + docker compose -f docker-compose.api.yml up -d --no-deps --force-recreate bat-worker diff --git a/Makefile b/Makefile index 31441dbd..7faabad8 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ local: api: docker compose up api +bat: + docker compose up api bat_worker asynqmon + storage: docker compose up postgres redis diff --git a/apps/api/.air.bat.toml b/apps/api/.air.bat.toml new file mode 100644 index 00000000..5604900b --- /dev/null +++ b/apps/api/.air.bat.toml @@ -0,0 +1,31 @@ +root = "." +tmp_dir = "tmp" + +[build] + cmd = "go build -o ./tmp/main ./cmd/BAT_worker" + bin = "./tmp/main" + exclude_dir = ["tmp", "vendor"] + include_dir = ["cmd", "internal", "docs"] # same as API if you want + exclude_regex = ["_test.go"] + include_ext = ["go", "tpl", "tmpl", "html"] + log = "tmp/build-errors.log" + poll = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = true + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/apps/api/.air.toml b/apps/api/.air.toml index 13fc3367..b0550626 100644 --- a/apps/api/.air.toml +++ b/apps/api/.air.toml @@ -6,6 +6,7 @@ testdata_dir = "testdata" cmd = "go build -o ./tmp/main ./cmd/api" bin = "./tmp/main" exclude_dir = ["assets", "tmp", "vendor", "testdata"] + include_dir = ["cmd", "internal", "docs", "pkgs"] exclude_regex = ["_test.go"] include_ext = ["go", "tpl", "tmpl", "html"] log = "build-errors.log" diff --git a/apps/api/cmd/BAT_worker/Dockerfile b/apps/api/cmd/BAT_worker/Dockerfile index 95928ed9..f1a8eaa3 100644 --- a/apps/api/cmd/BAT_worker/Dockerfile +++ b/apps/api/cmd/BAT_worker/Dockerfile @@ -13,7 +13,9 @@ FROM base AS dev RUN go install github.com/air-verse/air@latest -CMD ["air"] +COPY .air.bat.toml . + +CMD ["air", "-c", ".air.bat.toml"] # Production FROM base AS prod diff --git a/apps/api/cmd/BAT_worker/main.go b/apps/api/cmd/BAT_worker/main.go index fbd1150f..755343d8 100644 --- a/apps/api/cmd/BAT_worker/main.go +++ b/apps/api/cmd/BAT_worker/main.go @@ -5,7 +5,12 @@ import ( "github.com/hibiken/asynq" "github.com/swamphacks/core/apps/api/internal/config" + "github.com/swamphacks/core/apps/api/internal/db" + "github.com/swamphacks/core/apps/api/internal/db/repository" + "github.com/swamphacks/core/apps/api/internal/email" "github.com/swamphacks/core/apps/api/internal/logger" + "github.com/swamphacks/core/apps/api/internal/services" + "github.com/swamphacks/core/apps/api/internal/tasks" "github.com/swamphacks/core/apps/api/internal/workers" ) @@ -51,9 +56,24 @@ func main() { }, ) - _ = workers.NewBATWorker(logger) + database := db.NewDB(cfg.DatabaseURL) + defer database.Close() + + txm := db.NewTransactionManager(database) + + applicationRepo := repository.NewApplicationRepository(database) + eventRepo := repository.NewEventRespository(database) + userRepo := repository.NewUserRepository(database) + batRunsRepo := repository.NewBatRunsRepository(database) + + sesClient := email.NewSESClient(cfg.AWS.AccessKey, cfg.AWS.AccessKeySecret, cfg.AWS.Region, logger) + emailService := services.NewEmailService(nil, sesClient, logger) + batService := services.NewBatService(applicationRepo, eventRepo, userRepo, batRunsRepo, emailService, txm, nil, logger) + + BATWorker := workers.NewBATWorker(batService, logger) mux := asynq.NewServeMux() + mux.HandleFunc(tasks.TypeCalculateAdmissions, BATWorker.HandleCalculateAdmissionsTask) if err := srv.Run(mux); err != nil { logger.Fatal().Msg("Failed to run BAT worker") diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index b3b1c3f7..e932ac8e 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -81,6 +81,7 @@ func main() { teamRepo := repository.NewTeamRespository(database) teamMemberRepo := repository.NewTeamMemberRespository(database) teamJoinRequestRepo := repository.NewTeamJoinRequestRepository(database) + batRunsRepo := repository.NewBatRunsRepository(database) // Injections into services authService := services.NewAuthService(userRepo, accountRepo, sessionRepo, txm, client, logger, &cfg.Auth) @@ -90,9 +91,10 @@ func main() { emailService := services.NewEmailService(taskQueueClient, sesClient, logger) applicationService := services.NewApplicationService(applicationRepo, eventService, emailService, txm, r2Client, &cfg.CoreBuckets, logger) teamService := services.NewTeamService(teamRepo, teamMemberRepo, teamJoinRequestRepo, eventRepo, txm, logger) + batService := services.NewBatService(applicationRepo, eventRepo, userRepo, batRunsRepo, emailService, txm, taskQueueClient, logger) // Injections into handlers - apiHandlers := handlers.NewHandlers(authService, userService, eventInterestService, eventService, emailService, applicationService, teamService, cfg, logger) + apiHandlers := handlers.NewHandlers(authService, userService, eventInterestService, eventService, emailService, applicationService, teamService, batService, cfg, logger) api := api.NewAPI(&logger, apiHandlers, mw) diff --git a/apps/api/cmd/email_worker/main.go b/apps/api/cmd/email_worker/main.go index e872fef4..ec266505 100644 --- a/apps/api/cmd/email_worker/main.go +++ b/apps/api/cmd/email_worker/main.go @@ -2,6 +2,7 @@ package main import ( "log" + "os" "time" "github.com/hibiken/asynq" @@ -46,8 +47,14 @@ func main() { mux := asynq.NewServeMux() mux.HandleFunc(tasks.TypeSendConfirmationEmail, emailWorker.HandleSendConfirmationEmailTask) + mux.HandleFunc(tasks.TypeSendHtmlEmail, emailWorker.HandleSendHtmlEmailTask) - logger.Info().Msg("Starting email worker") + wd, err := os.Getwd() + if err != nil { + log.Fatalf("Failed to run email worker (could not get working directory)") + } + + logger.Info().Str("Working dir", wd).Msg("Starting email worker") if err := srv.Run(mux); err != nil { log.Fatalf("Failed to run email worker") diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 08fc0eb4..5fb09d04 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -122,8 +122,8 @@ func (api *API) setupRoutes(mw *mw.Middleware) { // --- Event routes --- api.Router.Route("/events", func(r chi.Router) { - // r.Post("/{eventId}/application/reset-reviews", api.Handlers.Application.ResetApplicationReviews) - // r.Post("/{eventId}/application/assign-reviewers", api.Handlers.Application.AssignApplicationReviewers) + r.Post("/{eventId}/calc-admissions", api.Handlers.Admission.HandleCalculateAdmissionsRequest) + r.Post("/{eventId}/reviews/bat-runs/{runId}/release", api.Handlers.Admission.ReleaseDecisions) // Superuser-only r.With(mw.Auth.RequireAuth, ensureSuperuser).Post("/", api.Handlers.Event.CreateEvent) @@ -151,6 +151,11 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.With(ensureEventAdmin).Post("/roles", api.Handlers.Event.AssignEventRole) r.With(ensureEventAdmin).Delete("/roles/{userId}", api.Handlers.Event.RevokeEventRole) r.With(ensureEventAdmin).Post("/roles/batch", api.Handlers.Event.BatchAssignEventRoles) + r.With(ensureEventAdmin).Get("/bat-runs", api.Handlers.Bat.GetRunsByEventId) + r.With(ensureEventAdmin).Delete("/bat-runs", api.Handlers.Bat.GetRunsByEventId) + r.With(ensureEventAdmin).Get("/review-status", api.Handlers.Bat.CheckApplicationReviewsComplete) + r.With(ensureEventAdmin).Post("/reviews/bat-runs", api.Handlers.Admission.HandleCalculateAdmissionsRequest) + r.With(ensureEventAdmin).Post("/reviews/bat-runs/{runId}/release", api.Handlers.Admission.ReleaseDecisions) // Superuser-only r.With(ensureSuperuser).Delete("/", api.Handlers.Event.DeleteEventById) @@ -177,6 +182,15 @@ func (api *API) setupRoutes(mw *mw.Middleware) { // Review admin routes (For Event Admins only) r.With(ensureEventAdmin).Post("/reset-reviews", api.Handlers.Application.ResetApplicationReviews) r.With(ensureEventAdmin).Post("/assign-reviewers", api.Handlers.Application.AssignApplicationReviewers) + + //withdraw Acceptance + r.Patch("/withdraw-acceptance", api.Handlers.Application.WithdrawAcceptance) + + //Accept acceptance + r.Patch("/accept-acceptance", api.Handlers.Application.AcceptApplicationAcceptance) + + //Waitlist application + r.Patch("/join-waitlist", api.Handlers.Application.JoinWaitlist) }) // Team routes diff --git a/apps/api/internal/api/handlers/admissions.go b/apps/api/internal/api/handlers/admissions.go new file mode 100644 index 00000000..b2d29d94 --- /dev/null +++ b/apps/api/internal/api/handlers/admissions.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/rs/zerolog" + res "github.com/swamphacks/core/apps/api/internal/api/response" + "github.com/swamphacks/core/apps/api/internal/services" + "github.com/swamphacks/core/apps/api/internal/web" +) + +type AdmissionHandler struct { + batService *services.BatService + logger zerolog.Logger +} + +func NewAdmissionHandler(batService *services.BatService, logger zerolog.Logger) *AdmissionHandler { + return &AdmissionHandler{ + batService: batService, + logger: logger.With().Str("handler", "AdmissionHandler").Logger(), + } +} + +func (h *AdmissionHandler) ReleaseDecisions(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing event_id."), + ) + return + } + + runId, err := web.PathParamToUUID(r, "runId") + if err != nil { + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing run_id."), + ) + return + } + + err = h.batService.ReleaseBatRunDecision(ctx, eventId, runId) + if err == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + switch { + case errors.Is(err, services.ErrRunMismatch): + res.SendError(w, http.StatusForbidden, + res.NewError("run_mismatch", err.Error()), + ) + + case errors.Is(err, services.ErrRunStatusInvalid): + res.SendError(w, http.StatusConflict, + res.NewError("invalid_run_status", err.Error()), + ) + + case errors.Is(err, services.ErrNoAcceptedApplicants): + res.SendError(w, http.StatusUnprocessableEntity, + res.NewError("no_accepted_applicants", err.Error()), + ) + + case errors.Is(err, services.ErrCouldNotGetEventInfo): + res.SendError(w, http.StatusNotFound, + res.NewError("resource_not_found", err.Error()), + ) + + case errors.Is(err, services.ErrFailedToUpdateRun): + res.SendError(w, http.StatusInternalServerError, + res.NewError("update_failed", "Failed to release decisions."), + ) + + default: + res.SendError(w, http.StatusInternalServerError, + res.NewError("internal_error", "An unexpected error occurred."), + ) + } +} + +func (h *AdmissionHandler) HandleCalculateAdmissionsRequest(w http.ResponseWriter, r *http.Request) { + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "Invalid request body")) + return + } + + _, err = h.batService.QueueCalculateAdmissionsTask(r.Context(), eventId) + if err != nil { + res.Send(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went terribly wrong.")) + return + } + + w.WriteHeader(http.StatusCreated) + +} diff --git a/apps/api/internal/api/handlers/application.go b/apps/api/internal/api/handlers/application.go index 878fc36b..b8c3d7de 100644 --- a/apps/api/internal/api/handlers/application.go +++ b/apps/api/internal/api/handlers/application.go @@ -588,3 +588,87 @@ func (h *ApplicationHandler) GetResumePresignedUrl(w http.ResponseWriter, r *htt res.Send(w, http.StatusOK, request.URL) } + +// Join Waitlist for an event +// +// @Summary Join event waitlist after rejected application status. +// @Description Adds a waitlist join time to application. Sets status to waitlisted +// @Tags Application +// +// @Param eventId path string true "ID of the event to join the waitlist for" +// @Success 200 "Event Waitlist joined successfully" +// @Failure 400 {object} res.ErrorResponse "Bad request: invalid event ID" +// @Failure 500 {object} res.ErrorResponse "Server error: failed to join waitlist" +// @Router /events/{eventId}/application/join-waitlist [patch] +func (h *ApplicationHandler) JoinWaitlist(w http.ResponseWriter, r *http.Request) { + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid.")) + return + } + userId := ctxutils.GetUserIdFromCtx(r.Context()) + + err = h.appService.JoinWaitlist(r.Context(), *userId, eventId) + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("join_waitlist_error", "Something went wrong while joining waitlist")) + return + } + + w.WriteHeader(http.StatusOK) +} + +// Withdraw an acceptance to an event +// +// @Summary Withdraw an acceptance after being accepted to an event. +// @Description Sets application status from accepted to rejected +// @Tags Application +// +// @Param eventId path string true "ID of the event to join the waitlist for" +// @Success 200 "Acceptance withdrawn joined successfully" +// @Failure 400 {object} res.ErrorResponse "Bad request: invalid event ID" +// @Failure 500 {object} res.ErrorResponse "Server error: failed to withdraw" +// @Router /events/{eventId}/application/withdraw-acceptance [patch] +func (h *ApplicationHandler) WithdrawAcceptance(w http.ResponseWriter, r *http.Request) { + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid.")) + return + } + userId := ctxutils.GetUserIdFromCtx(r.Context()) + + err = h.appService.WithdrawAcceptance(r.Context(), *userId, eventId) + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("withdraw_application_error", "Something went wrong while withdrawing acceptance")) + return + } + + w.WriteHeader(http.StatusOK) +} + +// Accept an Acceptance for an Event/Application +// +// @Summary Accept an acceptance after being accepted to an event. +// @Description Sets application status from accepted to rejected +// @Tags Application Event +// +// @Param eventId path string true "ID of the event to join the waitlist for" +// @Success 200 "Acceptance withdrawn joined successfully" +// @Failure 400 {object} res.ErrorResponse "Bad request: invalid event ID" +// @Failure 500 {object} res.ErrorResponse "Server error: failed to accept" +// @Router /events/{eventId}/application/accept-acceptance [patch] +func (h *ApplicationHandler) AcceptApplicationAcceptance(w http.ResponseWriter, r *http.Request) { + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid.")) + return + } + userId := ctxutils.GetUserIdFromCtx(r.Context()) + + err = h.appService.AcceptApplicationAcceptance(r.Context(), *userId, eventId) + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("accept-acceptance-error", "Something went wrong while accepting acceptance")) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/apps/api/internal/api/handlers/bat.go b/apps/api/internal/api/handlers/bat.go new file mode 100644 index 00000000..c699d16b --- /dev/null +++ b/apps/api/internal/api/handlers/bat.go @@ -0,0 +1,153 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/rs/zerolog" + res "github.com/swamphacks/core/apps/api/internal/api/response" + "github.com/swamphacks/core/apps/api/internal/services" +) + +type BatHandler struct { + BatService *services.BatService + logger zerolog.Logger +} + +func NewBatHandler(BatService *services.BatService, logger zerolog.Logger) *BatHandler { + return &BatHandler{ + BatService: BatService, + logger: logger.With().Str("handler", "BatRunHandler").Str("component", "event_interest").Logger(), + } +} + +// Get BatRuns +// +// @Summary Get BatRuns +// @Description Gets BatRuns. +// @Tags Bat +// @Accept json +// @Produce json +// @Success 200 {array} sqlc.GetBatRunsWithUserInfoRow "OK: BatRuns returned" +// @Router /events/{eventId}/bat-runs [get] +func (h *BatHandler) GetRunsByEventId(w http.ResponseWriter, r *http.Request) { + eventIdStr := chi.URLParam(r, "eventId") + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + eventId, err := uuid.Parse(eventIdStr) + + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID")) + return + } + + runs, err := h.BatService.GetRunsByEventId(r.Context(), eventId) + if errors.Is(err, services.ErrMissingFields) { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_fields", "Missing/malformed query. Available parameters: eventId")) + return + } + + if errors.Is(err, services.ErrMissingPerms) { + res.SendError(w, http.StatusForbidden, res.NewError("forbidden", "You are forbidden from this resource.")) + return + } + + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong encoding response")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(runs); err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong encoding response")) + return + } +} + +// Check application reviews complete +// +// @Summary Check if application reviews complete +// @Description Check if application reviews complete +// @Tags Bat +// @Accept json +// @Produce json +// @Param eventId path string true "Event ID" Format(uuid) +// @Success 200 "OK" +// @Failure 500 {object} response.ErrorResponse "Server Error: Something went terribly wrong on our end." +// @Router /events/{eventId}/review-status [get] +func (h *BatHandler) CheckApplicationReviewsComplete(w http.ResponseWriter, r *http.Request) { + eventIdStr := chi.URLParam(r, "eventId") + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + eventId, err := uuid.Parse(eventIdStr) + + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID")) + return + } + + reviewsComplete, err := h.BatService.CheckApplicationReviewsComplete(r.Context(), eventId) + if errors.Is(err, services.ErrMissingFields) { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_fields", "Missing/malformed query. Available parameters: eventId")) + return + } + + if errors.Is(err, services.ErrMissingPerms) { + res.SendError(w, http.StatusForbidden, res.NewError("forbidden", "You are forbidden from this resource.")) + return + } + + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong encoding response")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(reviewsComplete); err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong encoding response")) + return + } +} + +// Delete an event +// +// @Summary Delete an event +// @Description Delete an existing event +// @Tags Bat +// @Accept json +// @Produce json +// @Param eventId path string true "Run ID" Format(uuid) +// @Success 204 "OK - Run deleted" +// @Failure 500 {object} response.ErrorResponse "Server Error: Something went terribly wrong on our end." +// @Router /events/{eventId}/bat-runs [delete] +func (h *BatHandler) DeleteRunById(w http.ResponseWriter, r *http.Request) { + eventIdStr := chi.URLParam(r, "eventId") + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID")) + return + } + err = h.BatService.DeleteRunById(r.Context(), eventId) + + if err != nil { + switch err { + case services.ErrFailedToDeleteRun: + res.SendError(w, http.StatusInternalServerError, res.NewError("delete_error", "Failed to delete event")) + default: + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong")) + } + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/apps/api/internal/api/handlers/handlers.go b/apps/api/internal/api/handlers/handlers.go index 86f77699..c9578829 100644 --- a/apps/api/internal/api/handlers/handlers.go +++ b/apps/api/internal/api/handlers/handlers.go @@ -14,6 +14,8 @@ type Handlers struct { Email *EmailHandler Application *ApplicationHandler Teams *TeamHandler + Admission *AdmissionHandler + Bat *BatHandler } func NewHandlers( @@ -24,6 +26,7 @@ func NewHandlers( emailService *services.EmailService, appService *services.ApplicationService, teamService *services.TeamService, + batService *services.BatService, cfg *config.Config, logger zerolog.Logger, ) *Handlers { @@ -35,5 +38,7 @@ func NewHandlers( Email: NewEmailHandler(emailService, logger), Application: NewApplicationHandler(appService), Teams: NewTeamHandler(teamService, logger), + Admission: NewAdmissionHandler(batService, logger), + Bat: NewBatHandler(batService, logger), } } diff --git a/apps/api/internal/bat/engine.go b/apps/api/internal/bat/engine.go new file mode 100644 index 00000000..dcf29dbe --- /dev/null +++ b/apps/api/internal/bat/engine.go @@ -0,0 +1,421 @@ +package bat + +import ( + "errors" + "math" + "math/rand" + "sort" + "time" + + "github.com/google/uuid" +) + +var ( + ErrImproperWeights = errors.New("Passion and experience weights don't add to 1.0") + ErrScoreOutOfBounds = errors.New("The score can only range from 1 to 5") +) + +type BucketType int + +const ( + BucketTypeUFEarly BucketType = iota + BucketTypeUFLate + BucketTypeOtherEarly + BucketTypeOtherLate +) + +type BucketConfig struct { + Type BucketType + QuotaPtr *int32 + RolloverPtr *int32 +} + +type AdmissionCandidate struct { + UserID uuid.UUID + TeamID uuid.NullUUID + WeightedScore float64 + SortKey float64 + IsUFStudent bool + IsEarlyCareer bool +} + +// TeamEvaluationData holds the aggregated metrics for a group of applicants +// being considered as a single unit in the admissions process. +type TeamEvaluationData struct { + // TeamID is the unique identifier for the team. + TeamID uuid.UUID + + // Members is the list of all applicants belonging to this team. + Members []AdmissionCandidate + + // AverageWeightedScore is the average WeightedScore of all team members. + AverageWeightedScore float64 + + // SortKey is the non-deterministic key used for ranking teams against each other. + SortKey float64 +} + +type AdmissionContext struct { + School string `json:"school"` + Year string `json:"year"` +} + +type CategoryQuota struct { + // EarlyLeft is the number of remaining slots for Early Career applicants (Freshman/Sophomore). + EarlyLeft int32 + + // LateLeft is the number of remaining slots for Upper Career applicants (Junior/Senior/Grad). + LateLeft int32 +} + +// QuotaState tracks the remaining admission slots across all categories +// and for specific groups, ensuring the total capacity and category-specific +// limits are not exceeded during the admission process. +type QuotaState struct { + // TotalAccepted is the running count of applicants already admitted. + // This value is primarily informational and doesn't limit acceptance. + TotalAccepted int32 + + // TeamSlotsLeft is the maximum number of remaining *individual* applicants + // that can be admitted as part of a team (before solo admissions). + TeamSlotsLeft int32 + + // UF holds the remaining quota slots for applicants who are University of Florida students, + // categorized by career stage (Early vs. Late). + UF CategoryQuota + + // Other holds the remaining quota slots for applicants who are NOT University of Florida students, + // categorized by career stage (Early vs. Late). + Other CategoryQuota +} + +type BatEngine struct { + passionWeight float64 + experienceWeight float64 + weightedBaseConstant float64 + Quota QuotaState +} + +func NewBatEngine(passionW, experienceW float64) (*BatEngine, error) { + if !equalWithinTolerance(passionW+experienceW, 1.0, 1e-9) { + return nil, ErrImproperWeights + } + + // Allow this to be set outside at some point + quota := QuotaState{ + TotalAccepted: 0, + TeamSlotsLeft: 50, + UF: CategoryQuota{ + EarlyLeft: 210, + LateLeft: 140, + }, + Other: CategoryQuota{ + EarlyLeft: 90, + LateLeft: 60, + }, + } + + return &BatEngine{ + passionWeight: passionW, + experienceWeight: experienceW, + weightedBaseConstant: 0.1, + Quota: quota, + }, nil +} + +func (b *BatEngine) CalculateWeightedScore(passionS, expS int32) (float64, error) { + if 5 < passionS || 0 > passionS { + return 0.0, ErrScoreOutOfBounds + } + + if 5 < expS || 0 > expS { + return 0.0, ErrScoreOutOfBounds + } + + return (float64(passionS) * b.passionWeight) + (float64(expS) * b.experienceWeight) + b.weightedBaseConstant, nil +} + +func (b *BatEngine) GroupCandidates(admissionsData []AdmissionCandidate) ([]TeamEvaluationData, []AdmissionCandidate) { + teamMap := make(map[uuid.UUID][]AdmissionCandidate) + individualCandidates := make([]AdmissionCandidate, 0) + + // Sort admissions candidates on a valid TeamID + for _, app := range admissionsData { + if app.TeamID.Valid { + teamMap[app.TeamID.UUID] = append(teamMap[app.TeamID.UUID], app) + } else { + individualCandidates = append(individualCandidates, app) + } + } + + teamsEvalData := make([]TeamEvaluationData, 0) + for teamId, members := range teamMap { + var totalScore float64 + for _, member := range members { + totalScore += member.WeightedScore + } + + teamsEvalData = append(teamsEvalData, TeamEvaluationData{ + TeamID: teamId, + Members: members, + AverageWeightedScore: totalScore / float64(len(members)), + }) + } + + return teamsEvalData, individualCandidates +} + +func (b *BatEngine) AcceptIndividuals(idvs []AdmissionCandidate) ([]AdmissionCandidate, []AdmissionCandidate) { + accepted := make([]AdmissionCandidate, 0) + + pools := groupCandidateByType(idvs) + + buckets := []BucketConfig{ + { + Type: BucketTypeUFEarly, + QuotaPtr: &b.Quota.UF.EarlyLeft, + RolloverPtr: &b.Quota.UF.LateLeft, + }, + { + Type: BucketTypeUFLate, + QuotaPtr: &b.Quota.UF.LateLeft, + RolloverPtr: &b.Quota.UF.EarlyLeft, + }, + { + Type: BucketTypeOtherEarly, + QuotaPtr: &b.Quota.Other.EarlyLeft, + RolloverPtr: &b.Quota.Other.LateLeft, + }, + { + Type: BucketTypeOtherLate, + QuotaPtr: &b.Quota.Other.LateLeft, + RolloverPtr: &b.Quota.Other.EarlyLeft, + }, + } + + // Two pass is the minimum we need to ensure convergence + // on the rollover quotas. As of right now we do *not* support + // rollover between non-leaf nodes/conditions. + for range 2 { + for _, bucket := range buckets { + candidates := pools[bucket.Type] + if len(candidates) == 0 { + if bucket.RolloverPtr != nil { + *bucket.RolloverPtr += *bucket.QuotaPtr + *bucket.QuotaPtr = 0 + } + continue + } + + b.ApplyIndividualSortKey(candidates) + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].SortKey > candidates[j].SortKey + }) + + remainingQuota := *bucket.QuotaPtr + countToAccept := min(int(remainingQuota), len(candidates)) + + for i := range countToAccept { + candidate := candidates[i] + accepted = append(accepted, candidate) + + *bucket.QuotaPtr-- + b.Quota.TotalAccepted++ + } + + // Rollover leftover slots to nearest neighbor condition + if bucket.QuotaPtr != nil && *bucket.QuotaPtr > 0 { + *bucket.RolloverPtr += *bucket.QuotaPtr + *bucket.QuotaPtr = 0 + } + + // Removed accepted candidates (topK) from pool + pools[bucket.Type] = candidates[countToAccept:] + } + } + + rejected := make([]AdmissionCandidate, 0) + for _, remaining := range pools { + rejected = append(rejected, remaining...) + } + + return accepted, rejected +} + +func groupCandidateByType(idvs []AdmissionCandidate) map[BucketType][]AdmissionCandidate { + m := make(map[BucketType][]AdmissionCandidate) + for _, idv := range idvs { + t := determineCandidateBucketType(idv) + + m[t] = append(m[t], idv) + } + + return m +} + +func determineCandidateBucketType(idv AdmissionCandidate) BucketType { + if idv.IsEarlyCareer && idv.IsUFStudent { + return BucketTypeUFEarly + } else if !idv.IsEarlyCareer && idv.IsUFStudent { + return BucketTypeUFLate + } else if idv.IsEarlyCareer && !idv.IsUFStudent { + return BucketTypeOtherEarly + } else { + return BucketTypeOtherLate + } +} + +func (b *BatEngine) ScoreTeams(teams []TeamEvaluationData) { + for i := range teams { + team := &teams[i] + + var totalScore float64 + for _, member := range team.Members { + totalScore += member.WeightedScore + } + + team.AverageWeightedScore = totalScore / float64(len(team.Members)) + } +} + +func (b *BatEngine) ApplyIndividualSortKey(idv []AdmissionCandidate) { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + for i := range idv { + indie := &idv[i] + + indie.SortKey = generateSortKey(r, indie.WeightedScore) + } +} + +func (b *BatEngine) ApplyTeamSortKey(teams []TeamEvaluationData) { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + for i := range teams { + team := &teams[i] + + team.SortKey = generateSortKey(r, team.AverageWeightedScore) + } + + // Sort my descending for top-k results + sort.Slice(teams, func(i, j int) bool { + return teams[i].SortKey > teams[j].SortKey + }) +} + +func (b *BatEngine) AcceptTeams(teams []TeamEvaluationData) ([]AdmissionCandidate, []AdmissionCandidate) { + accepted := make([]AdmissionCandidate, 0) + remaining := make([]AdmissionCandidate, 0) + + b.ScoreTeams(teams) + b.ApplyTeamSortKey(teams) + + for _, team := range teams { + if b.Quota.TeamSlotsLeft <= int32(len(team.Members)) { + remaining = append(remaining, team.Members...) + continue + } + + requiredQuota := getTeamQuotaRequirement(&team) + + if b.canAcceptTeam(requiredQuota) { + accepted = append(accepted, team.Members...) + size := int32(len(team.Members)) + + b.Quota.TotalAccepted += size + b.Quota.TeamSlotsLeft -= size + + // Adjust primary quota buckets + b.Quota.UF.EarlyLeft -= requiredQuota.UF.EarlyLeft + b.Quota.UF.LateLeft -= requiredQuota.UF.LateLeft + b.Quota.Other.EarlyLeft -= requiredQuota.Other.EarlyLeft + b.Quota.Other.LateLeft -= requiredQuota.Other.LateLeft + } else { + remaining = append(remaining, team.Members...) + } + + } + + return accepted, remaining +} + +func getTeamQuotaRequirement(team *TeamEvaluationData) QuotaState { + reqQuota := QuotaState{ + TotalAccepted: 0, + TeamSlotsLeft: 0, + UF: CategoryQuota{ + EarlyLeft: 0, + LateLeft: 0, + }, + Other: CategoryQuota{ + EarlyLeft: 0, + LateLeft: 0, + }, + } + + for _, member := range team.Members { + if member.IsUFStudent { + if member.IsEarlyCareer { + reqQuota.UF.EarlyLeft += 1 + } else { + reqQuota.UF.LateLeft += 1 + } + } else { + if member.IsEarlyCareer { + reqQuota.Other.EarlyLeft += 1 + } else { + reqQuota.Other.LateLeft += 1 + } + } + + reqQuota.TotalAccepted += 1 + reqQuota.TeamSlotsLeft += 1 + } + + return reqQuota +} + +// Checks whether the current quota can fullfill the required +// quota calculated in getTeamQuotaRequirement +func (b BatEngine) canAcceptTeam(req QuotaState) bool { + return req.UF.EarlyLeft <= b.Quota.UF.EarlyLeft && + req.UF.LateLeft <= b.Quota.UF.LateLeft && + req.Other.EarlyLeft <= b.Quota.Other.EarlyLeft && + req.Other.LateLeft <= b.Quota.Other.LateLeft +} + +// generateSortKey generates a priority key for weighted random sampling. +// +// This follows the Efraimidis–Spirakis algorithm for weighted sampling without +// replacement. For an item with weight w, a uniform random value v ∈ (0,1) is +// drawn and transformed as v^(1/w). Sorting items by this key and selecting +// the top-k yields a sample where selection probability is proportional to w. +// +// Source: https://www.sciencedirect.com/science/article/abs/pii/S002001900500298X +func generateSortKey(rand *rand.Rand, weight float64) float64 { + v := rand.Float64() + exp := 1.0 / (weight) + + return math.Pow(v, exp) +} + +// func (b *BatEngine) GroupAndScoreAppli + +// equalWithinTolerance checks if two float64 values are equal within a given tolerance. +// It handles exact equality, zero values, and relative differences. We recommend you +// set the tolerance to 1e-9. +// +// TL;DR Compares a and b with a tolerance of e. +func equalWithinTolerance(a, b, e float64) bool { + if a == b { + return true + } + + d := math.Abs(a - b) + + if b == 0 { + return d < e + } + + return (d / math.Abs(b)) < e +} diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index a0d1a310..e1efc8e4 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -54,11 +54,12 @@ type CoreBuckets struct { } type Config struct { - DatabaseURL string `env:"DATABASE_URL"` - RedisURL string `env:"REDIS_URL"` - Port string `env:"PORT" envDefault:"8080"` - AllowedOriginsString string `env:"ALLOWED_ORIGINS"` - AllowedOrigins []string `` + DatabaseURL string `env:"DATABASE_URL"` + RedisURL string `env:"REDIS_URL"` + Port string `env:"PORT" envDefault:"8080"` + AllowedOriginsString string `env:"ALLOWED_ORIGINS"` + EmailTemplateDirectory string `env:"EMAIL_TEMPLATE_DIRECTORY"` + AllowedOrigins []string `` Auth AuthConfig `envPrefix:"AUTH_"` Cookie CookieConfig `envPrefix:"COOKIE_"` diff --git a/apps/api/internal/db/migrations/20251208221807_add_application_waitlist_time_column.sql b/apps/api/internal/db/migrations/20251208221807_add_application_waitlist_time_column.sql new file mode 100644 index 00000000..cc2713f0 --- /dev/null +++ b/apps/api/internal/db/migrations/20251208221807_add_application_waitlist_time_column.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE applications +ADD COLUMN waitlist_join_time TIMESTAMPTZ; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE applications +DROP COLUMN waitlist_join_time; +-- +goose StatementEnd diff --git a/apps/api/internal/db/migrations/20251215225937_add_bat_runs_schema.sql b/apps/api/internal/db/migrations/20251215225937_add_bat_runs_schema.sql new file mode 100644 index 00000000..da31c9c1 --- /dev/null +++ b/apps/api/internal/db/migrations/20251215225937_add_bat_runs_schema.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TYPE bat_run_status AS ENUM ('running','completed','failed'); + +CREATE TABLE bat_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + accepted_applicants UUID[] DEFAULT '{}', + rejected_applicants UUID[] DEFAULT '{}', + status bat_run_status DEFAULT 'running', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS bat_runs; +DROP TYPE IF EXISTS bat_run_status; +-- +goose StatementEnd + diff --git a/apps/api/internal/db/migrations/20251216200020_add_application_review_finished.sql b/apps/api/internal/db/migrations/20251216200020_add_application_review_finished.sql new file mode 100644 index 00000000..5a48ea40 --- /dev/null +++ b/apps/api/internal/db/migrations/20251216200020_add_application_review_finished.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE events +ADD COLUMN application_review_finished BOOLEAN NOT NULL DEFAULT FALSE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE events +DROP COLUMN application_review_finished; +-- +goose StatementEnd diff --git a/apps/api/internal/db/migrations/20251217065809_remove_application_review_finished_column.sql b/apps/api/internal/db/migrations/20251217065809_remove_application_review_finished_column.sql new file mode 100644 index 00000000..16f75232 --- /dev/null +++ b/apps/api/internal/db/migrations/20251217065809_remove_application_review_finished_column.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE events +DROP COLUMN application_review_finished +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE events +ADD COLUMN application_review_finished BOOLEAN NOT NULL DEFAULT FALSE; +-- +goose StatementEnd diff --git a/apps/api/internal/db/queries/applications.sql b/apps/api/internal/db/queries/applications.sql index a8237fa1..943c0d9e 100644 --- a/apps/api/internal/db/queries/applications.sql +++ b/apps/api/internal/db/queries/applications.sql @@ -39,6 +39,24 @@ WHERE event_id = $1 ORDER BY user_id ASC; +-- name: ListAdmissionCandidatesByEvent :many +SELECT a.user_id, + a.passion_rating, + a.experience_rating, + a.application, + t.id as team_id +FROM applications a +LEFT JOIN team_members tm + ON tm.user_id = a.user_id +LEFT JOIN teams t + ON t.id = tm.team_id + AND t.event_id = a.event_id +WHERE a.event_id = $1 + AND a.status = 'under_review' + AND a.passion_rating IS NOT NULL + AND a.experience_rating IS NOT NULL; + + -- name: AssignApplicationsToReviewer :exec UPDATE applications SET assigned_reviewer_id = @reviewer_id::uuid, @@ -60,4 +78,23 @@ SELECT user_id, passion_rating, experience_rating FROM applications WHERE assigned_reviewer_id = $1 AND event_id = $2 AND status IN ('under_review') -ORDER BY user_id ASC; \ No newline at end of file +ORDER BY user_id ASC; + +-- name: ListNonReviewedApplicationsByEvent :many +SELECT user_id +FROM applications +WHERE event_id = $1 + AND status = 'under_review' + AND (passion_rating IS NULL OR experience_rating IS NULL); + +-- name: JoinWaitlist :exec +UPDATE applications +SET waitlist_join_time = COALESCE(waitlist_join_time, NOW()), + status = 'waitlisted' +WHERE user_id = $1 AND event_id = $2; + +-- name: UpdateApplicationStatusByEventID :exec +UPDATE applications +SET status = @status::application_status +WHERE event_id = @event_id::uuid + AND user_id = ANY(@user_ids::uuid[]); diff --git a/apps/api/internal/db/queries/bat_runs.sql b/apps/api/internal/db/queries/bat_runs.sql new file mode 100644 index 00000000..1e2dd105 --- /dev/null +++ b/apps/api/internal/db/queries/bat_runs.sql @@ -0,0 +1,38 @@ +-- name: AddRun :one +INSERT INTO bat_runs ( + event_id +) VALUES ( + $1 +) RETURNING *; + +-- name: GetRunById :one +SELECT * +FROM bat_runs +WHERE id = $1; + +-- name: GetRunsByEventId :many +SELECT + id, + accepted_applicants, + rejected_applicants, + status, + created_at, + completed_at +FROM bat_runs +WHERE event_id = $1 +ORDER BY created_at DESC; + +-- name: UpdateRunById :exec +UPDATE bat_runs +SET + accepted_applicants = CASE WHEN @accepted_applicants_do_update::boolean THEN @accepted_applicants ELSE accepted_applicants END, + rejected_applicants = CASE WHEN @rejected_applicants_do_update::boolean THEN @rejected_applicants ELSE rejected_applicants END, + status = CASE WHEN @status_do_update::boolean THEN @status ELSE status END, + created_at = CASE WHEN @created_at_do_update::boolean THEN @created_at ELSE created_at END +WHERE + id = @id::uuid +RETURNING *; + +-- name: DeleteRunById :execrows +DELETE FROM bat_runs +WHERE id = $1; diff --git a/apps/api/internal/db/queries/event_roles.sql b/apps/api/internal/db/queries/event_roles.sql index aafa57ae..82e76ad2 100644 --- a/apps/api/internal/db/queries/event_roles.sql +++ b/apps/api/internal/db/queries/event_roles.sql @@ -19,4 +19,9 @@ WHERE event_id = $1 SELECT u.*, er.role AS event_role FROM auth.users u JOIN event_roles er ON u.id = er.user_id -WHERE er.event_id = $1; \ No newline at end of file +WHERE er.event_id = $1; + +-- name: UpdateRole :exec +UPDATE event_roles +SET role = $3 +WHERE event_id = $1 AND user_id = $2; \ No newline at end of file diff --git a/apps/api/internal/db/queries/users.sql b/apps/api/internal/db/queries/users.sql index f4c0e43a..cd9c7337 100644 --- a/apps/api/internal/db/queries/users.sql +++ b/apps/api/internal/db/queries/users.sql @@ -11,6 +11,18 @@ WHERE id = $1; SELECT * FROM auth.users WHERE email = $1; +-- name: GetUserEmailInfoById :one +SELECT + id, + name, + email_consent, + CASE + WHEN preferred_email IS NOT NULL AND preferred_email != '' THEN preferred_email + ELSE email + END AS contact_email +FROM auth.users +WHERE id = $1; + -- name: UpdateUserOnboarded :exec UPDATE auth.users SET onboarded = TRUE diff --git a/apps/api/internal/db/repository/application.go b/apps/api/internal/db/repository/application.go index 4daf1e9c..5b2a868c 100644 --- a/apps/api/internal/db/repository/application.go +++ b/apps/api/internal/db/repository/application.go @@ -66,6 +66,21 @@ func (r *ApplicationRepository) GetApplicationByUserAndEventID(ctx context.Conte return &application, nil } +func (r *ApplicationRepository) UpdateApplicationStatusByEventId(ctx context.Context, status sqlc.ApplicationStatus, eventId uuid.UUID, userIds uuid.UUIDs) error { + return r.db.Query.UpdateApplicationStatusByEventID(ctx, sqlc.UpdateApplicationStatusByEventIDParams{ + EventID: eventId, + Status: status, + UserIds: userIds, + }) +} + +// List all candidates considered for admission for an eventId. +// This queries for all applications who are 'under_review' and have their rating fields filled out. +// It also LEFT JOINs in their team id (if they have one) for further grouping based on teams. +func (r *ApplicationRepository) ListAdmissionCandidatesByEvent(ctx context.Context, eventId uuid.UUID) ([]sqlc.ListAdmissionCandidatesByEventRow, error) { + return r.db.Query.ListAdmissionCandidatesByEvent(ctx, eventId) +} + func (r *ApplicationRepository) SubmitApplication(ctx context.Context, data any, userId, eventId uuid.UUID) error { jsonBytes, err := json.Marshal(data) @@ -170,6 +185,17 @@ func (r *ApplicationRepository) GetApplicationStatuses(ctx context.Context, even return r.db.Query.GetApplicationStatusSplit(ctx, eventId) } +func (r *ApplicationRepository) GetNonReviewedApplications(ctx context.Context, eventId uuid.UUID) ([]uuid.UUID, error) { + return r.db.Query.ListNonReviewedApplicationsByEvent(ctx, eventId) +} + func (r *ApplicationRepository) GetSubmissionTimes(ctx context.Context, eventId uuid.UUID) ([]sqlc.GetSubmissionTimesRow, error) { return r.db.Query.GetSubmissionTimes(ctx, eventId) } + +func (r *ApplicationRepository) JoinWaitlist(ctx context.Context, userId, eventId uuid.UUID) error { + return r.db.Query.JoinWaitlist(ctx, sqlc.JoinWaitlistParams{ + UserID: userId, + EventID: eventId, + }) +} diff --git a/apps/api/internal/db/repository/bat_runs.go b/apps/api/internal/db/repository/bat_runs.go new file mode 100644 index 00000000..398cc804 --- /dev/null +++ b/apps/api/internal/db/repository/bat_runs.go @@ -0,0 +1,74 @@ +package repository + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/swamphacks/core/apps/api/internal/db" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) + +var ( + ErrDuplicateRun = errors.New("Run already exists in the database") + ErrRunNotFound = errors.New("Run not found") + ErrNoRunsDeleted = errors.New("No Runs deleted") + ErrMultipleRunsDeleted = errors.New("Multiple Runs affected by delete query expecting to delete one") +) + +type BatRunsRepository struct { + db *db.DB +} + +func NewBatRunsRepository(db *db.DB) *BatRunsRepository { + return &BatRunsRepository{ + db: db, + } +} + +func (r *BatRunsRepository) AddRun(ctx context.Context, eventId uuid.UUID) (*sqlc.BatRun, error) { + run, err := r.db.Query.AddRun(ctx, eventId) + if err != nil { + if db.IsUniqueViolation(err) { + return nil, ErrDuplicateRun + } + return nil, err + } + return &run, nil +} + +func (r *BatRunsRepository) GetRunById(ctx context.Context, id uuid.UUID) (sqlc.BatRun, error) { + return r.db.Query.GetRunById(ctx, id) +} + +func (r *BatRunsRepository) GetRunsByEventId(ctx context.Context, eventId uuid.UUID) (*[]sqlc.GetRunsByEventIdRow, error) { + runs, err := r.db.Query.GetRunsByEventId(ctx, eventId) + return &runs, err +} + +func (r *BatRunsRepository) UpdateRunById(ctx context.Context, params sqlc.UpdateRunByIdParams) error { + err := r.db.Query.UpdateRunById(ctx, params) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrEventNotFound + } + } + return err +} + +func (r *BatRunsRepository) DeleteRunById(ctx context.Context, id uuid.UUID) error { + affectedRows, err := r.db.Query.DeleteRunById(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrRunNotFound + } + } + if affectedRows == 0 { + return ErrNoRunsDeleted + } else if affectedRows > 1 { + return ErrMultipleRunsDeleted + } + + return err +} diff --git a/apps/api/internal/db/repository/events.go b/apps/api/internal/db/repository/events.go index 77c32075..99545661 100644 --- a/apps/api/internal/db/repository/events.go +++ b/apps/api/internal/db/repository/events.go @@ -143,6 +143,15 @@ func (r *EventRepository) RevokeRole(ctx context.Context, userId uuid.UUID, even return r.db.Query.RemoveRole(ctx, params) } +func (r *EventRepository) UpdateRole(ctx context.Context, userId uuid.UUID, eventId uuid.UUID, role sqlc.EventRoleType) error { + params := sqlc.UpdateRoleParams{ + UserID: userId, + EventID: eventId, + Role: role, + } + return r.db.Query.UpdateRole(ctx, params) +} + func (r *EventRepository) GetApplicationStatuses(ctx context.Context, eventId uuid.UUID) (sqlc.GetApplicationStatusSplitRow, error) { return r.db.Query.GetApplicationStatusSplit(ctx, eventId) } diff --git a/apps/api/internal/db/repository/users.go b/apps/api/internal/db/repository/users.go index 5bb1ffca..75ba3aac 100644 --- a/apps/api/internal/db/repository/users.go +++ b/apps/api/internal/db/repository/users.go @@ -65,6 +65,17 @@ func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*sqlc.Au return &user, nil } +func (r *UserRepository) GetUserEmailInfoById(ctx context.Context, id uuid.UUID) (*sqlc.GetUserEmailInfoByIdRow, error) { + row, err := r.db.Query.GetUserEmailInfoById(ctx, id) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } else if err != nil { + return nil, err + } + + return &row, nil +} + func (r *UserRepository) UpdateUser(ctx context.Context, params sqlc.UpdateUserParams) error { err := r.db.Query.UpdateUser(ctx, params) if err != nil { diff --git a/apps/api/internal/db/sqlc/applications.sql.go b/apps/api/internal/db/sqlc/applications.sql.go index e9f9051d..182face8 100644 --- a/apps/api/internal/db/sqlc/applications.sql.go +++ b/apps/api/internal/db/sqlc/applications.sql.go @@ -37,7 +37,7 @@ INSERT INTO applications ( ) VALUES ( $1, $2 ) -RETURNING user_id, event_id, status, application, created_at, saved_at, updated_at, submitted_at, experience_rating, passion_rating, assigned_reviewer_id +RETURNING user_id, event_id, status, application, created_at, saved_at, updated_at, submitted_at, experience_rating, passion_rating, assigned_reviewer_id, waitlist_join_time ` type CreateApplicationParams struct { @@ -60,6 +60,7 @@ func (q *Queries) CreateApplication(ctx context.Context, arg CreateApplicationPa &i.ExperienceRating, &i.PassionRating, &i.AssignedReviewerID, + &i.WaitlistJoinTime, ) return i, err } @@ -80,7 +81,7 @@ func (q *Queries) DeleteApplication(ctx context.Context, arg DeleteApplicationPa } const getApplicationByUserAndEventID = `-- name: GetApplicationByUserAndEventID :one -SELECT user_id, event_id, status, application, created_at, saved_at, updated_at, submitted_at, experience_rating, passion_rating, assigned_reviewer_id FROM applications +SELECT user_id, event_id, status, application, created_at, saved_at, updated_at, submitted_at, experience_rating, passion_rating, assigned_reviewer_id, waitlist_join_time FROM applications WHERE user_id = $1 AND event_id = $2 ` @@ -104,10 +105,80 @@ func (q *Queries) GetApplicationByUserAndEventID(ctx context.Context, arg GetApp &i.ExperienceRating, &i.PassionRating, &i.AssignedReviewerID, + &i.WaitlistJoinTime, ) return i, err } +const joinWaitlist = `-- name: JoinWaitlist :exec +UPDATE applications +SET waitlist_join_time = COALESCE(waitlist_join_time, NOW()), + status = 'waitlisted' +WHERE user_id = $1 AND event_id = $2 +` + +type JoinWaitlistParams struct { + UserID uuid.UUID `json:"user_id"` + EventID uuid.UUID `json:"event_id"` +} + +func (q *Queries) JoinWaitlist(ctx context.Context, arg JoinWaitlistParams) error { + _, err := q.db.Exec(ctx, joinWaitlist, arg.UserID, arg.EventID) + return err +} + +const listAdmissionCandidatesByEvent = `-- name: ListAdmissionCandidatesByEvent :many +SELECT a.user_id, + a.passion_rating, + a.experience_rating, + a.application, + t.id as team_id +FROM applications a +LEFT JOIN team_members tm + ON tm.user_id = a.user_id +LEFT JOIN teams t + ON t.id = tm.team_id + AND t.event_id = a.event_id +WHERE a.event_id = $1 + AND a.status = 'under_review' + AND a.passion_rating IS NOT NULL + AND a.experience_rating IS NOT NULL +` + +type ListAdmissionCandidatesByEventRow struct { + UserID uuid.UUID `json:"user_id"` + PassionRating *int32 `json:"passion_rating"` + ExperienceRating *int32 `json:"experience_rating"` + Application []byte `json:"application"` + TeamID *uuid.UUID `json:"team_id"` +} + +func (q *Queries) ListAdmissionCandidatesByEvent(ctx context.Context, eventID uuid.UUID) ([]ListAdmissionCandidatesByEventRow, error) { + rows, err := q.db.Query(ctx, listAdmissionCandidatesByEvent, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListAdmissionCandidatesByEventRow{} + for rows.Next() { + var i ListAdmissionCandidatesByEventRow + if err := rows.Scan( + &i.UserID, + &i.PassionRating, + &i.ExperienceRating, + &i.Application, + &i.TeamID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listApplicationByReviewerAndEvent = `-- name: ListApplicationByReviewerAndEvent :many SELECT user_id, passion_rating, experience_rating FROM applications WHERE assigned_reviewer_id = $1 @@ -180,6 +251,34 @@ func (q *Queries) ListAvailableApplicationsForEvent(ctx context.Context, eventID return items, nil } +const listNonReviewedApplicationsByEvent = `-- name: ListNonReviewedApplicationsByEvent :many +SELECT user_id +FROM applications +WHERE event_id = $1 + AND status = 'under_review' + AND (passion_rating IS NULL OR experience_rating IS NULL) +` + +func (q *Queries) ListNonReviewedApplicationsByEvent(ctx context.Context, eventID uuid.UUID) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, listNonReviewedApplicationsByEvent, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []uuid.UUID{} + for rows.Next() { + var user_id uuid.UUID + if err := rows.Scan(&user_id); err != nil { + return nil, err + } + items = append(items, user_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const resetApplicationReviews = `-- name: ResetApplicationReviews :exec UPDATE applications SET assigned_reviewer_id = NULL, @@ -249,3 +348,21 @@ func (q *Queries) UpdateApplication(ctx context.Context, arg UpdateApplicationPa ) return err } + +const updateApplicationStatusByEventID = `-- name: UpdateApplicationStatusByEventID :exec +UPDATE applications +SET status = $1::application_status +WHERE event_id = $2::uuid + AND user_id = ANY($3::uuid[]) +` + +type UpdateApplicationStatusByEventIDParams struct { + Status ApplicationStatus `json:"status"` + EventID uuid.UUID `json:"event_id"` + UserIds []uuid.UUID `json:"user_ids"` +} + +func (q *Queries) UpdateApplicationStatusByEventID(ctx context.Context, arg UpdateApplicationStatusByEventIDParams) error { + _, err := q.db.Exec(ctx, updateApplicationStatusByEventID, arg.Status, arg.EventID, arg.UserIds) + return err +} diff --git a/apps/api/internal/db/sqlc/bat_runs.sql.go b/apps/api/internal/db/sqlc/bat_runs.sql.go new file mode 100644 index 00000000..d43d44cb --- /dev/null +++ b/apps/api/internal/db/sqlc/bat_runs.sql.go @@ -0,0 +1,158 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: bat_runs.sql + +package sqlc + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const addRun = `-- name: AddRun :one +INSERT INTO bat_runs ( + event_id +) VALUES ( + $1 +) RETURNING id, event_id, accepted_applicants, rejected_applicants, status, created_at, completed_at +` + +func (q *Queries) AddRun(ctx context.Context, eventID uuid.UUID) (BatRun, error) { + row := q.db.QueryRow(ctx, addRun, eventID) + var i BatRun + err := row.Scan( + &i.ID, + &i.EventID, + &i.AcceptedApplicants, + &i.RejectedApplicants, + &i.Status, + &i.CreatedAt, + &i.CompletedAt, + ) + return i, err +} + +const deleteRunById = `-- name: DeleteRunById :execrows +DELETE FROM bat_runs +WHERE id = $1 +` + +func (q *Queries) DeleteRunById(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.Exec(ctx, deleteRunById, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getRunById = `-- name: GetRunById :one +SELECT id, event_id, accepted_applicants, rejected_applicants, status, created_at, completed_at +FROM bat_runs +WHERE id = $1 +` + +func (q *Queries) GetRunById(ctx context.Context, id uuid.UUID) (BatRun, error) { + row := q.db.QueryRow(ctx, getRunById, id) + var i BatRun + err := row.Scan( + &i.ID, + &i.EventID, + &i.AcceptedApplicants, + &i.RejectedApplicants, + &i.Status, + &i.CreatedAt, + &i.CompletedAt, + ) + return i, err +} + +const getRunsByEventId = `-- name: GetRunsByEventId :many +SELECT + id, + accepted_applicants, + rejected_applicants, + status, + created_at, + completed_at +FROM bat_runs +WHERE event_id = $1 +ORDER BY created_at DESC +` + +type GetRunsByEventIdRow struct { + ID uuid.UUID `json:"id"` + AcceptedApplicants []uuid.UUID `json:"accepted_applicants"` + RejectedApplicants []uuid.UUID `json:"rejected_applicants"` + Status NullBatRunStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at"` +} + +func (q *Queries) GetRunsByEventId(ctx context.Context, eventID uuid.UUID) ([]GetRunsByEventIdRow, error) { + rows, err := q.db.Query(ctx, getRunsByEventId, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetRunsByEventIdRow{} + for rows.Next() { + var i GetRunsByEventIdRow + if err := rows.Scan( + &i.ID, + &i.AcceptedApplicants, + &i.RejectedApplicants, + &i.Status, + &i.CreatedAt, + &i.CompletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateRunById = `-- name: UpdateRunById :exec +UPDATE bat_runs +SET + accepted_applicants = CASE WHEN $1::boolean THEN $2 ELSE accepted_applicants END, + rejected_applicants = CASE WHEN $3::boolean THEN $4 ELSE rejected_applicants END, + status = CASE WHEN $5::boolean THEN $6 ELSE status END, + created_at = CASE WHEN $7::boolean THEN $8 ELSE created_at END +WHERE + id = $9::uuid +RETURNING id, event_id, accepted_applicants, rejected_applicants, status, created_at, completed_at +` + +type UpdateRunByIdParams struct { + AcceptedApplicantsDoUpdate bool `json:"accepted_applicants_do_update"` + AcceptedApplicants []uuid.UUID `json:"accepted_applicants"` + RejectedApplicantsDoUpdate bool `json:"rejected_applicants_do_update"` + RejectedApplicants []uuid.UUID `json:"rejected_applicants"` + StatusDoUpdate bool `json:"status_do_update"` + Status NullBatRunStatus `json:"status"` + CreatedAtDoUpdate bool `json:"created_at_do_update"` + CreatedAt time.Time `json:"created_at"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdateRunById(ctx context.Context, arg UpdateRunByIdParams) error { + _, err := q.db.Exec(ctx, updateRunById, + arg.AcceptedApplicantsDoUpdate, + arg.AcceptedApplicants, + arg.RejectedApplicantsDoUpdate, + arg.RejectedApplicants, + arg.StatusDoUpdate, + arg.Status, + arg.CreatedAtDoUpdate, + arg.CreatedAt, + arg.ID, + ) + return err +} diff --git a/apps/api/internal/db/sqlc/event_roles.sql.go b/apps/api/internal/db/sqlc/event_roles.sql.go index 3a2b5aca..d7693126 100644 --- a/apps/api/internal/db/sqlc/event_roles.sql.go +++ b/apps/api/internal/db/sqlc/event_roles.sql.go @@ -155,3 +155,20 @@ func (q *Queries) RemoveRole(ctx context.Context, arg RemoveRoleParams) error { _, err := q.db.Exec(ctx, removeRole, arg.EventID, arg.UserID) return err } + +const updateRole = `-- name: UpdateRole :exec +UPDATE event_roles +SET role = $3 +WHERE event_id = $1 AND user_id = $2 +` + +type UpdateRoleParams struct { + EventID uuid.UUID `json:"event_id"` + UserID uuid.UUID `json:"user_id"` + Role EventRoleType `json:"role"` +} + +func (q *Queries) UpdateRole(ctx context.Context, arg UpdateRoleParams) error { + _, err := q.db.Exec(ctx, updateRole, arg.EventID, arg.UserID, arg.Role) + return err +} diff --git a/apps/api/internal/db/sqlc/models.go b/apps/api/internal/db/sqlc/models.go index 5cd4c35b..c4135181 100644 --- a/apps/api/internal/db/sqlc/models.go +++ b/apps/api/internal/db/sqlc/models.go @@ -101,6 +101,49 @@ func (ns NullAuthUserRole) Value() (driver.Value, error) { return string(ns.AuthUserRole), nil } +type BatRunStatus string + +const ( + BatRunStatusRunning BatRunStatus = "running" + BatRunStatusCompleted BatRunStatus = "completed" + BatRunStatusFailed BatRunStatus = "failed" +) + +func (e *BatRunStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = BatRunStatus(s) + case string: + *e = BatRunStatus(s) + default: + return fmt.Errorf("unsupported scan type for BatRunStatus: %T", src) + } + return nil +} + +type NullBatRunStatus struct { + BatRunStatus BatRunStatus `json:"bat_run_status"` + Valid bool `json:"valid"` // Valid is true if BatRunStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullBatRunStatus) Scan(value interface{}) error { + if value == nil { + ns.BatRunStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.BatRunStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullBatRunStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.BatRunStatus), nil +} + type EventRoleType string const ( @@ -287,6 +330,7 @@ type Application struct { ExperienceRating *int32 `json:"experience_rating"` PassionRating *int32 `json:"passion_rating"` AssignedReviewerID *uuid.UUID `json:"assigned_reviewer_id"` + WaitlistJoinTime *time.Time `json:"waitlist_join_time"` } type AuthAccount struct { @@ -330,6 +374,16 @@ type AuthUser struct { EmailConsent bool `json:"email_consent"` } +type BatRun struct { + ID uuid.UUID `json:"id"` + EventID uuid.UUID `json:"event_id"` + AcceptedApplicants []uuid.UUID `json:"accepted_applicants"` + RejectedApplicants []uuid.UUID `json:"rejected_applicants"` + Status NullBatRunStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at"` +} + type Event struct { ID uuid.UUID `json:"id"` Name string `json:"name"` diff --git a/apps/api/internal/db/sqlc/users.sql.go b/apps/api/internal/db/sqlc/users.sql.go index bead77ac..2798f1b5 100644 --- a/apps/api/internal/db/sqlc/users.sql.go +++ b/apps/api/internal/db/sqlc/users.sql.go @@ -100,6 +100,38 @@ func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (AuthUser, erro return i, err } +const getUserEmailInfoById = `-- name: GetUserEmailInfoById :one +SELECT + id, + name, + email_consent, + CASE + WHEN preferred_email IS NOT NULL AND preferred_email != '' THEN preferred_email + ELSE email + END AS contact_email +FROM auth.users +WHERE id = $1 +` + +type GetUserEmailInfoByIdRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + EmailConsent bool `json:"email_consent"` + ContactEmail interface{} `json:"contact_email"` +} + +func (q *Queries) GetUserEmailInfoById(ctx context.Context, id uuid.UUID) (GetUserEmailInfoByIdRow, error) { + row := q.db.QueryRow(ctx, getUserEmailInfoById, id) + var i GetUserEmailInfoByIdRow + err := row.Scan( + &i.ID, + &i.Name, + &i.EmailConsent, + &i.ContactEmail, + ) + return i, err +} + const getUsers = `-- name: GetUsers :many SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, role, preferred_email, email_consent FROM auth.users diff --git a/apps/api/internal/email/templates/ApplicationAcceptedEmail.html b/apps/api/internal/email/templates/ApplicationAcceptedEmail.html new file mode 100644 index 00000000..3191f4f3 --- /dev/null +++ b/apps/api/internal/email/templates/ApplicationAcceptedEmail.html @@ -0,0 +1,148 @@ + + + +
+ + +
+
|
+
+
|
+
+ Your application is{" "}
+
+ We couldn't accomodate all the applications this year. If you + are still interested, please join the waitlist!{" "} +
+
+ Your application is{" "}
+
+ Congratulations! We would love to see you at Swamphacks XI{" "} +
+
+ Your application is{" "}
+
+ Unfortunately, we weren't able to accomodate everybody who applied + this year. Join the waitlist, and we will let you know if a spot opens + up! +
++ Are you sure? This action cannot be undone. +
++ You can still join the waitlist after withdrawing. +
+Show applications decision page
+ ) : ( + <> + + > + )} +