package user import ( "encoding/json" "log" "net/http" "net/mail" "regexp" "strings" "crussell/db" "crussell/handlers/auth" "crussell/internal/validators" ) type CreateGuestUserRequest struct { FirstName string `json:"firstName" validate:"required,min=1,max=50"` LastName string `json:"lastName" validate:"required,min=1,max=50"` Email string `json:"email" validate:"required,email,max=254"` Phone string `json:"phone" validate:"required"` } 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 } if err := validators.Validate.Struct(&req); err != nil { http.Error(w, err.Error(), 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"}) }