diff --git a/.github/workflows/deploy-caddy.yml b/.github/workflows/deploy-caddy.yml
new file mode 100644
index 00000000..6e30a5c6
--- /dev/null
+++ b/.github/workflows/deploy-caddy.yml
@@ -0,0 +1,45 @@
+name: Deploy Caddy
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ deploy:
+ name: Deploy to Production Server
+ runs-on: ubuntu-latest
+
+ 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 caddy
+ docker compose -f docker-compose.api.yml up -d --no-deps --force-recreate caddy
diff --git a/.github/workflows/dev-deploy-asynqmon.yml b/.github/workflows/dev-deploy-asynqmon.yml
new file mode 100644
index 00000000..cd0010f8
--- /dev/null
+++ b/.github/workflows/dev-deploy-asynqmon.yml
@@ -0,0 +1,30 @@
+name: Deploy Asynqmon to Development
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ deploy:
+ name: Deploy to Development Server
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: SSH proxy commmand
+ 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
+
+ docker compose -f docker-compose.api.yml pull asynqmon-dev
+ docker compose -f docker-compose.api.yml up -d --no-deps --force-recreate asynqmon-dev
diff --git a/.github/workflows/prod-deploy-asynqmon.yml b/.github/workflows/prod-deploy-asynqmon.yml
new file mode 100644
index 00000000..8466d69f
--- /dev/null
+++ b/.github/workflows/prod-deploy-asynqmon.yml
@@ -0,0 +1,30 @@
+name: Deploy Asynqmon to Production
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ deploy:
+ name: Deploy to Production Server
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: SSH proxy commmand
+ 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
+
+ docker compose -f docker-compose.api.yml pull asynqmon
+ docker compose -f docker-compose.api.yml up -d --no-deps --force-recreate asynqmon
diff --git a/apps/api/.env.dev.example b/apps/api/.env.dev.example
index 036a4721..66b5994b 100644
--- a/apps/api/.env.dev.example
+++ b/apps/api/.env.dev.example
@@ -3,6 +3,11 @@ DATABASE_URL_MIGRATION="postgres://postgres:postgres@localhost:5432/coredb"
REDIS_URL="redis://redis:6379"
ALLOWED_ORIGINS="" # URLs in comma seperated list
+# Application waitlist
+MAX_ACCEPTED_APPLICATIONS=500
+ACCEPT_FROM_WAITLIST_COUNT=50
+ACCEPT_FROM_WAITLIST_PERIOD="@every 72h"
+
# For OAuth
AUTH_DISCORD_CLIENT_ID=
AUTH_DISCORD_CLIENT_SECRET=
diff --git a/apps/api/cmd/BAT_worker/main.go b/apps/api/cmd/BAT_worker/main.go
index 755343d8..6bdd404d 100644
--- a/apps/api/cmd/BAT_worker/main.go
+++ b/apps/api/cmd/BAT_worker/main.go
@@ -56,6 +56,19 @@ func main() {
},
)
+ schedulerLocation, err := time.LoadLocation("America/New_York")
+ if err != nil {
+ panic(err)
+ }
+ scheduler := asynq.NewScheduler(
+ redisOpt,
+ &asynq.SchedulerOpts{
+ Location: schedulerLocation,
+ },
+ )
+
+ taskQueueClient := asynq.NewClient(redisOpt)
+
database := db.NewDB(cfg.DatabaseURL)
defer database.Close()
@@ -64,16 +77,21 @@ func main() {
applicationRepo := repository.NewApplicationRepository(database)
eventRepo := repository.NewEventRespository(database)
userRepo := repository.NewUserRepository(database)
+ eventService := services.NewEventService(eventRepo, userRepo, nil, nil, logger)
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)
+ emailService := services.NewEmailService(taskQueueClient, sesClient, logger)
+ batService := services.NewBatService(applicationRepo, eventRepo, userRepo, batRunsRepo, emailService, txm, nil, scheduler, logger)
+ applicationService := services.NewApplicationService(applicationRepo, userRepo, eventService, emailService, txm, nil, nil, scheduler, logger)
- BATWorker := workers.NewBATWorker(batService, logger)
+ BATWorker := workers.NewBATWorker(batService, applicationService, scheduler, taskQueueClient, logger)
mux := asynq.NewServeMux()
mux.HandleFunc(tasks.TypeCalculateAdmissions, BATWorker.HandleCalculateAdmissionsTask)
+ mux.HandleFunc(tasks.TypeTransitionWaitlist, BATWorker.HandleTransitionWaitlistTask)
+ mux.HandleFunc(tasks.TypeScheduleTransitionWaitlist, BATWorker.HandleScheduleTransitionWaitlistTask)
+ mux.HandleFunc(tasks.TypeShutdownScheduler, BATWorker.HandleShutdownScheduler)
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 e932ac8e..fd15a735 100644
--- a/apps/api/cmd/api/main.go
+++ b/apps/api/cmd/api/main.go
@@ -89,9 +89,9 @@ func main() {
eventInterestService := services.NewEventInterestService(eventInterestRepo, logger)
eventService := services.NewEventService(eventRepo, userRepo, r2Client, &cfg.CoreBuckets, logger)
emailService := services.NewEmailService(taskQueueClient, sesClient, logger)
- applicationService := services.NewApplicationService(applicationRepo, eventService, emailService, txm, r2Client, &cfg.CoreBuckets, logger)
+ applicationService := services.NewApplicationService(applicationRepo, userRepo, eventService, emailService, txm, r2Client, &cfg.CoreBuckets, nil, logger)
teamService := services.NewTeamService(teamRepo, teamMemberRepo, teamJoinRequestRepo, eventRepo, txm, logger)
- batService := services.NewBatService(applicationRepo, eventRepo, userRepo, batRunsRepo, emailService, txm, taskQueueClient, logger)
+ batService := services.NewBatService(applicationRepo, eventRepo, userRepo, batRunsRepo, emailService, txm, taskQueueClient, nil, logger)
// Injections into handlers
apiHandlers := handlers.NewHandlers(authService, userService, eventInterestService, eventService, emailService, applicationService, teamService, batService, cfg, logger)
diff --git a/apps/api/cmd/email_worker/main.go b/apps/api/cmd/email_worker/main.go
index ec266505..d03da5bb 100644
--- a/apps/api/cmd/email_worker/main.go
+++ b/apps/api/cmd/email_worker/main.go
@@ -46,7 +46,6 @@ func main() {
mux := asynq.NewServeMux()
- mux.HandleFunc(tasks.TypeSendConfirmationEmail, emailWorker.HandleSendConfirmationEmailTask)
mux.HandleFunc(tasks.TypeSendHtmlEmail, emailWorker.HandleSendHtmlEmailTask)
wd, err := os.Getwd()
diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go
index 5fb09d04..9001c779 100644
--- a/apps/api/internal/api/api.go
+++ b/apps/api/internal/api/api.go
@@ -122,9 +122,6 @@ func (api *API) setupRoutes(mw *mw.Middleware) {
// --- Event routes ---
api.Router.Route("/events", func(r chi.Router) {
- 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)
@@ -143,6 +140,12 @@ func (api *API) setupRoutes(mw *mw.Middleware) {
r.With(ensureEventStaff).Get("/overview", api.Handlers.Event.GetEventOverview)
// Admin-only
+ r.With(ensureEventAdmin).Post("/queue-confirmation-email", api.Handlers.Email.QueueConfirmationEmail)
+ r.With(ensureEventAdmin).Post("/calc-admissions", api.Handlers.Admission.HandleCalculateAdmissionsRequest)
+ r.With(ensureEventAdmin).Patch("/transition-waitlisted-applications", api.Handlers.Application.TransitionWaitlistedApplications)
+ r.With(ensureEventAdmin).Post("/begin-waitlist-transition", api.Handlers.Bat.QueueScheduleWaitlistTransitionTask)
+ r.With(ensureEventAdmin).Post("/shutdown-waitlist-scheduler", api.Handlers.Bat.QueueShutdownWaitlistSchedulerTask)
+ r.With(ensureEventAdmin).Post("/reviews/bat-runs/{runId}/release", api.Handlers.Admission.ReleaseDecisions)
r.With(ensureEventAdmin).Patch("/", api.Handlers.Event.UpdateEventById)
r.With(ensureEventAdmin).Post("/banner", api.Handlers.Event.UploadEventBanner)
r.With(ensureEventAdmin).Delete("/banner", api.Handlers.Event.DeleteBanner)
diff --git a/apps/api/internal/api/handlers/application.go b/apps/api/internal/api/handlers/application.go
index b8c3d7de..7fcfc706 100644
--- a/apps/api/internal/api/handlers/application.go
+++ b/apps/api/internal/api/handlers/application.go
@@ -652,7 +652,7 @@ func (h *ApplicationHandler) WithdrawAcceptance(w http.ResponseWriter, r *http.R
// @Tags Application Event
//
// @Param eventId path string true "ID of the event to join the waitlist for"
-// @Success 200 "Acceptance withdrawn joined successfully"
+// @Success 200 "Acceptance successful"
// @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]
@@ -672,3 +672,32 @@ func (h *ApplicationHandler) AcceptApplicationAcceptance(w http.ResponseWriter,
w.WriteHeader(http.StatusOK)
}
+
+// Transition waitlisted applications
+//
+// @Summary Sets application status from accepted to rejected
+// @Description Transitions all accepted users to waitlist, and accepts 50 from the waitlist.
+// @Tags Application Event
+//
+// @Param eventId path string true "ID of the event to join the waitlist for"
+// @Success 200 "Transitioned application statuses successfully"
+// @Failure 400 {object} res.ErrorResponse "Bad request: invalid event ID"
+// @Failure 500 {object} res.ErrorResponse "Server error: failed to transition application statuses"
+// @Router /events/{eventId}/application/transition-waitlisted-applications [patch]
+func (h *ApplicationHandler) TransitionWaitlistedApplications(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
+ }
+
+ var acceptanceCount uint32 = 50
+ var acceptanceQuota uint32 = 500
+ err = h.appService.TransitionWaitlistedApplications(r.Context(), eventId, acceptanceCount, acceptanceQuota)
+ if err != nil {
+ res.SendError(w, http.StatusInternalServerError, res.NewError("transition-waitlisted-applications-error", "Something went wrong while transitioning waitlisted applications."))
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
diff --git a/apps/api/internal/api/handlers/bat.go b/apps/api/internal/api/handlers/bat.go
index c699d16b..dd796e8a 100644
--- a/apps/api/internal/api/handlers/bat.go
+++ b/apps/api/internal/api/handlers/bat.go
@@ -10,6 +10,7 @@ import (
"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 BatHandler struct {
@@ -116,10 +117,10 @@ func (h *BatHandler) CheckApplicationReviewsComplete(w http.ResponseWriter, r *h
}
}
-// Delete an event
+// Delete a run
//
-// @Summary Delete an event
-// @Description Delete an existing event
+// @Summary Delete a run
+// @Description Delete an existing BAT run
// @Tags Bat
// @Accept json
// @Produce json
@@ -151,3 +152,47 @@ func (h *BatHandler) DeleteRunById(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
+
+// Queue transition waitlist task
+//
+// @Summary Queues a waitlist transition task
+// @Description Queues an asynq task that transitions waitlisted applications, running every 3 days.
+// @Tags
+//
+// @Param eventId path string true "ID of the event to join the waitlist for"
+// @Success 200 "Transitioned application statuses successfully"
+// @Failure 400 {object} res.ErrorResponse "Bad request: invalid event ID"
+// @Failure 500 {object} res.ErrorResponse "Server error: failed to transition application statuses"
+// @Router /events/{eventId}/queue-transition-waitlist-task [post]
+func (h *BatHandler) QueueScheduleWaitlistTransitionTask(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 valid."))
+ return
+ }
+
+ err = h.BatService.QueueScheduleWaitlistTransitionTask(r.Context(), eventId)
+ if err != nil {
+ res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Failed to create ScheduleWaitlistTransition task."))
+ }
+
+ res.Send(w, http.StatusCreated, nil)
+}
+
+// Queue Shutdown scheduler task
+//
+// @Summary Shutsdown an asynq scheduler
+// @Description Shutsdown the scheduler used for the waitlist transition task. Error returned through logs if a scheduler is not active.
+// @Tags
+//
+// @Success 200 "Scheduler shutdown successfully"
+// @Failure 500 {object} res.ErrorResponse "Server error: failed to shutdown scheduler"
+// @Router /events/{eventId}/queue-transition-waitlist-task [post]
+func (h *BatHandler) QueueShutdownWaitlistSchedulerTask(w http.ResponseWriter, r *http.Request) {
+ err := h.BatService.QueueShutdownWaitlistScheduler()
+ if err != nil {
+ res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Failed to shutdown scheduler."))
+ }
+
+ res.Send(w, http.StatusOK, nil)
+}
diff --git a/apps/api/internal/api/handlers/email.go b/apps/api/internal/api/handlers/email.go
index dbc5aba5..d0639bdf 100644
--- a/apps/api/internal/api/handlers/email.go
+++ b/apps/api/internal/api/handlers/email.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
+ "github.com/go-playground/validator/v10"
"github.com/rs/zerolog"
res "github.com/swamphacks/core/apps/api/internal/api/response"
"github.com/swamphacks/core/apps/api/internal/email"
@@ -27,10 +28,6 @@ type QueueTextEmailRequest struct {
Subject string `json:"subject"`
Body string `json:"body"`
}
-type QueueConfirmationEmailRequest struct {
- To string `json:"to"`
- Name string `json:"name"`
-}
// Queue an Email Request
//
@@ -79,3 +76,30 @@ func (h *EmailHandler) QueueTextEmail(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
}
+
+type QueueConfirmationEmailFields struct {
+ Email string `json:"email" validate:"required"`
+ FirstName string `json:"firstName" validate:"required"`
+}
+
+func (h *EmailHandler) QueueConfirmationEmail(w http.ResponseWriter, r *http.Request) {
+ var req QueueConfirmationEmailFields
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+ err := decoder.Decode(&req)
+ if err != nil {
+ res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "Could not parse request body"))
+ return
+ }
+ validate := validator.New()
+ if err := validate.Struct(req); err != nil {
+ res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", err.Error()))
+ }
+
+ err = h.emailService.QueueConfirmationEmail(req.Email, req.FirstName)
+ if err != nil {
+ res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Confirmation email could not be queued."))
+ }
+
+ res.Send(w, http.StatusOK, nil)
+}
diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go
index e1efc8e4..fbb74fde 100644
--- a/apps/api/internal/config/config.go
+++ b/apps/api/internal/config/config.go
@@ -31,6 +31,7 @@ type CloudflareConfig struct {
AccessKeySecret string `env:"ACCESS_KEY_SECRET"`
}
+// TODO: deprecate.
type SmtpConfig struct {
Username string `env:"USERNAME"`
Password string `env:"PASSWORD"`
@@ -54,12 +55,15 @@ 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"`
- EmailTemplateDirectory string `env:"EMAIL_TEMPLATE_DIRECTORY"`
- 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 ``
+ MaxAcceptedApplications uint32 `env:"MAX_ACCEPTED_APPLICATIONS"`
+ AcceptFromWaitlistCount uint32 `env:"ACCEPT_FROM_WAITLIST_COUNT"`
+ AcceptFromWaitlistPeriod string `env:"ACCEPT_FROM_WAITLIST_PERIOD"`
Auth AuthConfig `envPrefix:"AUTH_"`
Cookie CookieConfig `envPrefix:"COOKIE_"`
diff --git a/apps/api/internal/db/queries/applications.sql b/apps/api/internal/db/queries/applications.sql
index 943c0d9e..4e70e8c5 100644
--- a/apps/api/internal/db/queries/applications.sql
+++ b/apps/api/internal/db/queries/applications.sql
@@ -98,3 +98,29 @@ UPDATE applications
SET status = @status::application_status
WHERE event_id = @event_id::uuid
AND user_id = ANY(@user_ids::uuid[]);
+
+-- name: TransitionAcceptedApplicationsToWaitlistByEventID :exec
+UPDATE applications
+SET waitlist_join_time = COALESCE(waitlist_join_time, NOW()),
+ status = 'waitlisted'
+WHERE event_id = @event_id::uuid
+ AND status = 'accepted'
+ AND user_id IN (
+ SELECT user_id from event_roles AS er
+ WHERE er.role = 'applicant'
+)
+;
+
+-- name: TransitionWaitlistedApplicationsToAcceptedByEventID :many
+UPDATE applications
+SET waitlist_join_time = NULL,
+ status = 'accepted'
+WHERE user_id IN (
+ SELECT user_id FROM applications
+ WHERE event_id = @event_id::uuid
+ AND status = 'waitlisted'
+ ORDER BY waitlist_join_time ASC
+ LIMIT @acceptanceCount::int
+)
+RETURNING user_id;
+
diff --git a/apps/api/internal/db/queries/event_roles.sql b/apps/api/internal/db/queries/event_roles.sql
index 82e76ad2..91b715ca 100644
--- a/apps/api/internal/db/queries/event_roles.sql
+++ b/apps/api/internal/db/queries/event_roles.sql
@@ -24,4 +24,9 @@ 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
+WHERE event_id = $1 AND user_id = $2;
+
+-- name: GetAttendeeCountByEventId :one
+SELECT COUNT(*) FROM event_roles AS er
+WHERE er.event_id = @event_id::uuid
+ AND er.role = 'attendee';
diff --git a/apps/api/internal/db/repository/application.go b/apps/api/internal/db/repository/application.go
index 5b2a868c..947d266c 100644
--- a/apps/api/internal/db/repository/application.go
+++ b/apps/api/internal/db/repository/application.go
@@ -199,3 +199,19 @@ func (r *ApplicationRepository) JoinWaitlist(ctx context.Context, userId, eventI
EventID: eventId,
})
}
+
+func (r *ApplicationRepository) TransitionAcceptedApplicationsToWaitlistByEventID(ctx context.Context, eventId uuid.UUID) error {
+ return r.db.Query.TransitionAcceptedApplicationsToWaitlistByEventID(ctx, eventId)
+}
+
+func (r *ApplicationRepository) TransitionWaitlistedApplicationsToAcceptedByEventID(ctx context.Context, eventId uuid.UUID, acceptanceCount uint32) ([]uuid.UUID, error) {
+ return r.db.Query.TransitionWaitlistedApplicationsToAcceptedByEventID(ctx, sqlc.TransitionWaitlistedApplicationsToAcceptedByEventIDParams{
+ EventID: eventId,
+ Acceptancecount: int32(acceptanceCount),
+ })
+}
+
+func (r *ApplicationRepository) GetAttendeeCountByEventId(ctx context.Context, eventId uuid.UUID) (uint32, error) {
+ amount, err := r.db.Query.GetAttendeeCountByEventId(ctx, eventId)
+ return uint32(amount), err
+}
diff --git a/apps/api/internal/db/sqlc/applications.sql.go b/apps/api/internal/db/sqlc/applications.sql.go
index 182face8..d541ba04 100644
--- a/apps/api/internal/db/sqlc/applications.sql.go
+++ b/apps/api/internal/db/sqlc/applications.sql.go
@@ -294,6 +294,62 @@ func (q *Queries) ResetApplicationReviews(ctx context.Context, eventID uuid.UUID
return err
}
+const transitionAcceptedApplicationsToWaitlistByEventID = `-- name: TransitionAcceptedApplicationsToWaitlistByEventID :exec
+UPDATE applications
+SET waitlist_join_time = COALESCE(waitlist_join_time, NOW()),
+ status = 'waitlisted'
+WHERE event_id = $1::uuid
+ AND status = 'accepted'
+ AND user_id IN (
+ SELECT user_id from event_roles AS er
+ WHERE er.role = 'applicant'
+)
+`
+
+func (q *Queries) TransitionAcceptedApplicationsToWaitlistByEventID(ctx context.Context, eventID uuid.UUID) error {
+ _, err := q.db.Exec(ctx, transitionAcceptedApplicationsToWaitlistByEventID, eventID)
+ return err
+}
+
+const transitionWaitlistedApplicationsToAcceptedByEventID = `-- name: TransitionWaitlistedApplicationsToAcceptedByEventID :many
+UPDATE applications
+SET waitlist_join_time = NULL,
+ status = 'accepted'
+WHERE user_id IN (
+ SELECT user_id FROM applications
+ WHERE event_id = $1::uuid
+ AND status = 'waitlisted'
+ ORDER BY waitlist_join_time ASC
+ LIMIT $2::int
+)
+RETURNING user_id
+`
+
+type TransitionWaitlistedApplicationsToAcceptedByEventIDParams struct {
+ EventID uuid.UUID `json:"event_id"`
+ Acceptancecount int32 `json:"acceptancecount"`
+}
+
+func (q *Queries) TransitionWaitlistedApplicationsToAcceptedByEventID(ctx context.Context, arg TransitionWaitlistedApplicationsToAcceptedByEventIDParams) ([]uuid.UUID, error) {
+ rows, err := q.db.Query(ctx, transitionWaitlistedApplicationsToAcceptedByEventID, arg.EventID, arg.Acceptancecount)
+ 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 updateApplication = `-- name: UpdateApplication :exec
UPDATE applications
SET
diff --git a/apps/api/internal/db/sqlc/event_roles.sql.go b/apps/api/internal/db/sqlc/event_roles.sql.go
index d7693126..0073dbe2 100644
--- a/apps/api/internal/db/sqlc/event_roles.sql.go
+++ b/apps/api/internal/db/sqlc/event_roles.sql.go
@@ -29,6 +29,19 @@ func (q *Queries) AssignRole(ctx context.Context, arg AssignRoleParams) error {
return err
}
+const getAttendeeCountByEventId = `-- name: GetAttendeeCountByEventId :one
+SELECT COUNT(*) FROM event_roles AS er
+WHERE er.event_id = $1::uuid
+ AND er.role = 'attendee'
+`
+
+func (q *Queries) GetAttendeeCountByEventId(ctx context.Context, eventID uuid.UUID) (int64, error) {
+ row := q.db.QueryRow(ctx, getAttendeeCountByEventId, eventID)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
const getEventStaff = `-- name: GetEventStaff :many
SELECT u.id, u.name, u.email, u.email_verified, u.onboarded, u.image, u.created_at, u.updated_at, u.role, u.preferred_email, u.email_consent, er.role AS event_role
FROM auth.users u
diff --git a/apps/api/internal/email/templates/WaitlistAcceptanceEmail.html b/apps/api/internal/email/templates/WaitlistAcceptanceEmail.html
new file mode 100644
index 00000000..aaec6d33
--- /dev/null
+++ b/apps/api/internal/email/templates/WaitlistAcceptanceEmail.html
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+ You're In! Confirm Your Spot at SwampHacks XI
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+ Hi {{ .Name }},
+ |
+
+
+
+
+ |
+
+ You're off the waitlist — congratulations on being accepted to hack in SwampHacks XI! 🎉
+
+
+
+ You have 72 hours to confirm your spot. If you
+ don’t,
+ you’ll be moved back to the
+ waitlist to make room for others.
+
+
+
+
+
+ Once confirmed, get ready to build, learn, and connect! Here’s what to do next:
+
+
+
+ - Find a team (or join one in the portal!)
+ - Review the tentative event schedule below
+
+
+ Hackathon Location
+
+ SwampHacks XI will 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.
+
+
+
+ Questions? Reach out in our Discord server or email us at contact@swamphacks.com.
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/api/internal/services/application.go b/apps/api/internal/services/application.go
index c568f6d0..6ceba220 100644
--- a/apps/api/internal/services/application.go
+++ b/apps/api/internal/services/application.go
@@ -5,6 +5,7 @@ import (
"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/config"
@@ -73,22 +74,26 @@ var (
type ApplicationService struct {
appRepo *repository.ApplicationRepository
+ userRepo *repository.UserRepository
eventsService *EventService
emailService *EmailService
storage storage.Storage
buckets *config.CoreBuckets
txm *db.TransactionManager
+ scheduler *asynq.Scheduler
logger zerolog.Logger
}
-func NewApplicationService(appRepo *repository.ApplicationRepository, eventsService *EventService, emailService *EmailService, txm *db.TransactionManager, storage storage.Storage, buckets *config.CoreBuckets, logger zerolog.Logger) *ApplicationService {
+func NewApplicationService(appRepo *repository.ApplicationRepository, userRepo *repository.UserRepository, eventsService *EventService, emailService *EmailService, txm *db.TransactionManager, storage storage.Storage, buckets *config.CoreBuckets, scheduler *asynq.Scheduler, logger zerolog.Logger) *ApplicationService {
return &ApplicationService{
appRepo: appRepo,
+ userRepo: userRepo,
eventsService: eventsService,
emailService: emailService,
storage: storage,
buckets: buckets,
txm: txm,
+ scheduler: scheduler,
logger: logger,
}
}
@@ -164,8 +169,7 @@ func (s *ApplicationService) SubmitApplication(ctx context.Context, data Applica
return nil
})
- taskInfo, err := s.emailService.QueueSendConfirmationEmail(data.PreferredEmail, data.FirstName)
- s.logger.Info().Str("TaskID", taskInfo.ID).Str("Task Queue", taskInfo.Queue).Str("Task Type", taskInfo.Type).Msg("Queued SendConfirmationEmail task!")
+ err = s.emailService.QueueConfirmationEmail(data.PreferredEmail, data.FirstName)
// Non-blocking error
if err != nil {
@@ -561,3 +565,66 @@ func (s *ApplicationService) AcceptApplicationAcceptance(ctx context.Context, us
}
return nil
}
+
+func (s *ApplicationService) TransitionWaitlistedApplications(ctx context.Context, eventId uuid.UUID, acceptanceCount uint32, acceptanceQuota uint32) error {
+ var acceptedUserIds []uuid.UUID
+ err := s.txm.WithTx(ctx, func(tx pgx.Tx) error {
+ txAppRepo := s.appRepo.NewTx(tx)
+
+ err := txAppRepo.TransitionAcceptedApplicationsToWaitlistByEventID(ctx, eventId)
+ if err != nil {
+ s.logger.Err(err).Msg(err.Error())
+ return err
+ }
+
+ attendeeCount, err := s.appRepo.GetAttendeeCountByEventId(ctx, eventId)
+ if err != nil {
+ s.logger.Err(err).Msg("Failed to get total accepted application amount.")
+ }
+ if (acceptanceQuota - attendeeCount) <= acceptanceCount {
+ s.logger.Info().Msgf("Acceptance quota is close, shutting down waitlist transition scheduler. Remaining acceptances: %v - %v <= %v", acceptanceQuota, attendeeCount, acceptanceCount)
+ if s.scheduler != nil {
+ // The API also uses this file, and this function can be run from an endpoint so we have to check that the scheduler exists.
+ // Technically the task should be removed from the scheduler via an scheduler ENTRY_ID. However the scheduler is only running for this task.
+ s.scheduler.Shutdown()
+ }
+ acceptanceCount = acceptanceQuota - attendeeCount
+ }
+
+ s.logger.Info().Msgf("Acceptance count: %v", acceptanceCount)
+ acceptedUserIds, err = txAppRepo.TransitionWaitlistedApplicationsToAcceptedByEventID(ctx, eventId, acceptanceCount)
+ if err != nil {
+ s.logger.Err(err).Msg(err.Error())
+ return err
+ }
+
+ s.logger.Debug().Msgf("Statuses transitioned: %s", acceptedUserIds)
+ return nil
+ })
+
+ if err != nil {
+ s.logger.Err(err).Msg(err.Error())
+ return err
+ }
+
+ for _, userId := range acceptedUserIds {
+ userContactInfo, err := s.userRepo.GetUserEmailInfoById(ctx, userId)
+ if err != nil {
+ s.logger.Err(err).Msg(err.Error())
+ return err
+ }
+
+ contactEmail, ok := userContactInfo.ContactEmail.(string)
+ if !ok {
+ return ErrFailedToGetContactEmail
+ }
+
+ err = s.emailService.QueueWaitlistAcceptanceEmail(contactEmail, userContactInfo.Name)
+ 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
index 818bbdad..748dc6db 100644
--- a/apps/api/internal/services/bat.go
+++ b/apps/api/internal/services/bat.go
@@ -47,12 +47,14 @@ type BatService struct {
emailService *EmailService
txm *db.TransactionManager
taskQueue *asynq.Client
+ scheduler *asynq.Scheduler
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 {
+func NewBatService(appRepo *repository.ApplicationRepository, eventRepo *repository.EventRepository, userRepo *repository.UserRepository, batRunsRepo *repository.BatRunsRepository, emailService *EmailService, txm *db.TransactionManager, taskQueue *asynq.Client, scheduler *asynq.Scheduler, logger zerolog.Logger) *BatService {
return &BatService{
taskQueue: taskQueue,
+ scheduler: scheduler,
appRepo: appRepo,
eventRepo: eventRepo,
userRepo: userRepo,
@@ -359,3 +361,42 @@ func (s *BatService) mapToCandidates(engine *bat.BatEngine, applications []sqlc.
return appAdmissionsData, nil
}
+
+func (s *BatService) QueueScheduleWaitlistTransitionTask(ctx context.Context, eventId uuid.UUID) error {
+
+ cfg := config.Load()
+ task, err := tasks.NewTaskScheduleTransitionWaitlist(tasks.ScheduleTransitionWaitlistPayload{
+ EventID: eventId,
+ Period: cfg.AcceptFromWaitlistPeriod,
+ })
+ if err != nil {
+ s.logger.Err(err).Msg("Failed to create ScheduleTransitionWaitlist task")
+ return err
+ }
+
+ _, err = s.taskQueue.Enqueue(task, asynq.Queue("bat"))
+ if err != nil {
+ s.logger.Err(err).Msg("Failed to queue ScheduleTransitionWaitlist task")
+ return err
+ }
+ s.logger.Info().Msg("Queued TransitionWaitlist task")
+
+ return nil
+}
+
+func (s *BatService) QueueShutdownWaitlistScheduler() error {
+ task, err := tasks.NewTaskShutdownScheduler()
+ if err != nil {
+ s.logger.Err(err).Msg("Failed to create ShutdownWaitlistScheduler task")
+ return err
+ }
+
+ _, err = s.taskQueue.Enqueue(task, asynq.Queue("bat"))
+ if err != nil {
+ s.logger.Err(err).Msg("Failed to queue ShutdownWaitlistScheduler task")
+ return err
+ }
+ s.logger.Info().Msg("Queued ShutdownWaitlistScheduler task")
+
+ return nil
+}
diff --git a/apps/api/internal/services/email.go b/apps/api/internal/services/email.go
index 989740f6..23196980 100644
--- a/apps/api/internal/services/email.go
+++ b/apps/api/internal/services/email.go
@@ -25,24 +25,32 @@ func NewEmailService(taskQueue *asynq.Client, SESClient *email.SESClient, logger
}
}
-// TODO: Refactor
-func (s *EmailService) SendConfirmationEmail(recipient string, name string) error {
+func (s *EmailService) QueueConfirmationEmail(recipient string, name string) error {
+ cfg := config.Load()
- var body bytes.Buffer
+ subject := "SwampHacks XI: we received your application!"
+ templateEmailFilepath := cfg.EmailTemplateDirectory + "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")
- }
+ _, err := s.QueueSendHtmlEmailTask(recipient, subject, name, templateEmailFilepath)
- 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'.")
+ s.logger.Err(err).Msg("Failed to send confirmation email to recipient")
+ return err
}
- err = s.SESClient.SendHTMLEmail([]string{recipient}, "noreply@swamphacks.com", "SwampHacks XI: we received your application!", body.String())
+
+ return nil
+}
+
+func (s *EmailService) QueueWaitlistAcceptanceEmail(recipient string, name string) error {
+ cfg := config.Load()
+
+ subject := "Congratulations! You're in – confirm in 72 hours to keep your spot in SwampHacks XI"
+ templateEmailFilepath := cfg.EmailTemplateDirectory + "WaitlistAcceptanceEmail.html"
+
+ _, err := s.QueueSendHtmlEmailTask(recipient, subject, name, templateEmailFilepath)
+
if err != nil {
- s.logger.Err(err).Msg("Failed to send confirmation email to recipient")
+ s.logger.Err(err).Msg("Failed to send waitlist acceptance email to recipient")
return err
}
@@ -64,7 +72,7 @@ func (s *EmailService) SendHtmlEmail(recipient string, subject string, name stri
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")
+ s.logger.Err(err).Msg("Failed to send html email to recipient")
return err
}
s.logger.Info().Str("Template", templateFilePath).Msg("Sent email")
@@ -74,6 +82,13 @@ func (s *EmailService) SendHtmlEmail(recipient string, subject string, name stri
// 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) {
+ if len(to) == 0 {
+ s.logger.Warn().Msgf("No recipient email found for email being sent from template '%s'", templateFilePath)
+ }
+ if len(name) == 0 {
+ s.logger.Warn().Msgf("No recipient name found for email being sent from template '%s'", templateFilePath)
+ }
+
task, err := tasks.NewTaskSendHtmlEmail(tasks.SendHtmlEmailPayload{
To: to,
Subject: subject,
@@ -86,13 +101,14 @@ func (s *EmailService) QueueSendHtmlEmailTask(to string, subject string, name st
return nil, err
}
- info, err := s.taskQueue.Enqueue(task, asynq.Queue("email"))
+ taskInfo, err := s.taskQueue.Enqueue(task, asynq.Queue("email"))
+ s.logger.Info().Str("TaskID", taskInfo.ID).Str("Task Queue", taskInfo.Queue).Str("Task Type", taskInfo.Type).Msg("Queued SendHtmlEmail task!")
if err != nil {
s.logger.Err(err).Msg("Failed to queue SendHtmlEmail task")
return nil, err
}
- return info, nil
+ return taskInfo, nil
}
func (s *EmailService) QueueSendTextEmail(to []string, subject string, body string) (*asynq.TaskInfo, error) {
@@ -115,23 +131,3 @@ func (s *EmailService) QueueSendTextEmail(to []string, subject string, body stri
return info, nil
}
-
-func (s *EmailService) QueueSendConfirmationEmail(to string, name string) (*asynq.TaskInfo, error) {
- task, err := tasks.NewTaskSendConfirmationEmail(tasks.SendConfirmationEmailPayload{
- To: to,
- Name: name,
- })
-
- if err != nil {
- s.logger.Err(err).Msg("Failed to create SendConfirmationEmail task")
- return nil, err
- }
-
- info, err := s.taskQueue.Enqueue(task, asynq.Queue("email"))
- if err != nil {
- s.logger.Err(err).Msg("Failed to queue SendConfirmationEmail task")
- return nil, err
- }
-
- return info, nil
-}
diff --git a/apps/api/internal/tasks/bat.go b/apps/api/internal/tasks/bat.go
index c6810d03..28164ea4 100644
--- a/apps/api/internal/tasks/bat.go
+++ b/apps/api/internal/tasks/bat.go
@@ -8,7 +8,10 @@ import (
)
const (
- TypeCalculateAdmissions = "admissions:calculate"
+ TypeCalculateAdmissions = "admissions:calculate"
+ TypeScheduleTransitionWaitlist = "waitlist:scheduletransition"
+ TypeTransitionWaitlist = "waitlist:transition"
+ TypeShutdownScheduler = "waitlist:shutdownscheduler"
)
type CalculateAdmissionsPayload struct {
@@ -16,6 +19,17 @@ type CalculateAdmissionsPayload struct {
BatRunID uuid.UUID
}
+type ScheduleTransitionWaitlistPayload struct {
+ EventID uuid.UUID
+ Period string
+}
+
+type TransitionWaitlistPayload struct {
+ EventID uuid.UUID
+ AcceptFromWaitlistCount uint32
+ MaxAcceptedApplications uint32
+}
+
func NewTaskCalculateAdmissions(payload CalculateAdmissionsPayload) (*asynq.Task, error) {
data, err := json.Marshal(payload)
if err != nil {
@@ -24,3 +38,25 @@ func NewTaskCalculateAdmissions(payload CalculateAdmissionsPayload) (*asynq.Task
return asynq.NewTask(TypeCalculateAdmissions, data), nil
}
+
+func NewTaskScheduleTransitionWaitlist(payload ScheduleTransitionWaitlistPayload) (*asynq.Task, error) {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ return asynq.NewTask(TypeScheduleTransitionWaitlist, data), nil
+}
+
+func NewTaskTransitionWaitlist(payload TransitionWaitlistPayload) (*asynq.Task, error) {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ return asynq.NewTask(TypeTransitionWaitlist, data), nil
+}
+
+func NewTaskShutdownScheduler() (*asynq.Task, error) {
+ return asynq.NewTask(TypeShutdownScheduler, nil), nil
+}
diff --git a/apps/api/internal/tasks/email.go b/apps/api/internal/tasks/email.go
index 5b10bacf..08acda63 100644
--- a/apps/api/internal/tasks/email.go
+++ b/apps/api/internal/tasks/email.go
@@ -7,9 +7,8 @@ import (
)
const (
- TypeSendTextEmail = "textemail:send"
- TypeSendHtmlEmail = "htmlemail:send"
- TypeSendConfirmationEmail = "confirmationemail:send"
+ TypeSendTextEmail = "textemail:send"
+ TypeSendHtmlEmail = "htmlemail:send"
)
type SendTextEmailPayload struct {
@@ -25,11 +24,6 @@ type SendHtmlEmailPayload struct {
TemplateFilePath string
}
-type SendConfirmationEmailPayload struct {
- To string
- Name string
-}
-
func NewTaskSendTextEmail(payload SendTextEmailPayload) (*asynq.Task, error) {
data, err := json.Marshal(payload)
if err != nil {
@@ -47,13 +41,3 @@ func NewTaskSendHtmlEmail(payload SendHtmlEmailPayload) (*asynq.Task, error) {
return asynq.NewTask(TypeSendHtmlEmail, data), nil
}
-
-// TODO: refactor
-func NewTaskSendConfirmationEmail(payload SendConfirmationEmailPayload) (*asynq.Task, error) {
- data, err := json.Marshal(payload)
- if err != nil {
- return nil, err
- }
-
- return asynq.NewTask(TypeSendConfirmationEmail, data), nil
-}
diff --git a/apps/api/internal/workers/bat.go b/apps/api/internal/workers/bat.go
index 886cd865..4b954738 100644
--- a/apps/api/internal/workers/bat.go
+++ b/apps/api/internal/workers/bat.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/db/sqlc"
"github.com/swamphacks/core/apps/api/internal/services"
"github.com/swamphacks/core/apps/api/internal/tasks"
@@ -19,14 +20,20 @@ import (
// other decision heuristics. It operates asynchronously to ensure
// fair, consistent, and scalable admissions handling.
type BATWorker struct {
- batService *services.BatService
- logger zerolog.Logger
+ batService *services.BatService
+ applicationService *services.ApplicationService
+ scheduler *asynq.Scheduler
+ taskQueue *asynq.Client
+ logger zerolog.Logger
}
-func NewBATWorker(batService *services.BatService, logger zerolog.Logger) *BATWorker {
+func NewBATWorker(batService *services.BatService, applicationService *services.ApplicationService, scheduler *asynq.Scheduler, taskQueue *asynq.Client, logger zerolog.Logger) *BATWorker {
return &BATWorker{
- batService: batService,
- logger: logger.With().Str("worker", "BATWorker").Str("component", "BAT").Logger(),
+ batService: batService,
+ applicationService: applicationService,
+ logger: logger.With().Str("worker", "BATWorker").Str("component", "BAT").Logger(),
+ scheduler: scheduler,
+ taskQueue: taskQueue,
}
}
@@ -58,3 +65,51 @@ func (w *BATWorker) HandleCalculateAdmissionsTask(ctx context.Context, t *asynq.
return nil
}
+func (w *BATWorker) HandleScheduleTransitionWaitlistTask(ctx context.Context, t *asynq.Task) error {
+ var payload tasks.ScheduleTransitionWaitlistPayload
+ if err := json.Unmarshal(t.Payload(), &payload); err != nil {
+ w.logger.Err(err).Msg("Failed to unmarshal payload.")
+ return err
+ }
+
+ cfg := config.Load()
+ task, err := tasks.NewTaskTransitionWaitlist(tasks.TransitionWaitlistPayload{
+ EventID: payload.EventID,
+ AcceptFromWaitlistCount: cfg.AcceptFromWaitlistCount,
+ MaxAcceptedApplications: cfg.MaxAcceptedApplications,
+ })
+
+ w.scheduler.Start()
+ // The scheduler will make its first run after the period cycles once. So we queue our task immediately as well.
+ _, err = w.scheduler.Register(payload.Period, task, asynq.Queue("bat"))
+ if err != nil {
+ w.logger.Err(err)
+ return nil
+ }
+ _, err = w.taskQueue.Enqueue(task, asynq.Queue("bat"))
+
+ return nil
+}
+
+func (w *BATWorker) HandleTransitionWaitlistTask(ctx context.Context, t *asynq.Task) error {
+ var payload tasks.TransitionWaitlistPayload
+ if err := json.Unmarshal(t.Payload(), &payload); err != nil {
+ w.logger.Err(err).Msg("Failed to unmarshal payload.")
+ return err
+ }
+
+ err := w.applicationService.TransitionWaitlistedApplications(ctx, payload.EventID, payload.AcceptFromWaitlistCount, payload.MaxAcceptedApplications)
+ if err != nil {
+ w.logger.Err(err)
+ return nil
+ }
+
+ return nil
+}
+
+func (w *BATWorker) HandleShutdownScheduler(ctx context.Context, t *asynq.Task) error {
+ w.scheduler.Shutdown()
+ // Error returned by logging.
+
+ return nil
+}
diff --git a/apps/api/internal/workers/email.go b/apps/api/internal/workers/email.go
index 6b2fb7f9..a881de80 100644
--- a/apps/api/internal/workers/email.go
+++ b/apps/api/internal/workers/email.go
@@ -36,17 +36,3 @@ func (w *EmailWorker) HandleSendHtmlEmailTask(ctx context.Context, t *asynq.Task
}
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 {
- w.logger.Err(err)
- return fmt.Errorf("HandleSendConfirmationEmailTask: json.Unmarshal failed: %v: %w", err, asynq.SkipRetry)
- }
-
- if err := w.emailService.SendConfirmationEmail(p.To, p.Name); err != nil {
- w.logger.Err(err).Msg("Failed to send ConfirmationEmail from worker")
- return err
- }
- return nil
-}
diff --git a/apps/web/src/lib/qr-intents/README.md b/apps/web/src/lib/qr-intents/README.md
new file mode 100644
index 00000000..8cd0c01a
--- /dev/null
+++ b/apps/web/src/lib/qr-intents/README.md
@@ -0,0 +1,26 @@
+# QR Intents
+
+This is a small library useful for standardizing and generation and parsing of QR strings. It is only applicable for SwampHacks platform and is used to abstract away action matching etc.
+
+Example:
+
+```ts
+ // Receive checkin::userId+eventId
+ const input = "checkin::9c2f4c3a-8e7b-4f2e-9a1d-3b6a1c5f9e42+3f6d1a92-1e5c-4c8a-bf2d-7a9e8c4b2d11"
+ const (action, inputs) = parseQrIntent(input)
+ switch (action) {
+ // Do something based on them
+ }
+
+```
+
+Here is an example of generating the QR string
+
+```ts
+ const userId = "3f6d1a92-1e5c-4c8a-bf2d-7a9e8c4b2d11"
+ const eventId = "event1"
+
+ const qrString = generateQrIntentString({Action.CheckIn, userId, eventId})
+
+ // qrString = "checkin::3f6d1a92-1e5c-4c8a-bf2d-7a9e8c4b2d11+event1"
+```
diff --git a/apps/web/src/lib/qr-intents/intent.ts b/apps/web/src/lib/qr-intents/intent.ts
new file mode 100644
index 00000000..0e9365c1
--- /dev/null
+++ b/apps/web/src/lib/qr-intents/intent.ts
@@ -0,0 +1,6 @@
+export const Intent = {
+ CHECK_IN: "CHECK_IN",
+ REDEEM: "REDEEM",
+} as const;
+
+export type Intent = (typeof Intent)[keyof typeof Intent];
diff --git a/apps/web/src/lib/qr-intents/parse.ts b/apps/web/src/lib/qr-intents/parse.ts
new file mode 100644
index 00000000..ba93c602
--- /dev/null
+++ b/apps/web/src/lib/qr-intents/parse.ts
@@ -0,0 +1,102 @@
+import * as z from "zod";
+import { Intent } from "./intent";
+
+// Used for checking attendees into an event
+type CheckInIntent = {
+ intent: typeof Intent.CHECK_IN;
+ user_id: string;
+ event_id: string;
+};
+
+// Used for redeeming redeemables (food, t-shirts, etc)
+type RedeemIntent = {
+ intent: typeof Intent.REDEEM;
+ redeemable_id: string;
+ user_id: string;
+ event_id: string;
+};
+
+export type QRIntent = CheckInIntent | RedeemIntent;
+export type IntentParseError = "MALFORMED_INTENT_HEADER" | "MALFORMED_BODY";
+
+// Can be abstracted away later
+type Result = { ok: true; value: T } | { ok: false; error: E };
+
+export function parseQrIntent(
+ input: string,
+): Result {
+ if (input.trim().length <= 0) {
+ return {
+ ok: false,
+ error: "MALFORMED_INTENT_HEADER",
+ };
+ }
+
+ const [head, body] = input.trim().split("::");
+
+ switch (head.toUpperCase()) {
+ case "CHECKIN": {
+ const res = parseCheckIn(body);
+
+ if (!res.ok) {
+ return {
+ ok: false,
+ error: "MALFORMED_BODY",
+ };
+ }
+
+ return {
+ ok: true,
+ value: {
+ intent: Intent.CHECK_IN,
+ ...res.value,
+ },
+ };
+ }
+
+ case "REDEEM": {
+ console.log("REDEEM detected");
+ break;
+ }
+
+ default: {
+ console.log(`Header not recognized : ${head.toUpperCase()}`);
+ return {
+ ok: false,
+ error: "MALFORMED_INTENT_HEADER",
+ };
+ }
+ }
+
+ return { ok: false, error: "MALFORMED_INTENT_HEADER" };
+}
+
+const CheckInSchema = z.object({
+ user_id: z.uuid(),
+ event_id: z.uuid(),
+});
+
+type CheckInFields = z.infer;
+
+type CheckInParseError = "CHECK_IN_PARSE_ERROR";
+
+function parseCheckIn(input: string): Result {
+ const [user_id, event_id] = input.split("+");
+
+ const { success, data } = CheckInSchema.safeParse({
+ user_id,
+ event_id,
+ });
+
+ if (!success) {
+ return {
+ ok: false,
+ error: "CHECK_IN_PARSE_ERROR",
+ };
+ }
+
+ return {
+ ok: true,
+ value: data,
+ };
+}
diff --git a/apps/web/src/lib/qr-intents/test/parse.test.ts b/apps/web/src/lib/qr-intents/test/parse.test.ts
new file mode 100644
index 00000000..befe74d1
--- /dev/null
+++ b/apps/web/src/lib/qr-intents/test/parse.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from "vitest";
+import { parseQrIntent } from "../parse.ts";
+import { Intent } from "../intent.ts";
+
+describe("Parse QR Intents", () => {
+ it("parses valid check in intent", () => {
+ const user_id = "aa62e8a1-b4fb-479a-8d15-e52328920d18";
+ const event_id = "fe395fb7-7d57-49fb-90ae-d56094445e45";
+
+ const input = `checkin::${user_id}+${event_id}`;
+ const result = parseQrIntent(input);
+
+ expect(result.ok).toBe(true);
+
+ if (!result.ok) {
+ expect.fail("Returned an error when parsing QR Intents");
+ }
+
+ expect(result.value.intent).toBe(Intent.CHECK_IN);
+ expect(result.value.user_id).toBe(user_id);
+ expect(result.value.event_id);
+ expect(result.value.event_id).toBe(event_id);
+ });
+
+ it("parses invalid header correctly", () => {
+ const input = "invalid::DoesntMatter+DoesntMatter";
+ const result = parseQrIntent(input);
+
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error).toBe("MALFORMED_INTENT_HEADER");
+ } else {
+ expect.fail("Result was ok, not expected.");
+ }
+ });
+});
diff --git a/infra/Caddyfile.api b/infra/Caddyfile.api
index 261f0f9b..d15ca2f5 100644
--- a/infra/Caddyfile.api
+++ b/infra/Caddyfile.api
@@ -47,3 +47,52 @@ api.swamphacks.com {
Referrer-Policy "strict-origin-when-cross-origin"
}
}
+
+# Development Asynqmon
+
+dev-asynqmon.swamphacks.com {
+ reverse_proxy asynqmon-dev:6767 {
+ header_up X-Real-IP {remote}
+ header_up X-Forwarded-For {remote}
+ header_up X-Forwarded-Port {server_port}
+ header_up X-Forwarded-Proto {scheme}
+ }
+
+ tls {
+ dns cloudflare {env.CF_API_TOKEN}
+ }
+
+ encode gzip zstd
+
+ header {
+ Strict-Transport-Security "max-age=31536000"
+ X-Content-Type-Options "nosniff"
+ X-Frame-Options "DENY"
+ Referrer-Policy "strict-origin-when-cross-origin"
+ }
+}
+
+# Production Asynqmon
+
+asynqmon.swamphacks.com {
+ reverse_proxy asynqmon-dev:6767 {
+ header_up X-Real-IP {remote}
+ header_up X-Forwarded-For {remote}
+ header_up X-Forwarded-Port {server_port}
+ header_up X-Forwarded-Proto {scheme}
+ }
+
+ tls {
+ dns cloudflare {env.CF_API_TOKEN}
+ }
+
+ encode gzip zstd
+
+ header {
+ Strict-Transport-Security "max-age=31536000"
+ X-Content-Type-Options "nosniff"
+ X-Frame-Options "DENY"
+ Referrer-Policy "strict-origin-when-cross-origin"
+ }
+}
+
diff --git a/infra/docker-compose.api.yml b/infra/docker-compose.api.yml
index 0f1589f5..56bcfeaa 100644
--- a/infra/docker-compose.api.yml
+++ b/infra/docker-compose.api.yml
@@ -68,6 +68,17 @@ services:
retries: 5
start_period: 5s
+ asynqmon-dev:
+ image: hibiken/asynqmon:latest
+ platform: linux/amd64
+ ports:
+ - "6768:6767"
+ environment:
+ - REDIS_ADDR=redis-dev:6379
+ - PORT=6767
+ depends_on:
+ - redis-dev
+
# ========================
# Production Services
# ========================
@@ -120,6 +131,18 @@ services:
retries: 5
start_period: 5s
+ asynqmon:
+ image: hibiken/asynqmon:latest
+ platform: linux/amd64
+ ports:
+ - "6767:6767"
+ environment:
+ - REDIS_ADDR=redis:6379
+ - PORT=6767
+ depends_on:
+ - redis-dev
+
+
volumes:
redis_data:
redis_data_dev: