package user import ( "encoding/json" "errors" "log" "log/slog" "net/http" "regexp" "strings" "crussell/db" "crussell/handlers/auth" "crussell/internal/validators" "github.com/jackc/pgx/v5" ) 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 { log.Printf("Failed to process request: %v", err) 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 if err := validators.ValidateEmail(req.Email); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", 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.Conn.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 tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin 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) } }() var userID string err = tx.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 } 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 } // Note: We intentionally don't sync to CardDAV - guests don't need calendar contacts w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(CreateGuestUserResponse{ID: userID, Role: "guest"}) } // GET /api/check-email?email=...&firstName=...&lastName=...&phone=... // Returns { suggestion: "login" | "check" | null } based on whether the email // belongs to a registered user and how closely the provided details match. // Relies on nginx restricting access to frontend-only traffic. func CheckEmailHandler(w http.ResponseWriter, r *http.Request) { email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("email"))) if email == "" { http.Error(w, "email query parameter required", http.StatusBadRequest) return } if err := validators.ValidateEmail(email); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } firstName := strings.TrimSpace(r.URL.Query().Get("firstName")) lastName := strings.TrimSpace(r.URL.Query().Get("lastName")) phone := strings.TrimSpace(r.URL.Query().Get("phone")) var dbFirstName, dbLastName, dbPhone *string err := db.Conn.QueryRow(r.Context(), ` SELECT n_first_name, n_last_name, phone FROM users WHERE email = $1 AND account_role != 'guest' `, email).Scan(&dbFirstName, &dbLastName, &dbPhone) var suggestion *string if err == nil { matchesNames := firstName != "" && lastName != "" && dbFirstName != nil && dbLastName != nil && strings.EqualFold(firstName, *dbFirstName) && strings.EqualFold(lastName, *dbLastName) matchesPhone := phone != "" && dbPhone != nil && phone == *dbPhone if matchesNames && matchesPhone { s := "login" suggestion = &s } else { s := "check" suggestion = &s } } else if !errors.Is(err, pgx.ErrNoRows) { log.Printf("Failed to check email: %v", err) http.Error(w, "database error", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]any{ "suggestion": suggestion, }) }