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:
@@ -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
|
||||||
@@ -51,15 +60,15 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// b. Extract client IP: check CF-Connecting-IP → X-Real-IP → X-Forwarded-For → RemoteAddr
|
// b. Extract client IP: check CF-Connecting-IP → X-Real-IP → X-Forwarded-For → RemoteAddr
|
||||||
ip := r.Header.Get("CF-Connecting-IP")
|
ip := r.Header.Get("CF-Connecting-IP")
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
|
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
ip = r.RemoteAddr
|
ip = r.RemoteAddr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// c. Detect auth: try context first, then parse Bearer token from Authorization header
|
// c. Detect auth: try context first, then parse Bearer token from Authorization header
|
||||||
userID, hasUser := r.Context().Value(mw.UserIDKey).(string)
|
userID, hasUser := r.Context().Value(mw.UserIDKey).(string)
|
||||||
hasAuth := hasUser && userID != ""
|
hasAuth := hasUser && userID != ""
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user