- backend/handlers/bookings/admin_reserve.go:
- Add explicit reservation_type field ("walkin" | "callin") to request struct
- Remove TTL-based heuristic for type detection
- Walk-in: uses duration_minutes, allows null user_id, 1min past grace
- Call-in: requires service_ids, validates future time, calculates duration from services
- Both types now use 15-minute TTL
- backend/handlers/scheduling/time-blockers.go:
- Update CleanupOldReservations: both walkin and callin use 15min TTL (was 10min/60min)
- frontend/WalkInBooking.svelte:
- Full rewrite of reservation logic
- If available now and >15min remaining: reserve from now to slot end
- If <=15min or not available: reserve next full slot
- Always reserves before opening modal (never open without hold)
- Passes reservedDuration to modal
- TTL changed from 5 to 15 minutes
- frontend/WalkInCreateModal.svelte:
- Replace dead commented-out guest code with working guest creation
- Guest account created at submit time (not earlier)
- Phone defaults to +447700900000 if blank
- Phone field marked optional with helper text
- Name split into firstName/lastName for backend
- Validation relaxed: only name required for guests
- frontend/BookingCreateModal.svelte:
- TTL changed from 60 to 15 minutes
- Add reservation_type: "callin" to reserve payload
- Guest creation uses correct firstName/lastName fields
- Default guest phone to +447700900000
- Reservation no longer requires selectedUserId (works for guests)
- docs: Update Future Work backlog to mark completed items
241 lines
7.8 KiB
Go
241 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"` // 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)
|
|
}
|
|
|
|
// 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 {
|
|
http.Error(w, "At least one service is required for call-in bookings", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.StartTime.Before(time.Now()) {
|
|
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var err error
|
|
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
|
|
}
|
|
} else {
|
|
if req.DurationMinutes <= 0 {
|
|
http.Error(w, "duration_minutes is required for walk-in reservations", http.StatusBadRequest)
|
|
return
|
|
}
|
|
svcDuration = req.DurationMinutes
|
|
allowablePast := time.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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
_, 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
|
|
}
|
|
|
|
customerID := "guest"
|
|
if req.UserID != nil && *req.UserID != "" {
|
|
customerID = *req.UserID
|
|
}
|
|
|
|
description := fmt.Sprintf("RESERVATION:admin:%s:%s:%d", req.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
|
|
}
|
|
|
|
expiresAt := createdAt.Add(time.Duration(req.TTLMinutes) * time.Minute)
|
|
|
|
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()
|
|
}
|