- Add TestMain to set test env vars and testdb.TruncateTables for test isolation - Add chi routing context to test helpers for path parameter extraction - Fix SQL error handling to use errors.Is() instead of == - Add validators package with ID validation - Fix admin test middleware chain (RequireAdmin wrapper) - Update test user inserts to include phone and date_of_birth fields - Update service delete test to check soft-delete (is_active=false) - Update holiday hours test to use new schema (weekday, is_open) - Add phone number validation tests for UK mobile numbers
501 lines
14 KiB
Go
501 lines
14 KiB
Go
package bookings
|
|
|
|
import (
|
|
"crussell/db"
|
|
"crussell/handlers/notifications"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// UserCancelBookingHandler allows an authenticated user to cancel a booking they own.
|
|
// The update is performed in a transaction with notification handling.
|
|
func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tx, err := db.DB.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
// Get current status before updating
|
|
var originalStatus string
|
|
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
res, err := tx.Exec(r.Context(), `
|
|
UPDATE bookings
|
|
SET status = 'client_cancelled', updated_at = $1
|
|
WHERE id = $2 AND user_id = $3 AND status IN ('pending', 'confirmed', 'in_progress')
|
|
`, time.Now(), bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to cancel booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
rowsAffected := res.RowsAffected()
|
|
if rowsAffected == 0 {
|
|
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Acknowledge pending notification if exists
|
|
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
|
|
if originalStatus != "pending" {
|
|
notificationQuery := `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
|
VALUES ($1, $2, $3)
|
|
`
|
|
_, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit user cancel: %v, %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// AdminCancelBookingHandler allows an admin to cancel any booking.
|
|
// The update uses a status filter and checks RowsAffected for existence.
|
|
func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
tx, err := db.DB.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
// Get current status before updating
|
|
var originalStatus string
|
|
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
res, err := tx.Exec(r.Context(), `
|
|
UPDATE bookings
|
|
SET status = 'we_cancelled', updated_at = $1
|
|
WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress')
|
|
`, time.Now(), bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to admin cancel booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
rowsAffected := res.RowsAffected()
|
|
if rowsAffected == 0 {
|
|
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Acknowledge pending notification if exists
|
|
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
|
|
if originalStatus != "pending" {
|
|
notificationQuery := `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
|
SELECT 'cancelled_booking', $1, user_id FROM bookings WHERE id = $1
|
|
`
|
|
_, err = tx.Exec(r.Context(), notificationQuery, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit admin cancel: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// AdminListPendingBookingsHandler returns all bookings with status `pending` by delegating to the existing admin list handler.
|
|
func AdminListPendingBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|
r = r.Clone(r.Context())
|
|
q := r.URL.Query()
|
|
q.Set("status", "pending")
|
|
r.URL.RawQuery = q.Encode()
|
|
|
|
GetAllAdminBookingsHandler(w, r)
|
|
}
|
|
|
|
// AdminGetInProgressBookingHandler returns the booking that is currently in progress.
|
|
// It joins the bookings table with users to populate the UserSummary in the returned Booking.
|
|
func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
var b Booking
|
|
var userID, fullName string
|
|
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT
|
|
b.id,
|
|
b.start_time,
|
|
b.status,
|
|
b.notes,
|
|
b.created_at,
|
|
b.updated_at,
|
|
b.created_by,
|
|
u.id,
|
|
u.fn
|
|
FROM bookings b
|
|
LEFT JOIN users u ON b.user_id = u.id
|
|
WHERE b.status = 'in_progress'
|
|
ORDER BY b.start_time
|
|
LIMIT 1
|
|
`).Scan(
|
|
&b.ID,
|
|
&b.StartTime,
|
|
&b.Status,
|
|
&b.Notes,
|
|
&b.CreatedAt,
|
|
&b.UpdatedAt,
|
|
&b.CreatedBy,
|
|
&userID,
|
|
&fullName,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "No in-progress booking found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to fetch in-progress booking: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Populate the UserSummary field
|
|
b.User = &UserSummary{
|
|
ID: userID,
|
|
FullName: fullName,
|
|
FirstName: "", // not available here
|
|
LastName: "", // not available here
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(b); err != nil {
|
|
log.Printf("Failed to encode booking response: %v", err)
|
|
}
|
|
}
|
|
|
|
// AdminEditBookingHandler allows an admin to modify the start time of any booking.
|
|
// It validates the new start time and returns 404 if the booking does not exist.
|
|
func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
var req EditBookingRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Basic validation: ensure the new time is not in the past
|
|
if time.Now().After(req.StartTime) {
|
|
http.Error(w, "Start time must be in the future", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
res, err := db.DB.Exec(r.Context(), `
|
|
UPDATE bookings
|
|
SET start_time = $1, updated_at = $2
|
|
WHERE id = $3
|
|
`, req.StartTime, time.Now(), bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to edit booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
rowsAffected := res.RowsAffected()
|
|
if rowsAffected == 0 {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type AdminCreateBookingForUserRequest struct {
|
|
UserID string `json:"user_id" validate:"required"`
|
|
StartTime time.Time `json:"start_time" validate:"required"`
|
|
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
|
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
|
Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff
|
|
}
|
|
|
|
func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Admin identity (creator)
|
|
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || adminID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req AdminCreateBookingForUserRequest
|
|
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
|
|
}
|
|
|
|
// Basic validation
|
|
if req.UserID == "" {
|
|
http.Error(w, "User ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.StartTime.IsZero() {
|
|
http.Error(w, "Start time is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.ServiceIDs) == 0 {
|
|
http.Error(w, "At least one service is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate overrides
|
|
for _, override := range req.ServiceOverrides {
|
|
if override.ServiceID == "" {
|
|
http.Error(w, "Service ID is required for overrides", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if override.OverridePrice != nil && *override.OverridePrice < 0 {
|
|
http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
|
|
http.Error(w, "Override duration must be positive", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check if booking time falls within a closed exceptional hours period
|
|
bookingDate := req.StartTime.Truncate(24 * time.Hour)
|
|
weekday := int(req.StartTime.Weekday())
|
|
bookingTime := req.StartTime.Format("15:04:05")
|
|
|
|
// Check if there's an exceptional hours entry that makes this time unavailable
|
|
var isClosed bool
|
|
var checkErr error
|
|
checkErr = db.DB.QueryRow(r.Context(), `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM exceptional_working_hours ewh
|
|
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
|
|
WHERE ega.week_start = $1
|
|
AND ewh.weekday = $2
|
|
AND ewh.is_open = false
|
|
AND ewh.start_time <= $3
|
|
AND ewh.end_time >= $3
|
|
)
|
|
`, bookingDate, weekday, bookingTime).Scan(&isClosed)
|
|
if checkErr != nil {
|
|
log.Printf("Failed to check exceptional hours: %v", checkErr)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if isClosed {
|
|
http.Error(w, "Cannot book during holiday hours when the salon is closed", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
|
|
tx, err := db.DB.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
// Create booking directly as confirmed
|
|
bookingQuery := `
|
|
INSERT INTO bookings (
|
|
user_id,
|
|
start_time,
|
|
status,
|
|
notes,
|
|
created_by
|
|
)
|
|
VALUES ($1, $2, 'confirmed', $3, $4)
|
|
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
|
`
|
|
|
|
var booking Booking
|
|
booking.User = &UserSummary{}
|
|
|
|
err = tx.QueryRow(
|
|
r.Context(),
|
|
bookingQuery,
|
|
req.UserID,
|
|
req.StartTime,
|
|
req.Notes,
|
|
adminID,
|
|
).Scan(
|
|
&booking.ID,
|
|
&booking.User.ID,
|
|
&booking.StartTime,
|
|
&booking.Status,
|
|
&booking.Notes,
|
|
&booking.CreatedAt,
|
|
&booking.UpdatedAt,
|
|
&booking.CreatedBy,
|
|
)
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to create admin booking: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Insert booking services
|
|
serviceInsertQuery := `
|
|
INSERT INTO booking_services (booking_id, service_id)
|
|
VALUES ($1, $2)
|
|
`
|
|
|
|
for _, serviceID := range req.ServiceIDs {
|
|
_, err := tx.Exec(r.Context(), serviceInsertQuery, booking.ID, serviceID)
|
|
if err != nil {
|
|
log.Printf("Failed to insert booking service %s: %v", serviceID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Apply overrides (optional)
|
|
if len(req.ServiceOverrides) > 0 {
|
|
// Ensure overrides only reference services in this booking
|
|
serviceCheckQuery := `
|
|
SELECT COUNT(*) FROM booking_services
|
|
WHERE booking_id = $1 AND service_id = ANY($2)
|
|
`
|
|
|
|
overrideServiceIDs := make([]string, len(req.ServiceOverrides))
|
|
for i, o := range req.ServiceOverrides {
|
|
overrideServiceIDs[i] = o.ServiceID
|
|
}
|
|
|
|
var count int
|
|
err = tx.QueryRow(
|
|
r.Context(),
|
|
serviceCheckQuery,
|
|
booking.ID,
|
|
overrideServiceIDs,
|
|
).Scan(&count)
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to verify service overrides: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if count != len(req.ServiceOverrides) {
|
|
http.Error(w, "One or more service overrides do not belong to this booking", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
overrideUpdateQuery := `
|
|
UPDATE booking_services
|
|
SET override_price = $1,
|
|
override_duration_minutes = $2
|
|
WHERE booking_id = $3 AND service_id = $4
|
|
`
|
|
|
|
for _, override := range req.ServiceOverrides {
|
|
_, err := tx.Exec(
|
|
r.Context(),
|
|
overrideUpdateQuery,
|
|
override.OverridePrice,
|
|
override.OverrideDurationMinutes,
|
|
booking.ID,
|
|
override.ServiceID,
|
|
)
|
|
if err != nil {
|
|
log.Printf(
|
|
"Failed to apply override (booking %s, service %s): %v",
|
|
booking.ID,
|
|
override.ServiceID,
|
|
err,
|
|
)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit admin booking creation: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
|
log.Printf("Failed to encode response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|