diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 58ebbe2f..8c385a85 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -82,6 +82,7 @@ func main() { teamMemberRepo := repository.NewTeamMemberRespository(database) teamJoinRequestRepo := repository.NewTeamJoinRequestRepository(database) batRunsRepo := repository.NewBatRunsRepository(database) + redeemablesRepo := repository.NewRedeemablesRepository(database) // Injections into services authService := services.NewAuthService(userRepo, accountRepo, sessionRepo, txm, client, logger, &cfg.Auth) @@ -92,6 +93,10 @@ func main() { 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, nil, logger) + redeemablesService := services.NewRedeemablesService(redeemablesRepo, logger) + + // Injections into handlers + apiHandlers := handlers.NewHandlers(authService, userService, eventInterestService, eventService, emailService, applicationService, teamService, batService, redeemablesService, cfg, logger) discordService := services.NewDiscordService(eventRepo, logger) // Injections into handlers diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 5fbbd5e2..610e3553 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -150,6 +150,8 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.With(ensureEventStaff).Post("/checkin", api.Handlers.Admission.HandleEventCheckIn) // Used to fetch user info for checking in r.With(ensureEventStaff).Get("/users/{userId}", api.Handlers.Event.GetUserForEvent) + // Get user ID by RFID + r.With(ensureEventStaff).Get("/users/by-rfid/{rfid}", api.Handlers.Event.GetUserByRFID) // Admin-only r.With(ensureEventAdmin).Post("/queue-confirmation-email", api.Handlers.Email.QueueConfirmationEmail) @@ -211,6 +213,25 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.Patch("/join-waitlist", api.Handlers.Application.JoinWaitlist) }) + r.Route("/redeemables", func(r chi.Router) { + r.Use(ensureEventStaff) + // Get all redeemables and create new redeemable + // eventId is available from parent route context + r.Get("/", api.Handlers.Redeemables.GetRedeemables) + r.Post("/", api.Handlers.Redeemables.CreateRedeemable) + + // Update and delete specific redeemable + r.Route("/{redeemableId}", func(r chi.Router) { + r.Patch("/", api.Handlers.Redeemables.UpdateRedeemable) + r.Delete("/", api.Handlers.Redeemables.DeleteRedeemable) + + r.Route("/users/{userId}", func(r chi.Router) { + r.Post("/", api.Handlers.Redeemables.RedeemRedeemable) + r.Patch("/", api.Handlers.Redeemables.UpdateRedemption) + }) + }) + }) + // Team routes r.Route("/teams", func(r chi.Router) { r.Post("/", api.Handlers.Teams.CreateTeam) diff --git a/apps/api/internal/api/handlers/events.go b/apps/api/internal/api/handlers/events.go index fbdd0547..c91d2ac9 100644 --- a/apps/api/internal/api/handlers/events.go +++ b/apps/api/internal/api/handlers/events.go @@ -826,3 +826,43 @@ func (h *EventHandler) GetUserForEvent(w http.ResponseWriter, r *http.Request) { res.Send(w, http.StatusOK, info) } + +// Get User by RFID +// +// @Summary Retrieves a user's ID by their RFID +// @Description Looks up a user's ID by their RFID code for a specific event. Returns the user ID which can be used for other operations. +// @Tags Event +// @Produce json +// @Param eventId path string true "Event ID" Format(uuid) +// @Param rfid path string true "RFID code (10 digits)" +// @Success 200 {object} map[string]string "OK - Returns user ID" +// @Failure 400 {object} response.ErrorResponse "Bad request/Malformed request." +// @Failure 404 {object} response.ErrorResponse "User not found with the provided RFID" +// @Failure 500 {object} response.ErrorResponse "Server Error: error getting user by RFID" +// @Router /events/{eventId}/users/by-rfid/{rfid} [get] +func (h *EventHandler) GetUserByRFID(w http.ResponseWriter, r *http.Request) { + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + + rfid := chi.URLParam(r, "rfid") + if rfid == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_rfid", "The RFID is missing from the URL!")) + return + } + + user, err := h.eventService.GetUserByRFID(r.Context(), eventId, rfid) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) || errors.Is(err, repository.ErrEventRoleNotFound) { + res.SendError(w, http.StatusNotFound, res.NewError("user_not_found", "No user found with the provided RFID")) + } else { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong while querying user by RFID")) + } + return + } + + // Return just the user ID as a simple JSON object + res.Send(w, http.StatusOK, map[string]string{"user_id": user.ID.String()}) +} \ No newline at end of file diff --git a/apps/api/internal/api/handlers/handlers.go b/apps/api/internal/api/handlers/handlers.go index a04e7569..2443a689 100644 --- a/apps/api/internal/api/handlers/handlers.go +++ b/apps/api/internal/api/handlers/handlers.go @@ -16,6 +16,7 @@ type Handlers struct { Teams *TeamHandler Admission *AdmissionHandler Bat *BatHandler + Redeemables *RedeemablesHandler Discord *DiscordHandler } @@ -28,6 +29,7 @@ func NewHandlers( appService *services.ApplicationService, teamService *services.TeamService, batService *services.BatService, + redeemablesService *services.RedeemablesService, discordService *services.DiscordService, cfg *config.Config, logger zerolog.Logger, @@ -42,6 +44,7 @@ func NewHandlers( Teams: NewTeamHandler(teamService, logger), Admission: NewAdmissionHandler(batService, logger), Bat: NewBatHandler(batService, logger), + Redeemables: NewRedeemablesHandler(redeemablesService, cfg, logger), Discord: NewDiscordHandler(discordService, logger), } } diff --git a/apps/api/internal/api/handlers/redeemables.go b/apps/api/internal/api/handlers/redeemables.go new file mode 100644 index 00000000..5178b416 --- /dev/null +++ b/apps/api/internal/api/handlers/redeemables.go @@ -0,0 +1,257 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/rs/zerolog" + res "github.com/swamphacks/core/apps/api/internal/api/response" + "github.com/swamphacks/core/apps/api/internal/config" + "github.com/swamphacks/core/apps/api/internal/services" + "github.com/swamphacks/core/apps/api/internal/web" +) + +type RedeemablesHandler struct { + redeemablesService *services.RedeemablesService + cfg *config.Config + logger zerolog.Logger +} + +func NewRedeemablesHandler( + redeemablesService *services.RedeemablesService, + cfg *config.Config, + logger zerolog.Logger, +) *RedeemablesHandler { + return &RedeemablesHandler{ + redeemablesService: redeemablesService, + cfg: cfg, + logger: logger, + } +} + +// GetRedeemables +// +// @Summary Get all redeemables for an event +// @Description Retrieve a list of all redeemable items associated with a specific event ID. +// @Tags Redeemables +// @Accept json +// @Produce json +// @Param eventId path string true "Event ID (UUID)" +// @Success 200 {array} sqlc.Redeemable +// @Failure 400 {object} response.ErrorResponse "Missing or invalid Event ID" +// @Failure 500 {object} response.ErrorResponse "Internal Server Error" +// @Router /events/{eventId}/redeemables [get] +func (h *RedeemablesHandler) GetRedeemables(w http.ResponseWriter, r *http.Request) { + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing event_id."), + ) + return + } + + redeemables, err := h.redeemablesService.GetRedeemablesByEventID(r.Context(), eventId) + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_server_error", "internal service error, failed to get redeemables by ID")) + return + } + + res.Send(w, http.StatusOK, redeemables) +} + +type CreateRedeemableRequest struct { + Name string `json:"name"` + Amount int `json:"amount"` + MaxUserAmount int `json:"max_user_amount"` +} + +// CreateRedeemable +// +// @Summary Create a new redeemable +// @Description Create a new redeemable item for a specific event. +// @Tags Redeemables +// @Accept json +// @Produce json +// @Param eventId path string true "Event ID (UUID)" +// @Param request body CreateRedeemableRequest true "Redeemable creation data" +// @Success 201 {object} sqlc.Redeemable +// @Failure 400 {object} response.ErrorResponse "Invalid request body or ID" +// @Failure 500 {object} response.ErrorResponse "Internal Server Error" +// @Router /events/{eventId}/redeemables [post] +func (h *RedeemablesHandler) CreateRedeemable(w http.ResponseWriter, r *http.Request) { + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing event_id."), + ) + return + } + + var req CreateRedeemableRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() // Prevents requests with extraneous fields + if err := decoder.Decode(&req); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request_body", "The request body is invalid: "+err.Error())) + return + } + + if req.Name == "" || req.Amount <= 0 { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_params", "name and amount are required")) + return + } + + redeemable, err := h.redeemablesService.CreateRedeemable(r.Context(), eventId, req.Name, req.Amount, req.MaxUserAmount) + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_server_error", "internal service error, failed to create redeemable")) + return + } + + res.Send(w, http.StatusCreated, redeemable) +} + +type UpdateRedeemableRequest struct { + Name *string `json:"name,omitempty"` + Amount *int `json:"total_stock,omitempty"` + MaxUserAmount *int `json:"max_user_amount,omitempty"` +} + +// UpdateRedeemable +// +// @Summary Update an existing redeemable +// @Description Update specific fields (name, stock, max per user) of a redeemable. +// @Tags Redeemables +// @Accept json +// @Produce json +// @Param redeemableId path string true "Redeemable ID (UUID)" +// @Param request body UpdateRedeemableRequest true "Redeemable update data (partial fields allowed)" +// @Success 200 {object} sqlc.Redeemable +// @Failure 400 {object} response.ErrorResponse "Invalid ID or request body" +// @Failure 500 {object} response.ErrorResponse "Internal Server Error" +// @Router /redeemables/{redeemableId} [patch] +func (h *RedeemablesHandler) UpdateRedeemable(w http.ResponseWriter, r *http.Request) { + redeemableId, err := web.PathParamToUUID(r, "redeemableId") + if err != nil { + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing redeemable_id."), + ) + return + } + + var req UpdateRedeemableRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() // Prevents requests with extraneous fields + if err := decoder.Decode(&req); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request_body", "The request body is invalid: "+err.Error())) + return + } + + redeemable, err := h.redeemablesService.UpdateRedeemable(r.Context(), redeemableId, req.Name, req.Amount, req.MaxUserAmount) + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_server_error", "internal service error, failed to update redeemable")) + return + } + + res.Send(w, http.StatusCreated, redeemable) + return +} + +// DeleteRedeemable +// +// @Summary Delete a redeemable +// @Description Permanently delete a redeemable item by ID. +// @Tags Redeemables +// @Param redeemableId path string true "Redeemable ID (UUID)" +// @Success 204 "No Content" +// @Failure 400 {object} response.ErrorResponse "Invalid Redeemable ID" +// @Failure 500 {object} response.ErrorResponse "Internal Server Error" +// @Router /redeemables/{redeemableId} [delete] +func (h *RedeemablesHandler) DeleteRedeemable(w http.ResponseWriter, r *http.Request) { + redeemableId, err := web.PathParamToUUID(r, "redeemableId") + if err != nil { + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing redeemable_id."), + ) + return + } + + err = h.redeemablesService.DeleteRedeemable(r.Context(), redeemableId) + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_server_error", "internal service error, failed to delete redeemable")) + return + } + res.Send(w, http.StatusNoContent, nil) +} + +// RedeemRedeemable +// +// @Summary Redeem an item for a user +// @Description Create a redemption record linking a specific user to a redeemable item. +// @Tags Redeemables +// @Param redeemableId path string true "Redeemable ID (UUID)" +// @Param userId path string true "User ID (UUID)" +// @Success 204 "No Content" +// @Failure 400 {object} response.ErrorResponse "Invalid IDs" +// @Failure 500 {object} response.ErrorResponse "Internal Server Error" +// @Router /redeemables/{redeemableId}/users/{userId} [post] +func (h *RedeemablesHandler) RedeemRedeemable(w http.ResponseWriter, r *http.Request) { + // user id, redeemable id + userId, err := web.PathParamToUUID(r, "userId") + if err != nil { + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing user_id."), + ) + return + } + + redeemableId, err := web.PathParamToUUID(r, "redeemableId") + if err != nil { + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing redeemable_id."), + ) + return + } + + err = h.redeemablesService.RedeemRedeemable(r.Context(), redeemableId, userId) + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_server_error", "internal service error, failed to redeem redeemable")) + return + } + res.Send(w, http.StatusNoContent, nil) +} + +type UpdateRedemptionRequest struct { + Amount int `json:"new_amount,omitempty"` +} + +func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Request) { + // user id, redeemable id + userId, err := web.PathParamToUUID(r, "userId") + if err != nil { + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing user_id."), + ) + return + } + + redeemableId, err := web.PathParamToUUID(r, "redeemableId") + if err != nil { + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing redeemable_id."), + ) + return + } + var req UpdateRedemptionRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request_body", "The request body is invalid: "+err.Error())) + return + } + + err = h.redeemablesService.UpdateRedemption(r.Context(), redeemableId, userId, req.Amount) + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_server_error", "internal service error, failed to update redemption")) + return + } + res.Send(w, http.StatusNoContent, nil) +} diff --git a/apps/api/internal/db/migrations/20260119015108_create_redeemables_tables.sql b/apps/api/internal/db/migrations/20260119015108_create_redeemables_tables.sql new file mode 100644 index 00000000..c7fb3aef --- /dev/null +++ b/apps/api/internal/db/migrations/20260119015108_create_redeemables_tables.sql @@ -0,0 +1,43 @@ +-- +goose Up +-- +goose StatementBegin +SELECT 'up SQL query'; +CREATE TABLE redeemables ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID REFERENCES events(id) ON DELETE CASCADE NOT NULL, + name VARCHAR(255) NOT NULL, + amount INT NOT NULL CHECK (amount >= 0), + max_user_amount INT NOT NULL CHECK (max_user_amount >= 1), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TRIGGER set_updated_at_redeemables +BEFORE UPDATE ON redeemables +FOR EACH ROW +EXECUTE FUNCTION update_modified_column(); + +CREATE TABLE user_redemptions ( + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, + redeemable_id UUID REFERENCES redeemables(id) ON DELETE CASCADE NOT NULL, + amount INT NOT NULL CHECK (amount >= 0), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, redeemable_id) +); + +CREATE TRIGGER set_updated_at_user_redemptions +BEFORE UPDATE ON user_redemptions +FOR EACH ROW +EXECUTE FUNCTION update_modified_column(); + + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'down SQL query'; +DROP TABLE IF EXISTS user_redemptions; +DROP TRIGGER IF EXISTS set_updated_at_redeemables ON redeemables; +DROP TABLE IF EXISTS redeemables; +DROP TRIGGER IF EXISTS set_updated_at_user_redemptions ON user_redemptions; +-- +goose StatementEnd diff --git a/apps/api/internal/db/queries/event_roles.sql b/apps/api/internal/db/queries/event_roles.sql index 92dec3de..12b8bf59 100644 --- a/apps/api/internal/db/queries/event_roles.sql +++ b/apps/api/internal/db/queries/event_roles.sql @@ -59,3 +59,10 @@ WHERE user_id = @user_id SELECT COUNT(*) FROM event_roles AS er WHERE er.event_id = @event_id::uuid AND er.role = 'attendee'; + +-- name: GetUserByRFID :one +SELECT u.* +FROM auth.users u +JOIN event_roles er ON u.id = er.user_id +WHERE er.event_id = $1 + AND er.rfid = $2; \ No newline at end of file diff --git a/apps/api/internal/db/queries/redeemables.sql b/apps/api/internal/db/queries/redeemables.sql new file mode 100644 index 00000000..9619b52b --- /dev/null +++ b/apps/api/internal/db/queries/redeemables.sql @@ -0,0 +1,66 @@ +-- name: GetRedeemablesByEventID :many +-- Using event id, return all redeemables associated. Should also return data like how many have been redeemed already +SELECT r.id, +r.event_id, +r.name, +r.amount AS total_stock, +r.max_user_amount, +r.created_at, +r.updated_at, +COALESCE(SUM(ur.amount), 0) AS total_redeemed +FROM redeemables r +LEFT JOIN user_redemptions ur ON r.id = ur.redeemable_id +WHERE r.event_id = $1 +GROUP BY r.id; + +-- name: RedeemRedeemable :one +-- Using user id and redeemable id, attempt! to redeem a redeemable +INSERT INTO user_redemptions (user_id, redeemable_id, amount) +SELECT $1, $2, 1 +WHERE ( + SELECT COALESCE(SUM(amount), 0) + FROM user_redemptions + WHERE redeemable_id = $2 +) < (SELECT amount FROM redeemables WHERE id = $2) +ON CONFLICT (user_id, redeemable_id) +DO UPDATE SET + amount = user_redemptions.amount + 1, + updated_at = CURRENT_TIMESTAMP +WHERE user_redemptions.amount < (SELECT max_user_amount FROM redeemables WHERE id = $2) +RETURNING *; + +-- name: GetRedemptionInfoByRedeemableID :many +-- Gather all redemption info for a specific reedeemable (who has redeemed already) +SELECT ur.user_id, ur.redeemable_id, ur.amount, ur.created_at, ur.updated_at +FROM user_redemptions ur +WHERE ur.redeemable_id = $1; + +-- name: CreateRedeemable :one +-- Create a new redeemable for an event +INSERT INTO redeemables (event_id, name, amount, max_user_amount) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: UpdateRedeemable :one +UPDATE redeemables +SET + name = COALESCE(sqlc.narg('name'), name), + amount = COALESCE(sqlc.narg('amount'), amount), + max_user_amount = COALESCE(sqlc.narg('max_user_amount'), max_user_amount), + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING *; + +-- name: DeleteRedeemable :exec +-- Delete a redeemable by id +DELETE FROM redeemables +WHERE id = $1; + +-- name: UpdateRedemption :exec +-- Update a redemption record for a user and redeemable (for removing redemption mostly) +UPDATE user_redemptions +SET + amount = $1 +WHERE user_id = $2 AND redeemable_id = $3; + + diff --git a/apps/api/internal/db/repository/events.go b/apps/api/internal/db/repository/events.go index 57f99212..43a4a005 100644 --- a/apps/api/internal/db/repository/events.go +++ b/apps/api/internal/db/repository/events.go @@ -120,6 +120,21 @@ func (r *EventRepository) GetEventRoleByIds(ctx context.Context, userId uuid.UUI return &eventRole, err } +func (r *EventRepository) GetUserByRFID(ctx context.Context, eventId uuid.UUID, rfid string) (*sqlc.AuthUser, error) { + params := sqlc.GetUserByRFIDParams{ + EventID: eventId, + Rfid: &rfid, + } + user, err := r.db.Query.GetUserByRFID(ctx, params) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrEventRoleNotFound + } + return nil, ErrUnknown + } + return &user, nil +} + func (r *EventRepository) GetEventStaff(ctx context.Context, eventId uuid.UUID) (*[]sqlc.GetEventStaffRow, error) { users, err := r.db.Query.GetEventStaff(ctx, eventId) return &users, err diff --git a/apps/api/internal/db/repository/redeemables.go b/apps/api/internal/db/repository/redeemables.go new file mode 100644 index 00000000..4270d0cf --- /dev/null +++ b/apps/api/internal/db/repository/redeemables.go @@ -0,0 +1,95 @@ +package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/swamphacks/core/apps/api/internal/db" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) + +type RedeemablesRepository struct { + db *db.DB +} + +func NewRedeemablesRepository(db *db.DB) *RedeemablesRepository { + return &RedeemablesRepository{ + db: db, + } +} + +func (r *RedeemablesRepository) GetRedeemablesByEventID(ctx context.Context, eventID uuid.UUID) (*[]sqlc.GetRedeemablesByEventIDRow, error) { + redeemables, err := r.db.Query.GetRedeemablesByEventID(ctx, eventID) + if err != nil { + return nil, err + } + return &redeemables, nil +} + +func (r *RedeemablesRepository) CreateRedeemable(ctx context.Context, eventID uuid.UUID, name string, amount int, maxUserAmount int) (*sqlc.Redeemable, error) { + params := sqlc.CreateRedeemableParams{ + EventID: eventID, + Name: name, + Amount: int32(amount), + MaxUserAmount: int32(maxUserAmount), + } + redeemable, err := r.db.Query.CreateRedeemable(ctx, params) + if err != nil { + return nil, err + } + return &redeemable, nil +} +func (r *RedeemablesRepository) DeleteRedeemable(ctx context.Context, redeemableID uuid.UUID) error { + err := r.db.Query.DeleteRedeemable(ctx, redeemableID) + if err != nil { + return err + } + return nil +} +func (r *RedeemablesRepository) UpdateRedeemable(ctx context.Context, redeemableID uuid.UUID, name *string, amount *int, maxUserAmount *int) (*sqlc.Redeemable, error) { + var amount32 *int32 + if amount != nil { + v := int32(*amount) + amount32 = &v + } + var maxUserAmount32 *int32 + if maxUserAmount != nil { + v := int32(*maxUserAmount) + maxUserAmount32 = &v + } + params := sqlc.UpdateRedeemableParams{ + ID: redeemableID, + Name: name, + Amount: amount32, + MaxUserAmount: maxUserAmount32, + } + redeemable, err := r.db.Query.UpdateRedeemable(ctx, params) + if err != nil { + return nil, err + } + return &redeemable, nil +} + +func (r *RedeemablesRepository) RedeemRedeemable(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID) (*sqlc.UserRedemption, error) { + params := sqlc.RedeemRedeemableParams{ + RedeemableID: redeemableID, + UserID: userID, + } + redemption, err := r.db.Query.RedeemRedeemable(ctx, params) + if err != nil { + return nil, err + } + return &redemption, nil +} + +func (r *RedeemablesRepository) UpdateRedemption(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID, amount int) error { + err := r.db.Query.UpdateRedemption(ctx, sqlc.UpdateRedemptionParams{ + RedeemableID: redeemableID, + UserID: userID, + Amount: int32(amount), + }) + if err != nil { + return err + } + return nil +} diff --git a/apps/api/internal/db/sqlc/event_roles.sql.go b/apps/api/internal/db/sqlc/event_roles.sql.go index 9c83ebd2..b32d9789 100644 --- a/apps/api/internal/db/sqlc/event_roles.sql.go +++ b/apps/api/internal/db/sqlc/event_roles.sql.go @@ -225,6 +225,38 @@ func (q *Queries) GetEventUsers(ctx context.Context, eventID uuid.UUID) ([]GetEv return items, nil } +const getUserByRFID = `-- name: GetUserByRFID :one +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 +FROM auth.users u +JOIN event_roles er ON u.id = er.user_id +WHERE er.event_id = $1 + AND er.rfid = $2 +` + +type GetUserByRFIDParams struct { + EventID uuid.UUID `json:"event_id"` + Rfid *string `json:"rfid"` +} + +func (q *Queries) GetUserByRFID(ctx context.Context, arg GetUserByRFIDParams) (AuthUser, error) { + row := q.db.QueryRow(ctx, getUserByRFID, arg.EventID, arg.Rfid) + var i AuthUser + err := row.Scan( + &i.ID, + &i.Name, + &i.Email, + &i.EmailVerified, + &i.Onboarded, + &i.Image, + &i.CreatedAt, + &i.UpdatedAt, + &i.Role, + &i.PreferredEmail, + &i.EmailConsent, + ) + return i, err +} + const removeRole = `-- name: RemoveRole :exec DELETE FROM event_roles WHERE event_id = $1 diff --git a/apps/api/internal/db/sqlc/models.go b/apps/api/internal/db/sqlc/models.go index 104e8869..50c6fb57 100644 --- a/apps/api/internal/db/sqlc/models.go +++ b/apps/api/internal/db/sqlc/models.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) type ApplicationStatus string @@ -422,6 +423,16 @@ type EventRole struct { Rfid *string `json:"rfid"` } +type Redeemable struct { + ID uuid.UUID `json:"id"` + EventID uuid.UUID `json:"event_id"` + Name string `json:"name"` + Amount int32 `json:"amount"` + MaxUserAmount int32 `json:"max_user_amount"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Team struct { ID uuid.UUID `json:"id"` Name string `json:"name"` @@ -460,3 +471,11 @@ type TeamMember struct { TeamID uuid.UUID `json:"team_id"` JoinedAt *time.Time `json:"joined_at"` } + +type UserRedemption struct { + UserID uuid.UUID `json:"user_id"` + RedeemableID uuid.UUID `json:"redeemable_id"` + Amount int32 `json:"amount"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} diff --git a/apps/api/internal/db/sqlc/redeemables.sql.go b/apps/api/internal/db/sqlc/redeemables.sql.go new file mode 100644 index 00000000..3086b7f0 --- /dev/null +++ b/apps/api/internal/db/sqlc/redeemables.sql.go @@ -0,0 +1,239 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: redeemables.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createRedeemable = `-- name: CreateRedeemable :one +INSERT INTO redeemables (event_id, name, amount, max_user_amount) +VALUES ($1, $2, $3, $4) +RETURNING id, event_id, name, amount, max_user_amount, created_at, updated_at +` + +type CreateRedeemableParams struct { + EventID uuid.UUID `json:"event_id"` + Name string `json:"name"` + Amount int32 `json:"amount"` + MaxUserAmount int32 `json:"max_user_amount"` +} + +// Create a new redeemable for an event +func (q *Queries) CreateRedeemable(ctx context.Context, arg CreateRedeemableParams) (Redeemable, error) { + row := q.db.QueryRow(ctx, createRedeemable, + arg.EventID, + arg.Name, + arg.Amount, + arg.MaxUserAmount, + ) + var i Redeemable + err := row.Scan( + &i.ID, + &i.EventID, + &i.Name, + &i.Amount, + &i.MaxUserAmount, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteRedeemable = `-- name: DeleteRedeemable :exec +DELETE FROM redeemables +WHERE id = $1 +` + +// Delete a redeemable by id +func (q *Queries) DeleteRedeemable(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteRedeemable, id) + return err +} + +const getRedeemablesByEventID = `-- name: GetRedeemablesByEventID :many +SELECT r.id, +r.event_id, +r.name, +r.amount AS total_stock, +r.max_user_amount, +r.created_at, +r.updated_at, +COALESCE(SUM(ur.amount), 0) AS total_redeemed +FROM redeemables r +LEFT JOIN user_redemptions ur ON r.id = ur.redeemable_id +WHERE r.event_id = $1 +GROUP BY r.id +` + +type GetRedeemablesByEventIDRow struct { + ID uuid.UUID `json:"id"` + EventID uuid.UUID `json:"event_id"` + Name string `json:"name"` + TotalStock int32 `json:"total_stock"` + MaxUserAmount int32 `json:"max_user_amount"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + TotalRedeemed interface{} `json:"total_redeemed"` +} + +// Using event id, return all redeemables associated. Should also return data like how many have been redeemed already +func (q *Queries) GetRedeemablesByEventID(ctx context.Context, eventID uuid.UUID) ([]GetRedeemablesByEventIDRow, error) { + rows, err := q.db.Query(ctx, getRedeemablesByEventID, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetRedeemablesByEventIDRow{} + for rows.Next() { + var i GetRedeemablesByEventIDRow + if err := rows.Scan( + &i.ID, + &i.EventID, + &i.Name, + &i.TotalStock, + &i.MaxUserAmount, + &i.CreatedAt, + &i.UpdatedAt, + &i.TotalRedeemed, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getRedemptionInfoByRedeemableID = `-- name: GetRedemptionInfoByRedeemableID :many +SELECT ur.user_id, ur.redeemable_id, ur.amount, ur.created_at, ur.updated_at +FROM user_redemptions ur +WHERE ur.redeemable_id = $1 +` + +// Gather all redemption info for a specific reedeemable (who has redeemed already) +func (q *Queries) GetRedemptionInfoByRedeemableID(ctx context.Context, redeemableID uuid.UUID) ([]UserRedemption, error) { + rows, err := q.db.Query(ctx, getRedemptionInfoByRedeemableID, redeemableID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []UserRedemption{} + for rows.Next() { + var i UserRedemption + if err := rows.Scan( + &i.UserID, + &i.RedeemableID, + &i.Amount, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const redeemRedeemable = `-- name: RedeemRedeemable :one +INSERT INTO user_redemptions (user_id, redeemable_id, amount) +SELECT $1, $2, 1 +WHERE ( + SELECT COALESCE(SUM(amount), 0) + FROM user_redemptions + WHERE redeemable_id = $2 +) < (SELECT amount FROM redeemables WHERE id = $2) +ON CONFLICT (user_id, redeemable_id) +DO UPDATE SET + amount = user_redemptions.amount + 1, + updated_at = CURRENT_TIMESTAMP +WHERE user_redemptions.amount < (SELECT max_user_amount FROM redeemables WHERE id = $2) +RETURNING user_id, redeemable_id, amount, created_at, updated_at +` + +type RedeemRedeemableParams struct { + UserID uuid.UUID `json:"user_id"` + RedeemableID uuid.UUID `json:"redeemable_id"` +} + +// Using user id and redeemable id, attempt! to redeem a redeemable +func (q *Queries) RedeemRedeemable(ctx context.Context, arg RedeemRedeemableParams) (UserRedemption, error) { + row := q.db.QueryRow(ctx, redeemRedeemable, arg.UserID, arg.RedeemableID) + var i UserRedemption + err := row.Scan( + &i.UserID, + &i.RedeemableID, + &i.Amount, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateRedeemable = `-- name: UpdateRedeemable :one +UPDATE redeemables +SET + name = COALESCE($2, name), + amount = COALESCE($3, amount), + max_user_amount = COALESCE($4, max_user_amount), + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING id, event_id, name, amount, max_user_amount, created_at, updated_at +` + +type UpdateRedeemableParams struct { + ID uuid.UUID `json:"id"` + Name *string `json:"name"` + Amount *int32 `json:"amount"` + MaxUserAmount *int32 `json:"max_user_amount"` +} + +func (q *Queries) UpdateRedeemable(ctx context.Context, arg UpdateRedeemableParams) (Redeemable, error) { + row := q.db.QueryRow(ctx, updateRedeemable, + arg.ID, + arg.Name, + arg.Amount, + arg.MaxUserAmount, + ) + var i Redeemable + err := row.Scan( + &i.ID, + &i.EventID, + &i.Name, + &i.Amount, + &i.MaxUserAmount, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateRedemption = `-- name: UpdateRedemption :exec +UPDATE user_redemptions +SET + amount = $1 +WHERE user_id = $2 AND redeemable_id = $3 +` + +type UpdateRedemptionParams struct { + Amount int32 `json:"amount"` + UserID uuid.UUID `json:"user_id"` + RedeemableID uuid.UUID `json:"redeemable_id"` +} + +// Update a redemption record for a user and redeemable (for removing redemption mostly) +func (q *Queries) UpdateRedemption(ctx context.Context, arg UpdateRedemptionParams) error { + _, err := q.db.Exec(ctx, updateRedemption, arg.Amount, arg.UserID, arg.RedeemableID) + return err +} diff --git a/apps/api/internal/services/events.go b/apps/api/internal/services/events.go index 16f88e91..67c893ea 100644 --- a/apps/api/internal/services/events.go +++ b/apps/api/internal/services/events.go @@ -372,6 +372,19 @@ type UserInfoForEvent struct { CheckedInAt *time.Time `json:"checked_in_at"` } +func (s *EventService) GetUserByRFID(ctx context.Context, eventId uuid.UUID, rfid string) (*sqlc.AuthUser, error) { + user, err := s.eventRepo.GetUserByRFID(ctx, eventId, rfid) + if err != nil { + if errors.Is(err, repository.ErrEventRoleNotFound) { + s.logger.Err(err).Msg("User not found with RFID") + return nil, repository.ErrUserNotFound + } + s.logger.Err(err).Msg("Failed to get user by RFID") + return nil, err + } + return user, nil +} + func (s *EventService) GetUserInfoForEvent(ctx context.Context, userId, eventId uuid.UUID) (*UserInfoForEvent, error) { g, ctx := errgroup.WithContext(ctx) diff --git a/apps/api/internal/services/redeemables.go b/apps/api/internal/services/redeemables.go new file mode 100644 index 00000000..bf02076e --- /dev/null +++ b/apps/api/internal/services/redeemables.go @@ -0,0 +1,79 @@ +package services + +import ( + "context" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/db/repository" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) + +type RedeemablesService struct { + redeemablesRepo *repository.RedeemablesRepository + logger zerolog.Logger +} + +func NewRedeemablesService( + redeemablesRepo *repository.RedeemablesRepository, + logger zerolog.Logger) *RedeemablesService { + return &RedeemablesService{ + redeemablesRepo: redeemablesRepo, + logger: logger, + } +} + +func (s *RedeemablesService) GetRedeemablesByEventID(ctx context.Context, eventID uuid.UUID) (*[]sqlc.GetRedeemablesByEventIDRow, error) { + redeemables, err := s.redeemablesRepo.GetRedeemablesByEventID(ctx, eventID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get redeemables by event ID") + return nil, err + } + return redeemables, nil +} + +func (s *RedeemablesService) CreateRedeemable(ctx context.Context, eventID uuid.UUID, name string, amount int, maxUserAmount int) (*sqlc.Redeemable, error) { + redeemable, err := s.redeemablesRepo.CreateRedeemable(ctx, eventID, name, amount, maxUserAmount) + if err != nil { + s.logger.Error().Err(err).Msg("failed to create redeemable") + return nil, err + } + return redeemable, nil +} + +func (s *RedeemablesService) DeleteRedeemable(ctx context.Context, redeemableID uuid.UUID) error { + err := s.redeemablesRepo.DeleteRedeemable(ctx, redeemableID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to delete redeemable") + return err + } + return nil +} + +func (s *RedeemablesService) UpdateRedeemable(ctx context.Context, redeemableID uuid.UUID, name *string, amount *int, maxUserAmount *int) (*sqlc.Redeemable, error) { + redeemable, err := s.redeemablesRepo.UpdateRedeemable(ctx, redeemableID, name, amount, maxUserAmount) + if err != nil { + s.logger.Error().Err(err).Msg("failed to update redeemable") + return nil, err + } + return redeemable, nil +} + +func (s *RedeemablesService) RedeemRedeemable(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID) error { + _, err := s.redeemablesRepo.RedeemRedeemable(ctx, redeemableID, userID) + + if err != nil { + s.logger.Error().Err(err).Msg("failed to redeem redeemable") + return err + } + return nil +} + +func (s *RedeemablesService) UpdateRedemption(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID, amount int) error { + err := s.redeemablesRepo.UpdateRedemption(ctx, redeemableID, userID, amount) + if err != nil { + s.logger.Error().Err(err).Msg("failed to update redemption") + return err + } + return nil +} diff --git a/apps/web/src/features/Redeemables/components/CreateRedeemableModal.tsx b/apps/web/src/features/Redeemables/components/CreateRedeemableModal.tsx new file mode 100644 index 00000000..c5c75bb6 --- /dev/null +++ b/apps/web/src/features/Redeemables/components/CreateRedeemableModal.tsx @@ -0,0 +1,156 @@ +import { Button } from "@/components/ui/Button"; +import { TextField } from "@/components/ui/TextField"; +import { useForm, type FormValidateOrFn } from "@tanstack/react-form"; +import { Form, OverlayTriggerStateContext } from "react-aria-components"; +import z from "zod"; +import { useFormErrors } from "@/components/Form"; +import { Modal } from "@/components/ui/Modal"; +import { useContext } from "react"; +import { useCreateRedeemable } from "../hooks/useRedeemables"; +import { showToast } from "@/lib/toast/toast"; + +const createRedeemableSchema = z.object({ + name: z.string().min(1, "Name is required"), + amount: z.number().int().min(1, "Amount must be at least 1"), + maxUserAmount: z.number().int().min(1, "Max user amount must be at least 1"), +}); + +interface CreateRedeemableFormValues { + name: string; + amount: number; + maxUserAmount: number; +} + +interface CreateRedeemableModalProps { + eventId: string; +} + +export function CreateRedeemableModal({ eventId }: CreateRedeemableModalProps) { + const state = useContext(OverlayTriggerStateContext)!; + const { mutateAsync } = useCreateRedeemable(eventId); + + const form = useForm< + CreateRedeemableFormValues, + undefined, + undefined, + undefined, + undefined, + undefined, + FormValidateOrFn, + undefined, + undefined, + undefined + >({ + defaultValues: { + name: "", + amount: 1, + maxUserAmount: 1, + }, + onSubmit: async ({ value }) => { + const { data, success } = createRedeemableSchema.safeParse(value); + if (!success) return; + + try { + await mutateAsync({ + name: data.name, + amount: data.amount, + max_user_amount: data.maxUserAmount, + }); + showToast({ + title: "Success", + message: "Redeemable created successfully!", + type: "success", + }); + state.close(); + form.reset(); + } catch (error) { + showToast({ + title: "Error", + message: "Failed to create redeemable. Please try again.", + type: "error", + }); + console.log("Error creating redeemable:", error); + } + }, + validators: { + onSubmit: createRedeemableSchema, + }, + }); + + const errors = useFormErrors(form); + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + validationErrors={errors} + > +
+ + {(field) => ( + field.handleChange(value)} + validationBehavior="aria" + isRequired + /> + )} + + + + {(field) => ( + + field.handleChange(value ? parseInt(value) : 0) + } + validationBehavior="aria" + isRequired + /> + )} + + + + {(field) => ( + + field.handleChange(value ? parseInt(value) : 0) + } + validationBehavior="aria" + isRequired + /> + )} + + + +
+
+
+ ); +} diff --git a/apps/web/src/features/Redeemables/components/DeleteRedeemableModal.tsx b/apps/web/src/features/Redeemables/components/DeleteRedeemableModal.tsx new file mode 100644 index 00000000..28d5b084 --- /dev/null +++ b/apps/web/src/features/Redeemables/components/DeleteRedeemableModal.tsx @@ -0,0 +1,105 @@ +import { Modal } from "@/components/ui/Modal"; +import { Button } from "@/components/ui/Button"; +import { api } from "@/lib/ky"; +import { showToast } from "@/lib/toast/toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState, useEffect, useContext } from "react"; +import TablerAlertTriangle from "~icons/tabler/alert-triangle"; +import { OverlayTriggerStateContext } from "react-aria-components"; + +interface DeleteRedeemableModalProps { + eventId: string; + redeemableId: string; +} + +function DeleteRedeemableModal({ + eventId, + redeemableId, +}: DeleteRedeemableModalProps) { + const queryClient = useQueryClient(); + const [countdown, setCountdown] = useState(3); + const state = useContext(OverlayTriggerStateContext)!; + + useEffect(() => { + if (state.isOpen) { + setCountdown(3); + } else { + return; + } + + // Only start the interval if we are still counting down + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + + // Clean up the timer if the modal is closed or component unmounts + return () => clearInterval(timer); + }, [state.isOpen]); + + const handleDeleteRedeemable = async ( + eventId: string, + redeemableId: string, + ) => { + try { + await api.delete(`events/${eventId}/redeemables/${redeemableId}`); + showToast({ + title: "Redeemable Deleted", + message: "Successfully deleted redeemable.", + type: "success", + }); + queryClient.invalidateQueries({ queryKey: ["redeemables", eventId] }); + } catch (error) { + console.error("Failed to delete redeemable", error); + showToast({ + title: "Delete Failed", + message: "Failed to delete redeemable. Please try again.", + type: "error", + }); + } + }; + + return ( + +
+
+ +
+ +

+ Delete Redeemable? +

+
+

+ This action is permanent. +

+

+ Deleting this will erase ALL records for future analytics. + + Do not delete redeemables that have already been used by guests, + as we may use that data in the future. + +

+
+
+ +
+ +
+
+ ); +} + +export { DeleteRedeemableModal }; diff --git a/apps/web/src/features/Redeemables/components/RedeemableCard.tsx b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx new file mode 100644 index 00000000..f53450aa --- /dev/null +++ b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx @@ -0,0 +1,73 @@ +import { Card } from "@/components/ui/Card"; +import { DialogTrigger, Button } from "react-aria-components"; +import { RedeemableDetailsModal } from "./RedeemableDetailsModal"; + +export interface RedeemableCardProps { + id: string; + name: string; + totalStock: number; + maxUserAmount: number; + totalRedeemed: number; + eventId: string; +} + +export function RedeemableCard({ + id, + name, + totalStock, + maxUserAmount, + totalRedeemed, + eventId, +}: RedeemableCardProps) { + const remaining = totalStock - (totalRedeemed as number); + const percentageRemaining = + totalStock > 0 ? ((remaining / totalStock) * 100).toFixed(0) : 0; + + return ( + +
+
+

{name}

+
+
+ {remaining} / {totalStock} remaining +
+
+ {maxUserAmount} max per user +
+
+
+ +
+ {/*
+ Max per user: + {maxUserAmount} +
*/} + +
+
+
+ +
+ + + + +
+
+
+ + ); +} diff --git a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx new file mode 100644 index 00000000..5986c132 --- /dev/null +++ b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx @@ -0,0 +1,424 @@ +import { Modal } from "@/components/ui/Modal"; +import { + OverlayTriggerStateContext, + DialogTrigger, +} from "react-aria-components"; +import { Button } from "@/components/ui/Button"; +import TablerTrash from "~icons/tabler/trash"; +import { useContext, useRef, useEffect, useState, useCallback } from "react"; +import { Text } from "react-aria-components"; +import { + useRedeemRedeemable, + getUserByRFID, + useUpdateRedeemable, +} from "../hooks/useRedeemables"; +import { DeleteRedeemableModal } from "./DeleteRedeemableModal"; +import { Scanner, type IDetectedBarcode } from "@yudiel/react-qr-scanner"; +import { showToast } from "@/lib/toast/toast"; +import TablerRefresh from "~icons/tabler/refresh"; +import { parseQrIntent } from "@/lib/qr-intents/parse"; +import { Intent } from "@/lib/qr-intents/intent"; + +export interface RedeemableDetailsModalProps { + id: string; + name: string; + totalStock: number; + maxUserAmount: number; + totalRedeemed: number; + eventId: string; +} + +type ModalMode = "details" | "qr" | "edit"; + +export function RedeemableDetailsModal({ + id, + name, + totalStock, + maxUserAmount, + totalRedeemed, + eventId, +}: RedeemableDetailsModalProps) { + const state = useContext(OverlayTriggerStateContext)!; + const remaining = totalStock - totalRedeemed; + const percentageRemaining = + totalStock > 0 ? ((remaining / totalStock) * 100).toFixed(0) : 0; + + const [rfidInput, setRfidInput] = useState(""); + const [mode, setMode] = useState("details"); + const inputRef = useRef(null); + const [isScanning, setIsScanning] = useState(false); + const { mutateAsync: redeemRedeemable, isPending } = useRedeemRedeemable( + eventId, + id, + ); + const [formData, setFormData] = useState({ + name: name, + totalStock: totalStock, + maxUserAmount: maxUserAmount, + }); + const [isUpdating, setIsUpdating] = useState(false); + const { mutateAsync: updateRedeemable } = useUpdateRedeemable(eventId, id); + + useEffect(() => { + setFormData({ name, totalStock, maxUserAmount }); + }, [name, totalStock, maxUserAmount]); + + const handleReset = () => { + setFormData({ name, totalStock, maxUserAmount }); + showToast({ title: "Reset", message: "Changes discarded", type: "info" }); + }; + + const handleConfirmEdit = async () => { + setIsUpdating(true); + try { + await updateRedeemable({ + name: formData.name, + total_stock: formData.totalStock, + max_user_amount: formData.maxUserAmount, + }); + + console.log("Updating redeemable:", id, formData); + showToast({ + title: "Success", + message: "Redeemable updated successfully!", + type: "success", + }); + setMode("details"); + } catch (error) { + showToast({ title: "Error", message: "Failed to update", type: "error" }); + console.log("Update redeemable error:", error); + } finally { + setIsUpdating(false); + } + }; + // Auto-focus the invisible input when modal opens + useEffect(() => { + if (state.isOpen) { + setMode("details"); + + setRfidInput(""); + + if (inputRef.current) { + inputRef.current.focus(); + } + } + }, [state.isOpen]); + + // Handle RFID input - when 10 digits are entered, trigger redemption + useEffect(() => { + if (rfidInput.length === 10) { + handleRedeem(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rfidInput]); + + const onScan = useCallback( + async (scannedData: IDetectedBarcode[]) => { + const res = parseQrIntent(scannedData[0].rawValue); + + if (res.ok && res.value.intent === Intent.IDENT) { + setIsScanning(true); + + try { + await redeemRedeemable(res.value.user_id); + + showToast({ + title: "Success", + message: "Redeemable redeemed successfully!", + type: "success", + }); + + // 4. Unlock after your desired delay + setTimeout(() => { + setIsScanning(false); + }, 5000); + } catch (error) { + showToast({ + title: "Error", + message: + "Failed to redeem with QR. User not found or already redeemed.", + type: "error", + }); + console.log("QR Scan error:", error); + setTimeout(() => { + setIsScanning(false); + }, 2000); + } + } + }, + [redeemRedeemable], + ); + const handleRedeem = async () => { + if (rfidInput.length !== 10) { + showToast({ + title: "Invalid RFID", + message: "RFID must be exactly 10 digits", + type: "error", + }); + return; + } + + try { + // First, convert RFID to UserID + const userResponse = await getUserByRFID(eventId, rfidInput); + + // Then, redeem using the user ID + await redeemRedeemable(userResponse.user_id); + + showToast({ + title: "Success", + message: "Redeemable redeemed successfully!", + type: "success", + }); + setRfidInput(""); + if (inputRef.current) { + inputRef.current.focus(); + } + } catch (error) { + showToast({ + title: "Error", + message: + "Failed to redeem with RFID. User not found or already redeemed.", + type: "error", + }); + setRfidInput(""); + if (inputRef.current) { + inputRef.current.focus(); + } + console.log("Redeem error:", error); + } + }; + + const handleRefresh = () => { + setRfidInput(""); + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + return ( + + {mode === "details" && ( +
+
+ + + +
+
+
+ Total Stock + {totalStock} +
+ +
+ Redeemed + + {totalRedeemed} + +
+ +
+ Remaining + {remaining} +
+ +
+ Max per User + + {maxUserAmount} + +
+ +
+
+ Stock Status + + {percentageRemaining}% remaining + +
+
+
+
+
+
+ +
+
+ + Tap RFID on Scanner to Redeem + +
+ ID: {id} + + +
+ + {/* Invisible RFID input field */} + { + // Only allow digits and limit to 10 + const value = e.target.value.replace(/\D/g, "").slice(0, 10); + setRfidInput(value); + }} + className="sr-only" + aria-label="RFID input" + disabled={isPending} + placeholder="" + /> + + {rfidInput.length > 0 && ( +
+ RFID: {"•".repeat(rfidInput.length)} +
+ )} +
+
+
+ )} + {mode === "qr" && ( +
+
+ +
+
+
+ console.log(err)} + /> +
+

+ Point the camera at a user's QR code to redeem. +

+
+
+ )} + {mode === "edit" && ( +
+
+ + + + + +
+ +
+ {/* Name Input */} +
+ + + setFormData({ ...formData, name: e.target.value }) + } + className="w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg focus:ring-2 focus:ring-purple-500 outline-none transition-all" + /> +
+ +
+ {/* Total Stock Input */} +
+ + + setFormData({ + ...formData, + totalStock: Number(e.target.value), + }) + } + className="w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg focus:ring-2 focus:ring-purple-500 outline-none transition-all" + /> +
+ + {/* Max Per User Input */} +
+ + + setFormData({ + ...formData, + maxUserAmount: Number(e.target.value), + }) + } + className="w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg focus:ring-2 focus:ring-purple-500 outline-none transition-all" + /> +
+
+
+
+ + +
+
+ )} + + ); +} diff --git a/apps/web/src/features/Redeemables/hooks/useRedeemables.ts b/apps/web/src/features/Redeemables/hooks/useRedeemables.ts new file mode 100644 index 00000000..bc2ea870 --- /dev/null +++ b/apps/web/src/features/Redeemables/hooks/useRedeemables.ts @@ -0,0 +1,97 @@ +import { api } from "@/lib/ky"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +export interface Redeemable { + id: string; + event_id: string; + name: string; + total_stock: number; + max_user_amount: number; + created_at: string; + updated_at: string; + total_redeemed: number | string; +} + +export async function fetchRedeemables(eventId: string): Promise { + const result = await api + .get(`events/${eventId}/redeemables`) + .json(); + return result; +} + +export function useRedeemables(eventId: string) { + return useQuery({ + queryKey: ["redeemables", eventId], + queryFn: () => fetchRedeemables(eventId), + }); +} + +interface CreateRedeemableRequest { + name: string; + amount: number; + max_user_amount: number; +} + +export function useCreateRedeemable(eventId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: CreateRedeemableRequest) => { + // The backend returns a Redeemable struct (which has 'amount' field) + // but we invalidate queries so the GET response (with 'total_stock') will be fetched + await api.post(`events/${eventId}/redeemables`, { + json: data, + }); + }, + onSuccess: () => { + // Invalidate to refetch with the correct format from GET endpoint + queryClient.invalidateQueries({ queryKey: ["redeemables", eventId] }); + }, + }); +} + +interface GetUserByRFIDResponse { + user_id: string; +} + +// Convert RFID to UserID +export async function getUserByRFID( + eventId: string, + rfid: string, +): Promise { + const result = await api + .get(`events/${eventId}/users/by-rfid/${rfid}`) + .json(); + return result; +} + +export function useRedeemRedeemable(eventId: string, redeemableId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (userId: string) => { + await api.post( + `events/${eventId}/redeemables/${redeemableId}/users/${userId}`, + ); + }, + onSuccess: () => { + // Invalidate to refetch updated redeemable data + queryClient.invalidateQueries({ queryKey: ["redeemables", eventId] }); + }, + }); +} + +export function useUpdateRedeemable(eventId: string, redeemableId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: Partial) => { + await api.patch(`events/${eventId}/redeemables/${redeemableId}`, { + json: data, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["redeemables", eventId] }); + }, + }); +} diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx index aac7793f..eeb679bd 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx @@ -1,5 +1,9 @@ -import { PageUnderConstruction } from "@/components/PageUnderConstruction"; import { createFileRoute } from "@tanstack/react-router"; +import { RedeemableCard } from "@/features/Redeemables/components/RedeemableCard"; +import { CreateRedeemableModal } from "@/features/Redeemables/components/CreateRedeemableModal"; +import { useRedeemables } from "@/features/Redeemables/hooks/useRedeemables"; +import { Button } from "@/components/ui/Button"; +import { DialogTrigger, Heading, Text } from "react-aria-components"; export const Route = createFileRoute( "/_protected/events/$eventId/dashboard/_staff/redeemables", @@ -8,5 +12,77 @@ export const Route = createFileRoute( }); function RouteComponent() { - return ; + const { eventId } = Route.useParams(); + const { data: redeemables, isLoading, isError } = useRedeemables(eventId); + + if (isLoading) { + return ( +
+
+ Redeemables +
+
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (isError) { + return ( +
+ Redeemables + + Error loading redeemables. Please refresh and try again. + +
+ ); + } + + return ( +
+
+ Redeemables + + + + +
+ + {redeemables && redeemables.length > 0 ? ( +
+ {redeemables.map((redeemable) => ( + + ))} +
+ ) : ( +
+ + No redeemables found + + + + + +
+ )} +
+ ); }