Two-tier notification system: new_booking (all public bookings) + pending_booking (notes/today). Priority-sorted queue, unread count polling, enriched responses with user_name/booking_start_time. Fix critical bug: edit_requested cleanup was broken (wrong reason string in 3 handlers). Add 15 new tests covering priority ordering, enrichment, and notification creation flows. Update Admin Manual, Technical Manual, and gap backlog docs.
1547 lines
49 KiB
Go
1547 lines
49 KiB
Go
package bookings
|
|
|
|
import (
|
|
"context"
|
|
"crussell/db"
|
|
"crussell/handlers/notifications"
|
|
"crussell/internal/validators"
|
|
"crussell/handlers/scheduling"
|
|
"crussell/mw"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
// 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.
|
|
// Admin can edit any booking EXCEPT completed or cancelled bookings.
|
|
// Admin can create/edit bookings outside working hours (with warning).
|
|
// Admin can create/edit bookings that overlap with existing bookings (with warning).
|
|
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
|
|
}
|
|
|
|
// Check if booking exists and is not completed/cancelled
|
|
var currentStatus string
|
|
err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Block edits on completed or cancelled bookings
|
|
if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
|
|
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Get booking duration for overlap check
|
|
var durationMinutes int
|
|
err = db.DB.QueryRow(r.Context(), `
|
|
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 = $1
|
|
`, bookingID).Scan(&durationMinutes)
|
|
if err != nil {
|
|
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
|
|
durationMinutes = 60 // fallback
|
|
}
|
|
|
|
// Check for overlapping bookings (excluding the current booking)
|
|
var overlapCount int
|
|
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
|
|
err = db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings
|
|
WHERE id != $1
|
|
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
|
|
AND start_time < $3
|
|
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
|
|
)) > $2
|
|
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount)
|
|
if err != nil {
|
|
log.Printf("Failed to check overlap %s: %v", bookingID, err)
|
|
}
|
|
|
|
// Check if salon is closed (exceptional hours) - admin gets warning but can proceed
|
|
weekday := int(req.StartTime.Weekday())
|
|
bookingTime := req.StartTime.Format("15:04:05")
|
|
daysToMonday := weekday
|
|
if daysToMonday == 0 {
|
|
daysToMonday = 7
|
|
}
|
|
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
|
|
|
// Check if salon is closed (exceptional hours)
|
|
var isClosed bool
|
|
err = 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
|
|
)
|
|
`, weekStart, weekday, bookingTime).Scan(&isClosed)
|
|
if err != nil {
|
|
log.Printf("Failed to check exceptional hours: %v", err)
|
|
}
|
|
|
|
isOutsideWorkingHours := isClosed
|
|
|
|
// Prevent overlap - block admin
|
|
if overlapCount > 0 {
|
|
http.Error(w, "This booking overlaps with an existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Build warning for outside working hours (admin can proceed with warning)
|
|
var warnings []string
|
|
if isOutsideWorkingHours {
|
|
warnings = append(warnings, "Warning: This booking is outside standard working hours")
|
|
}
|
|
|
|
// Check for time blocker overlap - admin can proceed with warning
|
|
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime)
|
|
if err != nil {
|
|
log.Printf("Failed to check time blocker overlap: %v", err)
|
|
}
|
|
if blockerOverlap {
|
|
warnings = append(warnings, fmt.Sprintf("Warning: This booking overlaps with a time blocker: %s", blockerDesc))
|
|
}
|
|
|
|
// Perform the update
|
|
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
|
|
}
|
|
|
|
// Clear any pending edit requests for this booking (admin edit takes priority)
|
|
_, err = db.DB.Exec(r.Context(), `
|
|
DELETE FROM booking_edit_requests
|
|
WHERE booking_id = $1
|
|
`, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to clear edit requests for booking %s: %v", bookingID, err)
|
|
// Don't fail the request, just log the error
|
|
}
|
|
|
|
// Return warnings if any
|
|
if len(warnings) > 0 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"message": "Booking updated",
|
|
"warnings": warnings,
|
|
})
|
|
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
|
|
EnforceDeposits *bool `json:"enforce_deposits,omitempty"` // Optional: if true, enforce outstanding deposit checks; if false or omitted, bypass checks
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Extract idempotency key from header
|
|
idempotencyKey := r.Header.Get("Idempotency-Key")
|
|
|
|
// If idempotency key provided, check for existing booking
|
|
if idempotencyKey != "" {
|
|
var existingID string
|
|
err := db.DB.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID)
|
|
if err == nil {
|
|
// Booking already exists with this key — fetch and return it
|
|
var existingBooking Booking
|
|
existingBooking.User = &UserSummary{}
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required
|
|
FROM bookings b WHERE b.id = $1
|
|
`, existingID).Scan(
|
|
&existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status,
|
|
&existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy,
|
|
&existingBooking.DepositRequired,
|
|
)
|
|
if err == nil {
|
|
// Fetch services for the response
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
|
|
s.name, s.description, s.price, s.duration_minutes
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
`, existingID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var bs BookingService
|
|
if err := rows.Scan(
|
|
&bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes,
|
|
&bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes,
|
|
); err != nil {
|
|
break
|
|
}
|
|
existingBooking.Services = append(existingBooking.Services, bs)
|
|
}
|
|
}
|
|
|
|
// Get deposit info
|
|
var depositRequired bool
|
|
var preStartPaid float64
|
|
db.DB.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
|
|
db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
|
|
populateDepositFields(&existingBooking, depositRequired, preStartPaid)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(existingBooking)
|
|
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
|
|
}
|
|
|
|
// Check patch test requirements for all services
|
|
for _, serviceID := range req.ServiceIDs {
|
|
// Find patch test for this service
|
|
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 {
|
|
// Service requires a patch test - check if user has valid record
|
|
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
|
|
`, req.UserID, patchTestID).Scan(&testedAt)
|
|
|
|
if err != nil {
|
|
// No valid patch test record
|
|
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if notice period has passed
|
|
eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
|
|
if req.StartTime.Before(eligibleFrom) {
|
|
hoursNeeded := time.Until(eligibleFrom).Hours()
|
|
http.Error(w, fmt.Sprintf("Booking time is before the %.0f hour notice period after patch test. Earliest booking: %s", hoursNeeded, eligibleFrom.Format("2006-01-02 15:04")), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if patch test has expired
|
|
var expiryMonths int
|
|
err = db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths)
|
|
if err == nil {
|
|
expiresAt := testedAt.AddDate(0, expiryMonths, 0)
|
|
if req.StartTime.After(expiresAt) {
|
|
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Enforce deposit checks if requested (default: true if not specified)
|
|
enforceDeposits := true
|
|
if req.EnforceDeposits != nil {
|
|
enforceDeposits = *req.EnforceDeposits
|
|
}
|
|
|
|
if enforceDeposits {
|
|
// Read live deposits_required from user
|
|
var depositsRequired int
|
|
if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, req.UserID).Scan(&depositsRequired); err != nil {
|
|
log.Printf("Failed to fetch deposits_required for user %s: %v", req.UserID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Check one-active-booking limit when deposits are outstanding
|
|
if depositsRequired > 0 {
|
|
var activeCount int
|
|
if err := db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings
|
|
WHERE user_id = $1 AND status IN ('pending', 'confirmed')
|
|
`, req.UserID).Scan(&activeCount); err != nil {
|
|
log.Printf("Failed to check active bookings for user %s: %v", req.UserID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if activeCount > 0 {
|
|
http.Error(w, "User already has an active booking. Cannot create another until deposit requirements are cleared.", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate overrides
|
|
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
|
|
// Calculate the Monday of the week containing the booking date
|
|
weekday := int(req.StartTime.Weekday())
|
|
daysToMonday := weekday
|
|
if daysToMonday == 0 {
|
|
daysToMonday = 7 // Sunday -> next Monday
|
|
}
|
|
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
|
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
|
|
)
|
|
`, weekStart, 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
|
|
}
|
|
|
|
// Check for overlapping confirmed/in_progress/completed bookings
|
|
var dur int
|
|
db.DB.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
|
|
`, req.ServiceIDs).Scan(&dur)
|
|
newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute)
|
|
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, newEnd).Scan(&cnt)
|
|
if cnt > 0 {
|
|
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Check for time blocker overlap - admin can proceed with warning
|
|
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEnd)
|
|
if err != nil {
|
|
log.Printf("Failed to check time blocker overlap: %v", err)
|
|
}
|
|
|
|
|
|
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,
|
|
idempotency_key
|
|
)
|
|
VALUES ($1, $2, 'confirmed', $3, $4, $5)
|
|
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,
|
|
sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""},
|
|
).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
|
|
}
|
|
// Build response with warnings if any
|
|
|
|
warnings := []string{}
|
|
|
|
if blockerOverlap {
|
|
|
|
warnings = append(warnings, fmt.Sprintf("Warning: This booking overlaps with a time blocker: %s", blockerDesc))
|
|
|
|
}
|
|
|
|
|
|
response := map[string]interface{}{
|
|
|
|
"booking": booking,
|
|
|
|
"warnings": warnings,
|
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// =============================================================================
|
|
// Booking Edit Request Handlers
|
|
// =============================================================================
|
|
|
|
// BookingEditRequest represents a user's request to edit a booking
|
|
type BookingEditRequest struct {
|
|
ID string `json:"id"`
|
|
BookingID string `json:"booking_id"`
|
|
RequestedBy string `json:"requested_by"`
|
|
NewStartTime *time.Time `json:"new_start_time,omitempty"`
|
|
NewServices []string `json:"new_services"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
HasOverrides bool `json:"has_overrides"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
// Joined fields
|
|
Booking *Booking `json:"booking,omitempty"`
|
|
User *UserSummary `json:"user,omitempty"`
|
|
}
|
|
|
|
// DeleteEditRequestHandler allows a user to delete/cancel their pending edit request
|
|
func DeleteEditRequestHandler(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
|
|
}
|
|
|
|
// Verify user owns this booking
|
|
var ownerID string
|
|
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if ownerID != userID {
|
|
http.Error(w, "Access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Use transaction to delete edit request and associated admin notification
|
|
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())
|
|
|
|
// Delete the edit request for this booking
|
|
res, err := tx.Exec(r.Context(), `
|
|
DELETE FROM booking_edit_requests
|
|
WHERE booking_id = $1 AND requested_by = $2
|
|
`, bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete edit request for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
rowsAffected := res.RowsAffected()
|
|
if rowsAffected == 0 {
|
|
http.Error(w, "No edit request found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
_, _ = tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description = $1
|
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
|
|
|
// Delete the admin notification for this edit request
|
|
_, err = tx.Exec(r.Context(), `
|
|
DELETE FROM admin_notifications
|
|
WHERE booking_id = $1 AND reason = 'edit_requested' AND user_id = $2
|
|
`, bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete 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 delete edit request: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// RequestEditHandler allows a user to request an edit to their booking
|
|
func RequestEditHandler(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
|
|
}
|
|
|
|
var req struct {
|
|
NewStartTime *time.Time `json:"new_start_time,omitempty"`
|
|
NewServices []string `json:"new_services"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate: at least one of new_start_time, new_services, or notes must be provided
|
|
if req.NewStartTime == nil && len(req.NewServices) == 0 && req.Notes == nil {
|
|
http.Error(w, "At least one of new_start_time, new_services, or notes is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify user owns this booking
|
|
var ownerID string
|
|
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if ownerID != userID {
|
|
http.Error(w, "Access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Check booking is not already completed/cancelled
|
|
var currentStatus string
|
|
err = db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus)
|
|
if err != nil {
|
|
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
|
|
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
|
|
return
|
|
}
|
|
if len(req.NewServices) > 0 {
|
|
var overrideCount int
|
|
err = db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM booking_services
|
|
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
|
|
`, bookingID).Scan(&overrideCount)
|
|
if err != nil {
|
|
log.Printf("Failed to check overrides for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if overrideCount > 0 {
|
|
http.Error(w, "Cannot change services on a booking that has overrides. Please contact the salon.", http.StatusForbidden)
|
|
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())
|
|
|
|
// Delete any existing edit request for this booking (upsert behavior)
|
|
_, err = tx.Exec(r.Context(), `
|
|
DELETE FROM booking_edit_requests
|
|
WHERE booking_id = $1 AND requested_by = $2
|
|
`, bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete existing edit request for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create edit request - has_overrides is false since user can't override
|
|
var editReq BookingEditRequest
|
|
err = tx.QueryRow(r.Context(), `
|
|
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
|
`, bookingID, userID, req.NewStartTime, pq.Array(req.NewServices), req.Notes, false).Scan(
|
|
&editReq.ID,
|
|
&editReq.BookingID,
|
|
&editReq.RequestedBy,
|
|
&editReq.NewStartTime,
|
|
pq.Array(&editReq.NewServices),
|
|
&editReq.Notes,
|
|
&editReq.HasOverrides,
|
|
&editReq.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to create edit request for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if req.NewStartTime != nil {
|
|
_, _ = tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description = $1
|
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
|
|
|
var durationMinutes int
|
|
if len(req.NewServices) > 0 {
|
|
_ = tx.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(s.duration_minutes), 60)
|
|
FROM services s
|
|
WHERE s.id = ANY($1)
|
|
`, req.NewServices).Scan(&durationMinutes)
|
|
} else {
|
|
_ = tx.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(s.duration_minutes), 60)
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
`, bookingID).Scan(&durationMinutes)
|
|
}
|
|
|
|
_, err = tx.Exec(r.Context(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, $2, $3, $4)
|
|
`, *req.NewStartTime, durationMinutes, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), userID)
|
|
if err != nil {
|
|
log.Printf("Failed to create time_blocker reservation for edit request %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Always create low-priority notification for edit requests
|
|
_, err = tx.Exec(r.Context(), `
|
|
DELETE FROM admin_notifications
|
|
WHERE booking_id = $1 AND reason = 'edit_requested'
|
|
`, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete old admin notification for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, err = tx.Exec(r.Context(), `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
|
VALUES ('edit_requested', $1, $2)
|
|
`, bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to create admin notification for edit request %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// If booking is pending, also create high-priority approval notification
|
|
if currentStatus == "pending" {
|
|
_, err = tx.Exec(r.Context(), `
|
|
UPDATE admin_notifications
|
|
SET acknowledged_at = NOW()
|
|
WHERE booking_id = $1 AND reason = 'pending_booking' AND acknowledged_at IS NULL
|
|
`, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err)
|
|
}
|
|
|
|
_, err = tx.Exec(r.Context(), `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
|
VALUES ('pending_booking', $1, $2)
|
|
`, bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to create pending booking notification for %s: %v", bookingID, err)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit edit request: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(editReq)
|
|
}
|
|
|
|
// AdminListEditRequestsHandler returns all edit requests
|
|
func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
|
baseQuery := `
|
|
SELECT ber.id, ber.booking_id, ber.requested_by, ber.new_start_time,
|
|
ber.new_services, ber.notes, ber.has_overrides, ber.updated_at,
|
|
b.start_time as original_start_time, b.status as booking_status,
|
|
u.fn as user_name
|
|
FROM booking_edit_requests ber
|
|
JOIN bookings b ON ber.booking_id = b.id
|
|
JOIN users u ON ber.requested_by = u.id
|
|
`
|
|
|
|
countQuery := `SELECT COUNT(*) FROM booking_edit_requests ber`
|
|
|
|
var args []interface{}
|
|
|
|
baseQuery += " ORDER BY ber.updated_at DESC"
|
|
|
|
// Get total count
|
|
var total int
|
|
err := db.DB.QueryRow(r.Context(), countQuery, args...).Scan(&total)
|
|
if err != nil {
|
|
log.Printf("Failed to count edit requests: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch edit requests: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var requests []BookingEditRequest
|
|
for rows.Next() {
|
|
var req BookingEditRequest
|
|
var origStartTime time.Time
|
|
var bookingStatus string
|
|
var userName string
|
|
var newServices []string
|
|
|
|
err := rows.Scan(
|
|
&req.ID,
|
|
&req.BookingID,
|
|
&req.RequestedBy,
|
|
&req.NewStartTime,
|
|
pq.Array(&newServices),
|
|
&req.Notes,
|
|
&req.HasOverrides,
|
|
&req.UpdatedAt,
|
|
&origStartTime,
|
|
&bookingStatus,
|
|
&userName,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to scan edit request: %v", err)
|
|
continue
|
|
}
|
|
|
|
req.NewServices = newServices
|
|
|
|
req.Booking = &Booking{
|
|
ID: req.BookingID,
|
|
StartTime: origStartTime,
|
|
Status: bookingStatus,
|
|
}
|
|
req.User = &UserSummary{
|
|
ID: req.RequestedBy,
|
|
FullName: userName,
|
|
}
|
|
|
|
requests = append(requests, req)
|
|
}
|
|
|
|
if requests == nil {
|
|
requests = []BookingEditRequest{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"requests": requests,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// AdminApproveEditRequestHandler approves an edit request and updates the booking
|
|
func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|
requestID := chi.URLParam(r, "request_id")
|
|
if requestID == "" || !validators.IsValidID(requestID) {
|
|
http.Error(w, "Edit request 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 the edit request
|
|
var bookingID string
|
|
var newStartTime *time.Time
|
|
var newServices []string
|
|
var notes *string
|
|
var hasOverrides bool
|
|
err = tx.QueryRow(r.Context(), `
|
|
SELECT booking_id, new_start_time, new_services, notes, has_overrides
|
|
FROM booking_edit_requests
|
|
WHERE id = $1
|
|
`, requestID).Scan(&bookingID, &newStartTime, pq.Array(&newServices), ¬es, &hasOverrides)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Edit request not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get edit request %s: %v", requestID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// If new_services provided and has_overrides is true, block with error
|
|
if len(newServices) > 0 && hasOverrides {
|
|
http.Error(w, "Cannot change services on a booking that has overrides. Please update services manually.", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Calculate duration for overlap check - use overrides if has_overrides is true
|
|
var durationMinutes int
|
|
if hasOverrides {
|
|
// Use the existing booking_services with overrides
|
|
err = tx.QueryRow(r.Context(), `
|
|
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 = $1
|
|
`, bookingID).Scan(&durationMinutes)
|
|
} else {
|
|
// Use standard durations or new_services if provided
|
|
if len(newServices) > 0 {
|
|
// Use new services to calculate duration
|
|
err = tx.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(s.duration_minutes), 60)
|
|
FROM services s
|
|
WHERE s.id = ANY($1)
|
|
`, newServices).Scan(&durationMinutes)
|
|
} else {
|
|
// Use existing booking services
|
|
err = tx.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(s.duration_minutes), 60)
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
`, bookingID).Scan(&durationMinutes)
|
|
}
|
|
}
|
|
if err != nil {
|
|
log.Printf("Failed to calculate duration: %v", err)
|
|
durationMinutes = 60 // fallback
|
|
}
|
|
|
|
// Check for overlapping bookings if start time is being changed
|
|
if newStartTime != nil {
|
|
newEndTime := newStartTime.Add(time.Duration(durationMinutes) * time.Minute)
|
|
var overlapCount int
|
|
err = tx.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings
|
|
WHERE id != $1
|
|
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
|
|
AND start_time < $3
|
|
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
|
|
)) > $2
|
|
`, bookingID, *newStartTime, newEndTime).Scan(&overlapCount)
|
|
if err != nil {
|
|
log.Printf("Failed to check overlap: %v", err)
|
|
}
|
|
if overlapCount > 0 {
|
|
http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime)
|
|
if err != nil {
|
|
log.Printf("Failed to check time blocker overlap: %v", err)
|
|
}
|
|
if blockerOverlap {
|
|
http.Error(w, fmt.Sprintf("This edit would overlap with a time blocker: %s", blockerDesc), http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Build update query for bookings table
|
|
if newStartTime != nil || notes != nil {
|
|
var setClauses []string
|
|
var args []interface{}
|
|
argNum := 1
|
|
|
|
if newStartTime != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum))
|
|
args = append(args, *newStartTime)
|
|
argNum++
|
|
}
|
|
if notes != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("notes = $%d", argNum))
|
|
args = append(args, *notes)
|
|
argNum++
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
|
|
args = append(args, time.Now())
|
|
argNum++
|
|
|
|
args = append(args, bookingID)
|
|
|
|
query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum)
|
|
_, err = tx.Exec(r.Context(), query, args...)
|
|
if err != nil {
|
|
log.Printf("Failed to update booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Handle new_services (only if has_overrides is false)
|
|
if len(newServices) > 0 && !hasOverrides {
|
|
// Delete existing booking_services
|
|
_, err = tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete existing services for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Insert new services
|
|
for _, serviceID := range newServices {
|
|
_, err = tx.Exec(r.Context(), `
|
|
INSERT INTO booking_services (booking_id, service_id)
|
|
VALUES ($1, $2)
|
|
`, bookingID, serviceID)
|
|
if err != nil {
|
|
log.Printf("Failed to insert booking service %s: %v", serviceID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete the edit request row (not update status)
|
|
_, err = tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", requestID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete edit request %s: %v", requestID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, _ = tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description = $1
|
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
|
|
|
// Acknowledge the admin notification for this edit request
|
|
_, err = tx.Exec(r.Context(), `
|
|
UPDATE admin_notifications
|
|
SET acknowledged_at = NOW()
|
|
WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL
|
|
`, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// TODO: Notify user that their edit request was approved.
|
|
// Options: (a) INSERT into user_notifications table (needs schema), (b) send email via SMTP provider.
|
|
// The user_notification_preferences table exists but no delivery mechanism is wired yet.
|
|
// See: obsidian/Crussell/Future Work - Gap Backlog.md → E5 (Email/SMS notification system).
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// AdminRejectEditRequestHandler rejects an edit request by deleting it and acknowledging the admin notification
|
|
func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|
requestID := chi.URLParam(r, "request_id")
|
|
if requestID == "" || !validators.IsValidID(requestID) {
|
|
http.Error(w, "Edit request not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// First get the booking_id from the edit request before deleting
|
|
var bookingID string
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT booking_id FROM booking_edit_requests WHERE id = $1
|
|
`, requestID).Scan(&bookingID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Edit request not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get edit request %s: %v", requestID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Use transaction to delete edit request and acknowledge associated admin notification
|
|
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())
|
|
|
|
// Delete the edit request
|
|
_, err = tx.Exec(r.Context(), `
|
|
DELETE FROM booking_edit_requests
|
|
WHERE id = $1
|
|
`, requestID)
|
|
if err != nil {
|
|
log.Printf("Failed to reject edit request %s: %v", requestID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, _ = tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description = $1
|
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
|
|
|
// Acknowledge the admin notification for this edit request
|
|
_, err = tx.Exec(r.Context(), `
|
|
UPDATE admin_notifications
|
|
SET acknowledged_at = NOW()
|
|
WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL
|
|
`, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// TODO: Notify user that their edit request was denied.
|
|
// Same as approve TODO above — needs user_notifications table or email delivery (E5).
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit reject edit request: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ========================================
|
|
// NO-SHOW HELPER FUNCTIONS
|
|
// ========================================
|
|
|
|
// CountUnforgivenNoShows counts the number of no-shows in the last 6 months
|
|
// that have not been forgiven (not in forgiven_no_shows table)
|
|
func CountUnforgivenNoShows(ctx context.Context, userID string) (int, error) {
|
|
var count int
|
|
err := db.DB.QueryRow(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM bookings b
|
|
WHERE b.user_id = $1
|
|
AND b.status = 'no_show'
|
|
AND b.start_time >= NOW() - INTERVAL '6 months'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM forgiven_no_shows WHERE booking_id = b.id
|
|
)
|
|
`, userID).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// ApplyDepositsIfNeeded checks if user has 2+ unforgiven no-shows
|
|
// and applies 3 deposits if so. Returns true if deposits were applied.
|
|
func ApplyDepositsIfNeeded(ctx context.Context, userID string) (bool, error) {
|
|
count, err := CountUnforgivenNoShows(ctx, userID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if count >= 2 {
|
|
// Apply 3 deposits
|
|
_, err := db.DB.Exec(ctx, `
|
|
UPDATE users SET deposits_required = 3 WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
|