From aa75217898ec05d4a73a1212cbde6a7738460918 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 29 May 2026 16:06:40 +0100 Subject: [PATCH] refactor: bookings and services handlers with shared formatting Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/bookings/bookings.go | 15 +++- backend/handlers/services/services.go | 119 +++++++++++++++++--------- 2 files changed, 89 insertions(+), 45 deletions(-) diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 5081653..3980604 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -36,7 +36,8 @@ type Booking struct { Notes *string `json:"notes,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - CreatedBy *string `json:"created_by,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` + CreatedByName *string `json:"created_by_name,omitempty"` // Deposit fields. // DepositRequired is snapshotted at creation from users.deposits_required > 0 @@ -752,25 +753,33 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { var booking Booking booking.User = &UserSummary{} var depositRequired bool + var dateOfBirth sql.NullTime err := db.DB.QueryRow(r.Context(), ` SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, - u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps, + u.fn, u.email, u.phone, u.profile_pic_url, u.date_of_birth, u.loyalty_stamps, u.referral_code, u.notes, + creator.fn, b.deposit_required FROM bookings b LEFT JOIN users u ON b.user_id = u.id + LEFT JOIN users creator ON b.created_by = creator.id WHERE b.id = $1 `, bookingID).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, &booking.User.FullName, &booking.User.Email, &booking.User.Phone, - &booking.User.ProfilePicURL, &booking.User.LoyaltyStamps, + &booking.User.ProfilePicURL, &dateOfBirth, &booking.User.LoyaltyStamps, &booking.User.ReferralCode, &booking.User.Notes, + &booking.CreatedByName, &depositRequired, ) + if dateOfBirth.Valid { + s := dateOfBirth.Time.Format("2006-01-02") + booking.User.DateOfBirth = &s + } if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) diff --git a/backend/handlers/services/services.go b/backend/handlers/services/services.go index fab5e7b..a2549c0 100644 --- a/backend/handlers/services/services.go +++ b/backend/handlers/services/services.go @@ -19,35 +19,38 @@ import ( // Service represents a service in the system type Service struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Price float64 `json:"price"` - DurationMinutes int `json:"duration_minutes"` - IsActive bool `json:"is_active"` - MinimumAgeRequired int `json:"minimum_age_required"` - CreatedAt time.Time `json:"created_at"` - CreatedBy *string `json:"created_by,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + DurationMinutes int `json:"duration_minutes"` + IsActive bool `json:"is_active"` + MinimumAgeRequired int `json:"minimum_age_required"` + PatchTestDurationHours int `json:"patch_test_duration_hours"` + CreatedAt time.Time `json:"created_at"` + CreatedBy *string `json:"created_by,omitempty"` } type ServiceResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Price float64 `json:"price"` - DurationMinutes int `json:"duration_minutes"` - MinimumAgeRequired int `json:"minimum_age_required"` - // Patch test status for non-admin users - PatchTestStatus *string `json:"patch_test_status,omitempty"` // nil = not checked, "ok" = valid, "required" = no record, "expired" = record too old + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + DurationMinutes int `json:"duration_minutes"` + MinimumAgeRequired int `json:"minimum_age_required"` + PatchTestDurationHours int `json:"patch_test_duration_hours"` + // Patch test status for non-admin logged-in users + PatchTestStatus *string `json:"patch_test_status,omitempty"` } // CreateServiceRequest represents the request payload for creating a new service type CreateServiceRequest struct { - Name string `json:"name" validate:"required,min=1,max=100"` - Description *string `json:"description,omitempty"` - Price float64 `json:"price" validate:"required,gt=0"` - DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"` - MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"` + Name string `json:"name" validate:"required,min=1,max=100"` + Description *string `json:"description,omitempty"` + Price float64 `json:"price" validate:"required,gt=0"` + DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"` + MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"` + PatchTestDurationHours int `json:"patch_test_duration_hours"` } // ToggleServiceHandler handles toggling a service's active status @@ -115,7 +118,7 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) { createdBy = &userID } - // Insert new service (no patch test columns) + // Insert new service query := ` INSERT INTO services ( name, description, price, duration_minutes, @@ -160,6 +163,29 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) { return } + // Create patch test record if duration > 0 + if req.PatchTestDurationHours > 0 { + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) + VALUES ($1, $2, $3, $4, $5) + `, req.Name+" Patch Test", "Patch test for "+req.Name, req.PatchTestDurationHours, 6, []string{service.ID}) + if err != nil { + http.Error(w, "Failed to create patch test: "+err.Error(), http.StatusInternalServerError) + return + } + service.PatchTestDurationHours = req.PatchTestDurationHours + } + + if err != nil { + // Check for duplicate name or other constraints + if err.Error() == "pq: duplicate key value violates unique constraint" { + http.Error(w, "A service with this name already exists", http.StatusConflict) + return + } + http.Error(w, "Failed to create service: "+err.Error(), http.StatusInternalServerError) + return + } + // Convert nullable fields to pointers if createdByDB.Valid { service.CreatedBy = &createdByDB.String @@ -230,11 +256,12 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { // If not logged in or admin, return all services (current behavior) if !hasUser || userID == "" || role == "admin" { query := ` - SELECT id, name, description, price, duration_minutes, - minimum_age_required - FROM services - WHERE is_active = TRUE - ORDER BY name + SELECT s.id, s.name, s.description, s.price, s.duration_minutes, + s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0) + FROM services s + LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids) + WHERE s.is_active = TRUE + ORDER BY s.name ` rows, err := db.DB.Query(r.Context(), query) @@ -256,6 +283,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { &service.Price, &service.DurationMinutes, &service.MinimumAgeRequired, + &service.PatchTestDurationHours, ) if err != nil { http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError) @@ -301,11 +329,12 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { // Get all active services query := ` - SELECT id, name, description, price, duration_minutes, - minimum_age_required - FROM services - WHERE is_active = TRUE - ORDER BY name + SELECT s.id, s.name, s.description, s.price, s.duration_minutes, + s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0) + FROM services s + LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids) + WHERE s.is_active = TRUE + ORDER BY s.name ` rows, err := db.DB.Query(r.Context(), query) @@ -328,6 +357,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { &service.Price, &service.DurationMinutes, &service.MinimumAgeRequired, + &service.PatchTestDurationHours, ) if err != nil { http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError) @@ -403,11 +433,12 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { // Get all active services query := ` - SELECT id, name, description, price, duration_minutes, - minimum_age_required - FROM services - WHERE is_active = TRUE - ORDER BY name + SELECT s.id, s.name, s.description, s.price, s.duration_minutes, + s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0) + FROM services s + LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids) + WHERE s.is_active = TRUE + ORDER BY s.name ` rows, err := db.DB.Query(r.Context(), query) @@ -430,6 +461,7 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { &service.Price, &service.DurationMinutes, &service.MinimumAgeRequired, + &service.PatchTestDurationHours, ) if err != nil { http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError) @@ -543,10 +575,12 @@ func checkPatchTestStatus(ctx context.Context, userID, serviceID string) *string func AllServicesHandler(w http.ResponseWriter, r *http.Request) { // Query all services including inactive ones query := ` - SELECT id, name, description, price, duration_minutes, is_active, - minimum_age_required, created_at, created_by - FROM services - ORDER BY is_active DESC, name + SELECT s.id, s.name, s.description, s.price, s.duration_minutes, s.is_active, + s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0), + s.created_at, s.created_by + FROM services s + LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids) + ORDER BY s.is_active DESC, s.name ` rows, err := db.DB.Query(r.Context(), query) @@ -570,6 +604,7 @@ func AllServicesHandler(w http.ResponseWriter, r *http.Request) { &service.DurationMinutes, &service.IsActive, &service.MinimumAgeRequired, + &service.PatchTestDurationHours, &service.CreatedAt, &createdBy, )