From a10497ce0d5da392777d9cdd60ddd585dc52db8f Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:00:21 -0500 Subject: [PATCH 01/16] Table Migration --- ...260114224323_create_redeemables_tables.sql | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/api/internal/db/migrations/20260114224323_create_redeemables_tables.sql diff --git a/apps/api/internal/db/migrations/20260114224323_create_redeemables_tables.sql b/apps/api/internal/db/migrations/20260114224323_create_redeemables_tables.sql new file mode 100644 index 00000000..c7fb3aef --- /dev/null +++ b/apps/api/internal/db/migrations/20260114224323_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 From 0048f003702ef1b055f5e3865170e5b9685250b1 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:25:55 -0500 Subject: [PATCH 02/16] SQLC Go functions --- apps/api/internal/db/queries/redeemables.sql | 66 +++++ apps/api/internal/db/sqlc/models.go | 19 ++ apps/api/internal/db/sqlc/redeemables.sql.go | 239 +++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 apps/api/internal/db/queries/redeemables.sql create mode 100644 apps/api/internal/db/sqlc/redeemables.sql.go 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/sqlc/models.go b/apps/api/internal/db/sqlc/models.go index c4135181..6fdb21f8 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 @@ -420,6 +421,16 @@ type EventRole struct { AssignedAt *time.Time `json:"assigned_at"` } +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"` @@ -458,3 +469,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 +} From 5999ecf0e6e277a855c75db1a8d0e4afc2988862 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:00:39 -0500 Subject: [PATCH 03/16] Backend Scaffolding --- apps/api/cmd/api/main.go | 4 +- apps/api/internal/api/api.go | 15 ++++++ apps/api/internal/api/handlers/handlers.go | 3 ++ apps/api/internal/api/handlers/redeemables.go | 51 +++++++++++++++++++ .../api/internal/db/repository/redeemables.go | 13 +++++ apps/api/internal/services/redeemables.go | 20 ++++++++ 6 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 apps/api/internal/api/handlers/redeemables.go create mode 100644 apps/api/internal/db/repository/redeemables.go create mode 100644 apps/api/internal/services/redeemables.go diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index fd15a735..c46437dd 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,9 +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, cfg, logger) + apiHandlers := handlers.NewHandlers(authService, userService, eventInterestService, eventService, emailService, applicationService, teamService, batService, redeemablesService, cfg, logger) api := api.NewAPI(&logger, apiHandlers, mw) diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 3fa89676..420f0cd6 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -199,6 +199,21 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.Patch("/join-waitlist", api.Handlers.Application.JoinWaitlist) }) + r.Route("/redeemables", func(r chi.Router) { + r.With(ensureEventStaff).Get("/", api.Handlers.Redeemables.GetRedeemables) + + r.With(ensureEventStaff).Post("/", api.Handlers.Redeemables.CreateRedeemable) + + r.Route("/{redeemableId}", func(r chi.Router) { + r.With(ensureEventStaff).Patch("/", api.Handlers.Redeemables.UpdateRedeemable) + + r.With(ensureEventStaff).Delete("/", api.Handlers.Redeemables.DeleteRedeemable) + + r.With(ensureEventStaff).Post("/redeem", api.Handlers.Redeemables.RedeemRedeemable) + r.With(ensureEventStaff).Patch("/users/{user_id}", 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/handlers.go b/apps/api/internal/api/handlers/handlers.go index c9578829..7f177a26 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 } func NewHandlers( @@ -27,6 +28,7 @@ func NewHandlers( appService *services.ApplicationService, teamService *services.TeamService, batService *services.BatService, + redeemablesService *services.RedeemablesService, cfg *config.Config, logger zerolog.Logger, ) *Handlers { @@ -40,5 +42,6 @@ func NewHandlers( Teams: NewTeamHandler(teamService, logger), Admission: NewAdmissionHandler(batService, logger), Bat: NewBatHandler(batService, logger), + Redeemables: NewRedeemablesHandler(redeemablesService, cfg, 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..4d58721a --- /dev/null +++ b/apps/api/internal/api/handlers/redeemables.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "net/http" + + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/config" + "github.com/swamphacks/core/apps/api/internal/services" +) + +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, + } +} + +func (h *RedeemablesHandler) GetRedeemables(w http.ResponseWriter, r *http.Request) { + return +} + +func (h *RedeemablesHandler) CreateRedeemable(w http.ResponseWriter, r *http.Request) { + return +} + +func (h *RedeemablesHandler) UpdateRedeemable(w http.ResponseWriter, r *http.Request) { + return +} + +func (h *RedeemablesHandler) DeleteRedeemable(w http.ResponseWriter, r *http.Request) { + return +} + +func (h *RedeemablesHandler) RedeemRedeemable(w http.ResponseWriter, r *http.Request) { + return +} + +func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Request) { + return +} diff --git a/apps/api/internal/db/repository/redeemables.go b/apps/api/internal/db/repository/redeemables.go new file mode 100644 index 00000000..62ba95d9 --- /dev/null +++ b/apps/api/internal/db/repository/redeemables.go @@ -0,0 +1,13 @@ +package repository + +import "github.com/swamphacks/core/apps/api/internal/db" + +type RedeemablesRepository struct { + db *db.DB +} + +func NewRedeemablesRepository(db *db.DB) *RedeemablesRepository { + return &RedeemablesRepository{ + db: db, + } +} diff --git a/apps/api/internal/services/redeemables.go b/apps/api/internal/services/redeemables.go new file mode 100644 index 00000000..d6da7760 --- /dev/null +++ b/apps/api/internal/services/redeemables.go @@ -0,0 +1,20 @@ +package services + +import ( + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/db/repository" +) + +type RedeemablesService struct { + redeemablesRepo *repository.RedeemablesRepository + logger zerolog.Logger +} + +func NewRedeemablesService( + redeemablesRepo *repository.RedeemablesRepository, + logger zerolog.Logger) *RedeemablesService { + return &RedeemablesService{ + redeemablesRepo: redeemablesRepo, + logger: logger, + } +} From 1c91f173dadd7960bd5a4d0835d0dcfbff4550f1 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:27:39 -0500 Subject: [PATCH 04/16] Basic CRUD operations + Refactor API --- apps/api/internal/api/api.go | 18 +-- apps/api/internal/api/handlers/redeemables.go | 107 +++++++++++++++++- .../api/internal/db/repository/redeemables.go | 38 ++++++- apps/api/internal/services/redeemables.go | 63 +++++++++++ 4 files changed, 215 insertions(+), 11 deletions(-) diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 420f0cd6..1f6a7e69 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -200,17 +200,21 @@ func (api *API) setupRoutes(mw *mw.Middleware) { }) r.Route("/redeemables", func(r chi.Router) { - r.With(ensureEventStaff).Get("/", api.Handlers.Redeemables.GetRedeemables) + r.Use(ensureEventStaff) + r.Route("/events/{eventID}", func(r chi.Router) { - r.With(ensureEventStaff).Post("/", api.Handlers.Redeemables.CreateRedeemable) + r.Get("/", api.Handlers.Redeemables.GetRedeemables) + r.Post("/", api.Handlers.Redeemables.CreateRedeemable) + }) r.Route("/{redeemableId}", func(r chi.Router) { - r.With(ensureEventStaff).Patch("/", api.Handlers.Redeemables.UpdateRedeemable) - - r.With(ensureEventStaff).Delete("/", api.Handlers.Redeemables.DeleteRedeemable) + r.Patch("/", api.Handlers.Redeemables.UpdateRedeemable) + r.Delete("/", api.Handlers.Redeemables.DeleteRedeemable) - r.With(ensureEventStaff).Post("/redeem", api.Handlers.Redeemables.RedeemRedeemable) - r.With(ensureEventStaff).Patch("/users/{user_id}", api.Handlers.Redeemables.UpdateRedemption) + r.Route("/users/{userID}", func(r chi.Router) { + r.Post("/", api.Handlers.Redeemables.RedeemRedeemable) + r.Patch("/", api.Handlers.Redeemables.UpdateRedemption) + }) }) }) diff --git a/apps/api/internal/api/handlers/redeemables.go b/apps/api/internal/api/handlers/redeemables.go index 4d58721a..a824c19d 100644 --- a/apps/api/internal/api/handlers/redeemables.go +++ b/apps/api/internal/api/handlers/redeemables.go @@ -1,9 +1,13 @@ package handlers import ( + "encoding/json" "net/http" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" "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" ) @@ -27,25 +31,122 @@ func NewRedeemablesHandler( } func (h *RedeemablesHandler) GetRedeemables(w http.ResponseWriter, r *http.Request) { - return + eventIdStr := chi.URLParam(r, "eventId") + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID")) + 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"` } func (h *RedeemablesHandler) CreateRedeemable(w http.ResponseWriter, r *http.Request) { - return + eventIdStr := chi.URLParam(r, "eventId") + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID")) + 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 + } + + 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:"amount,omitempty"` + MaxUserAmount *int `json:"max_user_amount,omitempty"` } func (h *RedeemablesHandler) UpdateRedeemable(w http.ResponseWriter, r *http.Request) { + redeemableIdStr := chi.URLParam(r, "redeemableId") + if redeemableIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_redeemable_id", "The redeemable ID is missing from the URL!")) + return + } + redeemableId, err := uuid.Parse(redeemableIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) + 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 + // decode request body } func (h *RedeemablesHandler) DeleteRedeemable(w http.ResponseWriter, r *http.Request) { - return + redeemableIdStr := chi.URLParam(r, "redeemableId") + if redeemableIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_redeemable_id", "The redeemable ID is missing from the URL!")) + return + } + redeemableId, err := uuid.Parse(redeemableIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) + 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) } func (h *RedeemablesHandler) RedeemRedeemable(w http.ResponseWriter, r *http.Request) { + // user id, redeemable id return } func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Request) { + // user id, redeemable id return } diff --git a/apps/api/internal/db/repository/redeemables.go b/apps/api/internal/db/repository/redeemables.go index 62ba95d9..55bb4a79 100644 --- a/apps/api/internal/db/repository/redeemables.go +++ b/apps/api/internal/db/repository/redeemables.go @@ -1,6 +1,12 @@ package repository -import "github.com/swamphacks/core/apps/api/internal/db" +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 @@ -11,3 +17,33 @@ func NewRedeemablesRepository(db *db.DB) *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 // TODO: more robust error handling + } + return &redeemables, nil +} + +func (r *RedeemablesRepository) CreateRedeemable(ctx context.Context, params sqlc.CreateRedeemableParams) (*sqlc.Redeemable, error) { + redeemable, err := r.db.Query.CreateRedeemable(ctx, params) + if err != nil { + return nil, err // TODO: more robust error handling + } + 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, params sqlc.UpdateRedeemableParams) (*sqlc.Redeemable, error) { + redeemable, err := r.db.Query.UpdateRedeemable(ctx, params) + if err != nil { + return nil, err + } + return &redeemable, nil +} diff --git a/apps/api/internal/services/redeemables.go b/apps/api/internal/services/redeemables.go index d6da7760..e0d5cc40 100644 --- a/apps/api/internal/services/redeemables.go +++ b/apps/api/internal/services/redeemables.go @@ -1,8 +1,12 @@ 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 { @@ -18,3 +22,62 @@ func NewRedeemablesService( 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) { + params := sqlc.CreateRedeemableParams{ + EventID: eventID, + Name: name, + Amount: int32(amount), + MaxUserAmount: int32(maxUserAmount), + } + redeemable, err := s.redeemablesRepo.CreateRedeemable(ctx, params) + 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) { + // need to convert to int32, extra code because they are nullable pointers + 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 := s.redeemablesRepo.UpdateRedeemable(ctx, params) + if err != nil { + s.logger.Error().Err(err).Msg("failed to update redeemable") + return nil, err + } + return redeemable, nil +} From 41484b7c3facb382658663ab42033f1b1b2afc52 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:46:06 -0500 Subject: [PATCH 05/16] Fixed repo level issue + finished logic --- apps/api/internal/api/handlers/redeemables.go | 54 ++++++++++++++++++- .../api/internal/db/repository/redeemables.go | 52 +++++++++++++++++- apps/api/internal/services/redeemables.go | 46 ++++++++-------- 3 files changed, 123 insertions(+), 29 deletions(-) diff --git a/apps/api/internal/api/handlers/redeemables.go b/apps/api/internal/api/handlers/redeemables.go index a824c19d..a4990372 100644 --- a/apps/api/internal/api/handlers/redeemables.go +++ b/apps/api/internal/api/handlers/redeemables.go @@ -143,10 +143,60 @@ func (h *RedeemablesHandler) DeleteRedeemable(w http.ResponseWriter, r *http.Req func (h *RedeemablesHandler) RedeemRedeemable(w http.ResponseWriter, r *http.Request) { // user id, redeemable id - return + userIdStr := chi.URLParam(r, "userId") + if userIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_user_id", "The user ID is missing from the URL!")) + return + } + userId, err := uuid.Parse(userIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_user_id", "The user ID is not a valid UUID")) + return + } + redeemableIdStr := chi.URLParam(r, "redeemableId") + if redeemableIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_redeemable_id", "The redeemable ID is missing from the URL!")) + return + } + redeemableId, err := uuid.Parse(redeemableIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) + return + } + err = h.redeemablesService.RedeemRedeemable(r.Context(), userId, redeemableId) + 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) } func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Request) { // user id, redeemable id - return + userIdStr := chi.URLParam(r, "userId") + if userIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_user_id", "The user ID is missing from the URL!")) + return + } + userId, err := uuid.Parse(userIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_user_id", "The user ID is not a valid UUID")) + return + } + redeemableIdStr := chi.URLParam(r, "redeemableId") + if redeemableIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_redeemable_id", "The redeemable ID is missing from the URL!")) + return + } + redeemableId, err := uuid.Parse(redeemableIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) + return + } + err = h.redeemablesService.UpdateRedemption(r.Context(), userId, redeemableId) + 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/repository/redeemables.go b/apps/api/internal/db/repository/redeemables.go index 55bb4a79..69191970 100644 --- a/apps/api/internal/db/repository/redeemables.go +++ b/apps/api/internal/db/repository/redeemables.go @@ -26,12 +26,19 @@ func (r *RedeemablesRepository) GetRedeemablesByEventID(ctx context.Context, eve return &redeemables, nil } -func (r *RedeemablesRepository) CreateRedeemable(ctx context.Context, params sqlc.CreateRedeemableParams) (*sqlc.Redeemable, error) { +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 // TODO: more robust error handling } return &redeemable, nil + // TODO: make sure this is used properly } func (r *RedeemablesRepository) DeleteRedeemable(ctx context.Context, redeemableID uuid.UUID) error { err := r.db.Query.DeleteRedeemable(ctx, redeemableID) @@ -40,10 +47,51 @@ func (r *RedeemablesRepository) DeleteRedeemable(ctx context.Context, redeemable } return nil } -func (r *RedeemablesRepository) UpdateRedeemable(ctx context.Context, params sqlc.UpdateRedeemableParams) (*sqlc.Redeemable, error) { +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 + // TODO: make sure the this is used properly +} + +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 + // TODO: make sure this is used properly +} + +func (r *RedeemablesRepository) UpdateRedemption(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID) error { + err := r.db.Query.UpdateRedemption(ctx, sqlc.UpdateRedemptionParams{ + RedeemableID: redeemableID, + UserID: userID, + }) + if err != nil { + return err + } + return nil } diff --git a/apps/api/internal/services/redeemables.go b/apps/api/internal/services/redeemables.go index e0d5cc40..8c5e27ff 100644 --- a/apps/api/internal/services/redeemables.go +++ b/apps/api/internal/services/redeemables.go @@ -33,13 +33,7 @@ func (s *RedeemablesService) GetRedeemablesByEventID(ctx context.Context, eventI } func (s *RedeemablesService) 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 := s.redeemablesRepo.CreateRedeemable(ctx, params) + 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 @@ -57,27 +51,29 @@ func (s *RedeemablesService) DeleteRedeemable(ctx context.Context, redeemableID } func (s *RedeemablesService) UpdateRedeemable(ctx context.Context, redeemableID uuid.UUID, name *string, amount *int, maxUserAmount *int) (*sqlc.Redeemable, error) { - // need to convert to int32, extra code because they are nullable pointers - 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 := s.redeemablesRepo.UpdateRedeemable(ctx, params) + 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) error { + err := s.redeemablesRepo.UpdateRedemption(ctx, redeemableID, userID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to update redemption") + return err + } + return nil +} From d9dce2644a9cff309143f931adbecd2474e46bcb Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Sun, 18 Jan 2026 20:46:56 -0500 Subject: [PATCH 06/16] Small Push --- ...260114224323_create_redeemables_tables.sql | 43 ------------------- .../Redeemables/components/RedeemableCard.tsx | 1 + .../$eventId/dashboard/_staff/redeemables.tsx | 4 ++ 3 files changed, 5 insertions(+), 43 deletions(-) delete mode 100644 apps/api/internal/db/migrations/20260114224323_create_redeemables_tables.sql create mode 100644 apps/web/src/features/Redeemables/components/RedeemableCard.tsx diff --git a/apps/api/internal/db/migrations/20260114224323_create_redeemables_tables.sql b/apps/api/internal/db/migrations/20260114224323_create_redeemables_tables.sql deleted file mode 100644 index c7fb3aef..00000000 --- a/apps/api/internal/db/migrations/20260114224323_create_redeemables_tables.sql +++ /dev/null @@ -1,43 +0,0 @@ --- +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/web/src/features/Redeemables/components/RedeemableCard.tsx b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx new file mode 100644 index 00000000..7240abb9 --- /dev/null +++ b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx @@ -0,0 +1 @@ +// TODO: implement redeemable card component 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..5c2c2229 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 @@ -7,6 +7,10 @@ export const Route = createFileRoute( component: RouteComponent, }); +// TODO: Implement redeemables dashboard page + function RouteComponent() { + // TODO: Create Redeemable Card Component (with edit and delete functionality) + // TODO: Create manager for loading redeemables return ; } From 94e8268bdbe8f91545a663f5be9af39a4f11c6d4 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Sun, 18 Jan 2026 20:52:08 -0500 Subject: [PATCH 07/16] Remigrate Deleted and remigrated a migration to avoid bugs from merging dev --- ...260119015108_create_redeemables_tables.sql | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/api/internal/db/migrations/20260119015108_create_redeemables_tables.sql 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 From ab655092c8ef072b6d75473bc25c4996039a2956 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:30:02 -0500 Subject: [PATCH 08/16] Redeemables UI --- apps/api/internal/api/api.go | 14 +- apps/api/internal/api/handlers/events.go | 40 ++++ apps/api/internal/api/handlers/redeemables.go | 4 +- apps/api/internal/db/queries/event_roles.sql | 7 + apps/api/internal/db/repository/events.go | 15 ++ apps/api/internal/db/sqlc/event_roles.sql.go | 32 +++ apps/api/internal/services/events.go | 13 ++ .../components/CreateRedeemableModal.tsx | 156 +++++++++++++++ .../Redeemables/components/RedeemableCard.tsx | 69 ++++++- .../components/RedeemableDetailsModal.tsx | 185 ++++++++++++++++++ .../Redeemables/hooks/useRedeemables.ts | 82 ++++++++ .../$eventId/dashboard/_staff/redeemables.tsx | 84 +++++++- 12 files changed, 686 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/features/Redeemables/components/CreateRedeemableModal.tsx create mode 100644 apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx create mode 100644 apps/web/src/features/Redeemables/hooks/useRedeemables.ts diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index ef9cee18..18201c60 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -143,6 +143,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) @@ -206,17 +208,17 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.Route("/redeemables", func(r chi.Router) { r.Use(ensureEventStaff) - r.Route("/events/{eventID}", func(r chi.Router) { - - r.Get("/", api.Handlers.Redeemables.GetRedeemables) - r.Post("/", api.Handlers.Redeemables.CreateRedeemable) - }) + // 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.Route("/users/{userId}", func(r chi.Router) { r.Post("/", api.Handlers.Redeemables.RedeemRedeemable) r.Patch("/", api.Handlers.Redeemables.UpdateRedemption) }) 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/redeemables.go b/apps/api/internal/api/handlers/redeemables.go index a4990372..df28bf11 100644 --- a/apps/api/internal/api/handlers/redeemables.go +++ b/apps/api/internal/api/handlers/redeemables.go @@ -163,7 +163,7 @@ func (h *RedeemablesHandler) RedeemRedeemable(w http.ResponseWriter, r *http.Req res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) return } - err = h.redeemablesService.RedeemRedeemable(r.Context(), userId, redeemableId) + 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 @@ -193,7 +193,7 @@ func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Req res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) return } - err = h.redeemablesService.UpdateRedemption(r.Context(), userId, redeemableId) + err = h.redeemablesService.UpdateRedemption(r.Context(), redeemableId, userId) if err != nil { res.SendError(w, http.StatusInternalServerError, res.NewError("internal_server_error", "internal service error, failed to update redemption")) return diff --git a/apps/api/internal/db/queries/event_roles.sql b/apps/api/internal/db/queries/event_roles.sql index 80bc95f3..0fcda444 100644 --- a/apps/api/internal/db/queries/event_roles.sql +++ b/apps/api/internal/db/queries/event_roles.sql @@ -39,3 +39,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/repository/events.go b/apps/api/internal/db/repository/events.go index f759f901..262a30ec 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/sqlc/event_roles.sql.go b/apps/api/internal/db/sqlc/event_roles.sql.go index bc45986e..c2f21240 100644 --- a/apps/api/internal/db/sqlc/event_roles.sql.go +++ b/apps/api/internal/db/sqlc/event_roles.sql.go @@ -153,6 +153,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/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/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/RedeemableCard.tsx b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx index 7240abb9..c71543d2 100644 --- a/apps/web/src/features/Redeemables/components/RedeemableCard.tsx +++ b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx @@ -1 +1,68 @@ -// TODO: implement redeemable card component +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 +
+
+ +
+
+ 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..ab84ab6d --- /dev/null +++ b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx @@ -0,0 +1,185 @@ +import { Modal } from "@/components/ui/Modal"; +import { OverlayTriggerStateContext, Button } from "react-aria-components"; +import { useContext, useRef, useEffect, useState } from "react"; +import { Text } from "react-aria-components"; +import { useRedeemRedeemable, getUserByRFID } from "../hooks/useRedeemables"; +import { showToast } from "@/lib/toast/toast"; +import TablerRefresh from "~icons/tabler/refresh"; + +export interface RedeemableDetailsModalProps { + id: string; + name: string; + totalStock: number; + maxUserAmount: number; + totalRedeemed: number; + eventId: string; +} + +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 inputRef = useRef(null); + const { mutateAsync: redeemRedeemable, isPending } = useRedeemRedeemable( + eventId, + id, + ); + + // Auto-focus the invisible input when modal opens + useEffect(() => { + if (state.isOpen && 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 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. 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 ( + +
+
+
+ Total Stock + {totalStock} +
+ +
+ Redeemed + + {totalRedeemed} + +
+ +
+ Remaining + {remaining} +
+ +
+ Max per User + + {maxUserAmount} + +
+ +
+
+ Stock Status + + {percentageRemaining}% remaining + +
+
+
+
+
+
+ +
+
+
+ + Redeemable 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)} +
+ )} +
+
+
+ + ); +} 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..66b4728c --- /dev/null +++ b/apps/web/src/features/Redeemables/hooks/useRedeemables.ts @@ -0,0 +1,82 @@ +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] }); + }, + }); +} 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 5c2c2229..1755ad92 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", @@ -7,10 +11,78 @@ export const Route = createFileRoute( component: RouteComponent, }); -// TODO: Implement redeemables dashboard page - function RouteComponent() { - // TODO: Create Redeemable Card Component (with edit and delete functionality) - // TODO: Create manager for loading redeemables - 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 + + + + + +
+ )} +
+ ); } From f16e9362bd3d74198e6b46ebee96664e75ec2d55 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:03:02 -0500 Subject: [PATCH 09/16] Update Modal + QR Code QR is untested currently --- apps/api/internal/api/handlers/redeemables.go | 2 +- .../components/RedeemableDetailsModal.tsx | 342 ++++++++++++++---- .../Redeemables/hooks/useRedeemables.ts | 15 + .../$eventId/dashboard/_staff/redeemables.tsx | 4 +- 4 files changed, 294 insertions(+), 69 deletions(-) diff --git a/apps/api/internal/api/handlers/redeemables.go b/apps/api/internal/api/handlers/redeemables.go index df28bf11..df37806f 100644 --- a/apps/api/internal/api/handlers/redeemables.go +++ b/apps/api/internal/api/handlers/redeemables.go @@ -87,7 +87,7 @@ func (h *RedeemablesHandler) CreateRedeemable(w http.ResponseWriter, r *http.Req type UpdateRedeemableRequest struct { Name *string `json:"name,omitempty"` - Amount *int `json:"amount,omitempty"` + Amount *int `json:"total_stock,omitempty"` MaxUserAmount *int `json:"max_user_amount,omitempty"` } diff --git a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx index ab84ab6d..2e0b6037 100644 --- a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx +++ b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx @@ -2,9 +2,16 @@ import { Modal } from "@/components/ui/Modal"; import { OverlayTriggerStateContext, Button } from "react-aria-components"; import { useContext, useRef, useEffect, useState } from "react"; import { Text } from "react-aria-components"; -import { useRedeemRedeemable, getUserByRFID } from "../hooks/useRedeemables"; +import { + useRedeemRedeemable, + getUserByRFID, + useUpdateRedeemable, +} from "../hooks/useRedeemables"; +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; @@ -15,6 +22,8 @@ export interface RedeemableDetailsModalProps { eventId: string; } +type ModalMode = "details" | "qr" | "edit"; + export function RedeemableDetailsModal({ id, name, @@ -29,16 +38,62 @@ export function RedeemableDetailsModal({ totalStock > 0 ? ((remaining / totalStock) * 100).toFixed(0) : 0; const [rfidInput, setRfidInput] = useState(""); + const [mode, setMode] = useState("details"); const inputRef = useRef(null); 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 && inputRef.current) { - inputRef.current.focus(); + if (state.isOpen) { + setMode("details"); + + setRfidInput(""); + + if (inputRef.current) { + inputRef.current.focus(); + } } }, [state.isOpen]); @@ -50,6 +105,29 @@ export function RedeemableDetailsModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [rfidInput]); + const onScan = async (scannedData: IDetectedBarcode[]) => { + const res = parseQrIntent(scannedData[0].rawValue); + if (res.ok && res.value.intent === Intent.IDENT) { + try { + await redeemRedeemable(res.value.user_id); + + showToast({ + title: "Success", + message: "Redeemable redeemed successfully!", + type: "success", + }); + } catch (error) { + showToast({ + title: "Error", + message: + "Failed to redeem with QR. User not found or already redeemed.", + type: "error", + }); + console.log("Redeem error:", error); + } + } + }; + const handleRedeem = async () => { if (rfidInput.length !== 10) { showToast({ @@ -79,7 +157,8 @@ export function RedeemableDetailsModal({ } catch (error) { showToast({ title: "Error", - message: "Failed to redeem. User not found or already redeemed.", + message: + "Failed to redeem with RFID. User not found or already redeemed.", type: "error", }); setRfidInput(""); @@ -99,87 +178,218 @@ export function RedeemableDetailsModal({ return ( -
-
-
- Total Stock - {totalStock} -
+ {mode === "details" && ( +
+
+ -
- Redeemed - - {totalRedeemed} - +
+
+
+ Total Stock + {totalStock} +
-
- Remaining - {remaining} -
+
+ Redeemed + + {totalRedeemed} + +
-
- Max per User - - {maxUserAmount} - -
+
+ Remaining + {remaining} +
-
- Stock Status + Max per User - {percentageRemaining}% remaining + {maxUserAmount}
-
-
+
+ Stock Status + + {percentageRemaining}% remaining + +
+
+
+
+
+
+ +
+
+
+ + Redeemable 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" && ( +
+
+ +
-
-
-
- - Redeemable ID: {id} - - +
+ {/* 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" + />
- {/* 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)} +
+ {/* 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 index 66b4728c..bc2ea870 100644 --- a/apps/web/src/features/Redeemables/hooks/useRedeemables.ts +++ b/apps/web/src/features/Redeemables/hooks/useRedeemables.ts @@ -80,3 +80,18 @@ export function useRedeemRedeemable(eventId: string, redeemableId: string) { }, }); } + +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 1755ad92..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 @@ -65,8 +65,8 @@ function RouteComponent() { maxUserAmount={redeemable.max_user_amount} totalRedeemed={ typeof redeemable.total_redeemed === "string" - ? parseInt(redeemable.total_redeemed, 10) || 0 - : Number(redeemable.total_redeemed) || 0 + ? parseInt(redeemable.total_redeemed) || 0 + : redeemable.total_redeemed } eventId={eventId} /> From 420780cd492d657b8f26cdb3330a51068ce33e60 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:30:20 -0500 Subject: [PATCH 10/16] Slight UI Adjustment + Deletion --- .../components/DeleteRedeemableModal.tsx | 105 ++++++++++++++++++ .../Redeemables/components/RedeemableCard.tsx | 13 ++- .../components/RedeemableDetailsModal.tsx | 17 ++- 3 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/features/Redeemables/components/DeleteRedeemableModal.tsx 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 index c71543d2..4eea1ef8 100644 --- a/apps/web/src/features/Redeemables/components/RedeemableCard.tsx +++ b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx @@ -28,16 +28,21 @@ export function RedeemableCard({

{name}

-
- {remaining} / {totalStock} remaining +
+
+ {remaining} / {totalStock} remaining +
+
+ {maxUserAmount} max per user +
-
+ {/*
Max per user: {maxUserAmount} -
+
*/}
Redeemable ID: {id} + + + + +
From cad58fc1046f5a5478398bcfb22457aa9b9a7403 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:37:04 -0500 Subject: [PATCH 11/16] UI Adjustment --- .../Redeemables/components/RedeemableDetailsModal.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx index 610ece6d..32479cd3 100644 --- a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx +++ b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx @@ -245,16 +245,14 @@ export function RedeemableDetailsModal({
- - Redeemable ID: {id} - + ID: {id}
From 91b4a5c3870fcfcfb612065e9392d361efacda33 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:41:01 -0500 Subject: [PATCH 12/16] Clarified Redemption Info --- .../src/features/Redeemables/components/RedeemableCard.tsx | 2 +- .../Redeemables/components/RedeemableDetailsModal.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/Redeemables/components/RedeemableCard.tsx b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx index 4eea1ef8..f53450aa 100644 --- a/apps/web/src/features/Redeemables/components/RedeemableCard.tsx +++ b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx @@ -54,7 +54,7 @@ export function RedeemableCard({
-
+
+ + Tap RFID on Scanner to Redeem +
ID: {id} From b18f86aee73d818521fbdf8fb542d5233280d454 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:56:46 -0500 Subject: [PATCH 13/16] Documentation --- apps/api/internal/api/handlers/redeemables.go | 61 ++++++++++++++++++- .../api/internal/db/repository/redeemables.go | 7 +-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/api/internal/api/handlers/redeemables.go b/apps/api/internal/api/handlers/redeemables.go index df37806f..8c14c82b 100644 --- a/apps/api/internal/api/handlers/redeemables.go +++ b/apps/api/internal/api/handlers/redeemables.go @@ -30,6 +30,18 @@ func NewRedeemablesHandler( } } +// 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) { eventIdStr := chi.URLParam(r, "eventId") if eventIdStr == "" { @@ -56,6 +68,19 @@ type CreateRedeemableRequest struct { 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) { eventIdStr := chi.URLParam(r, "eventId") if eventIdStr == "" { @@ -91,6 +116,19 @@ type UpdateRedeemableRequest struct { 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 201 {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) { redeemableIdStr := chi.URLParam(r, "redeemableId") if redeemableIdStr == "" { @@ -119,9 +157,18 @@ func (h *RedeemablesHandler) UpdateRedeemable(w http.ResponseWriter, r *http.Req res.Send(w, http.StatusCreated, redeemable) return - // decode request body } +// 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) { redeemableIdStr := chi.URLParam(r, "redeemableId") if redeemableIdStr == "" { @@ -141,6 +188,17 @@ func (h *RedeemablesHandler) DeleteRedeemable(w http.ResponseWriter, r *http.Req 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 userIdStr := chi.URLParam(r, "userId") @@ -171,6 +229,7 @@ func (h *RedeemablesHandler) RedeemRedeemable(w http.ResponseWriter, r *http.Req res.Send(w, http.StatusNoContent, nil) } +// UNUSED FOR NOW func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Request) { // user id, redeemable id userIdStr := chi.URLParam(r, "userId") diff --git a/apps/api/internal/db/repository/redeemables.go b/apps/api/internal/db/repository/redeemables.go index 69191970..9d2f460e 100644 --- a/apps/api/internal/db/repository/redeemables.go +++ b/apps/api/internal/db/repository/redeemables.go @@ -21,7 +21,7 @@ func NewRedeemablesRepository(db *db.DB) *RedeemablesRepository { 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 // TODO: more robust error handling + return nil, err } return &redeemables, nil } @@ -35,10 +35,9 @@ func (r *RedeemablesRepository) CreateRedeemable(ctx context.Context, eventID uu } redeemable, err := r.db.Query.CreateRedeemable(ctx, params) if err != nil { - return nil, err // TODO: more robust error handling + return nil, err } return &redeemable, nil - // TODO: make sure this is used properly } func (r *RedeemablesRepository) DeleteRedeemable(ctx context.Context, redeemableID uuid.UUID) error { err := r.db.Query.DeleteRedeemable(ctx, redeemableID) @@ -69,7 +68,6 @@ func (r *RedeemablesRepository) UpdateRedeemable(ctx context.Context, redeemable return nil, err } return &redeemable, nil - // TODO: make sure the this is used properly } func (r *RedeemablesRepository) RedeemRedeemable(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID) (*sqlc.UserRedemption, error) { @@ -82,7 +80,6 @@ func (r *RedeemablesRepository) RedeemRedeemable(ctx context.Context, redeemable return nil, err } return &redemption, nil - // TODO: make sure this is used properly } func (r *RedeemablesRepository) UpdateRedemption(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID) error { From 5c24a0ea5eee073ed9dd7d181cb0261820ab1fa4 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:01:52 -0500 Subject: [PATCH 14/16] UpdateRedemption missing body fix --- apps/api/internal/api/handlers/redeemables.go | 15 +++++++++++++-- apps/api/internal/db/repository/redeemables.go | 3 ++- apps/api/internal/services/redeemables.go | 4 ++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/api/internal/api/handlers/redeemables.go b/apps/api/internal/api/handlers/redeemables.go index 8c14c82b..5e39b54d 100644 --- a/apps/api/internal/api/handlers/redeemables.go +++ b/apps/api/internal/api/handlers/redeemables.go @@ -229,7 +229,10 @@ func (h *RedeemablesHandler) RedeemRedeemable(w http.ResponseWriter, r *http.Req res.Send(w, http.StatusNoContent, nil) } -// UNUSED FOR NOW +type UpdateRedemptionRequest struct { + amount int `json:"new_amount,omitempty"` +} + func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Request) { // user id, redeemable id userIdStr := chi.URLParam(r, "userId") @@ -252,7 +255,15 @@ func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Req res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) return } - err = h.redeemablesService.UpdateRedemption(r.Context(), redeemableId, userId) + 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 diff --git a/apps/api/internal/db/repository/redeemables.go b/apps/api/internal/db/repository/redeemables.go index 9d2f460e..4270d0cf 100644 --- a/apps/api/internal/db/repository/redeemables.go +++ b/apps/api/internal/db/repository/redeemables.go @@ -82,10 +82,11 @@ func (r *RedeemablesRepository) RedeemRedeemable(ctx context.Context, redeemable return &redemption, nil } -func (r *RedeemablesRepository) UpdateRedemption(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID) error { +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 diff --git a/apps/api/internal/services/redeemables.go b/apps/api/internal/services/redeemables.go index 8c5e27ff..bf02076e 100644 --- a/apps/api/internal/services/redeemables.go +++ b/apps/api/internal/services/redeemables.go @@ -69,8 +69,8 @@ func (s *RedeemablesService) RedeemRedeemable(ctx context.Context, redeemableID return nil } -func (s *RedeemablesService) UpdateRedemption(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID) error { - err := s.redeemablesRepo.UpdateRedemption(ctx, redeemableID, userID) +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 From e6562a74baf1b69235db3398e096a9c5cc150171 Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:02:45 -0500 Subject: [PATCH 15/16] 1 line bug fix --- apps/api/internal/api/handlers/redeemables.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/internal/api/handlers/redeemables.go b/apps/api/internal/api/handlers/redeemables.go index 5e39b54d..96f8fe58 100644 --- a/apps/api/internal/api/handlers/redeemables.go +++ b/apps/api/internal/api/handlers/redeemables.go @@ -230,7 +230,7 @@ func (h *RedeemablesHandler) RedeemRedeemable(w http.ResponseWriter, r *http.Req } type UpdateRedemptionRequest struct { - amount int `json:"new_amount,omitempty"` + Amount int `json:"new_amount,omitempty"` } func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Request) { @@ -263,7 +263,7 @@ func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Req return } - err = h.redeemablesService.UpdateRedemption(r.Context(), redeemableId, userId, req.amount) + 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 From 4b19a8560781c8fbe6555786420376d17c1dbeac Mon Sep 17 00:00:00 2001 From: Hugo Liu <98724522+hugoliu-code@users.noreply.github.com> Date: Mon, 19 Jan 2026 18:08:54 -0500 Subject: [PATCH 16/16] Cleaner handlers uuid parsing + qr throttling --- apps/api/internal/api/handlers/redeemables.go | 103 ++++++++---------- .../components/RedeemableDetailsModal.tsx | 61 +++++++---- 2 files changed, 82 insertions(+), 82 deletions(-) diff --git a/apps/api/internal/api/handlers/redeemables.go b/apps/api/internal/api/handlers/redeemables.go index 96f8fe58..5178b416 100644 --- a/apps/api/internal/api/handlers/redeemables.go +++ b/apps/api/internal/api/handlers/redeemables.go @@ -4,12 +4,11 @@ import ( "encoding/json" "net/http" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" "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 { @@ -43,16 +42,14 @@ func NewRedeemablesHandler( // @Failure 500 {object} response.ErrorResponse "Internal Server Error" // @Router /events/{eventId}/redeemables [get] func (h *RedeemablesHandler) GetRedeemables(w http.ResponseWriter, r *http.Request) { - eventIdStr := chi.URLParam(r, "eventId") - if eventIdStr == "" { - res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) - return - } - eventId, err := uuid.Parse(eventIdStr) + 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 UUID")) + 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")) @@ -82,14 +79,11 @@ type CreateRedeemableRequest struct { // @Failure 500 {object} response.ErrorResponse "Internal Server Error" // @Router /events/{eventId}/redeemables [post] func (h *RedeemablesHandler) CreateRedeemable(w http.ResponseWriter, r *http.Request) { - eventIdStr := chi.URLParam(r, "eventId") - if eventIdStr == "" { - res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) - return - } - eventId, err := uuid.Parse(eventIdStr) + 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 UUID")) + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing event_id."), + ) return } @@ -101,6 +95,11 @@ func (h *RedeemablesHandler) CreateRedeemable(w http.ResponseWriter, r *http.Req 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")) @@ -125,19 +124,16 @@ type UpdateRedeemableRequest struct { // @Produce json // @Param redeemableId path string true "Redeemable ID (UUID)" // @Param request body UpdateRedeemableRequest true "Redeemable update data (partial fields allowed)" -// @Success 201 {object} sqlc.Redeemable +// @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) { - redeemableIdStr := chi.URLParam(r, "redeemableId") - if redeemableIdStr == "" { - res.SendError(w, http.StatusBadRequest, res.NewError("missing_redeemable_id", "The redeemable ID is missing from the URL!")) - return - } - redeemableId, err := uuid.Parse(redeemableIdStr) + redeemableId, err := web.PathParamToUUID(r, "redeemableId") if err != nil { - res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing redeemable_id."), + ) return } @@ -170,16 +166,14 @@ func (h *RedeemablesHandler) UpdateRedeemable(w http.ResponseWriter, r *http.Req // @Failure 500 {object} response.ErrorResponse "Internal Server Error" // @Router /redeemables/{redeemableId} [delete] func (h *RedeemablesHandler) DeleteRedeemable(w http.ResponseWriter, r *http.Request) { - redeemableIdStr := chi.URLParam(r, "redeemableId") - if redeemableIdStr == "" { - res.SendError(w, http.StatusBadRequest, res.NewError("missing_redeemable_id", "The redeemable ID is missing from the URL!")) - return - } - redeemableId, err := uuid.Parse(redeemableIdStr) + redeemableId, err := web.PathParamToUUID(r, "redeemableId") if err != nil { - res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) + 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")) @@ -201,26 +195,22 @@ func (h *RedeemablesHandler) DeleteRedeemable(w http.ResponseWriter, r *http.Req // @Router /redeemables/{redeemableId}/users/{userId} [post] func (h *RedeemablesHandler) RedeemRedeemable(w http.ResponseWriter, r *http.Request) { // user id, redeemable id - userIdStr := chi.URLParam(r, "userId") - if userIdStr == "" { - res.SendError(w, http.StatusBadRequest, res.NewError("missing_user_id", "The user ID is missing from the URL!")) - return - } - userId, err := uuid.Parse(userIdStr) + userId, err := web.PathParamToUUID(r, "userId") if err != nil { - res.SendError(w, http.StatusBadRequest, res.NewError("invalid_user_id", "The user ID is not a valid UUID")) + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing user_id."), + ) return } - redeemableIdStr := chi.URLParam(r, "redeemableId") - if redeemableIdStr == "" { - res.SendError(w, http.StatusBadRequest, res.NewError("missing_redeemable_id", "The redeemable ID is missing from the URL!")) - return - } - redeemableId, err := uuid.Parse(redeemableIdStr) + + redeemableId, err := web.PathParamToUUID(r, "redeemableId") if err != nil { - res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) + 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")) @@ -235,24 +225,19 @@ type UpdateRedemptionRequest struct { func (h *RedeemablesHandler) UpdateRedemption(w http.ResponseWriter, r *http.Request) { // user id, redeemable id - userIdStr := chi.URLParam(r, "userId") - if userIdStr == "" { - res.SendError(w, http.StatusBadRequest, res.NewError("missing_user_id", "The user ID is missing from the URL!")) - return - } - userId, err := uuid.Parse(userIdStr) + userId, err := web.PathParamToUUID(r, "userId") if err != nil { - res.SendError(w, http.StatusBadRequest, res.NewError("invalid_user_id", "The user ID is not a valid UUID")) + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing user_id."), + ) return } - redeemableIdStr := chi.URLParam(r, "redeemableId") - if redeemableIdStr == "" { - res.SendError(w, http.StatusBadRequest, res.NewError("missing_redeemable_id", "The redeemable ID is missing from the URL!")) - return - } - redeemableId, err := uuid.Parse(redeemableIdStr) + + redeemableId, err := web.PathParamToUUID(r, "redeemableId") if err != nil { - res.SendError(w, http.StatusBadRequest, res.NewError("invalid_redeemable_id", "The redeemable ID is not a valid UUID")) + res.SendError(w, http.StatusBadRequest, + res.NewError("invalid_request", "Invalid or missing redeemable_id."), + ) return } var req UpdateRedemptionRequest diff --git a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx index d7cabd30..5986c132 100644 --- a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx +++ b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx @@ -5,7 +5,7 @@ import { } from "react-aria-components"; import { Button } from "@/components/ui/Button"; import TablerTrash from "~icons/tabler/trash"; -import { useContext, useRef, useEffect, useState } from "react"; +import { useContext, useRef, useEffect, useState, useCallback } from "react"; import { Text } from "react-aria-components"; import { useRedeemRedeemable, @@ -46,6 +46,7 @@ export function RedeemableDetailsModal({ 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, @@ -111,29 +112,42 @@ export function RedeemableDetailsModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [rfidInput]); - const onScan = async (scannedData: IDetectedBarcode[]) => { - const res = parseQrIntent(scannedData[0].rawValue); - if (res.ok && res.value.intent === Intent.IDENT) { - try { - await redeemRedeemable(res.value.user_id); - - showToast({ - title: "Success", - message: "Redeemable redeemed successfully!", - type: "success", - }); - } catch (error) { - showToast({ - title: "Error", - message: - "Failed to redeem with QR. User not found or already redeemed.", - type: "error", - }); - console.log("Redeem error:", error); - } - } - }; + 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({ @@ -301,6 +315,7 @@ export function RedeemableDetailsModal({ sound={false} formats={["qr_code"]} onScan={onScan} + paused={isScanning} scanDelay={500} onError={(err) => console.log(err)} />