Files
Crussell/backend/handlers/bookings/admin_reserve.go
T
popertotsandSisyphus 70cba5e412 feat: add guest booking system and admin slot reservation
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 <clio-agent@sisyphuslabs.ai>
2026-04-30 11:44:39 +01:00

242 lines
7.8 KiB
Go

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()
}