Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
351 lines
12 KiB
Go
351 lines
12 KiB
Go
package bookings
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"crussell/auth"
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/handlers/scheduling"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// ReserveSlotRequest represents the request body for reserving a slot
|
|
type ReserveSlotRequest struct {
|
|
StartTime time.Time `json:"start_time" validate:"required"`
|
|
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
|
}
|
|
|
|
// ReserveSlotResponse represents the response for a successful reservation
|
|
type ReserveSlotResponse struct {
|
|
ID string `json:"id"`
|
|
StartTime time.Time `json:"start_time"`
|
|
DurationMinutes int `json:"duration_minutes"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
IsAnonymous bool `json:"is_anonymous"`
|
|
}
|
|
|
|
// ReserveSlotHandler creates temporary slot reservations for both logged-in and anonymous users
|
|
func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
|
// a. Parse JSON request body, validate start_time and service_ids are present
|
|
var req ReserveSlotRequest
|
|
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
|
|
}
|
|
|
|
if err := validators.Validate.Struct(&req); err != nil {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// M8
|
|
// L5
|
|
|
|
if req.StartTime.IsZero() {
|
|
http.Error(w, "start_time is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.ServiceIDs) == 0 {
|
|
http.Error(w, "At least one service is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// b. Extract client IP through the SAME gated resolution the rate limiter
|
|
// uses (mw.ClientIP): CF-Connecting-IP is honored ONLY when
|
|
// TRUST_PROXY_HEADERS=true, so an origin-exposed backend can never be
|
|
// forced to key the anonymous-reservation ipHash on a client-controlled
|
|
// header (B7). An attacker who could rotate the header would otherwise mint
|
|
// a fresh anon bucket per request and evade the per-IP reservation cleanup.
|
|
ip := mw.ClientIP(r)
|
|
|
|
// c. Detect auth: try context first (set by OptionalAuth middleware).
|
|
// The inline Bearer fallback below is a safety net for the 22+ test
|
|
// invocations that call ReserveSlotHandler via http.HandlerFunc directly
|
|
// (without middleware). Keeping it is deliberate: the fallback never
|
|
// executes in production (middleware always sets context first), removing it
|
|
// would require refactoring those tests, and it acts as defense-in-depth
|
|
// against accidental middleware misconfiguration.
|
|
userID, hasUser := r.Context().Value(mw.UserIDKey).(string)
|
|
hasAuth := hasUser && userID != ""
|
|
if !hasAuth {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if after, ok := strings.CutPrefix(authHeader, "Bearer "); ok {
|
|
tokenString := after
|
|
var err error
|
|
userID, _, _, err = auth.VerifyToken(tokenString, r.Context())
|
|
if err != nil {
|
|
userID = ""
|
|
}
|
|
hasAuth = userID != ""
|
|
}
|
|
}
|
|
|
|
// d. Calculate total duration: SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
|
|
var svcDuration int
|
|
if err := db.Conn.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
|
|
`, req.ServiceIDs).Scan(&svcDuration); 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
|
|
}
|
|
|
|
// e. Validate start_time not in the past
|
|
if req.StartTime.Before(clock.Now()) {
|
|
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// f. Validate working hours: SELECT end_time::text FROM working_hours WHERE weekday = $1
|
|
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
|
localStart := req.StartTime.In(londonLocation)
|
|
weekday := int((localStart.Weekday() + 6) % 7)
|
|
var closeStr string
|
|
if err := db.Conn.QueryRow(r.Context(), `SELECT end_time FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
|
|
log.Printf("Failed to get hours: %v", err)
|
|
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
|
|
if err := checkClosingHours(localEndLondon, closeStr); err != nil {
|
|
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// g. Check existing booking overlap (same query as CreateBookingHandler)
|
|
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
|
|
|
// Clean up existing reservation BEFORE the overlap check,
|
|
// using db.Conn.Exec (not tx.Exec) so the delete is visible to the separate
|
|
// connection used by CheckTimeBlockerOverlap. The in-transaction DELETE
|
|
// is kept as a safety net for the insert-phase.
|
|
if hasAuth {
|
|
// Also compute IP hash to clean up anonymous reservations
|
|
// that may have been created before the user logged in.
|
|
ipHash := fmt.Sprintf("%x", sha256.Sum256([]byte(ip)))[:8]
|
|
if _, delErr := db.Conn.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE (created_by = $1 AND description LIKE 'RESERVATION:user:%')
|
|
OR (description LIKE 'RESERVATION:anon:' || $2 || ':%')
|
|
`, userID, ipHash); delErr != nil {
|
|
log.Printf("Failed to delete existing reservation: %v", delErr)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// h. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap
|
|
// Pass excludeUserID so this user's own RESERVATION doesn't self-block.
|
|
// The pre-overlap DELETE (above) and excludeUserID are complementary:
|
|
// the DELETE cleans stale reservations from prior attempts; excludeUserID
|
|
// prevents any remaining own-RESERVATION entries from blocking.
|
|
var excludeUserID *string
|
|
if hasAuth {
|
|
excludeUserID = &userID
|
|
}
|
|
blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime, excludeUserID)
|
|
if err != nil {
|
|
log.Printf("Failed to check time blocker overlap: %v", err)
|
|
} else if blockerOverlap {
|
|
http.Error(w, "Cannot book this time - slot is blocked", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// j. Branch on auth
|
|
var reservationID string
|
|
var createdAt time.Time
|
|
|
|
if hasAuth {
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
// Check booking overlap inside transaction (TOCTOU fix)
|
|
// pending_release is excluded — those bookings are evicted at creation time.
|
|
overlapRows, err := tx.Query(r.Context(), `
|
|
SELECT 1 FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
|
|
AND start_time < $2
|
|
AND end_time > $1
|
|
FOR UPDATE
|
|
`, req.StartTime, endTime)
|
|
if err != nil {
|
|
log.Printf("Failed to check overlap: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var cnt int
|
|
for overlapRows.Next() {
|
|
cnt++
|
|
}
|
|
overlapRows.Close()
|
|
if err := overlapRows.Err(); err != nil {
|
|
log.Printf("Overlap row iteration error: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if cnt > 0 {
|
|
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// LOGGED IN: Delete existing reservation
|
|
_, err = tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE created_by = $1 AND description LIKE 'RESERVATION:user:%'
|
|
`, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete existing reservation: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Insert new reservation
|
|
description := fmt.Sprintf("RESERVATION:user:%s:%d", userID, clock.Now().UnixNano())
|
|
err = tx.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, userID).Scan(&reservationID, &createdAt)
|
|
if err != nil {
|
|
log.Printf("Failed to create reservation: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
// Calculate ipHash from IP address
|
|
ipHash := fmt.Sprintf("%x", sha256.Sum256([]byte(ip)))[:8]
|
|
|
|
// ANONYMOUS: Use transaction for atomic rate cap + overlap check + insert
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
// Check anon rate cap inside transaction
|
|
tenMinutesAgo := clock.Now().Add(-10 * time.Minute)
|
|
var anonCount int
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM time_blockers
|
|
WHERE description LIKE 'RESERVATION:anon:' || $1 || ':%' AND created_at > $2
|
|
`, ipHash, tenMinutesAgo).Scan(&anonCount); err != nil {
|
|
log.Printf("Failed to check anon cap: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if anonCount >= 50 {
|
|
http.Error(w, "Too many active reservations. Please wait or log in.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
// Check overlap inside transaction
|
|
// pending_release is excluded — those bookings are evicted at creation time.
|
|
overlapRows, err := tx.Query(r.Context(), `
|
|
SELECT 1 FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
|
|
AND start_time < $2
|
|
AND end_time > $1
|
|
FOR UPDATE
|
|
`, req.StartTime, endTime)
|
|
if err != nil {
|
|
log.Printf("Failed to check overlap: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var anonCnt int
|
|
for overlapRows.Next() {
|
|
anonCnt++
|
|
}
|
|
overlapRows.Close()
|
|
if err := overlapRows.Err(); err != nil {
|
|
log.Printf("Overlap row iteration error: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if anonCnt > 0 {
|
|
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
description := fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, clock.Now().UnixNano())
|
|
err = tx.QueryRow(r.Context(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, $2, $3, NULL)
|
|
RETURNING id, created_at
|
|
`, req.StartTime, svcDuration, description).Scan(&reservationID, &createdAt)
|
|
if err != nil {
|
|
log.Printf("Failed to create reservation: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// k. Calculate expires_at: logged-in = created_at + 1 hour, anon = created_at + 10 minutes
|
|
var expiresAt time.Time
|
|
if hasAuth {
|
|
expiresAt = createdAt.Add(1 * time.Hour)
|
|
} else {
|
|
expiresAt = createdAt.Add(10 * time.Minute)
|
|
}
|
|
|
|
// l. Return 201 with JSON response
|
|
response := ReserveSlotResponse{
|
|
ID: reservationID,
|
|
StartTime: req.StartTime,
|
|
DurationMinutes: svcDuration,
|
|
ExpiresAt: expiresAt,
|
|
IsAnonymous: !hasAuth,
|
|
}
|
|
|
|
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 JSON response: %v", err)
|
|
}
|
|
}
|