diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 67612e28..e8bf2d3f 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -150,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/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 f40a842c..88c1e4b9 100644 --- a/apps/api/internal/api/handlers/handlers.go +++ b/apps/api/internal/api/handlers/handlers.go @@ -15,6 +15,7 @@ type Handlers struct { Application *ApplicationHandler Teams *TeamHandler Admission *AdmissionHandler + Bat *BatHandler } func NewHandlers( @@ -38,5 +39,6 @@ func NewHandlers( Application: NewApplicationHandler(appService), Teams: NewTeamHandler(teamService, logger), Admission: NewAdmissionHandler(batService), + Bat: NewBatHandler(batService, logger), } } 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/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/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/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 5cd4c35b..024d4e2e 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 ( @@ -330,6 +373,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 index 8653a3e5..81968d96 100644 --- a/apps/api/internal/services/bat.go +++ b/apps/api/internal/services/bat.go @@ -14,20 +14,26 @@ import ( "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 - taskQueue *asynq.Client - logger zerolog.Logger + 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 { @@ -40,6 +46,59 @@ func NewBatService(engine *bat.BatEngine, appRepo *repository.ApplicationReposit } } +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, @@ -94,22 +153,18 @@ type TeamAdmissionData struct { func (s *BatService) CalculateAdmissions(ctx context.Context, eventId uuid.UUID) error { s.logger.Info().Str("eventId", eventId.String()).Msg("") - // First aggregate data necessary + // 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 } - // event, err := s.eventRepo.GetEventByID(ctx, eventId) - // if err != nil { - // return err - // } - - // maxAttendees := int32(500) - // if event.MaxAttendees != nil { - // maxAttendees = *event.MaxAttendees - // } - var appAdmissionsData []ApplicantAdmissionsData for _, app := range applications { if app.ExperienceRating == nil || app.PassionRating == nil { @@ -158,6 +213,32 @@ func (s *BatService) CalculateAdmissions(ctx context.Context, eventId uuid.UUID) 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