feat(backend): update bookings core handlers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"crussell/handlers/notifications"
|
"crussell/handlers/notifications"
|
||||||
"crussell/internal/validators"
|
"crussell/handlers/payments"
|
||||||
"crussell/handlers/scheduling"
|
"crussell/handlers/scheduling"
|
||||||
|
"crussell/internal/validators"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -77,15 +78,21 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = tx.Exec(r.Context(), `
|
if _, err := tx.Exec(r.Context(), `
|
||||||
DELETE FROM booking_edit_requests WHERE booking_id = $1
|
DELETE FROM booking_edit_requests WHERE booking_id = $1
|
||||||
`, bookingID)
|
`, bookingID); err != nil {
|
||||||
_, _ = tx.Exec(r.Context(), `
|
log.Printf("ALERT: failed to delete edit requests: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(r.Context(), `
|
||||||
DELETE FROM time_blockers WHERE description = $1
|
DELETE FROM time_blockers WHERE description = $1
|
||||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
|
||||||
_, _ = tx.Exec(r.Context(), `
|
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'
|
DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'
|
||||||
`, bookingID)
|
`, 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)
|
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
|
||||||
if originalStatus != "pending" {
|
if originalStatus != "pending" {
|
||||||
@@ -110,8 +117,11 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCancelBookingHandler allows an admin to cancel any booking.
|
type AdminCancelBookingRequest struct {
|
||||||
// The update uses a status filter and checks RowsAffected for existence.
|
ForgiveFees *bool `json:"forgive_fees,omitempty"`
|
||||||
|
ForgiveNoShow *bool `json:"forgive_noshow,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
bookingID := chi.URLParam(r, "id")
|
bookingID := chi.URLParam(r, "id")
|
||||||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||||||
@@ -119,6 +129,51 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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.DB.Begin(r.Context())
|
tx, err := db.DB.Begin(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to start transaction: %v", err)
|
log.Printf("Failed to start transaction: %v", err)
|
||||||
@@ -127,9 +182,10 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(r.Context())
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
// Get current status before updating
|
// Get current status and user ID before updating
|
||||||
var originalStatus string
|
var originalStatus string
|
||||||
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus)
|
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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
||||||
@@ -156,6 +212,16 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Acknowledge pending notification if exists
|
||||||
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
|
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -176,12 +242,37 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
log.Printf("Failed to commit admin cancel: %v", err)
|
log.Printf("Failed to commit admin cancel: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,15 +406,19 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = db.DB.QueryRow(r.Context(), `
|
err = db.DB.QueryRow(r.Context(), `
|
||||||
SELECT COUNT(*) FROM bookings
|
SELECT COUNT(*) FROM bookings
|
||||||
WHERE id != $1
|
WHERE id != $1
|
||||||
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
|
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
|
||||||
AND start_time < $3
|
AND start_time < $3
|
||||||
AND start_time + (INTERVAL '1 minute' * (
|
AND start_time + (INTERVAL '1 minute' * (
|
||||||
SELECT COALESCE(SUM(dur), 60) FROM (
|
SELECT COALESCE(SUM(dur), 60) FROM (
|
||||||
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
|
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
|
||||||
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id
|
FROM booking_services bs
|
||||||
|
JOIN services s ON bs.service_id = s.id
|
||||||
|
WHERE bs.booking_id = bookings.id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
SELECT 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 = bookings.id
|
FROM booking_custom_services bcs
|
||||||
|
JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
||||||
|
WHERE bcs.booking_id = bookings.id
|
||||||
) sub
|
) sub
|
||||||
)) > $2
|
)) > $2
|
||||||
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount)
|
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount)
|
||||||
@@ -382,7 +477,15 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform the update
|
// Perform the update
|
||||||
res, err := db.DB.Exec(r.Context(), `
|
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())
|
||||||
|
|
||||||
|
res, err := tx.Exec(r.Context(), `
|
||||||
UPDATE bookings
|
UPDATE bookings
|
||||||
SET start_time = $1, updated_at = $2
|
SET start_time = $1, updated_at = $2
|
||||||
WHERE id = $3
|
WHERE id = $3
|
||||||
@@ -399,7 +502,7 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear any pending edit requests for this booking (admin edit takes priority)
|
// Clear any pending edit requests for this booking (admin edit takes priority)
|
||||||
_, err = db.DB.Exec(r.Context(), `
|
_, err = tx.Exec(r.Context(), `
|
||||||
DELETE FROM booking_edit_requests
|
DELETE FROM booking_edit_requests
|
||||||
WHERE booking_id = $1
|
WHERE booking_id = $1
|
||||||
`, bookingID)
|
`, bookingID)
|
||||||
@@ -408,6 +511,12 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Don't fail the request, just log the error
|
// Don't fail the request, just log the error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
log.Printf("Failed to commit transaction: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Notify user that their edit request was superseded by admin direct edit (blocked on E5 SMTP)
|
// TODO: Notify user that their edit request was superseded by admin direct edit (blocked on E5 SMTP)
|
||||||
|
|
||||||
// Return warnings if any
|
// Return warnings if any
|
||||||
@@ -531,44 +640,85 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check patch test requirements for all regular services (custom services skip patch tests)
|
// Check patch test requirements for all regular services (custom services skip patch tests)
|
||||||
for _, serviceID := range req.ServiceIDs {
|
if len(req.ServiceIDs) > 0 {
|
||||||
// Find patch test for this service
|
patchTestRows, err := db.DB.Query(r.Context(), `
|
||||||
var patchTestID string
|
SELECT id, service_ids, notice_duration_hours, expiry_months
|
||||||
var noticeHours int
|
|
||||||
err := db.DB.QueryRow(r.Context(), `
|
|
||||||
SELECT id, notice_duration_hours
|
|
||||||
FROM patch_tests
|
FROM patch_tests
|
||||||
WHERE $1 = ANY(service_ids)
|
WHERE service_ids::text[] && $1::text[]
|
||||||
`, serviceID).Scan(&patchTestID, ¬iceHours)
|
`, req.ServiceIDs)
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
// No valid patch test record
|
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.DB.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)
|
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if notice period has passed
|
eligibleFrom := testedAt.Add(time.Duration(pt.noticeHours) * time.Hour)
|
||||||
eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
|
|
||||||
if req.StartTime.Before(eligibleFrom) {
|
if req.StartTime.Before(eligibleFrom) {
|
||||||
hoursNeeded := time.Until(eligibleFrom).Hours()
|
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)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if patch test has expired
|
if pt.expiryMonths > 0 {
|
||||||
var expiryMonths int
|
expiresAt := testedAt.AddDate(0, pt.expiryMonths, 0)
|
||||||
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) {
|
if req.StartTime.After(expiresAt) {
|
||||||
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
|
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -687,9 +837,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
) combined
|
) combined
|
||||||
`, allIDs).Scan(&dur)
|
`, allIDs).Scan(&dur)
|
||||||
newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute)
|
newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute)
|
||||||
|
|
||||||
var cnt int
|
var cnt int
|
||||||
db.DB.QueryRow(r.Context(), `
|
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(dur),60) FROM (SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id UNION ALL SELECT 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=bookings.id) sub)) > $1
|
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(dur),60) FROM (SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id UNION ALL SELECT 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=bookings.id) sub)) > $1
|
||||||
`, req.StartTime, newEnd).Scan(&cnt)
|
`, req.StartTime, newEnd).Scan(&cnt)
|
||||||
if cnt > 0 {
|
if cnt > 0 {
|
||||||
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
|
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
|
||||||
@@ -702,7 +853,6 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("Failed to check time blocker overlap: %v", err)
|
log.Printf("Failed to check time blocker overlap: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
tx, err := db.DB.Begin(r.Context())
|
tx, err := db.DB.Begin(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to start transaction: %v", err)
|
log.Printf("Failed to start transaction: %v", err)
|
||||||
@@ -711,6 +861,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(r.Context())
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEnd); evictErr != nil {
|
||||||
|
log.Printf("Failed to evict pending_release bookings (admin create): %v", evictErr)
|
||||||
|
}
|
||||||
|
|
||||||
// Create booking directly as confirmed
|
// Create booking directly as confirmed
|
||||||
bookingQuery := `
|
bookingQuery := `
|
||||||
INSERT INTO bookings (
|
INSERT INTO bookings (
|
||||||
@@ -754,35 +908,35 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert booking services
|
// Insert booking services
|
||||||
serviceInsertQuery := `
|
if len(req.ServiceIDs) > 0 {
|
||||||
|
_, err = tx.Exec(r.Context(), `
|
||||||
INSERT INTO booking_services (booking_id, service_id)
|
INSERT INTO booking_services (booking_id, service_id)
|
||||||
VALUES ($1, $2)
|
SELECT $1, unnest($2::text[])
|
||||||
`
|
`, booking.ID, req.ServiceIDs)
|
||||||
|
|
||||||
for _, serviceID := range req.ServiceIDs {
|
|
||||||
_, err := tx.Exec(r.Context(), serviceInsertQuery, booking.ID, serviceID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to insert booking service %s: %v", serviceID, err)
|
log.Printf("Failed to insert booking services: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customServiceInsertQuery := `
|
if len(req.CustomServiceIDs) > 0 {
|
||||||
|
_, err = tx.Exec(r.Context(), `
|
||||||
INSERT INTO booking_custom_services (booking_id, custom_service_id)
|
INSERT INTO booking_custom_services (booking_id, custom_service_id)
|
||||||
VALUES ($1, $2)
|
SELECT $1, unnest($2::text[])
|
||||||
`
|
`, booking.ID, req.CustomServiceIDs)
|
||||||
|
|
||||||
for _, csID := range req.CustomServiceIDs {
|
|
||||||
_, err := tx.Exec(r.Context(), customServiceInsertQuery, booking.ID, csID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to insert custom booking service %s: %v", csID, err)
|
log.Printf("Failed to insert custom booking services: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = tx.Exec(r.Context(), `
|
for _, csID := range req.CustomServiceIDs {
|
||||||
|
if _, err := tx.Exec(r.Context(), `
|
||||||
UPDATE custom_services SET usage_count = usage_count + 1, last_used_at = NOW() WHERE id = $1
|
UPDATE custom_services SET usage_count = usage_count + 1, last_used_at = NOW() WHERE id = $1
|
||||||
`, csID)
|
`, csID); err != nil {
|
||||||
|
log.Printf("ALERT: failed to update custom service usage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply overrides (optional)
|
// Apply overrides (optional)
|
||||||
@@ -910,16 +1064,13 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
|
|
||||||
"booking": booking,
|
"booking": booking,
|
||||||
|
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
@@ -1220,10 +1371,12 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = tx.Exec(r.Context(), `
|
if _, err := tx.Exec(r.Context(), `
|
||||||
DELETE FROM time_blockers
|
DELETE FROM time_blockers
|
||||||
WHERE description = $1
|
WHERE description = $1
|
||||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
`, 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
|
// Delete the admin notification for this edit request
|
||||||
_, err = tx.Exec(r.Context(), `
|
_, err = tx.Exec(r.Context(), `
|
||||||
@@ -1269,6 +1422,14 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// 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 {
|
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)
|
http.Error(w, "At least one of new_start_time, new_services, or notes is required", http.StatusBadRequest)
|
||||||
@@ -1295,7 +1456,9 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Check booking is not already completed/cancelled
|
// Check booking is not already completed/cancelled
|
||||||
var currentStatus string
|
var currentStatus string
|
||||||
err = db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus)
|
var currentStartTime time.Time
|
||||||
|
var depositRequired bool
|
||||||
|
err = db.DB.QueryRow(r.Context(), "SELECT status, start_time, deposit_required FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, ¤tStartTime, &depositRequired)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -1306,6 +1469,33 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
|
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Query payment and timing info (used for validation AND auto-approval later)
|
||||||
|
var hasPayments bool
|
||||||
|
hoursUntilCurrent := currentStartTime.Sub(time.Now()).Hours()
|
||||||
|
db.DB.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.DB.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 {
|
if len(req.NewServices) > 0 {
|
||||||
var overrideCount int
|
var overrideCount int
|
||||||
err = db.DB.QueryRow(r.Context(), `
|
err = db.DB.QueryRow(r.Context(), `
|
||||||
@@ -1369,11 +1559,121 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if req.NewStartTime != nil {
|
||||||
_, _ = tx.Exec(r.Context(), `
|
// Calculate duration for the new time
|
||||||
|
var durMinutes 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(&durMinutes)
|
||||||
|
} else {
|
||||||
|
_ = tx.QueryRow(r.Context(), `
|
||||||
|
SELECT COALESCE(SUM(dur), 60) FROM (
|
||||||
|
SELECT COALESCE(bs.override_duration_minutes, 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 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
|
||||||
|
) sub
|
||||||
|
`, bookingID).Scan(&durMinutes)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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 start_time + (INTERVAL '1 minute' * (
|
||||||
|
SELECT COALESCE(SUM(dur), 60) FROM (
|
||||||
|
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
|
||||||
|
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id
|
||||||
|
UNION ALL
|
||||||
|
SELECT 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 = bookings.id
|
||||||
|
) sub
|
||||||
|
)) > $2
|
||||||
|
`, bookingID, *req.NewStartTime, newEnd).Scan(&overlapCount)
|
||||||
|
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
|
DELETE FROM time_blockers
|
||||||
WHERE description = $1
|
WHERE description = $1
|
||||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
|
||||||
|
log.Printf("ALERT: failed to delete time_blocker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
var durationMinutes int
|
var durationMinutes int
|
||||||
if len(req.NewServices) > 0 {
|
if len(req.NewServices) > 0 {
|
||||||
@@ -1470,7 +1770,8 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// AdminListEditRequestsHandler returns all edit requests
|
// AdminListEditRequestsHandler returns all edit requests
|
||||||
func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
baseQuery := `
|
baseQuery := `
|
||||||
SELECT ber.id, ber.booking_id, ber.requested_by, ber.new_start_time,
|
SELECT
|
||||||
|
ber.id, ber.booking_id, ber.requested_by, ber.new_start_time,
|
||||||
ber.new_services, ber.notes, ber.has_overrides, ber.updated_at,
|
ber.new_services, ber.notes, ber.has_overrides, ber.updated_at,
|
||||||
b.start_time as original_start_time, b.status as booking_status,
|
b.start_time as original_start_time, b.status as booking_status,
|
||||||
u.fn as user_name
|
u.fn as user_name
|
||||||
@@ -1479,20 +1780,14 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
JOIN users u ON ber.requested_by = u.id
|
JOIN users u ON ber.requested_by = u.id
|
||||||
`
|
`
|
||||||
|
|
||||||
countQuery := `SELECT COUNT(*) FROM booking_edit_requests ber`
|
|
||||||
|
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
baseQuery += " ORDER BY ber.updated_at DESC"
|
baseQuery += " ORDER BY ber.updated_at DESC"
|
||||||
|
|
||||||
// Get total count
|
|
||||||
var total int
|
var total int
|
||||||
err := db.DB.QueryRow(r.Context(), countQuery, args...).Scan(&total)
|
|
||||||
if err != nil {
|
// Count query (no ORDER BY needed).
|
||||||
log.Printf("Failed to count edit requests: %v", err)
|
db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
|
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1597,6 +1892,13 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for applied discounts (admin warning only — discounts remain locked in)
|
||||||
|
var discountCount int
|
||||||
|
db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount)
|
||||||
|
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
|
// Calculate duration for overlap check - use overrides if has_overrides is true
|
||||||
var durationMinutes int
|
var durationMinutes int
|
||||||
if hasOverrides {
|
if hasOverrides {
|
||||||
@@ -1644,19 +1946,15 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = tx.QueryRow(r.Context(), `
|
err = tx.QueryRow(r.Context(), `
|
||||||
SELECT COUNT(*) FROM bookings
|
SELECT COUNT(*) FROM bookings
|
||||||
WHERE id != $1
|
WHERE id != $1
|
||||||
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
|
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
|
||||||
AND start_time < $3
|
AND start_time < $3
|
||||||
AND start_time + (INTERVAL '1 minute' * (
|
AND start_time + (INTERVAL '1 minute' * (
|
||||||
SELECT COALESCE(SUM(dur), 60) FROM (
|
SELECT COALESCE(SUM(dur), 60) FROM (
|
||||||
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
|
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
|
||||||
FROM booking_services bs
|
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id
|
||||||
JOIN services s ON bs.service_id = s.id
|
|
||||||
WHERE bs.booking_id = bookings.id
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
||||||
FROM booking_custom_services bcs
|
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bookings.id
|
||||||
JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
||||||
WHERE bcs.booking_id = bookings.id
|
|
||||||
) sub
|
) sub
|
||||||
)) > $2
|
)) > $2
|
||||||
`, bookingID, *newStartTime, newEndTime).Scan(&overlapCount)
|
`, bookingID, *newStartTime, newEndTime).Scan(&overlapCount)
|
||||||
@@ -1668,12 +1966,12 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime)
|
blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to check time blocker overlap: %v", err)
|
log.Printf("Failed to check time blocker overlap: %v", err)
|
||||||
}
|
}
|
||||||
if blockerOverlap {
|
if blockerOverlap {
|
||||||
http.Error(w, fmt.Sprintf("This edit would overlap with a time blocker: %s", blockerDesc), http.StatusConflict)
|
http.Error(w, "This edit would overlap with a time blocker", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1779,10 +2077,12 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = tx.Exec(r.Context(), `
|
if _, err := tx.Exec(r.Context(), `
|
||||||
DELETE FROM time_blockers
|
DELETE FROM time_blockers
|
||||||
WHERE description = $1
|
WHERE description = $1
|
||||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
`, 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
|
// Acknowledge the admin notification for this edit request
|
||||||
_, err = tx.Exec(r.Context(), `
|
_, err = tx.Exec(r.Context(), `
|
||||||
@@ -1850,10 +2150,12 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = tx.Exec(r.Context(), `
|
if _, err := tx.Exec(r.Context(), `
|
||||||
DELETE FROM time_blockers
|
DELETE FROM time_blockers
|
||||||
WHERE description = $1
|
WHERE description = $1
|
||||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
`, 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
|
// Acknowledge the admin notification for this edit request
|
||||||
_, err = tx.Exec(r.Context(), `
|
_, err = tx.Exec(r.Context(), `
|
||||||
@@ -1928,7 +2230,11 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
http.Error(w, "No edit request pending for this booking", http.StatusNotFound)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"edit_request": nil,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
|
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
|
||||||
@@ -2150,5 +2456,3 @@ func ApplyDepositsIfNeeded(ctx context.Context, userID string) (bool, error) {
|
|||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user