diff --git a/apps/api/cmd/BAT_worker/main.go b/apps/api/cmd/BAT_worker/main.go
index 755343d8..018123e6 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,20 @@ 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)
+ emailService := services.NewEmailService(taskQueueClient, sesClient, logger)
batService := services.NewBatService(applicationRepo, eventRepo, userRepo, batRunsRepo, emailService, txm, nil, logger)
+ applicationService := services.NewApplicationService(applicationRepo, userRepo, eventService, emailService, txm, nil, nil, scheduler, logger)
- BATWorker := workers.NewBATWorker(batService, logger)
+ BATWorker := workers.NewBATWorker(batService, applicationService, scheduler, logger)
mux := asynq.NewServeMux()
mux.HandleFunc(tasks.TypeCalculateAdmissions, BATWorker.HandleCalculateAdmissionsTask)
+ mux.HandleFunc(tasks.TypeTransitionWaitlist, BATWorker.HandleTransitionWaitlistTask)
+ mux.HandleFunc(tasks.TypeScheduleTransitionWaitlist, BATWorker.HandleScheduleTransitionWaitlistTask)
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..08e97cca 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,11 @@ 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("/queue-transition-waitlist-task", api.Handlers.Bat.QueueScheduleWaitlistTransitionTask)
+ 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..1c8a5377 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,29 @@ 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)
+}
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..e80d604f 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,14 @@ 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 ``
+ ApiOrigin string `env:"API_ORIGIN"`
+ WaitlistWorkerSessionIdCookie string `env:WAITLIST_WORKER_SESSION_ID_COOKIE`
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..6cab6457 100644
--- a/apps/api/internal/db/queries/applications.sql
+++ b/apps/api/internal/db/queries/applications.sql
@@ -98,3 +98,28 @@ 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';
+
+-- 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;
+
+-- name: GetTotalAcceptedApplicationsByEventId :one
+SELECT COUNT(*) FROM applications
+WHERE event_id = @event_id::uuid
+ AND status = 'accepted';
diff --git a/apps/api/internal/db/repository/application.go b/apps/api/internal/db/repository/application.go
index 5b2a868c..cf1dd890 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) GetTotalAcceptedApplicationsByEventId(ctx context.Context, eventId uuid.UUID) (uint32, error) {
+ amount, err := r.db.Query.GetTotalAcceptedApplicationsByEventId(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..9b7bc37a 100644
--- a/apps/api/internal/db/sqlc/applications.sql.go
+++ b/apps/api/internal/db/sqlc/applications.sql.go
@@ -110,6 +110,19 @@ func (q *Queries) GetApplicationByUserAndEventID(ctx context.Context, arg GetApp
return i, err
}
+const getTotalAcceptedApplicationsByEventId = `-- name: GetTotalAcceptedApplicationsByEventId :one
+SELECT COUNT(*) FROM applications
+WHERE event_id = $1::uuid
+ AND status = 'accepted'
+`
+
+func (q *Queries) GetTotalAcceptedApplicationsByEventId(ctx context.Context, eventID uuid.UUID) (int64, error) {
+ row := q.db.QueryRow(ctx, getTotalAcceptedApplicationsByEventId, eventID)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
const joinWaitlist = `-- name: JoinWaitlist :exec
UPDATE applications
SET waitlist_join_time = COALESCE(waitlist_join_time, NOW()),
@@ -294,6 +307,58 @@ 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'
+`
+
+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/email/templates/WaitlistAcceptanceEmail.html b/apps/api/internal/email/templates/WaitlistAcceptanceEmail.html
new file mode 100644
index 00000000..fca4d4db
--- /dev/null
+++ b/apps/api/internal/email/templates/WaitlistAcceptanceEmail.html
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+ 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..ba3b4785 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,65 @@ 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
+ }
+
+ totalAccepted, err := s.appRepo.GetTotalAcceptedApplicationsByEventId(ctx, eventId)
+ if err != nil {
+ s.logger.Err(err).Msg("Failed to get total accepted application amount.")
+ }
+ if (acceptanceQuota - totalAccepted) <= acceptanceCount {
+ s.logger.Info().Msgf("%v - %v <= %v", acceptanceQuota, totalAccepted, 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. However the scheduler is only running for this task.
+ s.scheduler.Shutdown()
+ }
+ acceptanceCount = acceptanceQuota - totalAccepted
+ }
+
+ 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..885dcc63 100644
--- a/apps/api/internal/services/bat.go
+++ b/apps/api/internal/services/bat.go
@@ -359,3 +359,25 @@ func (s *BatService) mapToCandidates(engine *bat.BatEngine, applications []sqlc.
return appAdmissionsData, nil
}
+
+func (s *BatService) QueueScheduleWaitlistTransitionTask(ctx context.Context, eventId uuid.UUID) error {
+
+ // TODO: add period to config?
+ task, err := tasks.NewTaskScheduleTransitionWaitlist(tasks.ScheduleTransitionWaitlistPayload{
+ EventID: eventId,
+ Period: "@every 72h",
+ })
+ 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
+}
diff --git a/apps/api/internal/services/email.go b/apps/api/internal/services/email.go
index 989740f6..afe37f83 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")
@@ -86,13 +94,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 +124,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..c54b6fca 100644
--- a/apps/api/internal/tasks/bat.go
+++ b/apps/api/internal/tasks/bat.go
@@ -8,7 +8,9 @@ import (
)
const (
- TypeCalculateAdmissions = "admissions:calculate"
+ TypeCalculateAdmissions = "admissions:calculate"
+ TypeScheduleTransitionWaitlist = "waitlist:scheduletransition"
+ TypeTransitionWaitlist = "waitlist:transition"
)
type CalculateAdmissionsPayload struct {
@@ -16,6 +18,17 @@ type CalculateAdmissionsPayload struct {
BatRunID uuid.UUID
}
+type ScheduleTransitionWaitlistPayload struct {
+ EventID uuid.UUID
+ Period string
+}
+
+type TransitionWaitlistPayload struct {
+ EventID uuid.UUID
+ AcceptanceCount uint32
+ AcceptanceQuota uint32
+}
+
func NewTaskCalculateAdmissions(payload CalculateAdmissionsPayload) (*asynq.Task, error) {
data, err := json.Marshal(payload)
if err != nil {
@@ -24,3 +37,21 @@ 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
+}
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..dc9c289a 100644
--- a/apps/api/internal/workers/bat.go
+++ b/apps/api/internal/workers/bat.go
@@ -19,14 +19,18 @@ 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
+ logger zerolog.Logger
}
-func NewBATWorker(batService *services.BatService, logger zerolog.Logger) *BATWorker {
+func NewBATWorker(batService *services.BatService, applicationService *services.ApplicationService, scheduler *asynq.Scheduler, 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,
}
}
@@ -58,3 +62,41 @@ 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
+ }
+
+ task, err := tasks.NewTaskTransitionWaitlist(tasks.TransitionWaitlistPayload{
+ EventID: payload.EventID,
+ AcceptanceCount: 50,
+ AcceptanceQuota: 500,
+ })
+
+ w.scheduler.Start()
+ _, err = w.scheduler.Register(payload.Period, task, asynq.Queue("bat"))
+ if err != nil {
+ w.logger.Err(err)
+ return nil
+ }
+
+ 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.AcceptanceCount, payload.AcceptanceQuota)
+ if err != nil {
+ w.logger.Err(err)
+ return nil
+ }
+
+ 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
-}