Refactor patch test system and add booking edit requests
- Replace patch_test_duration_hours on services with separate patch_tests table - Add user_patch_tests table to track user patch test records - Add booking edit request system: users can request time changes - Add admin handlers to list, approve, and reject edit requests - Add validation to prevent editing completed/cancelled bookings - Add overlap and closed-day checks for booking edits
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -239,7 +240,9 @@ func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// AdminEditBookingHandler allows an admin to modify the start time of any booking.
|
||||
// It validates the new start time and returns 404 if the booking does not exist.
|
||||
// Admin can edit any booking EXCEPT completed or cancelled bookings.
|
||||
// Admin can create/edit bookings outside working hours (with warning).
|
||||
// Admin can create/edit bookings that overlap with existing bookings (with warning).
|
||||
func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||||
@@ -258,6 +261,98 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if booking exists and is not completed/cancelled
|
||||
var currentStatus string
|
||||
err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Block edits on completed or cancelled bookings
|
||||
if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
|
||||
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Get booking duration for overlap check
|
||||
var durationMinutes int
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = $1
|
||||
`, bookingID).Scan(&durationMinutes)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
|
||||
durationMinutes = 60 // fallback
|
||||
}
|
||||
|
||||
// Check for overlapping bookings (excluding the current booking)
|
||||
var overlapCount int
|
||||
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*) FROM bookings
|
||||
WHERE id != $1
|
||||
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
|
||||
AND start_time < $3
|
||||
AND start_time + (INTERVAL '1 minute' * (
|
||||
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = bookings.id
|
||||
)) > $2
|
||||
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check overlap %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
// Check if salon is closed (exceptional hours) - admin gets warning but can proceed
|
||||
weekday := int(req.StartTime.Weekday())
|
||||
bookingTime := req.StartTime.Format("15:04:05")
|
||||
daysToMonday := weekday
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
||||
|
||||
// Check if salon is closed (exceptional hours)
|
||||
var isClosed bool
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM exceptional_working_hours ewh
|
||||
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
|
||||
WHERE ega.week_start = $1
|
||||
AND ewh.weekday = $2
|
||||
AND ewh.is_open = false
|
||||
AND ewh.start_time <= $3
|
||||
AND ewh.end_time >= $3
|
||||
)
|
||||
`, weekStart, weekday, bookingTime).Scan(&isClosed)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check exceptional hours: %v", err)
|
||||
}
|
||||
|
||||
isOutsideWorkingHours := isClosed
|
||||
|
||||
// Prevent overlap - block admin
|
||||
if overlapCount > 0 {
|
||||
http.Error(w, "This booking overlaps with an existing booking", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Build warning for outside working hours (admin can proceed with warning)
|
||||
var warnings []string
|
||||
if isOutsideWorkingHours {
|
||||
warnings = append(warnings, "Warning: This booking is outside standard working hours")
|
||||
}
|
||||
|
||||
// Perform the update
|
||||
res, err := db.DB.Exec(r.Context(), `
|
||||
UPDATE bookings
|
||||
SET start_time = $1, updated_at = $2
|
||||
@@ -274,6 +369,26 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any pending edit requests for this booking (admin edit takes priority)
|
||||
_, err = db.DB.Exec(r.Context(), `
|
||||
DELETE FROM booking_edit_requests
|
||||
WHERE booking_id = $1 AND status = 'pending'
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to clear edit requests for booking %s: %v", bookingID, err)
|
||||
// Don't fail the request, just log the error
|
||||
}
|
||||
|
||||
// Return warnings if any
|
||||
if len(warnings) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Booking updated",
|
||||
"warnings": warnings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -331,8 +446,13 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Check if booking time falls within a closed exceptional hours period
|
||||
bookingDate := req.StartTime.Truncate(24 * time.Hour)
|
||||
// Calculate the Monday of the week containing the booking date
|
||||
weekday := int(req.StartTime.Weekday())
|
||||
daysToMonday := weekday
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7 // Sunday -> next Monday
|
||||
}
|
||||
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
||||
bookingTime := req.StartTime.Format("15:04:05")
|
||||
|
||||
// Check if there's an exceptional hours entry that makes this time unavailable
|
||||
@@ -348,7 +468,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
AND ewh.start_time <= $3
|
||||
AND ewh.end_time >= $3
|
||||
)
|
||||
`, bookingDate, weekday, bookingTime).Scan(&isClosed)
|
||||
`, 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)
|
||||
@@ -498,3 +618,299 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
RequestedStartTime time.Time `json:"requested_start_time"`
|
||||
Status string `json:"status"`
|
||||
AdminNotes *string `json:"admin_notes,omitempty"`
|
||||
RequestedBy string `json:"requested_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Joined fields
|
||||
Booking *Booking `json:"booking,omitempty"`
|
||||
User *UserSummary `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// RequestEditHandler allows a user to request an edit to their booking's start time
|
||||
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 {
|
||||
RequestedStartTime time.Time `json:"requested_start_time" validate:"required"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user owns this booking
|
||||
var ownerID string
|
||||
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ownerID != userID {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Check booking is not already completed/cancelled
|
||||
var currentStatus string
|
||||
err = db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
|
||||
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Create edit request
|
||||
var editReq BookingEditRequest
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
INSERT INTO booking_edit_requests (booking_id, requested_start_time, requested_by)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, booking_id, requested_start_time, status, requested_by, created_at, updated_at
|
||||
`, bookingID, req.RequestedStartTime, userID).Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedStartTime,
|
||||
&editReq.Status,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.CreatedAt,
|
||||
&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
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(editReq)
|
||||
}
|
||||
|
||||
// AdminListEditRequestsHandler returns all pending edit requests
|
||||
func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
status := query.Get("status")
|
||||
|
||||
baseQuery := `
|
||||
SELECT ber.id, ber.booking_id, ber.requested_start_time, ber.status,
|
||||
ber.admin_notes, ber.requested_by, ber.created_at, ber.updated_at,
|
||||
b.start_time as original_start_time, b.status as booking_status,
|
||||
u.fn as user_name
|
||||
FROM booking_edit_requests ber
|
||||
JOIN bookings b ON ber.booking_id = b.id
|
||||
JOIN users u ON ber.requested_by = u.id
|
||||
`
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM booking_edit_requests ber`
|
||||
|
||||
var args []interface{}
|
||||
paramCount := 1
|
||||
|
||||
if status != "" {
|
||||
baseQuery += fmt.Sprintf(" WHERE ber.status = $%d", paramCount)
|
||||
countQuery += fmt.Sprintf(" WHERE ber.status = $%d", paramCount)
|
||||
args = append(args, status)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
baseQuery += " ORDER BY ber.created_at DESC"
|
||||
|
||||
// Get total count
|
||||
var total int
|
||||
err := db.DB.QueryRow(r.Context(), countQuery, args...).Scan(&total)
|
||||
if err != nil {
|
||||
log.Printf("Failed to count edit requests: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch edit requests: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var requests []BookingEditRequest
|
||||
for rows.Next() {
|
||||
var req BookingEditRequest
|
||||
var origStartTime time.Time
|
||||
var bookingStatus string
|
||||
var userName string
|
||||
|
||||
err := rows.Scan(
|
||||
&req.ID,
|
||||
&req.BookingID,
|
||||
&req.RequestedStartTime,
|
||||
&req.Status,
|
||||
&req.AdminNotes,
|
||||
&req.RequestedBy,
|
||||
&req.CreatedAt,
|
||||
&req.UpdatedAt,
|
||||
&origStartTime,
|
||||
&bookingStatus,
|
||||
&userName,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to scan edit request: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var req struct {
|
||||
AdminNotes *string `json:"admin_notes,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to start transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
// Get the edit request
|
||||
var bookingID string
|
||||
var newStartTime time.Time
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
SELECT booking_id, requested_start_time FROM booking_edit_requests
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
`, requestID).Scan(&bookingID, &newStartTime)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "Edit request not found or already processed", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get edit request %s: %v", requestID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the booking
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
UPDATE bookings SET start_time = $1, updated_at = NOW() WHERE id = $2
|
||||
`, newStartTime, bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark request as approved
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
UPDATE booking_edit_requests
|
||||
SET status = 'approved', admin_notes = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`, req.AdminNotes, requestID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update edit request %s: %v", requestID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
var req struct {
|
||||
AdminNotes string `json:"admin_notes" validate:"required"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := db.DB.Exec(r.Context(), `
|
||||
UPDATE booking_edit_requests
|
||||
SET status = 'rejected', admin_notes = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND status = 'pending'
|
||||
`, req.AdminNotes, requestID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to reject edit request %s: %v", requestID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user