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,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()
|
||||
}
|
||||
@@ -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, ¬iceHours)
|
||||
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, ¬iceHours)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user