feat(backend): update reservations

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-18 16:26:22 +01:00
co-authored by Sisyphus
parent 2493137c39
commit c805fea7df
2 changed files with 43 additions and 19 deletions
+30 -7
View File
@@ -12,14 +12,15 @@ import (
"crussell/auth" "crussell/auth"
"crussell/db" "crussell/db"
"crussell/handlers/scheduling" "crussell/handlers/scheduling"
"crussell/internal/validators"
"crussell/mw" "crussell/mw"
"net" "net"
) )
// ReserveSlotRequest represents the request body for reserving a slot // ReserveSlotRequest represents the request body for reserving a slot
type ReserveSlotRequest struct { type ReserveSlotRequest struct {
StartTime time.Time `json:"start_time"` StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids"` ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
} }
// ReserveSlotResponse represents the response for a successful reservation // ReserveSlotResponse represents the response for a successful reservation
@@ -41,6 +42,14 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
if req.StartTime.IsZero() { if req.StartTime.IsZero() {
http.Error(w, "start_time is required", http.StatusBadRequest) http.Error(w, "start_time is required", http.StatusBadRequest)
return return
@@ -121,7 +130,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute) endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
var cnt int var cnt int
db.DB.QueryRow(r.Context(), ` db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed') SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
AND start_time < $2 AND start_time < $2
AND start_time + (INTERVAL '1 minute' * ( AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur),60) FROM ( SELECT COALESCE(SUM(dur),60) FROM (
@@ -139,11 +148,11 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
} }
// h. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap // h. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime) blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime)
if err != nil { if err != nil {
log.Printf("Failed to check time blocker overlap: %v", err) log.Printf("Failed to check time blocker overlap: %v", err)
} else if blockerOverlap { } else if blockerOverlap {
http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict) http.Error(w, "Cannot book this time - slot is blocked", http.StatusConflict)
return return
} }
@@ -152,8 +161,16 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
var createdAt time.Time var createdAt time.Time
if hasAuth { if hasAuth {
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// LOGGED IN: Delete existing reservation // LOGGED IN: Delete existing reservation
_, err := db.DB.Exec(r.Context(), ` _, err = tx.Exec(r.Context(), `
DELETE FROM time_blockers DELETE FROM time_blockers
WHERE created_by = $1 AND description LIKE 'RESERVATION:user:%' WHERE created_by = $1 AND description LIKE 'RESERVATION:user:%'
`, userID) `, userID)
@@ -165,7 +182,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
// Insert new reservation // Insert new reservation
description := fmt.Sprintf("RESERVATION:user:%s:%d", userID, time.Now().UnixNano()) description := fmt.Sprintf("RESERVATION:user:%s:%d", userID, time.Now().UnixNano())
err = db.DB.QueryRow(r.Context(), ` err = tx.QueryRow(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING id, created_at RETURNING id, created_at
@@ -175,6 +192,12 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return 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 { } else {
// ANONYMOUS: Check cap (50 in 10 minutes) // ANONYMOUS: Check cap (50 in 10 minutes)
tenMinutesAgo := time.Now().Add(-10 * time.Minute) tenMinutesAgo := time.Now().Add(-10 * time.Minute)
+5 -4
View File
@@ -1,5 +1,5 @@
//go:build test //go:build test && dev
// +build test // +build test,dev
package bookings package bookings
@@ -43,7 +43,7 @@ func makeReserveRequest(method, path string, body interface{}, token string) *ht
// Add chi middleware stack for proper routing context // Add chi middleware stack for proper routing context
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
r.Use(middleware.RealIP) r.Use(middleware.ClientIPFromRemoteAddr)
if token != "" { if token != "" {
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
@@ -398,7 +398,8 @@ func nextWeekday(weekday time.Weekday, loc *time.Location) time.Time {
if daysAhead < 2 { if daysAhead < 2 {
daysAhead += 7 daysAhead += 7
} }
return now.AddDate(0, 0, daysAhead).Truncate(24 * time.Hour) next := now.AddDate(0, 0, daysAhead)
return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location())
} }
// TestReserveSlot_WeekdayConversion verifies that Go's time.Weekday (0=Sunday) // TestReserveSlot_WeekdayConversion verifies that Go's time.Weekday (0=Sunday)