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 + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ SwampHacks XI Banner +
+

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. +

+
+ + Discord + + + Instagram + + + LinkedIn + +
+
+ + + \ 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 -}