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 @@ + + + + + + + Congratulations on being accepted to hack in SwampHacks XI! + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ SwampHacks XI Banner +
+

Hi {{ .Name }},

+
+

+ Congratulations on being accepted to hack in SwampHacks XI! +

+ + + +

+ Please confirm your attendance by January 2nd. Failure to do so means you are giving up your + spot, and + we will admit someone from a waitlist. +

+ +

+ To prepare for the event, please take the following next steps: +

+ +
    +
  • Find a team (you can also find open ones in the portal!)
  • +
  • Check out our tentative schedule of events below
  • +
+ +

Hackathon Location

+

+ SwampHacks XI is currently set to take place at Newell Hall, Little Hall, and Carleton + Auditorium on + the University of Florida campus. These buildings will host all events, hacking spaces, and + activities throughout the weekend. +

+ +

Tentative Outline of the Schedule:

+
    +
  • Friday, January 23rd +
      +
    • 5:30pm-6:30pm: Hacker Check-In +
        +
      • Note: Late Check-In will be available until 9pm, but you must complete a late form + which will be released the same day +
      • +
      +
    • +
    • 6:30pm-7:30pm: Opening Ceremony
    • +
    • 8pm: Hacking Begins
    • +
    • 9pm: Dinner
    • +
    • Evening Workshop/Socials
    • +
    +
  • +
  • Saturday, January 24th +
      +
    • 8am-9am: Breakfast
    • +
    • Morning Workshops/Socials
    • +
    • 12pm-2pm: The Company Connect
    • +
    • 2pm-3:30pm: Lunch
    • +
    • Afternoon Workshops/Socials
    • +
    • 8pm-9pm: Dinner
    • +
    • Evening Workshops/Socials
    • +
    +
  • +
  • Sunday, January 25th +
      +
    • 8am: Hacking Ends
    • +
    • 8:30am-10am: Breakfast
    • +
    • 11am-1pm: Project Expo (Judging)
    • +
    • 1:30pm-2:30pm: Lunch
    • +
    • 2:30pm-3:30pm: Closing Ceremony & Awards
    • +
    +
  • +
+

+ Please note that these are estimated times and might be shifted prior to or during the event. +

+ +

+ If you have any questions, reach out in our Discord server or email us at contact@swamphacks.com. +

+
+ + Discord + + + Instagram + + + LinkedIn + +
+
+ + + \ No newline at end of file diff --git a/apps/api/internal/email/templates/ApplicationRejectedEmail.html b/apps/api/internal/email/templates/ApplicationRejectedEmail.html new file mode 100644 index 00000000..9c96665c --- /dev/null +++ b/apps/api/internal/email/templates/ApplicationRejectedEmail.html @@ -0,0 +1,96 @@ + + + + + + + Update on Your Application to SwampHacks XI + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ SwampHacks XI Banner +
+

Hi {{ .Name }},

+
+

+ We sincerely appreciate your interest in SwampHacks XI and the time you took to apply. After careful + consideration, we are unable to accept you as a hacker at this time. +

+ +

+ However, we’d love to stay connected and invite you to get involved in other ways: +

+ +
    +
  1. Join the Waitlist: We may have openings available closer to the event. You can + join the waitlist in the SwampHacks Portal or by + signing up + in person on the day of check-in if space allows. +
  2. +
  3. Mentor: Share your knowledge and guide hackers through their projects. Sign up to be + a + mentor here.
  4. +
  5. Volunteer: Help us run the event smoothly. Sign up to + volunteer here.
  6. +
+ +

+ If you have any questions, reach out in our Discord server or email us at contact@swamphacks.com. +

+
+ + Discord + + + Instagram + + + LinkedIn + +
+
+ + + \ No newline at end of file diff --git a/apps/api/internal/services/application.go b/apps/api/internal/services/application.go index 67ebc516..c568f6d0 100644 --- a/apps/api/internal/services/application.go +++ b/apps/api/internal/services/application.go @@ -522,3 +522,42 @@ func (s *ApplicationService) SaveApplicationReview(ctx context.Context, reviewer return nil } + +func (s *ApplicationService) JoinWaitlist(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) error { + err := s.appRepo.JoinWaitlist(ctx, userId, eventId) + if err != nil { + s.logger.Err(err).Msg(err.Error()) + return err + } + return nil +} + +func (s *ApplicationService) WithdrawAcceptance(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) error { + err := s.appRepo.UpdateApplication(ctx, sqlc.UpdateApplicationParams{ + UserID: userId, + EventID: eventId, + //TODO: Make it so I don't have to set this! + StatusDoUpdate: true, + Status: sqlc.ApplicationStatusRejected, + }) + if err != nil { + s.logger.Err(err).Msg(err.Error()) + return err + } + return nil +} + +func (s *ApplicationService) AcceptApplicationAcceptance(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) error { + // is a check for a user being accepted necessary here? or is the frontend enough + + err := s.eventsService.eventRepo.UpdateRole(ctx, + userId, + eventId, + sqlc.EventRoleTypeAttendee, + ) + if err != nil { + s.logger.Err(err).Msg(err.Error()) + return err + } + return nil +} diff --git a/apps/api/internal/services/bat.go b/apps/api/internal/services/bat.go new file mode 100644 index 00000000..818bbdad --- /dev/null +++ b/apps/api/internal/services/bat.go @@ -0,0 +1,361 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + + "github.com/google/uuid" + "github.com/hibiken/asynq" + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/bat" + "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/db/sqlc" + "github.com/swamphacks/core/apps/api/internal/tasks" + "golang.org/x/sync/errgroup" +) + +var ( + ErrListApplicationsFailure = errors.New("Failed to retrieve applications") + ErrMissingRatings = errors.New("Some applications are missing their review ratings") + ErrRunConflict = errors.New("Run already exists for this event") + ErrFailedToAddRun = errors.New("Failed to add run") + ErrFailedToDeleteRun = errors.New("Failed to delete run") + ErrFailedToUpdateRun = errors.New("Failed to update run") + ErrCouldNotGetEventInfo = errors.New("Could not retreive event info.") + ErrReviewsNotFinished = errors.New("Please make sure reviews are finished before calculating application decisions.") + ErrRunMismatch = errors.New("That bat run does not belong to this event.") + ErrRunStatusInvalid = errors.New("This run status is not valid for this action.") + ErrNoAcceptedApplicants = errors.New("No applicants marked as accepted.") + ErrFailedToCheckAppReviewsComplete = errors.New("Could not get determine if application reviews have finished.") + ErrReviewsNotComplete = errors.New("Please make sure reviews are finished before calculating application decisions.") + ErrCouldNotGetEmailInfo = errors.New("Could not get email info for applicant.") + ErrParseTemplateFilepathFailed = errors.New("Could not parse filepath for template.") + ErrFailedToSendDecisionEmails = errors.New("Failed to send decision emails") + ErrTestErr = errors.New("Err while testing") + ErrFailedToGetContactEmail = errors.New("Failed to get contact email") +) + +type BatService struct { + appRepo *repository.ApplicationRepository + eventRepo *repository.EventRepository + userRepo *repository.UserRepository + batRunsRepo *repository.BatRunsRepository + emailService *EmailService + txm *db.TransactionManager + taskQueue *asynq.Client + logger zerolog.Logger +} + +func NewBatService(appRepo *repository.ApplicationRepository, eventRepo *repository.EventRepository, userRepo *repository.UserRepository, batRunsRepo *repository.BatRunsRepository, emailService *EmailService, txm *db.TransactionManager, taskQueue *asynq.Client, logger zerolog.Logger) *BatService { + return &BatService{ + taskQueue: taskQueue, + appRepo: appRepo, + eventRepo: eventRepo, + userRepo: userRepo, + batRunsRepo: batRunsRepo, + emailService: emailService, + txm: txm, + logger: logger.With().Str("service", "Bat Service").Str("component", "admissions").Logger(), + } +} + +func (s *BatService) ReleaseBatRunDecision(ctx context.Context, eventId, batRunId uuid.UUID) error { + // Retrieve Bat Run AND Event + g, egCtx := errgroup.WithContext(ctx) + + var event sqlc.Event + g.Go(func() error { + eventPtr, err := s.eventRepo.GetEventByID(egCtx, eventId) + if err != nil { + return err + } + + event = *eventPtr + return nil + }) + + var batRun sqlc.BatRun + g.Go(func() error { + run, err := s.batRunsRepo.GetRunById(egCtx, batRunId) + if err != nil { + return err + } + + batRun = run + return nil + }) + + if err := g.Wait(); err != nil { + return err + } + + if event.ID != batRun.EventID { + return ErrRunMismatch + } + + if batRun.Status.Valid != true || (batRun.Status.Valid == true && batRun.Status.BatRunStatus != sqlc.BatRunStatusCompleted) { + return ErrRunStatusInvalid + } + + if len(batRun.AcceptedApplicants) == 0 { + return ErrNoAcceptedApplicants + } + + err := s.txm.WithTx(ctx, func(tx pgx.Tx) error { + txAppRepo := s.appRepo.NewTx(tx) + + err := txAppRepo.UpdateApplicationStatusByEventId(ctx, sqlc.ApplicationStatusAccepted, event.ID, batRun.AcceptedApplicants) + if err != nil { + return err + } + + return txAppRepo.UpdateApplicationStatusByEventId(ctx, sqlc.ApplicationStatusRejected, event.ID, batRun.RejectedApplicants) + }) + if err != nil { + return err + } + + err = s.SendDecisionEmails(ctx, batRun) + if err != nil { + return ErrFailedToSendDecisionEmails + } + + return nil +} + +func (s *BatService) SendDecisionEmails(ctx context.Context, batRun sqlc.BatRun) error { + cfg := config.Load() + accepetedEmailTemplatePath := cfg.EmailTemplateDirectory + "ApplicationAcceptedEmail.html" + rejectedEmailTemplatePath := cfg.EmailTemplateDirectory + "ApplicationRejectedEmail.html" + acceptedEmailSubject := "Congratulations on being accepted to hack in SwampHacks XI!" + rejectedEmailSubject := "Update on Your SwampHacks XI Application" + + for _, uuid := range batRun.AcceptedApplicants { + emailInfo, err := s.userRepo.GetUserEmailInfoById(ctx, uuid) + if err != nil { + return ErrCouldNotGetEmailInfo + } + + contactEmail, ok := emailInfo.ContactEmail.(string) + if !ok { + return ErrFailedToGetContactEmail + } + taskInfo, err := s.emailService.QueueSendHtmlEmailTask(contactEmail, acceptedEmailSubject, emailInfo.Name, accepetedEmailTemplatePath) + s.logger.Info().Str("TaskID", taskInfo.ID).Str("Task Queue", taskInfo.Queue).Str("Task Type", taskInfo.Type).Msg("Queued acceptance email") + } + + for _, uuid := range batRun.RejectedApplicants { + emailInfo, err := s.userRepo.GetUserEmailInfoById(ctx, uuid) + if err != nil { + return ErrCouldNotGetEmailInfo + } + + contactEmail, ok := emailInfo.ContactEmail.(string) + if !ok { + return ErrFailedToGetContactEmail + } + taskInfo, err := s.emailService.QueueSendHtmlEmailTask(contactEmail, rejectedEmailSubject, emailInfo.Name, rejectedEmailTemplatePath) + s.logger.Info().Str("TaskID", taskInfo.ID).Str("Task Queue", taskInfo.Queue).Str("Task Type", taskInfo.Type).Msg("Queued rejection email") + } + + return nil +} + +func (s *BatService) AddRun(ctx context.Context, eventId uuid.UUID) (*sqlc.BatRun, error) { + + run, err := s.batRunsRepo.AddRun(ctx, eventId) + if err != nil && errors.Is(err, repository.ErrDuplicateRun) { + s.logger.Err(err).Msg("Could not insert result as it already exists.") + return nil, ErrRunConflict + } else if err != nil { + s.logger.Err(err).Msg("An unknown error was caught!") + return nil, ErrFailedToAddRun + } + + return run, nil +} + +func (s *BatService) GetRunsByEventId(ctx context.Context, eventId uuid.UUID) (*[]sqlc.GetRunsByEventIdRow, error) { + return s.batRunsRepo.GetRunsByEventId(ctx, eventId) +} + +func (s *BatService) UpdateRunById(ctx context.Context, params sqlc.UpdateRunByIdParams) (*sqlc.BatRun, error) { + err := s.batRunsRepo.UpdateRunById(ctx, params) + if err != nil { + if errors.Is(err, repository.ErrRunNotFound) { + s.logger.Err(err).Msg(repository.ErrRunNotFound.Error()) + } else { + s.logger.Err(err).Msg(repository.ErrUnknown.Error()) + } + return nil, ErrFailedToUpdateRun + } + + run, err := s.batRunsRepo.GetRunById(ctx, params.ID) + + return &run, err +} + +func (s *BatService) DeleteRunById(ctx context.Context, id uuid.UUID) error { + err := s.batRunsRepo.DeleteRunById(ctx, id) + if err != nil { + switch err { + case repository.ErrRunNotFound: + s.logger.Err(err).Msg(repository.ErrRunNotFound.Error()) + case repository.ErrNoRunsDeleted: + s.logger.Err(err).Msg(repository.ErrNoRunsDeleted.Error()) + case repository.ErrMultipleRunsDeleted: + s.logger.Err(err).Msg(repository.ErrMultipleRunsDeleted.Error()) + default: + s.logger.Err(err).Msg(repository.ErrUnknown.Error()) + } + return ErrFailedToDeleteRun + } + + return err +} + +func (s *BatService) CheckApplicationReviewsComplete(ctx context.Context, eventId uuid.UUID) (bool, error) { + nonReviewedApplicantUUIDs, err := s.appRepo.GetNonReviewedApplications(ctx, eventId) + if err != nil { + return false, ErrFailedToCheckAppReviewsComplete + } + + return len(nonReviewedApplicantUUIDs) == 0, nil +} + +func (s *BatService) QueueCalculateAdmissionsTask(ctx context.Context, eventId uuid.UUID) (*asynq.TaskInfo, error) { + newRun, err := s.AddRun(ctx, eventId) + if err != nil { + return nil, ErrFailedToAddRun + } + + task, err := tasks.NewTaskCalculateAdmissions(tasks.CalculateAdmissionsPayload{ + EventID: eventId, + BatRunID: newRun.ID, + }) + if err != nil { + s.logger.Err(err).Msg("Failed to create CalculateAdmissions task") + return nil, err + } + + info, err := s.taskQueue.Enqueue(task, asynq.Queue("bat")) + if err != nil { + s.logger.Err(err).Msg("Failed to queue CalculateAdmissions task") + return nil, err + } + + return info, nil +} + +func (s *BatService) CalculateAdmissions(ctx context.Context, eventId, batRunId uuid.UUID) error { + s.logger.Debug().Str("RunID", batRunId.String()).Msg("") + + // check to make sure reviews are done, update state if true, return error if not + reviewStatus, err := s.CheckApplicationReviewsComplete(ctx, eventId) + if err != nil { + return ErrFailedToCheckAppReviewsComplete + } + if reviewStatus == false { + return ErrReviewsNotComplete + } + + engine, err := bat.NewBatEngine(0.6, 0.4) + if err != nil { + return err + } + + // Aggregate data necessary + applications, err := s.appRepo.ListAdmissionCandidatesByEvent(ctx, eventId) + if err != nil || len(applications) == 0 { + return ErrListApplicationsFailure + } + + admissionCandidates, err := s.mapToCandidates(engine, applications) + if err != nil { + return err + } + + teams, idvs := engine.GroupCandidates(admissionCandidates) + acceptedTeamMembers, remainder := engine.AcceptTeams(teams) + + idvs = append(idvs, remainder...) + acceptedIdvs, rejected := engine.AcceptIndividuals(idvs) + + accepted := append(acceptedTeamMembers, acceptedIdvs...) + + acceptedIDs := make([]uuid.UUID, 0, len(accepted)) + rejectedIDs := make([]uuid.UUID, 0, len(rejected)) + + for _, applicant := range accepted { + acceptedIDs = append(acceptedIDs, applicant.UserID) + } + for _, applicant := range rejected { + rejectedIDs = append(rejectedIDs, applicant.UserID) + } + + params := sqlc.UpdateRunByIdParams{ + // TODO: add UF/other/early/late info? + AcceptedApplicantsDoUpdate: true, + RejectedApplicantsDoUpdate: true, + StatusDoUpdate: true, + AcceptedApplicants: acceptedIDs, + RejectedApplicants: rejectedIDs, + Status: sqlc.NullBatRunStatus{ + BatRunStatus: sqlc.BatRunStatusCompleted, + Valid: true, + }, + ID: batRunId, + } + + err = s.batRunsRepo.UpdateRunById(ctx, params) + if err != nil { + return ErrFailedToUpdateRun + } + + s.logger.Info().Int("Teams Members Accepted", int(len(acceptedTeamMembers))).Int("Accepted", int(engine.Quota.TotalAccepted)).Int("Rejected", len(rejected)).Msg("Finished Algo") + + return nil +} + +// This could be moved into the engine instead, or some mapping function within the bat package. +func (s *BatService) mapToCandidates(engine *bat.BatEngine, applications []sqlc.ListAdmissionCandidatesByEventRow) ([]bat.AdmissionCandidate, error) { + var appAdmissionsData []bat.AdmissionCandidate + for _, app := range applications { + if app.ExperienceRating == nil || app.PassionRating == nil { + return []bat.AdmissionCandidate{}, ErrMissingRatings + } + + var admissionContext bat.AdmissionContext + if err := json.Unmarshal(app.Application, &admissionContext); err != nil { + s.logger.Debug().Bytes("App", app.Application).Msg("Application data") + return []bat.AdmissionCandidate{}, err + } + + var teamId uuid.UUID + if app.TeamID != nil { + teamId = *app.TeamID + } + + wScore, err := engine.CalculateWeightedScore(*app.PassionRating, *app.ExperienceRating) + if err != nil { + return []bat.AdmissionCandidate{}, err + } + appAdmissionsData = append(appAdmissionsData, bat.AdmissionCandidate{ + UserID: app.UserID, + TeamID: uuid.NullUUID{ + UUID: teamId, + Valid: app.TeamID != nil, + }, + WeightedScore: wScore, + SortKey: 0.0, + IsUFStudent: admissionContext.School == "University of Florida", + IsEarlyCareer: admissionContext.Year == "first_year" || admissionContext.Year == "second_year", + }) + } + + return appAdmissionsData, nil +} diff --git a/apps/api/internal/services/email.go b/apps/api/internal/services/email.go index dbda22ea..b7c26b95 100644 --- a/apps/api/internal/services/email.go +++ b/apps/api/internal/services/email.go @@ -6,6 +6,7 @@ import ( "github.com/hibiken/asynq" "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/config" "github.com/swamphacks/core/apps/api/internal/email" "github.com/swamphacks/core/apps/api/internal/tasks" ) @@ -24,12 +25,13 @@ func NewEmailService(taskQueue *asynq.Client, SESClient *email.SESClient, logger } } -// TODO: Make this generic for any event +// TODO: Refactor func (s *EmailService) SendConfirmationEmail(recipient string, name string) error { var body bytes.Buffer - template, err := template.ParseFiles("./internal/email/templates/ConfirmationEmail.html") + cfg := config.Load() + template, err := template.ParseFiles(cfg.EmailTemplateDirectory + "ConfirmationEmail.html") if err != nil { s.logger.Err(err).Msg("Failed to parse email template for recipient") } @@ -47,6 +49,52 @@ func (s *EmailService) SendConfirmationEmail(recipient string, name string) erro return nil } +func (s *EmailService) SendHtmlEmail(recipient string, subject string, name string, templateFilePath string) error { + var body bytes.Buffer + + template, err := template.ParseFiles(templateFilePath) + if err != nil { + s.logger.Err(err).Msg("Failed to parse email template for recipient") + } + + s.logger.Info().Str("Name", name) + err = template.Execute(&body, struct{ Name string }{Name: name}) + if err != nil { + s.logger.Err(err).Msg("Failed to inject template variables for recipient '%s'.") + } + + err = s.SESClient.SendHTMLEmail([]string{recipient}, "noreply@swamphacks.com", subject, body.String()) + if err != nil { + s.logger.Err(err).Msg("Failed to send confirmation email to recipient") + return err + } + + return nil +} + +// TODO: refactor other queue functions to use a similar naming scheme +func (s *EmailService) QueueSendHtmlEmailTask(to string, subject string, name string, templateFilePath string) (*asynq.TaskInfo, error) { + task, err := tasks.NewTaskSendHtmlEmail(tasks.SendHtmlEmailPayload{ + To: to, + Subject: subject, + Name: name, + TemplateFilePath: templateFilePath, + }) + + if err != nil { + s.logger.Err(err).Msg("Failed to create SendHtmlEmail task") + return nil, err + } + + info, err := s.taskQueue.Enqueue(task, asynq.Queue("email")) + if err != nil { + s.logger.Err(err).Msg("Failed to queue SendHtmlEmail task") + return nil, err + } + + return info, nil +} + func (s *EmailService) QueueSendTextEmail(to []string, subject string, body string) (*asynq.TaskInfo, error) { task, err := tasks.NewTaskSendTextEmail(tasks.SendTextEmailPayload{ To: to, diff --git a/apps/api/internal/services/events.go b/apps/api/internal/services/events.go index bafb5ebd..9bdc134c 100644 --- a/apps/api/internal/services/events.go +++ b/apps/api/internal/services/events.go @@ -207,6 +207,10 @@ func (s *EventService) RevokeEventRole(ctx context.Context, userId uuid.UUID, ev return s.eventRepo.RevokeRole(ctx, userId, eventId) } +func (s *EventService) UpdateEventRole(ctx context.Context, userId uuid.UUID, eventId uuid.UUID, role sqlc.EventRoleType) error { + return s.eventRepo.UpdateRole(ctx, userId, eventId, role) +} + func (s *EventService) IsApplicationsOpen(ctx context.Context, eventId uuid.UUID) (bool, error) { event, err := s.GetEventByID(ctx, eventId) if err != nil { diff --git a/apps/api/internal/tasks/bat.go b/apps/api/internal/tasks/bat.go new file mode 100644 index 00000000..c6810d03 --- /dev/null +++ b/apps/api/internal/tasks/bat.go @@ -0,0 +1,26 @@ +package tasks + +import ( + "encoding/json" + + "github.com/google/uuid" + "github.com/hibiken/asynq" +) + +const ( + TypeCalculateAdmissions = "admissions:calculate" +) + +type CalculateAdmissionsPayload struct { + EventID uuid.UUID + BatRunID uuid.UUID +} + +func NewTaskCalculateAdmissions(payload CalculateAdmissionsPayload) (*asynq.Task, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + return asynq.NewTask(TypeCalculateAdmissions, data), nil +} diff --git a/apps/api/internal/tasks/email.go b/apps/api/internal/tasks/email.go index 149244d0..5b10bacf 100644 --- a/apps/api/internal/tasks/email.go +++ b/apps/api/internal/tasks/email.go @@ -8,6 +8,7 @@ import ( const ( TypeSendTextEmail = "textemail:send" + TypeSendHtmlEmail = "htmlemail:send" TypeSendConfirmationEmail = "confirmationemail:send" ) @@ -17,6 +18,13 @@ type SendTextEmailPayload struct { Body string } +type SendHtmlEmailPayload struct { + To string + Name string + Subject string + TemplateFilePath string +} + type SendConfirmationEmailPayload struct { To string Name string @@ -31,6 +39,16 @@ func NewTaskSendTextEmail(payload SendTextEmailPayload) (*asynq.Task, error) { return asynq.NewTask(TypeSendTextEmail, data), nil } +func NewTaskSendHtmlEmail(payload SendHtmlEmailPayload) (*asynq.Task, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + return asynq.NewTask(TypeSendHtmlEmail, data), nil +} + +// TODO: refactor func NewTaskSendConfirmationEmail(payload SendConfirmationEmailPayload) (*asynq.Task, error) { data, err := json.Marshal(payload) if err != nil { diff --git a/apps/api/internal/workers/bat.go b/apps/api/internal/workers/bat.go index 00ef169d..886cd865 100644 --- a/apps/api/internal/workers/bat.go +++ b/apps/api/internal/workers/bat.go @@ -1,6 +1,15 @@ package workers -import "github.com/rs/zerolog" +import ( + "context" + "encoding/json" + + "github.com/hibiken/asynq" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" + "github.com/swamphacks/core/apps/api/internal/services" + "github.com/swamphacks/core/apps/api/internal/tasks" +) // BAT Worker // The BAT worker runs the background execution pipeline for our @@ -10,11 +19,42 @@ import "github.com/rs/zerolog" // other decision heuristics. It operates asynchronously to ensure // fair, consistent, and scalable admissions handling. type BATWorker struct { - logger zerolog.Logger + batService *services.BatService + logger zerolog.Logger } -func NewBATWorker(logger zerolog.Logger) *BATWorker { +func NewBATWorker(batService *services.BatService, logger zerolog.Logger) *BATWorker { return &BATWorker{ - logger: logger.With().Str("worker", "BATWorker").Str("component", "BAT").Logger(), + batService: batService, + logger: logger.With().Str("worker", "BATWorker").Str("component", "BAT").Logger(), } } + +func (w *BATWorker) HandleCalculateAdmissionsTask(ctx context.Context, t *asynq.Task) error { + var p tasks.CalculateAdmissionsPayload + if err := json.Unmarshal(t.Payload(), &p); err != nil { + w.logger.Err(err).Msg("Failed to unmarshal payload.") + return err + } + + err := w.batService.CalculateAdmissions(ctx, p.EventID, p.BatRunID) + + // On error, mark run as failed. + // To be fair, this should be handled more granulary. Like for example, + // what if the original run was never created? Just food for thought for now. + if err != nil { + w.logger.Err(err).Msg("Something went wrong calculating admissions.") + _, _ = w.batService.UpdateRunById(ctx, sqlc.UpdateRunByIdParams{ + ID: p.BatRunID, + StatusDoUpdate: true, + Status: sqlc.NullBatRunStatus{ + BatRunStatus: sqlc.BatRunStatusFailed, + Valid: true, + }, + }) + + return err + } + + return nil +} diff --git a/apps/api/internal/workers/email.go b/apps/api/internal/workers/email.go index 7b309da6..6b2fb7f9 100644 --- a/apps/api/internal/workers/email.go +++ b/apps/api/internal/workers/email.go @@ -23,6 +23,20 @@ func NewEmailWorker(emailService *services.EmailService, logger zerolog.Logger) } } +func (w *EmailWorker) HandleSendHtmlEmailTask(ctx context.Context, t *asynq.Task) error { + var p tasks.SendHtmlEmailPayload + if err := json.Unmarshal(t.Payload(), &p); err != nil { + w.logger.Err(err) + return fmt.Errorf("HandleSendHtmlEmailTask: json.Unmarshal failed: %v: %w", err, asynq.SkipRetry) + } + + if err := w.emailService.SendHtmlEmail(p.To, p.Subject, p.Name, p.TemplateFilePath); err != nil { + w.logger.Err(err).Msg("Failed to send ConfirmationEmail from worker") + return err + } + return nil +} + func (w *EmailWorker) HandleSendConfirmationEmailTask(ctx context.Context, t *asynq.Task) error { var p tasks.SendConfirmationEmailPayload if err := json.Unmarshal(t.Payload(), &p); err != nil { diff --git a/apps/web/src/features/Application/components/ApplicationStatus.tsx b/apps/web/src/features/Application/components/ApplicationStatus.tsx index e5c1fff2..bedf5f90 100644 --- a/apps/web/src/features/Application/components/ApplicationStatus.tsx +++ b/apps/web/src/features/Application/components/ApplicationStatus.tsx @@ -7,6 +7,7 @@ import TablerUserCode from "~icons/tabler/user-code"; import TablerUsersGroup from "~icons/tabler/users-group"; import TablerDownload from "~icons/tabler/download"; import { api } from "@/lib/ky"; +import { EventButton } from "@/features/Event/components/EventButton"; interface ApplicationStatusProps { eventId: string; @@ -39,6 +40,73 @@ export default function ApplicationStatus({ eventId }: ApplicationStatusProps) { ); + case "rejected": + return ( +
+
+

+ Your application is{" "} + +

+

+ We couldn't accomodate all the applications this year. If you + are still interested, please join the waitlist!{" "} +

+
+
+ +
+
+ ); + case "accepted": + return ( +
+
+

+ Your application is{" "} + +

+

+ Congratulations! We would love to see you at Swamphacks XI{" "} +

+
+
+ + +
+
+ ); + case "waitlisted": + return ( +
+
+

+ Your application is{" "} + +

+
+
+ +
+
+ ); default: break; } diff --git a/apps/web/src/features/Application/hooks/useMyApplication.ts b/apps/web/src/features/Application/hooks/useMyApplication.ts index 651546e9..daa43e1d 100644 --- a/apps/web/src/features/Application/hooks/useMyApplication.ts +++ b/apps/web/src/features/Application/hooks/useMyApplication.ts @@ -1,6 +1,8 @@ import { api } from "@/lib/ky"; import { useQuery } from "@tanstack/react-query"; +export const myApplicationBaseKey = ["events", "application"]; + export async function fetchMyApplication(eventId: string): Promise { const result = await api.get(`events/${eventId}/application`).json(); return result; @@ -8,7 +10,7 @@ export async function fetchMyApplication(eventId: string): Promise { export function useMyApplication(eventId: string) { return useQuery({ - queryKey: ["events", "application"], + queryKey: [...myApplicationBaseKey, eventId], queryFn: () => fetchMyApplication(eventId), staleTime: Infinity, }); diff --git a/apps/web/src/features/ApplicationDecisions/hooks/useUpdateEventDecisions.ts b/apps/web/src/features/ApplicationDecisions/hooks/useUpdateEventDecisions.ts new file mode 100644 index 00000000..5160b59f --- /dev/null +++ b/apps/web/src/features/ApplicationDecisions/hooks/useUpdateEventDecisions.ts @@ -0,0 +1,39 @@ +import { useUpdateEvent } from "@/features/Event/hooks/useUpdateEvent"; +import { type Event } from "@/features/Event/schemas/event"; +import { api } from "@/lib/ky"; +import { toast } from "react-toastify"; + +interface checkAppReviewStatus { + checkAppReviewStatus: (event: Event) => Promise; +} + +export const useCheckAppReviewStatus = ( + eventId: string, +): checkAppReviewStatus => { + const { mutateAsync: updateEvent } = useUpdateEvent(eventId); + + const checkAppReviewStatus = async (event: Event) => { + const { id, application_review_finished } = event; + + await api.post(`events/${eventId}/app-review-decision-status`, { + json: { + eventId: id, + }, + }); + + await updateEvent( + { application_review_finished: application_review_finished }, + { + onError: () => + toast.error( + "Failed to check if application reviews are finished for this event.", + { position: "bottom-right" }, + ), + }, + ); + + return application_review_finished; + }; + + return { checkAppReviewStatus }; +}; diff --git a/apps/web/src/features/Dashboard/components/StaffAppShell.tsx b/apps/web/src/features/Dashboard/components/StaffAppShell.tsx index f72ed90b..a685cde6 100644 --- a/apps/web/src/features/Dashboard/components/StaffAppShell.tsx +++ b/apps/web/src/features/Dashboard/components/StaffAppShell.tsx @@ -4,6 +4,7 @@ import { NavLink } from "@/components/AppShell/NavLink"; import TablerLayoutDashboard from "~icons/tabler/layout-dashboard"; import TablerUsersGroup from "~icons/tabler/users-group"; import TablerClipboardCheck from "~icons/tabler/clipboard-check"; +import TablerGavel from "~icons/tabler/gavel"; import TablerMail from "~icons/tabler/mail"; import TablerSettings from "~icons/tabler/settings"; import TablerFileText from "~icons/tabler/file-text"; @@ -37,6 +38,8 @@ export default function StaffDashboardShell({ const applicationReviewActive = /^\/events\/[^/]+\/dashboard\/application-review\/?$/.test(pathname); + const applicationDecisionsActive = + /^\/events\/[^/]+\/dashboard\/application-decisions\/?$/.test(pathname); const waitlistActive = /^\/events\/[^/]+\/dashboard\/waitlist\/?$/.test( pathname, ); @@ -87,6 +90,7 @@ export default function StaffDashboardShell({ initialExpanded={ waitlistActive || applicationReviewActive || + applicationDecisionsActive || applicationStatisticsActive } > @@ -96,6 +100,14 @@ export default function StaffDashboardShell({ leftSection={} active={applicationReviewActive} /> + {eventRole == "admin" && ( + } + active={applicationDecisionsActive} + /> + )} { + const queryClient = useQueryClient(); + + const router = useRouter(); + const { data: userData } = auth.useUser(); + if (!userData?.user) { + return
Loading...
; + } + + const { user } = userData; + const eventButtonClassName = eventButton({ status: statusProp, variant: "skeleton", className, }); - const router = useRouter(); + + const handleAcceptAcceptance = async (userId: string, eventId: string) => { + try { + console.log("Accepted button clicked for event"); + await api.patch( + `events/${eventId}/application/accept-acceptance?userId=${userId}`, + ); + await queryClient.invalidateQueries({ queryKey: eventsQueryKey }); + showToast({ + title: "Acceptance Accepted", + message: "Successfully Joined Event as Attendee.", + type: "success", + }); + router.navigate({ + to: `/events/${eventId}/dashboard`, + }); + } catch (error) { + console.error("Failed to join waitlist", error); + showToast({ + title: "Acceptance Failed", + message: "Failed to Accept. Please try again.", + type: "error", + }); + } + }; const onClick = () => { switch (statusProp) { case "accepted": // Make API call and then navigate to dashboard with accepted query param - console.log("Accepted button clicked for event"); - router.navigate({ - to: `/events/${eventId}/dashboard`, - }); + handleAcceptAcceptance(user.userId, eventId); break; case "attending": case "staff": @@ -69,10 +107,6 @@ const EventButton = ({ }); break; case "rejected": - // Navigate to the rejected info page - router.navigate({ - to: `/events/${eventId}/rejected`, - }); break; case "notApplied": // Navigate to the application page @@ -81,10 +115,6 @@ const EventButton = ({ }); break; case "notGoing": - // Navigate to the decline feedback page - router.navigate({ - to: `/events/${eventId}/feedback/decline`, - }); break; case "completed": // Navigate to the event summary @@ -100,15 +130,41 @@ const EventButton = ({ } }; - return ( - - ); + if (statusProp === "notGoing") { + return ( + + + + + ); + } else if (statusProp === "rejected") { + return ( + + + + + ); + } else { + return ( + + ); + } }; export { EventButton }; diff --git a/apps/web/src/features/Event/components/EventWaitlistModal.tsx b/apps/web/src/features/Event/components/EventWaitlistModal.tsx new file mode 100644 index 00000000..a41ea89d --- /dev/null +++ b/apps/web/src/features/Event/components/EventWaitlistModal.tsx @@ -0,0 +1,77 @@ +import { Modal } from "@/components/ui/Modal"; +import { auth } from "@/lib/authClient"; +import { Button } from "@/components/ui/Button"; +import { api } from "@/lib/ky"; +import { showToast } from "@/lib/toast/toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { eventsQueryKey } from "../hooks/useEventsWithUserInfo"; +import { myApplicationBaseKey } from "@/features/Application/hooks/useMyApplication"; + +interface EventWaitlistModalProps { + eventId: string; +} + +function EventWaitlistModal({ eventId }: EventWaitlistModalProps) { + const queryClient = useQueryClient(); + + const { data: userData } = auth.useUser(); + if (!userData?.user) { + return
Loading...
; + } + + const { user } = userData; + + const handleJoinWaitlist = async (userId: string, eventId: string) => { + try { + await api.patch( + `events/${eventId}/application/join-waitlist?userId=${userId}`, + ); + showToast({ + title: "Waitlist Joined", + message: "Successfully Joined Waitlist.", + type: "success", + }); + await queryClient.invalidateQueries({ + queryKey: eventsQueryKey, + }); + await queryClient.invalidateQueries({ + queryKey: [...myApplicationBaseKey, eventId], + }); + } catch (error) { + console.error("Failed to join waitlist", error); + showToast({ + title: "Join Failed", + message: "Failed to Join Waitlist. Please try again.", + type: "error", + }); + } + }; + + return ( + +
+

+ Join Waitlist? +

+

+ 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! +

+
+
+ +
+
+ ); +} + +export { EventWaitlistModal }; diff --git a/apps/web/src/features/Event/components/EventWithdrawalModal.tsx b/apps/web/src/features/Event/components/EventWithdrawalModal.tsx new file mode 100644 index 00000000..228745cd --- /dev/null +++ b/apps/web/src/features/Event/components/EventWithdrawalModal.tsx @@ -0,0 +1,78 @@ +import { Modal } from "@/components/ui/Modal"; +import { auth } from "@/lib/authClient"; +import { Button } from "@/components/ui/Button"; +import { api } from "@/lib/ky"; +import { showToast } from "@/lib/toast/toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { eventsQueryKey } from "../hooks/useEventsWithUserInfo"; +import { myApplicationBaseKey } from "@/features/Application/hooks/useMyApplication"; + +interface EventWithdrawalModalProps { + eventId: string; +} + +function EventWithdrawalModal({ eventId }: EventWithdrawalModalProps) { + const queryClient = useQueryClient(); + + const { data: userData } = auth.useUser(); + if (!userData?.user) { + return
Loading...
; + } + + const { user } = userData; + + const handleWithdrawAcceptance = async (userId: string, eventId: string) => { + try { + await api.patch( + `events/${eventId}/application/withdraw-acceptance?userId=${userId}`, + ); + showToast({ + title: "Acceptance Withdrawn", + message: "Successfully Withdrawn Application.", + type: "success", + }); + await queryClient.invalidateQueries({ + queryKey: eventsQueryKey, + }); + await queryClient.invalidateQueries({ + queryKey: [...myApplicationBaseKey, eventId], + }); + } catch (error) { + console.error("Failed to join waitlist", error); + showToast({ + title: "Failed to Withdraw", + message: "Failed to Withdraw Acceptance. Please try again.", + type: "error", + }); + } + }; + + return ( + +
+

+ Withdraw Acceptance? +

+

+ Are you sure? This action cannot be undone. +

+

+ You can still join the waitlist after withdrawing. +

+
+
+ +
+
+ ); +} + +export { EventWithdrawalModal }; diff --git a/apps/web/src/features/Event/schemas/event.ts b/apps/web/src/features/Event/schemas/event.ts index f941188b..d21e80f5 100644 --- a/apps/web/src/features/Event/schemas/event.ts +++ b/apps/web/src/features/Event/schemas/event.ts @@ -19,6 +19,7 @@ export const EventSchema = z.object({ updated_at: z.coerce.date().nullable(), banner: z.url().or(z.literal("")).nullable(), application_review_started: z.boolean(), + application_review_finished: z.boolean(), }); export type Event = z.infer; diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/application-decisions.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/application-decisions.tsx new file mode 100644 index 00000000..b93773ed --- /dev/null +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/application-decisions.tsx @@ -0,0 +1,71 @@ +import { Button } from "@/components/ui/Button"; +import { useUpdateEventAppReviewsFinishedStatus } from "@/features/ApplicationDecisions/hooks/useUpdateEventDecisions"; +import { useEvent } from "@/features/Event/hooks/useEvent"; +import { createFileRoute } from "@tanstack/react-router"; +import { Heading } from "react-aria-components"; +import { toast } from "react-toastify"; + +export const Route = createFileRoute( + "/_protected/events/$eventId/dashboard/_admin/application-decisions", +)({ + component: RouteComponent, +}); + +function RouteComponent() { + const { eventId } = Route.useParams(); + const { user } = Route.useRouteContext(); + const { checkAppReviewStatus } = useUpdateEventAppReviewsFinishedStatus(); + const event = useEvent(eventId); + + const checkReviewsCompleted = async () => { + if (!event.data) return; + + const isCompleted = await checkAppReviewStatus(event.data); + if (isCompleted) { + toast.success( + "All application reviews completed. You can now proceed with calculating decisions.", + ); + } + }; + + const loading = !user || event.isLoading; + + if (loading) { + return ( +
+ + Application Decisions + +
+
+ ); + } + + if (!event.data || event.isError) { + return
Event not found
; + } + + return ( +
+
+ + Application Decisions + + + {event.data.application_review_finished ? ( +

Show applications decision page

+ ) : ( + <> + + + )} +
+
+ ); +} diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/index.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/index.tsx index dbf05947..15602176 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/index.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/index.tsx @@ -18,7 +18,7 @@ export const Route = createFileRoute("/_protected/events/$eventId/dashboard/")({ function RouteComponent() { const { eventRole } = Route.useRouteContext(); - const { eventId } = Route.useParams() + const { eventId } = Route.useParams(); // Both staff and admin are technically "staff" for dashboard purposes const isStaff = eventRole === "staff" || eventRole === "admin"; diff --git a/infra/docker-compose.api.yml b/infra/docker-compose.api.yml index 1b5c8e19..f3ac1875 100644 --- a/infra/docker-compose.api.yml +++ b/infra/docker-compose.api.yml @@ -37,6 +37,14 @@ services: depends_on: - redis-dev + bat-worker-dev: + image: ghcr.io/swamphacks/core-bat-worker:dev + restart: always + env_file: + - ./secrets/.env.dev.api + depends_on: + - redis-dev + redis-dev: image: redis:8.2.1-alpine container_name: redis-dev