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/cmd/BAT_worker/main.go b/apps/api/cmd/BAT_worker/main.go index fbd1150f..3bba489b 100644 --- a/apps/api/cmd/BAT_worker/main.go +++ b/apps/api/cmd/BAT_worker/main.go @@ -4,8 +4,13 @@ import ( "time" "github.com/hibiken/asynq" + "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/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,23 @@ func main() { }, ) - _ = workers.NewBATWorker(logger) + batEngine, err := bat.NewBatEngine(0.5, 0.5) + if err != nil { + logger.Fatal().Err(err).Msg("Bat engine failed to initialize.") + } + + database := db.NewDB(cfg.DatabaseURL) + defer database.Close() + + eventRepo := repository.NewEventRespository(database) + applicationRepo := repository.NewApplicationRepository(database) + + batService := services.NewBatService(batEngine, applicationRepo, eventRepo, 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..66adaeda 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -9,6 +9,7 @@ import ( "github.com/swamphacks/core/apps/api/internal/api" "github.com/swamphacks/core/apps/api/internal/api/handlers" "github.com/swamphacks/core/apps/api/internal/api/middleware" + "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" @@ -55,6 +56,12 @@ func main() { // Create SES Client for email service sesClient := email.NewSESClient(cfg.AWS.AccessKey, cfg.AWS.AccessKeySecret, cfg.AWS.Region, logger) + // BAT Engine, needs to be synced with ENV soon + batEngine, err := bat.NewBatEngine(0.5, 0.5) + if err != nil { + logger.Fatal().Msg("Failed to init Bat Engine") + } + // Create asynq client redisOpt, err := asynq.ParseRedisURI(cfg.RedisURL) if err != nil { @@ -90,9 +97,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(batEngine, applicationRepo, eventRepo, 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/internal/api/api.go b/apps/api/internal/api/api.go index cb7a5e43..242c12c9 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -122,8 +122,7 @@ 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) // Superuser-only r.With(mw.Auth.RequireAuth, ensureSuperuser).Post("/", api.Handlers.Event.CreateEvent) @@ -151,6 +150,8 @@ 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) // Superuser-only r.With(ensureSuperuser).Delete("/", api.Handlers.Event.DeleteEventById) diff --git a/apps/api/internal/api/handlers/admissions.go b/apps/api/internal/api/handlers/admissions.go new file mode 100644 index 00000000..2188782b --- /dev/null +++ b/apps/api/internal/api/handlers/admissions.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "net/http" + + 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 +} + +func NewAdmissionHandler(batService *services.BatService) *AdmissionHandler { + return &AdmissionHandler{ + batService: batService, + } +} + +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(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/bat.go b/apps/api/internal/api/handlers/bat.go new file mode 100644 index 00000000..9430d72f --- /dev/null +++ b/apps/api/internal/api/handlers/bat.go @@ -0,0 +1,106 @@ +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 + } +} + +// 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..88c1e4b9 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), + Bat: NewBatHandler(batService, logger), } } diff --git a/apps/api/internal/bat/scores.go b/apps/api/internal/bat/scores.go new file mode 100644 index 00000000..5c1fbf16 --- /dev/null +++ b/apps/api/internal/bat/scores.go @@ -0,0 +1,60 @@ +package bat + +import ( + "errors" + "math" +) + +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 BatEngine struct { + passionWeight float64 + experienceWeight float64 + weightedBaseConstant float64 +} + +func NewBatEngine(passionW, experienceW float64) (*BatEngine, error) { + if !equalWithinTolerance(passionW+experienceW, 1.0, 1e-9) { + return nil, ErrImproperWeights + } + + return &BatEngine{ + passionWeight: passionW, + experienceWeight: experienceW, + weightedBaseConstant: 0.1, + }, 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 +} + +// 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/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/queries/applications.sql b/apps/api/internal/db/queries/applications.sql index eb317fb5..12121df8 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, @@ -66,4 +84,4 @@ ORDER BY user_id ASC; UPDATE applications SET waitlist_join_time = COALESCE(waitlist_join_time, NOW()), status = 'waitlisted' -WHERE user_id = $1 AND event_id = $2; \ No newline at end of file +WHERE user_id = $1 AND event_id = $2; 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..549f8d56 --- /dev/null +++ b/apps/api/internal/db/queries/bat_runs.sql @@ -0,0 +1,44 @@ +-- name: AddRun :one +INSERT INTO bat_runs ( + event_id +) VALUES ( + $1 +) RETURNING *; + +-- name: GetRunByEventId :one +SELECT + id, + accepted_applicants, + rejected_applicants, + status, + created_at, + completed_at +FROM bat_runs +WHERE event_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/repository/application.go b/apps/api/internal/db/repository/application.go index 2e6b75eb..108decec 100644 --- a/apps/api/internal/db/repository/application.go +++ b/apps/api/internal/db/repository/application.go @@ -66,6 +66,13 @@ func (r *ApplicationRepository) GetApplicationByUserAndEventID(ctx context.Conte return &application, nil } +// 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) 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..6f1847e8 --- /dev/null +++ b/apps/api/internal/db/repository/bat_runs.go @@ -0,0 +1,75 @@ +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) GetRunByEventId(ctx context.Context, eventId uuid.UUID) (*sqlc.GetRunByEventIdRow, error) { + run, err := r.db.Query.GetRunByEventId(ctx, eventId) + return &run, err +} + +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) DeletRunById(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/sqlc/applications.sql.go b/apps/api/internal/db/sqlc/applications.sql.go index 983d1297..e10ba4b7 100644 --- a/apps/api/internal/db/sqlc/applications.sql.go +++ b/apps/api/internal/db/sqlc/applications.sql.go @@ -127,6 +127,58 @@ func (q *Queries) JoinWaitlist(ctx context.Context, arg JoinWaitlistParams) erro 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 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..4edd17df --- /dev/null +++ b/apps/api/internal/db/sqlc/bat_runs.sql.go @@ -0,0 +1,172 @@ +// 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 getRunByEventId = `-- name: GetRunByEventId :one +SELECT + id, + accepted_applicants, + rejected_applicants, + status, + created_at, + completed_at +FROM bat_runs +WHERE event_id = $1 +` + +type GetRunByEventIdRow 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) GetRunByEventId(ctx context.Context, eventID uuid.UUID) (GetRunByEventIdRow, error) { + row := q.db.QueryRow(ctx, getRunByEventId, eventID) + var i GetRunByEventIdRow + err := row.Scan( + &i.ID, + &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/models.go b/apps/api/internal/db/sqlc/models.go index 0b843881..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 ( @@ -331,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/services/bat.go b/apps/api/internal/services/bat.go new file mode 100644 index 00000000..81968d96 --- /dev/null +++ b/apps/api/internal/services/bat.go @@ -0,0 +1,607 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "math" + "math/rand" + "sort" + "time" + + "github.com/google/uuid" + "github.com/hibiken/asynq" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/bat" + "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" +) + +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") +) + +type BatService struct { + engine *bat.BatEngine + appRepo *repository.ApplicationRepository + eventRepo *repository.EventRepository + batRunsRepo *repository.BatRunsRepository + taskQueue *asynq.Client + logger zerolog.Logger +} + +func NewBatService(engine *bat.BatEngine, appRepo *repository.ApplicationRepository, eventRepo *repository.EventRepository, taskQueue *asynq.Client, logger zerolog.Logger) *BatService { + return &BatService{ + engine: engine, + taskQueue: taskQueue, + appRepo: appRepo, + eventRepo: eventRepo, + logger: logger.With().Str("service", "Bat Service").Str("component", "admissions").Logger(), + } +} + +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, ErrFailedToCreateRun + } + + 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.GetRunByEventIdRow, 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.GetRunByEventId(ctx, params.ID) + + return run, err +} + +func (s *BatService) DeleteRunById(ctx context.Context, id uuid.UUID) error { + err := s.batRunsRepo.DeletRunById(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) QueueCalculateAdmissionsTask(eventId uuid.UUID) (*asynq.TaskInfo, error) { + task, err := tasks.NewTaskCalculateAdmissions(tasks.CalculateAdmissionsPayload{ + EventID: eventId, + }) + 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 +} + +type ApplicantAdmissionsData struct { + ID uuid.UUID + TeamID uuid.NullUUID + WeightedScore float64 + SortKey float64 + IsUFStudent bool // Is a University of Florida student? + IsEarlyCareer bool // Is a freshman to sophomore? +} + +// Unmarshal into this reduced struct since +// our application fields are fucked +// beyond all fuck man. TODO: FIX OUR APP FIELDS! +type ApplicationSchoolAndYear struct { + School string `json:"school"` + Year string `json:"year"` +} + +type QuotaState struct { + TotalAccepted int32 + TeamSlotsLeft int32 + UFEarlyLeft int32 + UFLateLeft int32 + OtherEarlyLeft int32 + OtherLateLeft int32 +} + +type TeamAdmissionData struct { + TeamID uuid.UUID + MembersAdmissionData []ApplicantAdmissionsData + AverageWeightedScore float64 + SortKey float64 +} + +func (s *BatService) CalculateAdmissions(ctx context.Context, eventId uuid.UUID) error { + s.logger.Info().Str("eventId", eventId.String()).Msg("") + + // Create run, adds state as "running" into db + newRun, err := s.AddRun(ctx, eventId) + if err != nil { + return ErrFailedToAddRun + } + + // Aggregate data necessary + applications, err := s.appRepo.ListAdmissionCandidatesByEvent(ctx, eventId) + if err != nil || len(applications) == 0 { + return ErrListApplicationsFailure + } + + var appAdmissionsData []ApplicantAdmissionsData + for _, app := range applications { + if app.ExperienceRating == nil || app.PassionRating == nil { + return ErrMissingRatings + } + + var applicationShoolAndYear ApplicationSchoolAndYear + if err := json.Unmarshal(app.Application, &applicationShoolAndYear); err != nil { + s.logger.Debug().Bytes("App", app.Application).Msg("Application data") + return err + } + + var teamId uuid.UUID + if app.TeamID != nil { + teamId = *app.TeamID + } + + wScore, err := s.engine.CalculateWeightedScore(*app.PassionRating, *app.ExperienceRating) + if err != nil { + return err + } + appAdmissionsData = append(appAdmissionsData, ApplicantAdmissionsData{ + ID: app.UserID, + TeamID: uuid.NullUUID{ + UUID: teamId, + Valid: app.TeamID != nil, + }, + WeightedScore: wScore, + SortKey: 0.0, + IsUFStudent: applicationShoolAndYear.School == "University of Florida", + IsEarlyCareer: applicationShoolAndYear.Year == "first_year" || applicationShoolAndYear.Year == "second_year", + }) + } + + quota := QuotaState{ + TotalAccepted: 0, + TeamSlotsLeft: 50, + UFEarlyLeft: 210, + UFLateLeft: 140, + OtherEarlyLeft: 90, + OtherLateLeft: 60, + } + + teams, solo := groupAndSortTeams(appAdmissionsData) + applyTeamSortKey(teams) + acceptedTeams, remaining, quota := admitTeams(teams, solo, quota) + accepted, rejected, quota := admitSoloApplicants(remaining, quota) + + var acceptedUUIDs []uuid.UUID + var rejectedUUIDs []uuid.UUID + + for _, applicant := range accepted { + acceptedUUIDs = append(acceptedUUIDs, applicant.ID) + } + + for _, applicant := range rejected { + rejectedUUIDs = append(rejectedUUIDs, applicant.ID) + } + + params := sqlc.UpdateRunByIdParams{ + AcceptedApplicantsDoUpdate: true, + RejectedApplicantsDoUpdate: true, + StatusDoUpdate: true, + AcceptedApplicants: acceptedUUIDs, + RejectedApplicants: rejectedUUIDs, + Status: sqlc.NullBatRunStatus{BatRunStatus: sqlc.BatRunStatusCompleted, Valid: true}, + ID: newRun.ID, + } + _, err = s.UpdateRunById(ctx, params) + + if err != nil { + return ErrFailedToUpdateRun + } + + s.logger.Info().Int("Accepted", len(accepted)+len(acceptedTeams)).Int("Rejected", len(rejected)).Msg("Finished Algo") + + return nil +} + +func admitTeams(teams []TeamAdmissionData, solo []ApplicantAdmissionsData, initialQuota QuotaState) ( + []ApplicantAdmissionsData, // Accepted Applicants + []ApplicantAdmissionsData, // remaining applicants (joined with solo) + QuotaState, // Updated Quote +) { + admittedApplicants := make([]ApplicantAdmissionsData, 0, initialQuota.TotalAccepted) + remainingApplicants := make([]ApplicantAdmissionsData, 0) + remainingApplicants = append(remainingApplicants, solo...) + quota := initialQuota + + for _, team := range teams { + // All remaining members get appended to solo/remaining selection + if quota.TeamSlotsLeft <= int32(len(team.MembersAdmissionData)) { + remainingApplicants = append(remainingApplicants, team.MembersAdmissionData...) + continue + } + + required := countTeamSlots(team.MembersAdmissionData) + if canAdmitTeam(required, quota) { + admittedApplicants = append(admittedApplicants, team.MembersAdmissionData...) + + quota.TotalAccepted += int32(len(team.MembersAdmissionData)) + quota.TeamSlotsLeft -= int32(len(team.MembersAdmissionData)) + + quota.UFEarlyLeft -= required.UFEarlyLeft + quota.UFLateLeft -= required.UFLateLeft + quota.OtherEarlyLeft -= required.OtherEarlyLeft + quota.OtherLateLeft -= required.OtherLateLeft + } else { + remainingApplicants = append(remainingApplicants, team.MembersAdmissionData...) + } + } + + return admittedApplicants, remainingApplicants, quota +} + +type BucketConfig struct { + Name string + QuotaPtr *int32 + RolloverPtr *int32 +} + +func admitSoloApplicants(solo []ApplicantAdmissionsData, quota QuotaState) ( + []ApplicantAdmissionsData, // Accepted + []ApplicantAdmissionsData, // Rejected + QuotaState, +) { + var admittedSolo []ApplicantAdmissionsData + + pool := make(map[uuid.UUID]ApplicantAdmissionsData) + for _, app := range solo { + pool[app.ID] = app + } + + buckets := []BucketConfig{ + {"UF_Early", "a.UFEarlyLeft, "a.UFLateLeft}, + {"UF_Late", "a.UFLateLeft, "a.UFEarlyLeft}, + {"Other_Early", "a.OtherEarlyLeft, "a.OtherLateLeft}, + {"Other_Late", "a.OtherLateLeft, "a.OtherEarlyLeft}, + } + + for { + passAdmittedCount := int32(0) + + for _, bucket := range buckets { + originalCount := *bucket.QuotaPtr + if originalCount <= 0 { + continue + } + + bucketPool := filterApplicants(pool, bucket.Name) + applyIndividualSortKey(bucketPool) + sort.Slice(bucketPool, func(i, j int) bool { + return bucketPool[i].SortKey > bucketPool[j].SortKey + }) + + numToSelect := min(originalCount, int32(len(bucketPool))) + for i := range numToSelect { + + app := bucketPool[i] + admittedSolo = append(admittedSolo, app) + + *bucket.QuotaPtr -= 1 + quota.TotalAccepted += 1 + delete(pool, app.ID) + + passAdmittedCount++ + } + + slotsUnfilled := *bucket.QuotaPtr + if slotsUnfilled > 0 && bucket.RolloverPtr != nil { + *bucket.RolloverPtr += slotsUnfilled + + *bucket.QuotaPtr = 0 + + } + } + + if passAdmittedCount == 0 { + break // No admissions in a pass anymore! + } + } + + var rejected []ApplicantAdmissionsData + for _, applicant := range pool { + rejected = append(rejected, applicant) + } + + return admittedSolo, rejected, quota + + // for bucket, targetCount := range buckets { + // count := *targetCount + + // if count <= 0 { + // continue // Skip if bucket is already full/completed + // } + + // bucketPool := filterApplicants(pool, bucket) + // applyIndividualSortKey(bucketPool) + + // // Sort + // sort.Slice(bucketPool, func(i, j int) bool { + // return bucketPool[i].SortKey > bucketPool[j].SortKey + // }) + + // numToSelect := int(min(count, int32(len(bucketPool)))) + + // for i := range numToSelect { + // applicant := bucketPool[i] + // admittedSolo = append(admittedSolo, applicant) + + // *targetCount -= 1 + // quota.TotalAccepted += 1 + // delete(pool, applicant.ID) + // } + // } + + // var rejectedSolo []ApplicantAdmissionsData + // for _, applicant := range pool { + // rejectedSolo = append(rejectedSolo, applicant) + // } + + // return admittedSolo, rejectedSolo, quota +} + +func applyIndividualSortKey(apps []ApplicantAdmissionsData) { + var Epsilon float64 = 0.001 + + r := rand.New(rand.NewSource(time.Now().UnixNano())) + for i := range apps { + app := &apps[i] + randVal := r.Float64() + exp := 1.0 / (app.WeightedScore + Epsilon) + + app.SortKey = math.Pow(randVal, exp) + } +} + +func filterApplicants( + pool map[uuid.UUID]ApplicantAdmissionsData, + bucketName string, +) []ApplicantAdmissionsData { + + var filtered []ApplicantAdmissionsData + + for _, app := range pool { + switch bucketName { + case "UF_Early": + if app.IsUFStudent && app.IsEarlyCareer { + filtered = append(filtered, app) + } + case "UF_Late": + if app.IsUFStudent && !app.IsEarlyCareer { + filtered = append(filtered, app) + } + case "Other_Early": + if !app.IsUFStudent && app.IsEarlyCareer { + filtered = append(filtered, app) + } + case "Other_Late": + if !app.IsUFStudent && !app.IsEarlyCareer { + filtered = append(filtered, app) + } + default: + // Should not happen if buckets map is defined correctly + } + } + return filtered +} +func canAdmitTeam(req QuotaState, curr QuotaState) bool { + return req.UFEarlyLeft <= curr.UFEarlyLeft && + req.UFLateLeft <= curr.UFLateLeft && + req.OtherEarlyLeft <= curr.OtherEarlyLeft && + req.OtherLateLeft <= curr.OtherLateLeft +} + +func countTeamSlots(members []ApplicantAdmissionsData) QuotaState { + reqQuota := QuotaState{ + TotalAccepted: 0, + TeamSlotsLeft: 0, + UFEarlyLeft: 0, + UFLateLeft: 0, + OtherEarlyLeft: 0, + OtherLateLeft: 0, + } + + for _, member := range members { + if member.IsUFStudent { + if member.IsEarlyCareer { + reqQuota.UFEarlyLeft += 1 + } else { + reqQuota.UFLateLeft += 1 + } + } else { + if member.IsEarlyCareer { + reqQuota.OtherEarlyLeft += 1 + } else { + reqQuota.OtherLateLeft += 1 + } + } + + reqQuota.TotalAccepted += 1 + reqQuota.TeamSlotsLeft += 1 + } + + return reqQuota +} + +func applyTeamSortKey(teams []TeamAdmissionData) { + const Epsilon float64 = 0.001 + + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + for i := range teams { + // Get reference to apply in place + team := &teams[i] + + randVal := r.Float64() + + exponent := 1.0 / (team.AverageWeightedScore + Epsilon) + team.SortKey = math.Pow(randVal, exponent) + } + + sort.Slice(teams, func(i, j int) bool { + return teams[i].SortKey > teams[j].SortKey + }) +} + +func groupAndSortTeams(data []ApplicantAdmissionsData) ([]TeamAdmissionData, []ApplicantAdmissionsData) { + teamMap := make(map[uuid.UUID][]ApplicantAdmissionsData) + var soloApplicants []ApplicantAdmissionsData + for _, app := range data { + if app.TeamID.Valid { + teamMap[app.TeamID.UUID] = append(teamMap[app.TeamID.UUID], app) + } else { + soloApplicants = append(soloApplicants, app) + } + } + + var teams []TeamAdmissionData + for teamID, members := range teamMap { + var totalScore float64 + for _, member := range members { + totalScore += member.WeightedScore + } + + teams = append(teams, TeamAdmissionData{ + TeamID: teamID, + MembersAdmissionData: members, + AverageWeightedScore: totalScore / float64(len(members)), + }) + } + + return teams, soloApplicants +} + +func (s *BatService) LogAdmissionsStats(data []ApplicantAdmissionsData) { + type scoreStats struct { + count int + sum float64 + min float64 + max float64 + } + + newStats := func() scoreStats { + return scoreStats{ + min: math.Inf(1), + max: math.Inf(-1), + } + } + + update := func(s *scoreStats, score float64) { + s.count++ + s.sum += score + if score < s.min { + s.min = score + } + if score > s.max { + s.max = score + } + } + + finalize := func(s scoreStats) map[string]any { + if s.count == 0 { + return map[string]any{ + "count": 0, + } + } + return map[string]any{ + "count": s.count, + "avg": s.sum / float64(s.count), + "min": s.min, + "max": s.max, + } + } + + var ( + total = newStats() + uf = newStats() + nonUF = newStats() + earlyCareer = newStats() + upperCareer = newStats() + teamApplicants = newStats() + soloApplicants = newStats() + ) + + for _, a := range data { + update(&total, a.WeightedScore) + + if a.IsUFStudent { + update(&uf, a.WeightedScore) + } else { + update(&nonUF, a.WeightedScore) + } + + if a.IsEarlyCareer { + update(&earlyCareer, a.WeightedScore) + } else { + update(&upperCareer, a.WeightedScore) + } + + if a.TeamID.Valid { + update(&teamApplicants, a.WeightedScore) + } else { + update(&soloApplicants, a.WeightedScore) + } + } + + s.logger.Info(). + Int("total_applicants", len(data)). + Fields(map[string]any{ + "overall": finalize(total), + "uf_students": finalize(uf), + "non_uf_students": finalize(nonUF), + "early_career": finalize(earlyCareer), + "upper_career": finalize(upperCareer), + "team_applicants": finalize(teamApplicants), + "solo_applicants": finalize(soloApplicants), + }). + Msg("Admissions statistics snapshot") +} diff --git a/apps/api/internal/tasks/bat.go b/apps/api/internal/tasks/bat.go new file mode 100644 index 00000000..d235ee14 --- /dev/null +++ b/apps/api/internal/tasks/bat.go @@ -0,0 +1,25 @@ +package tasks + +import ( + "encoding/json" + + "github.com/google/uuid" + "github.com/hibiken/asynq" +) + +const ( + TypeCalculateAdmissions = "admissions:calculate" +) + +type CalculateAdmissionsPayload struct { + EventID 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/workers/bat.go b/apps/api/internal/workers/bat.go index 00ef169d..a44cdc6e 100644 --- a/apps/api/internal/workers/bat.go +++ b/apps/api/internal/workers/bat.go @@ -1,6 +1,14 @@ 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/services" + "github.com/swamphacks/core/apps/api/internal/tasks" +) // BAT Worker // The BAT worker runs the background execution pipeline for our @@ -10,11 +18,30 @@ 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 } + + // Fire off bat service + err := w.batService.CalculateAdmissions(ctx, p.EventID) + if err != nil { + w.logger.Err(err).Msg("Something went wrong calculating admissions.") + return err + } + + return nil }