Skip to content
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ local:
api:
docker compose up api

bat:
docker compose up api bat_worker asynqmon

storage:
docker compose up postgres redis

Expand Down
21 changes: 20 additions & 1 deletion apps/api/cmd/BAT_worker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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")
Expand Down
10 changes: 9 additions & 1 deletion apps/api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)

Expand Down
5 changes: 3 additions & 2 deletions apps/api/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions apps/api/internal/api/handlers/admissions.go
Original file line number Diff line number Diff line change
@@ -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)

}
106 changes: 106 additions & 0 deletions apps/api/internal/api/handlers/bat.go
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 5 additions & 0 deletions apps/api/internal/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type Handlers struct {
Email *EmailHandler
Application *ApplicationHandler
Teams *TeamHandler
Admission *AdmissionHandler
Bat *BatHandler
}

func NewHandlers(
Expand All @@ -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 {
Expand All @@ -35,5 +38,7 @@ func NewHandlers(
Email: NewEmailHandler(emailService, logger),
Application: NewApplicationHandler(appService),
Teams: NewTeamHandler(teamService, logger),
Admission: NewAdmissionHandler(batService),
Bat: NewBatHandler(batService, logger),
}
}
60 changes: 60 additions & 0 deletions apps/api/internal/bat/scores.go
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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

20 changes: 19 additions & 1 deletion apps/api/internal/db/queries/applications.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
WHERE user_id = $1 AND event_id = $2;
Loading