From 70cba5e41270f3f8c8628a93a27b063d986198a6 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 30 Apr 2026 11:44:39 +0100 Subject: [PATCH] feat: add guest booking system and admin slot reservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guest flow: CreateGuestUserHandler creates disposable guest accounts on-the-fly. CreateBookingHandler uses OptionalAuth — accepts authenticated or guest (user_id in body, validated as account_role='guest'). Guests bypass deposits, patch tests, and the 24h deposit advance rule. Admin reserve: AdminReserveSlotHandler supports walk-in (5min TTL) and call-in (60min TTL) reservations with configurable TTL. Validates against bookings, blockers, working hours. Route restructuring: POST /bookings moved to OptionalAuth group. POST /bookings/reserve added for public reservation. POST /admin/bookings/reserve added for admin. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/bookings/admin_reserve.go | 241 +++++++++++++++++++++ backend/handlers/bookings/bookings.go | 119 ++++++---- backend/handlers/user/guest.go | 122 +++++++++++ backend/main.go | 8 +- 4 files changed, 441 insertions(+), 49 deletions(-) create mode 100644 backend/handlers/bookings/admin_reserve.go create mode 100644 backend/handlers/user/guest.go diff --git a/backend/handlers/bookings/admin_reserve.go b/backend/handlers/bookings/admin_reserve.go new file mode 100644 index 0000000..0862c94 --- /dev/null +++ b/backend/handlers/bookings/admin_reserve.go @@ -0,0 +1,241 @@ +package bookings + +import ( + "context" + "crussell/db" + "crussell/handlers/scheduling" + "crussell/mw" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "time" +) + +// ServiceOverrideRequest represents override values for a specific service in a reservation +type ServiceOverrideRequest struct { + ServiceID string `json:"service_id"` + OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"` +} + +// AdminReserveSlotRequest represents the request payload for admin slot reservation +type AdminReserveSlotRequest struct { + UserID *string `json:"user_id"` + StartTime time.Time `json:"start_time"` + ServiceIDs []string `json:"service_ids"` + ServiceOverrides []ServiceOverrideRequest `json:"service_overrides"` + TTLMinutes int `json:"ttl_minutes"` // 5 for walk-in, 60 for call-in +} + +// AdminReserveSlotResponse represents the response for admin slot reservation +type AdminReserveSlotResponse struct { + ID string `json:"id"` + StartTime time.Time `json:"start_time"` + DurationMinutes int `json:"duration_minutes"` + ExpiresAt time.Time `json:"expires_at"` + TTLMinutes int `json:"ttl_minutes"` +} + +// AdminReserveSlotHandler creates a temporary admin slot reservation (walk-in or call-in) +func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) { + // a. Extract admin user ID from context + adminID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || adminID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // b. Parse JSON body + var req AdminReserveSlotRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Failed to decode request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // Validate start_time is present + if req.StartTime.IsZero() { + http.Error(w, "start_time is required", http.StatusBadRequest) + return + } + + // Validate service_ids are present + if len(req.ServiceIDs) == 0 { + http.Error(w, "At least one service is required", http.StatusBadRequest) + return + } + + // Default ttl_minutes to 60 if 0 + if req.TTLMinutes == 0 { + req.TTLMinutes = 60 + } + + // c. Calculate total duration from services, respecting overrides + svcDuration, err := calculateServiceDurationWithOverrides(r.Context(), req.ServiceIDs, req.ServiceOverrides) + if err != nil { + log.Printf("Failed to calculate duration: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if svcDuration == 0 { + http.Error(w, "Invalid service IDs", http.StatusBadRequest) + return + } + + // d. Validate start_time not in the past + if req.StartTime.Before(time.Now()) { + http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) + return + } + + // e. Validate working hours exist for that weekday and slot fits within open->close + weekday := int(req.StartTime.Weekday()) + var closeStr string + if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "Not open on this day", http.StatusBadRequest) + return + } + log.Printf("Failed to get hours: %v", err) + http.Error(w, "Could not verify hours", http.StatusInternalServerError) + return + } + + endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute) + closeTime, _ := time.Parse("15:04:05", closeStr) + if endTime.Hour() > closeTime.Hour() || (endTime.Hour() == closeTime.Hour() && endTime.Minute() > closeTime.Minute()) { + http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest) + return + } + + // f. Check existing booking overlap (same query as CreateBookingHandler line ~1197) + var cnt int + db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed') + AND start_time < $2 + AND start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) + FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id + )) > $1 + `, req.StartTime, endTime).Scan(&cnt) + if cnt > 0 { + http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict) + return + } + + // g. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap + blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime) + if err != nil { + log.Printf("Failed to check time blocker overlap: %v", err) + } else if blockerOverlap { + http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict) + return + } + + // h. Delete any existing admin reservation for this admin + _, err = db.DB.Exec(r.Context(), ` + DELETE FROM time_blockers + WHERE description LIKE 'RESERVATION:admin:%' + AND created_by = $1 + `, adminID) + if err != nil { + log.Printf("Failed to delete existing admin reservation: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // i. Determine reservation type: if ttl_minutes <= 10 → "walkin", else → "callin" + reservationType := "callin" + if req.TTLMinutes <= 10 { + reservationType = "walkin" + } + + // j. Determine customer ID from request or use "guest" + customerID := "guest" + if req.UserID != nil && *req.UserID != "" { + customerID = *req.UserID + } + + // Insert new reservation + description := fmt.Sprintf("RESERVATION:admin:%s:%s:%d", reservationType, customerID, time.Now().UnixNano()) + var reservationID string + var createdAt time.Time + err = db.DB.QueryRow(r.Context(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at + `, req.StartTime, svcDuration, description, adminID).Scan(&reservationID, &createdAt) + if err != nil { + log.Printf("Failed to create reservation: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // k. Calculate expires_at based on TTL + expiresAt := createdAt.Add(time.Duration(req.TTLMinutes) * time.Minute) + + // Return 201 with response + response := AdminReserveSlotResponse{ + ID: reservationID, + StartTime: req.StartTime, + DurationMinutes: svcDuration, + ExpiresAt: expiresAt, + TTLMinutes: req.TTLMinutes, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Failed to encode response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } +} + +// calculateServiceDurationWithOverrides calculates total duration considering service overrides +func calculateServiceDurationWithOverrides(ctx context.Context, serviceIDs []string, overrides []ServiceOverrideRequest) (int, error) { + // If no overrides, use simple sum + if len(overrides) == 0 { + var duration int + err := db.DB.QueryRow(ctx, ` + SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1) + `, serviceIDs).Scan(&duration) + return duration, err + } + + // Build override map + overrideMap := make(map[string]int) + for _, o := range overrides { + if o.OverrideDurationMinutes != nil { + overrideMap[o.ServiceID] = *o.OverrideDurationMinutes + } + } + + // Get all services + rows, err := db.DB.Query(ctx, ` + SELECT id, duration_minutes FROM services WHERE id = ANY($1) + `, serviceIDs) + if err != nil { + return 0, err + } + defer rows.Close() + + var totalDuration int + for rows.Next() { + var svcID string + var baseDuration int + if err := rows.Scan(&svcID, &baseDuration); err != nil { + return 0, err + } + if overrideDuration, exists := overrideMap[svcID]; exists { + totalDuration += overrideDuration + } else { + totalDuration += baseDuration + } + } + + return totalDuration, rows.Err() +} diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index d09bf5e..8cf7019 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -111,6 +111,7 @@ type CreateBookingRequest struct { StartTime time.Time `json:"start_time" validate:"required"` ServiceIDs []string `json:"service_ids" validate:"required,min=1"` Notes *string `json:"notes,omitempty"` + UserID *string `json:"user_id,omitempty"` } // EditBookingRequest represents the request payload for editing a booking's start time @@ -1066,12 +1067,6 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { // POST /api/bookings func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { - userID, ok := r.Context().Value(mw.UserIDKey).(string) - if !ok || userID == "" { - http.Error(w, "Authentication required", http.StatusUnauthorized) - return - } - var req CreateBookingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) @@ -1079,6 +1074,28 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { return } + userID, ok := r.Context().Value(mw.UserIDKey).(string) + isGuest := false + + if !ok || userID == "" { + if req.UserID == nil || *req.UserID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + var accountRole string + if err := db.DB.QueryRow(r.Context(), `SELECT account_role FROM users WHERE id = $1`, *req.UserID).Scan(&accountRole); err != nil { + http.Error(w, "User not found", http.StatusBadRequest) + return + } + if accountRole != "guest" { + http.Error(w, "Invalid user_id - guest account required", http.StatusBadRequest) + return + } + userID = *req.UserID + isGuest = true + } + if req.StartTime.IsZero() { http.Error(w, "Start time is required", http.StatusBadRequest) return @@ -1088,17 +1105,16 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Read deposits_required live from the user — this is the enforcement value, - // not a display value, so it must reflect current standing. var depositsRequired int - if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired); err != nil { - log.Printf("Failed to fetch deposits_required for user %s: %v", userID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return + if !isGuest { + if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired); err != nil { + log.Printf("Failed to fetch deposits_required for user %s: %v", userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } } - // Enforce one-active-booking limit when deposits are outstanding - if depositsRequired > 0 { + if !isGuest && depositsRequired > 0 { var activeCount int if err := db.DB.QueryRow(r.Context(), ` SELECT COUNT(*) FROM bookings @@ -1120,46 +1136,53 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Deposit users must book at least 24h in advance to allow time for deposit payment + if !isGuest && depositsRequired > 0 && req.StartTime.Before(time.Now().Add(24*time.Hour)) { + http.Error(w, "When deposits are required, bookings must be made at least 24 hours in advance to allow time for payment.", http.StatusBadRequest) + return + } + // Snapshot whether a deposit is required at the moment of booking creation. // Stored on the bookings row so historic GET responses are accurate regardless // of the user's future deposits_required changes. depositRequiredSnapshot := depositsRequired > 0 - // Check patch test requirements for all services - for _, serviceID := range req.ServiceIDs { - var patchTestID string - var noticeHours int - err := db.DB.QueryRow(r.Context(), ` - SELECT id, notice_duration_hours - FROM patch_tests - WHERE $1 = ANY(service_ids) - `, serviceID).Scan(&patchTestID, ¬iceHours) + if !isGuest { + for _, serviceID := range req.ServiceIDs { + var patchTestID string + var noticeHours int + err := db.DB.QueryRow(r.Context(), ` + SELECT id, notice_duration_hours + FROM patch_tests + WHERE $1 = ANY(service_ids) + `, serviceID).Scan(&patchTestID, ¬iceHours) - if err == nil { - var testedAt time.Time - err = db.DB.QueryRow(r.Context(), ` - SELECT tested_at - FROM user_patch_tests - WHERE user_id = $1 AND patch_test_id = $2 - `, userID, patchTestID).Scan(&testedAt) - if err != nil { - http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest) - return - } - - eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour) - if time.Now().Before(eligibleFrom) { - hoursLeft := time.Until(eligibleFrom).Hours() - http.Error(w, fmt.Sprintf("You must wait %.0f hours after your patch test before booking this service.", hoursLeft), http.StatusBadRequest) - return - } - - var expiryMonths int - if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil { - if time.Now().After(testedAt.AddDate(0, expiryMonths, 0)) { - http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest) + if err == nil { + var testedAt time.Time + err = db.DB.QueryRow(r.Context(), ` + SELECT tested_at + FROM user_patch_tests + WHERE user_id = $1 AND patch_test_id = $2 + `, userID, patchTestID).Scan(&testedAt) + if err != nil { + http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest) return } + + eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour) + if time.Now().Before(eligibleFrom) { + hoursLeft := time.Until(eligibleFrom).Hours() + http.Error(w, fmt.Sprintf("You must wait %.0f hours after your patch test before booking this service.", hoursLeft), http.StatusBadRequest) + return + } + + var expiryMonths int + if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil { + if time.Now().After(testedAt.AddDate(0, expiryMonths, 0)) { + http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest) + return + } + } } } } @@ -1243,7 +1266,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { booking.User = &UserSummary{} if err := tx.QueryRow(r.Context(), ` INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status) - VALUES ($1, $2, $3, $4, $5, CASE WHEN $3 IS NOT NULL AND $3 != '' THEN 'pending' ELSE 'confirmed' END) + VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END) RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required `, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, @@ -2145,7 +2168,7 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { - http.Error(w, "Booking not found", http.StatusNotFound) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } diff --git a/backend/handlers/user/guest.go b/backend/handlers/user/guest.go new file mode 100644 index 0000000..11127b1 --- /dev/null +++ b/backend/handlers/user/guest.go @@ -0,0 +1,122 @@ +package user + +import ( + "encoding/json" + "log" + "net/http" + "net/mail" + "regexp" + "strings" + + "crussell/db" + "crussell/handlers/auth" +) + +type CreateGuestUserRequest struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + Phone string `json:"phone"` +} + +type CreateGuestUserResponse struct { + ID string `json:"id"` + Role string `json:"role"` +} + +// POST /api/user/guest +func CreateGuestUserHandler(w http.ResponseWriter, r *http.Request) { + var req CreateGuestUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + // Normalize input + req.FirstName = strings.TrimSpace(req.FirstName) + req.LastName = strings.TrimSpace(req.LastName) + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + req.Phone = strings.TrimSpace(req.Phone) + + // Validate all 4 fields are non-empty + if req.FirstName == "" || req.LastName == "" || req.Email == "" || req.Phone == "" { + http.Error(w, "first name, last name, email and phone are required", http.StatusBadRequest) + return + } + + // Validate names (unicode letters, spaces, hyphen, apostrophe, dot) + nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`) + if !nameRegex.MatchString(req.FirstName) || !nameRegex.MatchString(req.LastName) { + http.Error(w, "invalid characters in name", http.StatusBadRequest) + return + } + + // Validate name lengths + if len(req.FirstName) < 1 || len(req.FirstName) > 50 { + http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest) + return + } + if len(req.LastName) < 1 || len(req.LastName) > 50 { + http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest) + return + } + + // Validate email format (contains @ and .) + _, err := mail.ParseAddress(req.Email) + if err != nil { + http.Error(w, "invalid email format", http.StatusBadRequest) + return + } + + // Normalize phone (strip non-digit/+ chars) + req.Phone = strings.Map(func(r rune) rune { + if (r >= '0' && r <= '9') || r == '+' { + return r + } + return -1 + }, req.Phone) + + // Validate UK phone number + phone, err := auth.ValidateUKPhoneNumber(req.Phone) + if err != nil { + http.Error(w, "invalid phone number format", http.StatusBadRequest) + return + } + req.Phone = strings.TrimSpace(phone) + + // Check if email already exists with a registered (non-guest) role + var existingRole string + err = db.DB.QueryRow(r.Context(), ` + SELECT account_role FROM users WHERE email = $1 + `, req.Email).Scan(&existingRole) + + if err == nil && existingRole != "guest" { + http.Error(w, "Email already registered - please log in to book", http.StatusConflict) + return + } + + // Always create a new guest user — even if email/phone is used by another guest. + // Each booking is a disposable account; we don't track identity across guest bookings. + + // Create new guest user + var userID string + err = db.DB.QueryRow(r.Context(), ` + INSERT INTO users + (n_first_name, n_last_name, email, phone, date_of_birth, + account_role, account_type, password_hash, privacy_policy_and_terms_consent) + VALUES ($1, $2, $3, $4, '1900-01-01', 'guest', 'email', NULL, TRUE) + RETURNING id + `, req.FirstName, req.LastName, req.Email, req.Phone).Scan(&userID) + + if err != nil { + log.Printf("Failed to create guest user: %v", err) + http.Error(w, "failed to create guest user", http.StatusInternalServerError) + return + } + + // Note: We intentionally don't sync to CardDAV - guests don't need calendar contacts + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(CreateGuestUserResponse{ID: userID, Role: "guest"}) +} diff --git a/backend/main.go b/backend/main.go index db75778..e448270 100644 --- a/backend/main.go +++ b/backend/main.go @@ -140,6 +140,12 @@ func main() { // Public booking endpoints (optional auth for slot reservation) r.With(mw.RateLimit(30, time.Minute), mw.OptionalAuth).Post("/bookings/reserve", bookings.ReserveSlotHandler) + // Guest user creation (public, no auth required) + r.With(mw.RateLimit(10, time.Minute)).Post("/users/guest", user.CreateGuestUserHandler) + + // Booking creation (accepts both authenticated and guest users) + r.With(mw.RateLimit(30, time.Minute), mw.OptionalAuth).Post("/bookings", bookings.CreateBookingHandler) + // Authenticated users r.Group(func(r chi.Router) { r.Use(mw.RequireAuth) @@ -155,7 +161,6 @@ func main() { r.Get("/user/loyalty", user.GetLoyaltyHandler) r.Route("/bookings", func(r chi.Router) { - r.Post("/", bookings.CreateBookingHandler) r.Get("/", bookings.GetAllUserBookingsHandler) r.Get("/{id}", bookings.GetBookingHandler) r.Get("/{id}/calendar", bookings.GetBookingCalendarHandler) @@ -189,6 +194,7 @@ func main() { r.Put("/{id}/progress", bookings.ProgressBookingHandler) r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) r.Post("/{id}/cancel", bookings.AdminCancelBookingHandler) + r.Post("/reserve", bookings.AdminReserveSlotHandler) // Edit request endpoints r.Get("/{id}/edit-requests", bookings.AdminListEditRequestsHandler) r.Post("/{id}/edit-requests/{request_id}/approve", bookings.AdminApproveEditRequestHandler)