Files
Crussell/backend/handlers/bookings/manage.go
T
popertots c6fe9e92a7 refactor: migrate patch test schema from service-level to dedicated tables
- Remove patch_test_duration_hours from services table
- Add new patch_tests table with service_ids array, notice_duration_hours, expiry_months
- Add new user_patch_tests table linking users to patch_tests with tested_at
- Update services handler to check patch_tests.service_ids for eligibility
- Update booking creation to validate patch test requirements (24h notice, 6mo expiry)
- Update booking completion to extend patch test validity (reset tested_at)
- Update admin handlers for new patch test CRUD operations
- Update test fixtures and test cases for new schema
- Update seeding script to create patch_tests and link to gel services
2026-02-24 22:17:50 +00:00

978 lines
29 KiB
Go

package bookings
import (
"crussell/db"
"crussell/handlers/notifications"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// UserCancelBookingHandler allows an authenticated user to cancel a booking they own.
// The update is performed in a transaction with notification handling.
func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Get current status before updating
var originalStatus string
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not cancellable", http.StatusNotFound)
return
}
log.Printf("Failed to get booking status %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
res, err := tx.Exec(r.Context(), `
UPDATE bookings
SET status = 'client_cancelled', updated_at = $1
WHERE id = $2 AND user_id = $3 AND status IN ('pending', 'confirmed', 'in_progress')
`, time.Now(), bookingID, userID)
if err != nil {
log.Printf("Failed to cancel booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rowsAffected := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Booking not cancellable", http.StatusNotFound)
return
}
// Acknowledge pending notification if exists
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
if originalStatus != "pending" {
notificationQuery := `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`
_, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
if err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit user cancel: %v, %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// AdminCancelBookingHandler allows an admin to cancel any booking.
// The update uses a status filter and checks RowsAffected for existence.
func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Get current status before updating
var originalStatus string
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not cancellable", http.StatusNotFound)
return
}
log.Printf("Failed to get booking status %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
res, err := tx.Exec(r.Context(), `
UPDATE bookings
SET status = 'we_cancelled', updated_at = $1
WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress')
`, time.Now(), bookingID)
if err != nil {
log.Printf("Failed to admin cancel booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rowsAffected := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Booking not cancellable", http.StatusNotFound)
return
}
// Acknowledge pending notification if exists
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
if originalStatus != "pending" {
notificationQuery := `
INSERT INTO admin_notifications (reason, booking_id, user_id)
SELECT 'cancelled_booking', $1, user_id FROM bookings WHERE id = $1
`
_, err = tx.Exec(r.Context(), notificationQuery, bookingID)
if err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit admin cancel: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// AdminListPendingBookingsHandler returns all bookings with status `pending` by delegating to the existing admin list handler.
func AdminListPendingBookingsHandler(w http.ResponseWriter, r *http.Request) {
r = r.Clone(r.Context())
q := r.URL.Query()
q.Set("status", "pending")
r.URL.RawQuery = q.Encode()
GetAllAdminBookingsHandler(w, r)
}
// AdminGetInProgressBookingHandler returns the booking that is currently in progress.
// It joins the bookings table with users to populate the UserSummary in the returned Booking.
func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
var b Booking
var userID, fullName string
err := db.DB.QueryRow(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
b.notes,
b.created_at,
b.updated_at,
b.created_by,
u.id,
u.fn
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.status = 'in_progress'
ORDER BY b.start_time
LIMIT 1
`).Scan(
&b.ID,
&b.StartTime,
&b.Status,
&b.Notes,
&b.CreatedAt,
&b.UpdatedAt,
&b.CreatedBy,
&userID,
&fullName,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "No in-progress booking found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch in-progress booking: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Populate the UserSummary field
b.User = &UserSummary{
ID: userID,
FullName: fullName,
FirstName: "", // not available here
LastName: "", // not available here
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(b); err != nil {
log.Printf("Failed to encode booking response: %v", err)
}
}
// AdminEditBookingHandler allows an admin to modify the start time of any booking.
// Admin can edit any booking EXCEPT completed or cancelled bookings.
// Admin can create/edit bookings outside working hours (with warning).
// Admin can create/edit bookings that overlap with existing bookings (with warning).
func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
var req EditBookingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Basic validation: ensure the new time is not in the past
if time.Now().After(req.StartTime) {
http.Error(w, "Start time must be in the future", http.StatusBadRequest)
return
}
// Check if booking exists and is not completed/cancelled
var currentStatus string
err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus)
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
WHERE id = $3
`, req.StartTime, time.Now(), bookingID)
if err != nil {
log.Printf("Failed to edit booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rowsAffected := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
// Clear any pending edit requests for this booking (admin edit takes priority)
_, err = db.DB.Exec(r.Context(), `
DELETE FROM booking_edit_requests
WHERE booking_id = $1 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)
}
type AdminCreateBookingForUserRequest struct {
UserID string `json:"user_id" validate:"required"`
StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff
}
func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// Admin identity (creator)
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req AdminCreateBookingForUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Basic validation
if req.UserID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
if req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
// Check patch test requirements for all services
for _, serviceID := range req.ServiceIDs {
// Find patch test for this service
var patchTestID string
var noticeHours int
err := db.DB.QueryRow(r.Context(), `
SELECT id, notice_duration_hours
FROM patch_tests
WHERE $1 = ANY(service_ids)
`, serviceID).Scan(&patchTestID, &noticeHours)
if err == nil {
// Service requires a patch test - check if user has valid record
var testedAt time.Time
err = db.DB.QueryRow(r.Context(), `
SELECT tested_at
FROM user_patch_tests
WHERE user_id = $1 AND patch_test_id = $2
`, req.UserID, patchTestID).Scan(&testedAt)
if err != nil {
// No valid patch test record
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
return
}
// Check if notice period has passed
eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
if req.StartTime.Before(eligibleFrom) {
hoursNeeded := time.Until(eligibleFrom).Hours()
http.Error(w, fmt.Sprintf("Booking time is before the %.0f hour notice period after patch test. Earliest booking: %s", hoursNeeded, eligibleFrom.Format("2006-01-02 15:04")), http.StatusBadRequest)
return
}
// Check if patch test has expired
var expiryMonths int
err = db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths)
if err == nil {
expiresAt := testedAt.AddDate(0, expiryMonths, 0)
if req.StartTime.After(expiresAt) {
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
return
}
}
}
}
// Validate overrides
if req.UserID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
if req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
// Validate overrides
for _, override := range req.ServiceOverrides {
if override.ServiceID == "" {
http.Error(w, "Service ID is required for overrides", http.StatusBadRequest)
return
}
if override.OverridePrice != nil && *override.OverridePrice < 0 {
http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
return
}
if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
http.Error(w, "Override duration must be positive", http.StatusBadRequest)
return
}
}
// Check if booking time falls within a closed exceptional hours period
// Calculate the Monday of the week containing the booking date
weekday := int(req.StartTime.Weekday())
daysToMonday := weekday
if daysToMonday == 0 {
daysToMonday = 7 // Sunday -> next Monday
}
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
bookingTime := req.StartTime.Format("15:04:05")
// Check if there's an exceptional hours entry that makes this time unavailable
var isClosed bool
var checkErr error
checkErr = db.DB.QueryRow(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM exceptional_working_hours ewh
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
WHERE ega.week_start = $1
AND ewh.weekday = $2
AND ewh.is_open = false
AND ewh.start_time <= $3
AND ewh.end_time >= $3
)
`, weekStart, weekday, bookingTime).Scan(&isClosed)
if checkErr != nil {
log.Printf("Failed to check exceptional hours: %v", checkErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if isClosed {
http.Error(w, "Cannot book during holiday hours when the salon is closed", http.StatusConflict)
return
}
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Create booking directly as confirmed
bookingQuery := `
INSERT INTO bookings (
user_id,
start_time,
status,
notes,
created_by
)
VALUES ($1, $2, 'confirmed', $3, $4)
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
`
var booking Booking
booking.User = &UserSummary{}
err = tx.QueryRow(
r.Context(),
bookingQuery,
req.UserID,
req.StartTime,
req.Notes,
adminID,
).Scan(
&booking.ID,
&booking.User.ID,
&booking.StartTime,
&booking.Status,
&booking.Notes,
&booking.CreatedAt,
&booking.UpdatedAt,
&booking.CreatedBy,
)
if err != nil {
log.Printf("Failed to create admin booking: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Insert booking services
serviceInsertQuery := `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`
for _, serviceID := range req.ServiceIDs {
_, err := tx.Exec(r.Context(), serviceInsertQuery, booking.ID, serviceID)
if err != nil {
log.Printf("Failed to insert booking service %s: %v", serviceID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Apply overrides (optional)
if len(req.ServiceOverrides) > 0 {
// Ensure overrides only reference services in this booking
serviceCheckQuery := `
SELECT COUNT(*) FROM booking_services
WHERE booking_id = $1 AND service_id = ANY($2)
`
overrideServiceIDs := make([]string, len(req.ServiceOverrides))
for i, o := range req.ServiceOverrides {
overrideServiceIDs[i] = o.ServiceID
}
var count int
err = tx.QueryRow(
r.Context(),
serviceCheckQuery,
booking.ID,
overrideServiceIDs,
).Scan(&count)
if err != nil {
log.Printf("Failed to verify service overrides: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if count != len(req.ServiceOverrides) {
http.Error(w, "One or more service overrides do not belong to this booking", http.StatusBadRequest)
return
}
overrideUpdateQuery := `
UPDATE booking_services
SET override_price = $1,
override_duration_minutes = $2
WHERE booking_id = $3 AND service_id = $4
`
for _, override := range req.ServiceOverrides {
_, err := tx.Exec(
r.Context(),
overrideUpdateQuery,
override.OverridePrice,
override.OverrideDurationMinutes,
booking.ID,
override.ServiceID,
)
if err != nil {
log.Printf(
"Failed to apply override (booking %s, service %s): %v",
booking.ID,
override.ServiceID,
err,
)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit admin booking creation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// =============================================================================
// 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(&currentStatus)
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)
}