Files
Crussell/backend/handlers/bookings/admin_reserve.go
T
popertotsandSisyphus 407de74b51
CI / Frontend deps check (push) Successful in 22s
CI / Go vulnerabilities (push) Successful in 32s
CI / Go build (push) Successful in 32s
CI / go mod tidy (push) Successful in 13s
CI / Knip (push) Failing after 33s
CI / Frontend build (push) Successful in 1m12s
CI / Svelte strict check (push) Has been skipped
CI / Frontend QC (audit) (push) Has been skipped
CI / Frontend QC (typecheck) (push) Has been skipped
CI / Frontend QC (lint) (push) Has been skipped
CI / Go vet (push) Successful in 57s
CI / golangci-lint (push) Successful in 1m8s
CI / Tests (prod) (push) Successful in 1m45s
CI / Tests (dev) (push) Successful in 2m5s
CI / Race (prod) (push) Successful in 3m27s
CI / Race (dev) (push) Successful in 4m52s
fix: restore test-used functions, silence tx.Rollback closed errors, prune knip dead code
Restore processImage (images.go) and nonDepositPaymentType (handlers.go) with //nolint:unused — used in test files.
Fix 97 tx.Rollback defers to silently discard expected "tx is closed" error after commit.
Frontend: remove 44 unused shadcn-svelte files, 2 dead components, 9 stale npm deps, prune unused exports.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-09 19:20:25 +01:00

320 lines
10 KiB
Go

package bookings
import (
"context"
"crypto/md5"
"crussell/clock"
"crussell/db"
"crussell/handlers/scheduling"
"crussell/mw"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"log"
"log/slog"
"net"
"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"`
CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
ServiceOverrides []ServiceOverrideRequest `json:"service_overrides"`
TTLMinutes int `json:"ttl_minutes"` // 15 for both walk-in and call-in
ReservationType string `json:"reservation_type"` // "walkin" or "callin"
DurationMinutes int `json:"duration_minutes"` // explicit duration for walk-in (ignored for call-in)
OutOfHours bool `json:"out_of_hours"`
}
// 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) {
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
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
}
if req.StartTime.IsZero() {
http.Error(w, "start_time is required", http.StatusBadRequest)
return
}
if req.ReservationType != "walkin" && req.ReservationType != "callin" {
http.Error(w, "reservation_type must be 'walkin' or 'callin'", http.StatusBadRequest)
return
}
if req.TTLMinutes == 0 {
req.TTLMinutes = 15
}
var svcDuration int
if req.ReservationType == "callin" {
if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
http.Error(w, "At least one service or custom service is required for call-in bookings", http.StatusBadRequest)
return
}
if req.StartTime.Before(clock.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return
}
var err error
allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
svcDuration, err = calculateServiceDurationWithOverrides(r.Context(), allIDs, 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
}
} else {
if req.DurationMinutes <= 0 {
http.Error(w, "duration_minutes is required for walk-in reservations", http.StatusBadRequest)
return
}
svcDuration = req.DurationMinutes
allowablePast := clock.Now().Add(-1 * time.Minute)
if req.StartTime.Before(allowablePast) {
http.Error(w, "Start time cannot be more than 1 minute in the past", http.StatusBadRequest)
return
}
}
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
if !req.OutOfHours {
localStart := req.StartTime.In(londonLocation)
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
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 {
if errors.Is(err, pgx.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
}
localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
if err := checkClosingHours(localEndLondon, closeStr); err != nil {
if errors.Is(err, ErrPastClosing) {
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
} else {
http.Error(w, "Invalid closing time in schedule", http.StatusInternalServerError)
}
return
}
}
// Clean up existing admin reservation BEFORE the overlap check,
// using db.Conn.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.
// Also clean up anonymous reservations matching this admin's IP
// (edge case: admin previously reserved without authentication).
ip := r.Header.Get("CF-Connecting-IP")
if ip == "" {
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
if ip == "" {
ip = r.RemoteAddr
}
}
ipHash := fmt.Sprintf("%x", md5.Sum([]byte(ip)))[:8]
if _, delErr := db.Conn.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE (description LIKE 'RESERVATION:admin:%' AND created_by = $1)
OR (description LIKE 'RESERVATION:anon:' || $2 || ':%')
`, adminID, ipHash); delErr != nil {
log.Printf("Failed to delete existing admin reservation: %v", delErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime, &adminID)
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
}
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 && err.Error() != "tx is closed" {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// Check booking overlap inside transaction
// pending_release is excluded — those bookings are evicted at creation time
// by AdminCreateBookingForUserHandler / CreateBookingHandler.
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
}
_, err = tx.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
}
customerID := "guest"
if req.UserID != nil && *req.UserID != "" {
customerID = *req.UserID
}
description := fmt.Sprintf("RESERVATION:admin:%s:%s:%d", req.ReservationType, customerID, clock.Now().UnixNano())
var reservationID string
var createdAt time.Time
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, adminID).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
}
expiresAt := createdAt.Add(time.Duration(req.TTLMinutes) * time.Minute)
response := AdminReserveSlotResponse{
ID: reservationID,
StartTime: req.StartTime,
DurationMinutes: svcDuration,
ExpiresAt: expiresAt,
TTLMinutes: req.TTLMinutes,
}
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.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(dur), 0) FROM (
SELECT duration_minutes AS dur FROM services WHERE id = ANY($1)
UNION ALL
SELECT duration_minutes FROM custom_services WHERE id = ANY($1)
) combined
`, 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.Conn.Query(ctx, `
SELECT id, duration_minutes FROM services WHERE id = ANY($1)
UNION ALL
SELECT id, duration_minutes FROM custom_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()
}