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:
@@ -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"})
|
||||
}
|
||||
Reference in New Issue
Block a user