feat: add guest booking system and admin slot reservation

Guest flow: CreateGuestUserHandler creates disposable guest accounts on-the-fly.
CreateBookingHandler uses OptionalAuth — accepts authenticated or guest (user_id
in body, validated as account_role='guest'). Guests bypass deposits, patch tests,
and the 24h deposit advance rule.

Admin reserve: AdminReserveSlotHandler supports walk-in (5min TTL) and call-in
(60min TTL) reservations with configurable TTL. Validates against bookings,
blockers, working hours.

Route restructuring: POST /bookings moved to OptionalAuth group. POST /bookings/reserve
added for public reservation. POST /admin/bookings/reserve added for admin.

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-04-30 11:44:39 +01:00
co-authored by Sisyphus
parent 91dbdb199e
commit 70cba5e412
4 changed files with 441 additions and 49 deletions
+241
View File
@@ -0,0 +1,241 @@
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"` // 5 for walk-in, 60 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) {
// a. Extract admin user ID from context
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
// b. Parse JSON body
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
}
// Validate start_time is present
if req.StartTime.IsZero() {
http.Error(w, "start_time is required", http.StatusBadRequest)
return
}
// Validate service_ids are present
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
// Default ttl_minutes to 60 if 0
if req.TTLMinutes == 0 {
req.TTLMinutes = 60
}
// c. Calculate total duration from services, respecting overrides
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
}
// d. Validate start_time not in the past
if req.StartTime.Before(time.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return
}
// e. Validate working hours exist for that weekday and slot fits within open->close
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
}
// f. Check existing booking overlap (same query as CreateBookingHandler line ~1197)
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
}
// g. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap
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
}
// h. Delete any existing admin reservation for this admin
_, 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
}
// i. Determine reservation type: if ttl_minutes <= 10 → "walkin", else → "callin"
reservationType := "callin"
if req.TTLMinutes <= 10 {
reservationType = "walkin"
}
// j. Determine customer ID from request or use "guest"
customerID := "guest"
if req.UserID != nil && *req.UserID != "" {
customerID = *req.UserID
}
// Insert new reservation
description := fmt.Sprintf("RESERVATION:admin:%s:%s:%d", 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
}
// k. Calculate expires_at based on TTL
expiresAt := createdAt.Add(time.Duration(req.TTLMinutes) * time.Minute)
// Return 201 with response
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()
}
+71 -48
View File
@@ -111,6 +111,7 @@ type CreateBookingRequest struct {
StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
Notes *string `json:"notes,omitempty"`
UserID *string `json:"user_id,omitempty"`
}
// EditBookingRequest represents the request payload for editing a booking's start time
@@ -1066,12 +1067,6 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
// POST /api/bookings
func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req CreateBookingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode request: %v", err)
@@ -1079,6 +1074,28 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
isGuest := false
if !ok || userID == "" {
if req.UserID == nil || *req.UserID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var accountRole string
if err := db.DB.QueryRow(r.Context(), `SELECT account_role FROM users WHERE id = $1`, *req.UserID).Scan(&accountRole); err != nil {
http.Error(w, "User not found", http.StatusBadRequest)
return
}
if accountRole != "guest" {
http.Error(w, "Invalid user_id - guest account required", http.StatusBadRequest)
return
}
userID = *req.UserID
isGuest = true
}
if req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
@@ -1088,17 +1105,16 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Read deposits_required live from the user — this is the enforcement value,
// not a display value, so it must reflect current standing.
var depositsRequired int
if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired); err != nil {
log.Printf("Failed to fetch deposits_required for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
if !isGuest {
if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired); err != nil {
log.Printf("Failed to fetch deposits_required for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Enforce one-active-booking limit when deposits are outstanding
if depositsRequired > 0 {
if !isGuest && depositsRequired > 0 {
var activeCount int
if err := db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
@@ -1120,46 +1136,53 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Deposit users must book at least 24h in advance to allow time for deposit payment
if !isGuest && depositsRequired > 0 && req.StartTime.Before(time.Now().Add(24*time.Hour)) {
http.Error(w, "When deposits are required, bookings must be made at least 24 hours in advance to allow time for payment.", http.StatusBadRequest)
return
}
// Snapshot whether a deposit is required at the moment of booking creation.
// Stored on the bookings row so historic GET responses are accurate regardless
// of the user's future deposits_required changes.
depositRequiredSnapshot := depositsRequired > 0
// Check patch test requirements for all services
for _, serviceID := range req.ServiceIDs {
var patchTestID string
var noticeHours int
err := db.DB.QueryRow(r.Context(), `
SELECT id, notice_duration_hours
FROM patch_tests
WHERE $1 = ANY(service_ids)
`, serviceID).Scan(&patchTestID, &noticeHours)
if !isGuest {
for _, serviceID := range req.ServiceIDs {
var patchTestID string
var noticeHours int
err := db.DB.QueryRow(r.Context(), `
SELECT id, notice_duration_hours
FROM patch_tests
WHERE $1 = ANY(service_ids)
`, serviceID).Scan(&patchTestID, &noticeHours)
if err == nil {
var testedAt time.Time
err = db.DB.QueryRow(r.Context(), `
SELECT tested_at
FROM user_patch_tests
WHERE user_id = $1 AND patch_test_id = $2
`, userID, patchTestID).Scan(&testedAt)
if err != nil {
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
return
}
eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
if time.Now().Before(eligibleFrom) {
hoursLeft := time.Until(eligibleFrom).Hours()
http.Error(w, fmt.Sprintf("You must wait %.0f hours after your patch test before booking this service.", hoursLeft), http.StatusBadRequest)
return
}
var expiryMonths int
if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil {
if time.Now().After(testedAt.AddDate(0, expiryMonths, 0)) {
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
if err == nil {
var testedAt time.Time
err = db.DB.QueryRow(r.Context(), `
SELECT tested_at
FROM user_patch_tests
WHERE user_id = $1 AND patch_test_id = $2
`, userID, patchTestID).Scan(&testedAt)
if err != nil {
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
return
}
eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
if time.Now().Before(eligibleFrom) {
hoursLeft := time.Until(eligibleFrom).Hours()
http.Error(w, fmt.Sprintf("You must wait %.0f hours after your patch test before booking this service.", hoursLeft), http.StatusBadRequest)
return
}
var expiryMonths int
if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil {
if time.Now().After(testedAt.AddDate(0, expiryMonths, 0)) {
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
return
}
}
}
}
}
@@ -1243,7 +1266,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.User = &UserSummary{}
if err := tx.QueryRow(r.Context(), `
INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status)
VALUES ($1, $2, $3, $4, $5, CASE WHEN $3 IS NOT NULL AND $3 != '' THEN 'pending' ELSE 'confirmed' END)
VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END)
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required
`, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot).Scan(
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
@@ -2145,7 +2168,7 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Booking not found", http.StatusNotFound)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
+122
View File
@@ -0,0 +1,122 @@
package user
import (
"encoding/json"
"log"
"net/http"
"net/mail"
"regexp"
"strings"
"crussell/db"
"crussell/handlers/auth"
)
type CreateGuestUserRequest struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email"`
Phone string `json:"phone"`
}
type CreateGuestUserResponse struct {
ID string `json:"id"`
Role string `json:"role"`
}
// POST /api/user/guest
func CreateGuestUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateGuestUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// Normalize input
req.FirstName = strings.TrimSpace(req.FirstName)
req.LastName = strings.TrimSpace(req.LastName)
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Phone = strings.TrimSpace(req.Phone)
// Validate all 4 fields are non-empty
if req.FirstName == "" || req.LastName == "" || req.Email == "" || req.Phone == "" {
http.Error(w, "first name, last name, email and phone are required", http.StatusBadRequest)
return
}
// Validate names (unicode letters, spaces, hyphen, apostrophe, dot)
nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`)
if !nameRegex.MatchString(req.FirstName) || !nameRegex.MatchString(req.LastName) {
http.Error(w, "invalid characters in name", http.StatusBadRequest)
return
}
// Validate name lengths
if len(req.FirstName) < 1 || len(req.FirstName) > 50 {
http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest)
return
}
if len(req.LastName) < 1 || len(req.LastName) > 50 {
http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest)
return
}
// Validate email format (contains @ and .)
_, err := mail.ParseAddress(req.Email)
if err != nil {
http.Error(w, "invalid email format", http.StatusBadRequest)
return
}
// Normalize phone (strip non-digit/+ chars)
req.Phone = strings.Map(func(r rune) rune {
if (r >= '0' && r <= '9') || r == '+' {
return r
}
return -1
}, req.Phone)
// Validate UK phone number
phone, err := auth.ValidateUKPhoneNumber(req.Phone)
if err != nil {
http.Error(w, "invalid phone number format", http.StatusBadRequest)
return
}
req.Phone = strings.TrimSpace(phone)
// Check if email already exists with a registered (non-guest) role
var existingRole string
err = db.DB.QueryRow(r.Context(), `
SELECT account_role FROM users WHERE email = $1
`, req.Email).Scan(&existingRole)
if err == nil && existingRole != "guest" {
http.Error(w, "Email already registered - please log in to book", http.StatusConflict)
return
}
// Always create a new guest user — even if email/phone is used by another guest.
// Each booking is a disposable account; we don't track identity across guest bookings.
// Create new guest user
var userID string
err = db.DB.QueryRow(r.Context(), `
INSERT INTO users
(n_first_name, n_last_name, email, phone, date_of_birth,
account_role, account_type, password_hash, privacy_policy_and_terms_consent)
VALUES ($1, $2, $3, $4, '1900-01-01', 'guest', 'email', NULL, TRUE)
RETURNING id
`, req.FirstName, req.LastName, req.Email, req.Phone).Scan(&userID)
if err != nil {
log.Printf("Failed to create guest user: %v", err)
http.Error(w, "failed to create guest user", http.StatusInternalServerError)
return
}
// Note: We intentionally don't sync to CardDAV - guests don't need calendar contacts
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(CreateGuestUserResponse{ID: userID, Role: "guest"})
}
+7 -1
View File
@@ -140,6 +140,12 @@ func main() {
// Public booking endpoints (optional auth for slot reservation)
r.With(mw.RateLimit(30, time.Minute), mw.OptionalAuth).Post("/bookings/reserve", bookings.ReserveSlotHandler)
// Guest user creation (public, no auth required)
r.With(mw.RateLimit(10, time.Minute)).Post("/users/guest", user.CreateGuestUserHandler)
// Booking creation (accepts both authenticated and guest users)
r.With(mw.RateLimit(30, time.Minute), mw.OptionalAuth).Post("/bookings", bookings.CreateBookingHandler)
// Authenticated users
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
@@ -155,7 +161,6 @@ func main() {
r.Get("/user/loyalty", user.GetLoyaltyHandler)
r.Route("/bookings", func(r chi.Router) {
r.Post("/", bookings.CreateBookingHandler)
r.Get("/", bookings.GetAllUserBookingsHandler)
r.Get("/{id}", bookings.GetBookingHandler)
r.Get("/{id}/calendar", bookings.GetBookingCalendarHandler)
@@ -189,6 +194,7 @@ func main() {
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
r.Post("/{id}/cancel", bookings.AdminCancelBookingHandler)
r.Post("/reserve", bookings.AdminReserveSlotHandler)
// Edit request endpoints
r.Get("/{id}/edit-requests", bookings.AdminListEditRequestsHandler)
r.Post("/{id}/edit-requests/{request_id}/approve", bookings.AdminApproveEditRequestHandler)