Add OutOfHours field to AdminCreateBookingForUserRequest. When true, skip exceptional hours closed check and include out_of_hours in INSERT. Add tests verifying: bypass of exceptional closure, rejection without flag, overlap detection still works, and time blocker warning. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2300 lines
74 KiB
Go
2300 lines
74 KiB
Go
package bookings
|
|
|
|
import (
|
|
"context"
|
|
"crussell/db"
|
|
"github.com/jackc/pgx/v5"
|
|
"crussell/handlers/notifications"
|
|
"crussell/handlers/payments"
|
|
"crussell/handlers/scheduling"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"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.Conn.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, pgx.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
|
|
}
|
|
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM booking_edit_requests WHERE booking_id = $1
|
|
`, bookingID); err != nil {
|
|
log.Printf("ALERT: failed to delete edit requests: %v", err)
|
|
}
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers WHERE description = $1
|
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
|
|
log.Printf("ALERT: failed to delete time_blocker: %v", err)
|
|
}
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'
|
|
`, bookingID); err != nil {
|
|
log.Printf("ALERT: failed to delete edit_requested notification: %v", err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
type AdminCancelBookingRequest struct {
|
|
ForgiveFees *bool `json:"forgive_fees,omitempty"`
|
|
ForgiveNoShow *bool `json:"forgive_noshow,omitempty"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var req AdminCancelBookingRequest
|
|
if r.Body != nil {
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Printf("failed to decode admin cancel request body: %v", err)
|
|
}
|
|
}
|
|
forgiveFees := req.ForgiveFees != nil && *req.ForgiveFees
|
|
forgiveNoShow := req.ForgiveNoShow != nil && *req.ForgiveNoShow
|
|
|
|
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || adminID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Process refund FIRST, before the cancel transaction. If the refund fails,
|
|
// the booking stays active and the admin can retry. This mirrors the
|
|
// DeleteBookingHandler pattern — the booking status change is independent
|
|
// of the refund execution.
|
|
var refundResult *payments.RefundCalculationResult
|
|
paySvc := payments.NewPaymentService()
|
|
payInfo, payErr := paySvc.GetBookingPaymentInfo(r.Context(), bookingID)
|
|
if payErr != nil {
|
|
log.Printf("Failed to get booking payment info for %s: %v", bookingID, payErr)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
totalAmount := payInfo.TotalAmount
|
|
totalPaid := payInfo.TotalPaid
|
|
if totalPaid > 0 {
|
|
if forgiveFees {
|
|
refundResult = &payments.RefundCalculationResult{
|
|
TotalPrePaid: totalPaid,
|
|
RefundableAmount: totalPaid,
|
|
KeptAmount: 0,
|
|
Tier: "admin_full_refund",
|
|
}
|
|
} else {
|
|
calc, err := payments.ProcessCancellationRefund(r.Context(), bookingID, totalAmount, totalPaid, payInfo.StartTime, time.Now(), "admin_cancelled", &adminID)
|
|
if err == nil {
|
|
refundResult = calc
|
|
}
|
|
}
|
|
}
|
|
|
|
tx, err := db.Conn.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 and user ID before updating
|
|
var originalStatus string
|
|
var bookingUserID string
|
|
err = tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus, &bookingUserID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.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
|
|
}
|
|
|
|
if forgiveNoShow && bookingUserID != "" {
|
|
if _, err := tx.Exec(r.Context(), `
|
|
INSERT INTO forgiven_no_shows (booking_id, forgiven_by)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (booking_id) DO NOTHING
|
|
`, bookingID, adminID); err != nil {
|
|
log.Printf("Failed to insert forgiven_no_show for booking %s: %v", bookingID, err)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Clean up any pending edit requests for this booking.
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM booking_edit_requests WHERE booking_id = $1
|
|
`, bookingID); err != nil {
|
|
log.Printf("ALERT: failed to delete edit requests on admin cancel: %v", err)
|
|
}
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers WHERE description = $1
|
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
|
|
log.Printf("ALERT: failed to delete edit request time_blocker on admin cancel: %v", err)
|
|
}
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'
|
|
`, bookingID); err != nil {
|
|
log.Printf("ALERT: failed to delete edit_requested notification on admin cancel: %v", err)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if refundResult != nil && refundResult.RefundableAmount > 0 {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"message": "Booking cancelled",
|
|
"refund_calculation": refundResult,
|
|
})
|
|
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.Conn.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, pgx.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)
|
|
}
|
|
}
|
|
|
|
type AdminCreateBookingForUserRequest struct {
|
|
UserID string `json:"user_id" validate:"required"`
|
|
StartTime time.Time `json:"start_time" validate:"required"`
|
|
ServiceIDs []string `json:"service_ids,omitempty"`
|
|
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
|
CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
|
|
CustomOverrides []ServiceOverride `json:"custom_service_overrides,omitempty"`
|
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
|
EnforceDeposits *bool `json:"enforce_deposits,omitempty"`
|
|
OutOfHours bool `json:"out_of_hours"`
|
|
}
|
|
|
|
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
|
|
}
|
|
if err := validators.Validate.Struct(&req); err != nil {
|
|
http.Error(w, err.Error(), 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.Conn.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.Conn.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.Conn.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
|
|
UNION ALL
|
|
SELECT bcs.booking_id, bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
|
|
cs.name, cs.description, cs.price, cs.duration_minutes
|
|
FROM booking_custom_services bcs
|
|
JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
WHERE bcs.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.Conn.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
|
|
db.Conn.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 && len(req.CustomServiceIDs) == 0 {
|
|
http.Error(w, "At least one service or custom service is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check patch test requirements for all regular services (custom services skip patch tests)
|
|
if len(req.ServiceIDs) > 0 {
|
|
patchTestRows, err := db.Conn.Query(r.Context(), `
|
|
SELECT id, service_ids, notice_duration_hours, expiry_months
|
|
FROM patch_tests
|
|
WHERE service_ids::text[] && $1::text[]
|
|
`, req.ServiceIDs)
|
|
if err != nil {
|
|
log.Printf("Failed to query patch tests: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
type ptInfo struct {
|
|
id string
|
|
noticeHours int
|
|
expiryMonths int
|
|
}
|
|
patchTestsByService := make(map[string]ptInfo)
|
|
var allPtIDs []string
|
|
for patchTestRows.Next() {
|
|
var id string
|
|
var serviceIDs []string
|
|
var noticeHours, expiryMonths int
|
|
if err := patchTestRows.Scan(&id, &serviceIDs, ¬iceHours, &expiryMonths); err != nil {
|
|
log.Printf("Failed to scan patch test: %v", err)
|
|
continue
|
|
}
|
|
allPtIDs = append(allPtIDs, id)
|
|
for _, sid := range serviceIDs {
|
|
patchTestsByService[sid] = ptInfo{id, noticeHours, expiryMonths}
|
|
}
|
|
}
|
|
patchTestRows.Close()
|
|
|
|
userPatchTests := make(map[string]time.Time)
|
|
if len(allPtIDs) > 0 {
|
|
uptRows, err := db.Conn.Query(r.Context(), `
|
|
SELECT patch_test_id, tested_at
|
|
FROM user_patch_tests
|
|
WHERE user_id = $1 AND patch_test_id = ANY($2)
|
|
`, req.UserID, allPtIDs)
|
|
if err != nil {
|
|
log.Printf("Failed to query user patch tests: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
for uptRows.Next() {
|
|
var ptID string
|
|
var testedAt time.Time
|
|
if err := uptRows.Scan(&ptID, &testedAt); err != nil {
|
|
log.Printf("Failed to scan user patch test: %v", err)
|
|
continue
|
|
}
|
|
userPatchTests[ptID] = testedAt
|
|
}
|
|
uptRows.Close()
|
|
}
|
|
|
|
for _, serviceID := range req.ServiceIDs {
|
|
pt, needsPatch := patchTestsByService[serviceID]
|
|
if !needsPatch {
|
|
continue
|
|
}
|
|
|
|
testedAt, hasTest := userPatchTests[pt.id]
|
|
if !hasTest {
|
|
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
eligibleFrom := testedAt.Add(time.Duration(pt.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
|
|
}
|
|
|
|
if pt.expiryMonths > 0 {
|
|
expiresAt := testedAt.AddDate(0, pt.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.Conn.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.Conn.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 && len(req.CustomServiceIDs) == 0 {
|
|
http.Error(w, "At least one service or custom 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
|
|
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
|
weekday := int((req.StartTime.Weekday() + 6) % 7)
|
|
daysToMonday := int(req.StartTime.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")
|
|
|
|
if !req.OutOfHours {
|
|
// Check if there's an exceptional hours entry that makes this time unavailable
|
|
var isClosed bool
|
|
var checkErr error
|
|
checkErr = db.Conn.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
|
|
allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
|
|
var dur int
|
|
err := db.Conn.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(dur), 0) FROM (
|
|
SELECT duration_minutes AS dur FROM services WHERE id = ANY($1)
|
|
UNION ALL
|
|
SELECT duration_minutes FROM custom_services WHERE id = ANY($1)
|
|
) combined
|
|
`, allIDs).Scan(&dur)
|
|
if err != nil {
|
|
log.Printf("Failed to calculate duration: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute)
|
|
|
|
// 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.Conn.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())
|
|
|
|
// Check for overlapping confirmed/in_progress/completed bookings (inside transaction)
|
|
var cnt int
|
|
err = tx.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND end_time > $1
|
|
`, req.StartTime, newEnd).Scan(&cnt)
|
|
if err != nil {
|
|
log.Printf("Failed to check overlap: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if cnt > 0 {
|
|
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEnd); evictErr != nil {
|
|
log.Printf("Failed to evict pending_release bookings (admin create): %v", evictErr)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create booking directly as confirmed
|
|
bookingQuery := `
|
|
INSERT INTO bookings (
|
|
user_id,
|
|
start_time,
|
|
status,
|
|
notes,
|
|
created_by,
|
|
idempotency_key,
|
|
out_of_hours
|
|
)
|
|
VALUES ($1, $2, 'confirmed', $3, $4, $5, $6)
|
|
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, out_of_hours
|
|
`
|
|
|
|
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 != ""},
|
|
req.OutOfHours,
|
|
).Scan(
|
|
&booking.ID,
|
|
&booking.User.ID,
|
|
&booking.StartTime,
|
|
&booking.Status,
|
|
&booking.Notes,
|
|
&booking.CreatedAt,
|
|
&booking.UpdatedAt,
|
|
&booking.CreatedBy,
|
|
&booking.OutOfHours,
|
|
)
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to create admin booking: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Insert booking services
|
|
if len(req.ServiceIDs) > 0 {
|
|
_, err = tx.Exec(r.Context(), `
|
|
INSERT INTO booking_services (booking_id, service_id)
|
|
SELECT $1, unnest($2::text[])
|
|
`, booking.ID, req.ServiceIDs)
|
|
if err != nil {
|
|
log.Printf("Failed to insert booking services: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if len(req.CustomServiceIDs) > 0 {
|
|
_, err = tx.Exec(r.Context(), `
|
|
INSERT INTO booking_custom_services (booking_id, custom_service_id)
|
|
SELECT $1, unnest($2::text[])
|
|
`, booking.ID, req.CustomServiceIDs)
|
|
if err != nil {
|
|
log.Printf("Failed to insert custom booking services: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := tx.Exec(r.Context(), `
|
|
UPDATE custom_services SET usage_count = usage_count + 1, last_used_at = NOW() WHERE id = ANY($1)
|
|
`, req.CustomServiceIDs); err != nil {
|
|
log.Printf("ALERT: failed to update custom service usage: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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 len(req.CustomOverrides) > 0 {
|
|
customOverrideServiceIDs := make([]string, len(req.CustomOverrides))
|
|
for i, o := range req.CustomOverrides {
|
|
customOverrideServiceIDs[i] = o.ServiceID
|
|
}
|
|
var customCount int
|
|
err = tx.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM booking_custom_services
|
|
WHERE booking_id = $1 AND custom_service_id = ANY($2)
|
|
`, booking.ID, customOverrideServiceIDs).Scan(&customCount)
|
|
if err != nil {
|
|
log.Printf("Failed to verify custom service overrides: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if customCount != len(req.CustomOverrides) {
|
|
http.Error(w, "One or more custom service overrides do not belong to this booking", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
customOverrideUpdateQuery := `
|
|
UPDATE booking_custom_services
|
|
SET override_price = $1,
|
|
override_duration_minutes = $2
|
|
WHERE booking_id = $3 AND custom_service_id = $4
|
|
`
|
|
|
|
for _, override := range req.CustomOverrides {
|
|
_, err := tx.Exec(
|
|
r.Context(),
|
|
customOverrideUpdateQuery,
|
|
override.OverridePrice,
|
|
override.OverrideDurationMinutes,
|
|
booking.ID,
|
|
override.ServiceID,
|
|
)
|
|
if err != nil {
|
|
log.Printf(
|
|
"Failed to apply custom service override (booking %s, custom 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" validate:"omitempty,max=1000000"`
|
|
HasOverrides bool `json:"has_overrides"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
// Joined fields
|
|
Booking *Booking `json:"booking,omitempty"`
|
|
User *UserSummary `json:"user,omitempty"`
|
|
}
|
|
|
|
// Enriched response types for edit request detail views
|
|
type EditServiceDetail struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Price float64 `json:"price"`
|
|
DurationMinutes int `json:"duration_minutes"`
|
|
}
|
|
|
|
type EditSnapshot struct {
|
|
StartTime *time.Time `json:"start_time"`
|
|
EndTime *time.Time `json:"end_time"`
|
|
Services []EditServiceDetail `json:"services"`
|
|
Notes *string `json:"notes" validate:"omitempty,max=1000000"`
|
|
}
|
|
|
|
type EditUserSummary struct {
|
|
ID string `json:"id"`
|
|
FullName string `json:"full_name"`
|
|
Email *string `json:"email,omitempty"`
|
|
Phone *string `json:"phone,omitempty"`
|
|
PreviousFirstName *string `json:"previous_first_name,omitempty"`
|
|
PreviousLastName *string `json:"previous_last_name,omitempty"`
|
|
}
|
|
|
|
type EnrichedEditRequest struct {
|
|
ID string `json:"id"`
|
|
BookingID string `json:"booking_id"`
|
|
RequestedBy string `json:"requested_by"`
|
|
RequestedAt time.Time `json:"requested_at"`
|
|
Notes *string `json:"notes" validate:"omitempty,max=1000000"`
|
|
Original *EditSnapshot `json:"original"`
|
|
Proposed *EditSnapshot `json:"proposed"`
|
|
User *EditUserSummary `json:"user,omitempty"`
|
|
}
|
|
|
|
// buildEnrichedEditRequest builds a full enriched response from a pending edit request.
|
|
// It queries the database for original booking details, services, and user info.
|
|
func buildEnrichedEditRequest(ctx context.Context, editReq *BookingEditRequest) (*EnrichedEditRequest, error) {
|
|
var bStartTime time.Time
|
|
var bNotes *string
|
|
err := db.Conn.QueryRow(ctx, `
|
|
SELECT start_time, notes FROM bookings WHERE id = $1
|
|
`, editReq.BookingID).Scan(&bStartTime, &bNotes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get booking %s: %w", editReq.BookingID, err)
|
|
}
|
|
|
|
origServices, err := queryBookingServicesWithDetails(ctx, editReq.BookingID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get booking services for %s: %w", editReq.BookingID, err)
|
|
}
|
|
|
|
var proposedServices []EditServiceDetail
|
|
if len(editReq.NewServices) > 0 && !editReq.HasOverrides {
|
|
proposedServices, err = queryServiceDetailsByIDs(ctx, editReq.NewServices)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get service details: %w", err)
|
|
}
|
|
} else {
|
|
proposedServices = origServices
|
|
}
|
|
|
|
var proposedStartTime *time.Time
|
|
if editReq.NewStartTime != nil {
|
|
proposedStartTime = editReq.NewStartTime
|
|
} else {
|
|
proposedStartTime = &bStartTime
|
|
}
|
|
|
|
var proposedNotes *string
|
|
if editReq.Notes != nil {
|
|
proposedNotes = editReq.Notes
|
|
} else {
|
|
proposedNotes = bNotes
|
|
}
|
|
|
|
origDuration := sumServiceDurations(origServices)
|
|
proposedDuration := sumServiceDurations(proposedServices)
|
|
|
|
origEndTime := bStartTime.Add(time.Duration(origDuration) * time.Minute)
|
|
var proposedEndTime *time.Time
|
|
if editReq.NewStartTime != nil {
|
|
et := editReq.NewStartTime.Add(time.Duration(proposedDuration) * time.Minute)
|
|
proposedEndTime = &et
|
|
} else {
|
|
proposedEndTime = &origEndTime
|
|
}
|
|
|
|
// Non-fatal: still return the request without user details
|
|
userSummary, err := queryUserSummary(ctx, editReq.RequestedBy)
|
|
if err != nil {
|
|
// Non-fatal: still return the request without user details
|
|
log.Printf("Failed to get user summary for %s: %v", editReq.RequestedBy, err)
|
|
}
|
|
|
|
result := &EnrichedEditRequest{
|
|
ID: editReq.ID,
|
|
BookingID: editReq.BookingID,
|
|
RequestedBy: editReq.RequestedBy,
|
|
RequestedAt: editReq.UpdatedAt,
|
|
Notes: editReq.Notes,
|
|
Original: &EditSnapshot{
|
|
StartTime: &bStartTime,
|
|
EndTime: &origEndTime,
|
|
Services: origServices,
|
|
Notes: bNotes,
|
|
},
|
|
Proposed: &EditSnapshot{
|
|
StartTime: proposedStartTime,
|
|
EndTime: proposedEndTime,
|
|
Services: proposedServices,
|
|
Notes: proposedNotes,
|
|
},
|
|
User: userSummary,
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// queryBookingServicesWithDetails returns service details for a booking, respecting overrides.
|
|
func queryBookingServicesWithDetails(ctx context.Context, bookingID string) ([]EditServiceDetail, error) {
|
|
rows, err := db.Conn.Query(ctx, `
|
|
SELECT s.id, s.name,
|
|
COALESCE(bs.override_price, s.price) as price,
|
|
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
UNION ALL
|
|
SELECT cs.id, cs.name,
|
|
COALESCE(bcs.override_price, cs.price),
|
|
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
|
FROM booking_custom_services bcs
|
|
JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
WHERE bcs.booking_id = $1
|
|
ORDER BY name
|
|
`, bookingID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var services []EditServiceDetail
|
|
for rows.Next() {
|
|
var svc EditServiceDetail
|
|
if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil {
|
|
return nil, err
|
|
}
|
|
services = append(services, svc)
|
|
}
|
|
if services == nil {
|
|
services = []EditServiceDetail{}
|
|
}
|
|
return services, rows.Err()
|
|
}
|
|
|
|
// queryServiceDetailsByIDs returns service details for the given service IDs.
|
|
func queryServiceDetailsByIDs(ctx context.Context, serviceIDs []string) ([]EditServiceDetail, error) {
|
|
if len(serviceIDs) == 0 {
|
|
return []EditServiceDetail{}, nil
|
|
}
|
|
|
|
rows, err := db.Conn.Query(ctx, `
|
|
SELECT id, name, price, duration_minutes
|
|
FROM services
|
|
WHERE id = ANY($1)
|
|
ORDER BY name
|
|
`, serviceIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var services []EditServiceDetail
|
|
for rows.Next() {
|
|
var svc EditServiceDetail
|
|
if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil {
|
|
return nil, err
|
|
}
|
|
services = append(services, svc)
|
|
}
|
|
if services == nil {
|
|
services = []EditServiceDetail{}
|
|
}
|
|
return services, rows.Err()
|
|
}
|
|
|
|
// sumServiceDurations returns the total duration in minutes from a slice of EditServiceDetail.
|
|
func sumServiceDurations(services []EditServiceDetail) int {
|
|
total := 0
|
|
for _, s := range services {
|
|
total += s.DurationMinutes
|
|
}
|
|
if total == 0 {
|
|
return 60 // fallback
|
|
}
|
|
return total
|
|
}
|
|
|
|
// queryUserSummary fetches user details for the enriched edit request response.
|
|
func queryUserSummary(ctx context.Context, userID string) (*EditUserSummary, error) {
|
|
var summary EditUserSummary
|
|
var prevFirstName, prevLastName sql.NullString
|
|
err := db.Conn.QueryRow(ctx, `
|
|
SELECT u.id, u.fn, u.email, u.phone,
|
|
nh.previous_first_name, nh.previous_last_name
|
|
FROM users u
|
|
LEFT JOIN LATERAL (
|
|
SELECT previous_first_name, previous_last_name
|
|
FROM name_history
|
|
WHERE user_id = u.id
|
|
ORDER BY changed_at DESC
|
|
LIMIT 1
|
|
) nh ON true
|
|
WHERE u.id = $1
|
|
`, userID).Scan(&summary.ID, &summary.FullName, &summary.Email, &summary.Phone,
|
|
&prevFirstName, &prevLastName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if prevFirstName.Valid {
|
|
summary.PreviousFirstName = &prevFirstName.String
|
|
}
|
|
if prevLastName.Valid {
|
|
summary.PreviousLastName = &prevLastName.String
|
|
}
|
|
return &summary, nil
|
|
}
|
|
|
|
// 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.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.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.Conn.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
|
|
}
|
|
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description = $1
|
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
|
|
log.Printf("ALERT: failed to delete time_blocker: %v", err)
|
|
}
|
|
|
|
// 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" validate:"omitempty,max=1000000"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := validators.Validate.Struct(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// M8
|
|
// L5
|
|
|
|
// 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.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.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
|
|
var currentStartTime time.Time
|
|
var depositRequired bool
|
|
err = db.Conn.QueryRow(r.Context(), "SELECT status, start_time, deposit_required FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, ¤tStartTime, &depositRequired)
|
|
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
|
|
}
|
|
|
|
// Query payment and timing info (used for validation AND auto-approval later)
|
|
var hasPayments bool
|
|
hoursUntilCurrent := currentStartTime.Sub(time.Now()).Hours()
|
|
db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND status = 'completed')", bookingID).Scan(&hasPayments)
|
|
|
|
// Check if booking has discounts (affects auto-approval decisions)
|
|
var hasDiscounts bool
|
|
db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)", bookingID).Scan(&hasDiscounts)
|
|
|
|
if req.NewStartTime != nil && !req.NewStartTime.Equal(currentStartTime) {
|
|
if hasPayments && hoursUntilCurrent < 72 {
|
|
http.Error(w, "This booking is too close to the appointment time to reschedule online. Please contact us to discuss options, or cancel and rebook (note: cancellation fees may apply based on our deposit policy).", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if !hasPayments && hoursUntilCurrent < 24 {
|
|
http.Error(w, "This booking is too close to the appointment time to reschedule online. Please contact us to discuss options, or cancel and rebook.", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Warn when within 72h with no payments (24-72h window — close enough
|
|
// to reschedule but counts toward no-show history).
|
|
if !hasPayments && hoursUntilCurrent < 72 {
|
|
w.Header().Set("X-No-Show-Warning", "Rescheduling within 72h counts as a no-show towards your deposit obligations. Two no-shows within 6 months will require deposits on future bookings.")
|
|
}
|
|
}
|
|
if len(req.NewServices) > 0 {
|
|
var overrideCount int
|
|
err = db.Conn.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM (
|
|
SELECT 1 FROM booking_services
|
|
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
|
|
UNION ALL
|
|
SELECT 1 FROM booking_custom_services
|
|
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
|
|
) overrides
|
|
`, 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.Conn.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, req.NewServices, req.Notes, false).Scan(
|
|
&editReq.ID,
|
|
&editReq.BookingID,
|
|
&editReq.RequestedBy,
|
|
&editReq.NewStartTime,
|
|
&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
|
|
}
|
|
|
|
// Auto-approve if no payments exist, the booking is >48h away,
|
|
// and no discounts+time-change combo (requires admin review)
|
|
if !hasPayments && hoursUntilCurrent > 48 && !(hasDiscounts && req.NewStartTime != nil) {
|
|
if req.NewStartTime != nil {
|
|
// Calculate duration for the new time
|
|
var durMinutes int
|
|
if len(req.NewServices) > 0 {
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(s.duration_minutes), 60)
|
|
FROM services s WHERE s.id = ANY($1)
|
|
`, req.NewServices).Scan(&durMinutes); err != nil {
|
|
log.Printf("Failed to get duration for new services: %v", err)
|
|
durMinutes = 60
|
|
}
|
|
} else {
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT total_duration_minutes FROM bookings WHERE id = $1
|
|
`, bookingID).Scan(&durMinutes); err != nil {
|
|
log.Printf("Failed to get duration for existing services: %v", err)
|
|
durMinutes = 60
|
|
}
|
|
}
|
|
if durMinutes <= 0 {
|
|
durMinutes = 60
|
|
}
|
|
|
|
// Quick overlap check — block if slot is taken
|
|
newEnd := req.NewStartTime.Add(time.Duration(durMinutes) * time.Minute)
|
|
var overlapCount int
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings
|
|
WHERE id != $1
|
|
AND status NOT IN ('completed','client_cancelled','we_cancelled','no_show','deposit_lapsed')
|
|
AND start_time < $3
|
|
AND end_time > $2
|
|
`, bookingID, *req.NewStartTime, newEnd).Scan(&overlapCount); err != nil {
|
|
log.Printf("Failed to check overlap: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if overlapCount > 0 {
|
|
http.Error(w, "The requested time slot has been taken. Please choose a different time.", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Update booking start_time, notes, and services directly
|
|
if req.NewStartTime != nil || req.Notes != nil {
|
|
var setClauses []string
|
|
var args []interface{}
|
|
argNum := 1
|
|
if req.NewStartTime != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum))
|
|
args = append(args, *req.NewStartTime)
|
|
argNum++
|
|
}
|
|
if req.Notes != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("notes = $%d", argNum))
|
|
args = append(args, *req.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)
|
|
if _, err := tx.Exec(r.Context(), query, args...); err != nil {
|
|
log.Printf("Failed to auto-approve booking update %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Update services if requested
|
|
if len(req.NewServices) > 0 {
|
|
_, _ = tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID)
|
|
_, _ = tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID)
|
|
for _, sid := range req.NewServices {
|
|
if _, err := tx.Exec(r.Context(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", bookingID, sid); err != nil {
|
|
log.Printf("Failed to insert auto-approve service %s: %v", sid, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up the edit request and reservations
|
|
_, _ = tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", editReq.ID)
|
|
_, _ = tx.Exec(r.Context(), `DELETE FROM time_blockers WHERE description = $1`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
|
_, _ = 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 := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit auto-approve edit request: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"auto_approved": true,
|
|
"edit_request": editReq,
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.NewStartTime != nil {
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description = $1
|
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
|
|
log.Printf("ALERT: failed to delete time_blocker: %v", err)
|
|
}
|
|
|
|
var durationMinutes int
|
|
if len(req.NewServices) > 0 {
|
|
_ = tx.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(dur), 60) FROM (
|
|
SELECT s.duration_minutes AS dur
|
|
FROM services s
|
|
WHERE s.id = ANY($1)
|
|
UNION ALL
|
|
SELECT cs.duration_minutes
|
|
FROM custom_services cs
|
|
WHERE cs.id = ANY($1)
|
|
) sub
|
|
`, req.NewServices).Scan(&durationMinutes)
|
|
} else {
|
|
_ = tx.QueryRow(r.Context(), `
|
|
SELECT total_duration_minutes FROM bookings WHERE 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
|
|
`
|
|
|
|
var args []interface{}
|
|
|
|
baseQuery += " ORDER BY ber.updated_at DESC"
|
|
|
|
var total int
|
|
|
|
// Count query (no ORDER BY needed).
|
|
db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total)
|
|
|
|
rows, err := db.Conn.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,
|
|
&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.Conn.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, &newServices, ¬es, &hasOverrides)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.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
|
|
}
|
|
|
|
// Check for applied discounts (admin warning only — discounts remain locked in)
|
|
var discountCount int
|
|
if err := db.Conn.QueryRow(r.Context(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount); err != nil {
|
|
log.Printf("ADMIN APPROVE EDIT: Failed to check discounts: %v", err)
|
|
}
|
|
if discountCount > 0 {
|
|
log.Printf("ADMIN APPROVE EDIT: Booking %s has %d discount(s) applied — discounts remain locked in after reschedule", bookingID, discountCount)
|
|
}
|
|
|
|
// 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 total_duration_minutes FROM bookings WHERE 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(dur), 60) FROM (
|
|
SELECT s.duration_minutes AS dur
|
|
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
|
|
UNION ALL
|
|
SELECT cs.duration_minutes
|
|
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1
|
|
) sub
|
|
`, 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', 'no_show', 'deposit_lapsed')
|
|
AND start_time < $3
|
|
AND end_time > $2
|
|
`, bookingID, *newStartTime, newEndTime).Scan(&overlapCount)
|
|
if err != nil {
|
|
log.Printf("Failed to check overlap: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if overlapCount > 0 {
|
|
http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Delete the edit request's reservation BEFORE checking time blockers.
|
|
// The reservation (RESERVATION:edit_request:*) was created by RequestEditHandler
|
|
// to temporarily hold the slot. If not removed first, it would show up as a
|
|
// blocker and prevent the approve from succeeding.
|
|
if _, delErr := tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description = $1
|
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); delErr != nil {
|
|
log.Printf("ALERT: failed to delete time_blocker: %v", delErr)
|
|
}
|
|
|
|
blockerOverlap, _, 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, "This edit would overlap with a time blocker", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Check working hours (admin gets warning)
|
|
weekday := int((newStartTime.Weekday() + 6) % 7)
|
|
bookingTime := newStartTime.Format("15:04:05")
|
|
daysToMonday := int(newStartTime.Weekday())
|
|
if daysToMonday == 0 {
|
|
daysToMonday = 7
|
|
}
|
|
weekStart := newStartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
|
|
|
var isClosed bool
|
|
err = tx.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)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if isClosed {
|
|
http.Error(w, "Cannot approve: the proposed time falls during a period when the salon is closed", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Build update query for bookings table
|
|
if newStartTime != nil || notes != nil {
|
|
// setClauses contains only hardcoded column name assignments ("start_time = $N", "notes = $N").
|
|
// Column names are never derived from user input. User values are in args and always parameterised.
|
|
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
|
|
}
|
|
_, err = tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete existing custom 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
|
|
}
|
|
|
|
// 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 (blocked on E5 SMTP)
|
|
|
|
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.Conn.QueryRow(r.Context(), `
|
|
SELECT booking_id FROM booking_edit_requests WHERE id = $1
|
|
`, requestID).Scan(&bookingID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.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.Conn.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
|
|
}
|
|
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description = $1
|
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
|
|
log.Printf("ALERT: failed to delete time_blocker: %v", err)
|
|
}
|
|
|
|
// 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 with option to cancel (blocked on E5 SMTP)
|
|
|
|
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)
|
|
}
|
|
|
|
// GetMyEditRequestHandler returns the pending edit request for a specific booking the user owns.
|
|
// GET /api/bookings/{id}/edit-request
|
|
func GetMyEditRequestHandler(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 ownerID string
|
|
err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.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
|
|
}
|
|
|
|
var editReq BookingEditRequest
|
|
var newServices []string
|
|
err = db.Conn.QueryRow(r.Context(), `
|
|
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
|
FROM booking_edit_requests
|
|
WHERE booking_id = $1 AND requested_by = $2
|
|
`, bookingID, userID).Scan(
|
|
&editReq.ID,
|
|
&editReq.BookingID,
|
|
&editReq.RequestedBy,
|
|
&editReq.NewStartTime,
|
|
&newServices,
|
|
&editReq.Notes,
|
|
&editReq.HasOverrides,
|
|
&editReq.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"edit_request": nil,
|
|
})
|
|
return
|
|
}
|
|
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
editReq.NewServices = newServices
|
|
|
|
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
|
if err != nil {
|
|
log.Printf("Failed to build enriched edit request: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"edit_request": enriched,
|
|
})
|
|
}
|
|
|
|
// GetMyEditRequestsHandler returns all pending edit requests for the current user across all bookings.
|
|
// GET /api/bookings/edit-requests
|
|
func GetMyEditRequestsHandler(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
|
|
}
|
|
|
|
rows, err := db.Conn.Query(r.Context(), `
|
|
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
|
FROM booking_edit_requests
|
|
WHERE requested_by = $1
|
|
ORDER BY updated_at DESC
|
|
`, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch edit requests for user %s: %v", userID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Collect all edit requests first to avoid nested queries on the same tx connection.
|
|
var editReqs []BookingEditRequest
|
|
for rows.Next() {
|
|
var editReq BookingEditRequest
|
|
var newServices []string
|
|
if err := rows.Scan(
|
|
&editReq.ID,
|
|
&editReq.BookingID,
|
|
&editReq.RequestedBy,
|
|
&editReq.NewStartTime,
|
|
&newServices,
|
|
&editReq.Notes,
|
|
&editReq.HasOverrides,
|
|
&editReq.UpdatedAt,
|
|
); err != nil {
|
|
log.Printf("Failed to scan edit request: %v", err)
|
|
continue
|
|
}
|
|
editReq.NewServices = newServices
|
|
editReqs = append(editReqs, editReq)
|
|
}
|
|
|
|
var enrichedRequests []*EnrichedEditRequest
|
|
for _, editReq := range editReqs {
|
|
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
|
if err != nil {
|
|
log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
|
|
continue
|
|
}
|
|
enrichedRequests = append(enrichedRequests, enriched)
|
|
}
|
|
|
|
if enrichedRequests == nil {
|
|
enrichedRequests = []*EnrichedEditRequest{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"edit_requests": enrichedRequests,
|
|
})
|
|
}
|
|
|
|
// AdminListAllEditRequestsHandler returns ALL pending edit requests across all bookings.
|
|
// GET /api/admin/bookings/edit-requests
|
|
func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := db.Conn.Query(r.Context(), `
|
|
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
|
FROM booking_edit_requests
|
|
ORDER BY updated_at DESC
|
|
`)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch all edit requests: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Collect all edit requests first to avoid nested queries on the same tx connection.
|
|
var editReqs []BookingEditRequest
|
|
for rows.Next() {
|
|
var editReq BookingEditRequest
|
|
var newServices []string
|
|
if err := rows.Scan(
|
|
&editReq.ID,
|
|
&editReq.BookingID,
|
|
&editReq.RequestedBy,
|
|
&editReq.NewStartTime,
|
|
&newServices,
|
|
&editReq.Notes,
|
|
&editReq.HasOverrides,
|
|
&editReq.UpdatedAt,
|
|
); err != nil {
|
|
log.Printf("Failed to scan edit request: %v", err)
|
|
continue
|
|
}
|
|
editReq.NewServices = newServices
|
|
editReqs = append(editReqs, editReq)
|
|
}
|
|
|
|
var enrichedRequests []*EnrichedEditRequest
|
|
for _, editReq := range editReqs {
|
|
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
|
if err != nil {
|
|
log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
|
|
continue
|
|
}
|
|
enrichedRequests = append(enrichedRequests, enriched)
|
|
}
|
|
|
|
if enrichedRequests == nil {
|
|
enrichedRequests = []*EnrichedEditRequest{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"edit_requests": enrichedRequests,
|
|
})
|
|
}
|
|
|
|
// AdminGetBookingEditRequestHandler returns the pending edit request for a specific booking.
|
|
// GET /api/admin/bookings/{id}/edit-request
|
|
func AdminGetBookingEditRequestHandler(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 editReq BookingEditRequest
|
|
var newServices []string
|
|
err := db.Conn.QueryRow(r.Context(), `
|
|
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
|
FROM booking_edit_requests
|
|
WHERE booking_id = $1
|
|
`, bookingID).Scan(
|
|
&editReq.ID,
|
|
&editReq.BookingID,
|
|
&editReq.RequestedBy,
|
|
&editReq.NewStartTime,
|
|
&newServices,
|
|
&editReq.Notes,
|
|
&editReq.HasOverrides,
|
|
&editReq.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "No edit request pending for this booking", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
editReq.NewServices = newServices
|
|
|
|
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
|
if err != nil {
|
|
log.Printf("Failed to build enriched edit request: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"edit_request": enriched,
|
|
})
|
|
}
|
|
|
|
// ========================================
|
|
// 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.Conn.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.Conn.Exec(ctx, `
|
|
UPDATE users SET deposits_required = 3 WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|