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