Files
Crussell/backend/handlers/user/guest.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

200 lines
5.9 KiB
Go

package user
import (
"encoding/json"
"errors"
"log"
"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 {
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
if err := validators.ValidateEmail(req.Email); err != nil {
http.Error(w, err.Error(), 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 tx.Rollback(r.Context())
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 {
http.Error(w, err.Error(), 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]interface{}{
"suggestion": suggestion,
})
}