partial edit fixes

This commit is contained in:
2026-02-26 20:03:11 +00:00
parent 4fc84e6d39
commit 72124bac98
3 changed files with 438 additions and 103 deletions
+318 -86
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -372,12 +373,8 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
// 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'
WHERE booking_id = $1
`, bookingID)
if err != nil {
log.Printf("Failed to clear edit requests for booking %s: %v", bookingID, err)
// Don't fail the request, just log the error
}
// Return warnings if any
if len(warnings) > 0 {
@@ -685,20 +682,72 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// 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"`
ID string `json:"id"`
BookingID string `json:"booking_id"`
RequestedBy string `json:"requested_by"`
NewStartTime *time.Time `json:"new_start_time,omitempty"`
NewServices []string `json:"new_services"`
Notes *string `json:"notes,omitempty"`
HasOverrides bool `json:"has_overrides"`
UpdatedAt time.Time `json:"updated_at"`
// Joined fields
Booking *Booking `json:"booking,omitempty"`
Booking *Booking `json:"booking,omitempty"`
User *UserSummary `json:"user,omitempty"`
}
// DeleteEditRequestHandler allows a user to delete/cancel their pending edit request
func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
// RequestEditHandler allows a user to request an edit to their booking's start time
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
// Verify user owns this booking
var ownerID string
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if ownerID != userID {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Delete the edit request for this booking
res, err := db.DB.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
}
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) {
@@ -713,13 +762,21 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
}
var req struct {
RequestedStartTime time.Time `json:"requested_start_time" validate:"required"`
NewStartTime *time.Time `json:"new_start_time,omitempty"`
NewServices []string `json:"new_services"`
Notes *string `json:"notes,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate: at least one of new_start_time, new_services, or notes must be provided
if req.NewStartTime == nil && len(req.NewServices) == 0 && req.Notes == nil {
http.Error(w, "At least one of new_start_time, new_services, or notes is required", http.StatusBadRequest)
return
}
// Verify user owns this booking
var ownerID string
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
@@ -751,20 +808,56 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
return
}
if len(req.NewServices) > 0 {
var overrideCount int
err = db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM booking_services
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
`, bookingID).Scan(&overrideCount)
if err != nil {
log.Printf("Failed to check overrides for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if overrideCount > 0 {
http.Error(w, "Cannot change services on a booking that has overrides. Please contact the salon.", http.StatusForbidden)
return
}
}
// Create edit request
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Delete any existing edit request for this booking (upsert behavior)
_, err = tx.Exec(r.Context(), `
DELETE FROM booking_edit_requests
WHERE booking_id = $1 AND requested_by = $2
`, bookingID, userID)
if err != nil {
log.Printf("Failed to delete existing edit request for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Create edit request - has_overrides is false since user can't override
var editReq BookingEditRequest
err = 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(
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.RequestedStartTime,
&editReq.Status,
&editReq.RequestedBy,
&editReq.CreatedAt,
&editReq.NewStartTime,
&editReq.NewServices,
&editReq.Notes,
&editReq.HasOverrides,
&editReq.UpdatedAt,
)
if err != nil {
@@ -773,39 +866,67 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Create admin notification with reason 'edit_request'
_, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`, "edit_request", 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 status is 'pending', acknowledge existing pending_booking notification and create new one
if currentStatus == "pending" {
// Acknowledge existing pending_booking notification
_, 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)
}
// Create new pending_booking notification (admin will see the edit request)
_, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`, "pending_booking", 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 pending edit requests
// AdminListEditRequestsHandler returns all 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,
SELECT ber.id, ber.booking_id, ber.requested_by, ber.new_start_time,
ber.new_services, ber.notes, ber.has_overrides, ber.updated_at,
b.start_time as original_start_time, b.status as booking_status,
u.fn as user_name
FROM booking_edit_requests ber
JOIN bookings b ON ber.booking_id = b.id
JOIN users u ON ber.requested_by = u.id
`
countQuery := `SELECT COUNT(*) FROM booking_edit_requests ber`
var args []interface{}
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"
baseQuery += " ORDER BY ber.updated_at DESC"
// Get total count
var total int
@@ -830,15 +951,16 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
var origStartTime time.Time
var bookingStatus string
var userName string
var newServicesJSON []byte
err := rows.Scan(
&req.ID,
&req.BookingID,
&req.RequestedStartTime,
&req.Status,
&req.AdminNotes,
&req.RequestedBy,
&req.CreatedAt,
&req.NewStartTime,
&newServicesJSON,
&req.Notes,
&req.HasOverrides,
&req.UpdatedAt,
&origStartTime,
&bookingStatus,
@@ -849,6 +971,13 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
continue
}
// Unmarshal JSON services
if len(newServicesJSON) > 0 {
if err := json.Unmarshal(newServicesJSON, &req.NewServices); err != nil {
log.Printf("Failed to unmarshal new_services: %v", err)
}
}
req.Booking = &Booking{
ID: req.BookingID,
StartTime: origStartTime,
@@ -881,14 +1010,6 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
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)
@@ -899,14 +1020,18 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
// Get the edit request
var bookingID string
var newStartTime time.Time
var newStartTime *time.Time
var newServicesJSON []byte
var notes *string
var hasOverrides bool
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)
SELECT booking_id, new_start_time, new_services, notes, has_overrides
FROM booking_edit_requests
WHERE id = $1
`, requestID).Scan(&bookingID, &newStartTime, &newServicesJSON, &notes, &hasOverrides)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Edit request not found or already processed", http.StatusNotFound)
http.Error(w, "Edit request not found", http.StatusNotFound)
return
}
log.Printf("Failed to get edit request %s: %v", requestID, err)
@@ -914,24 +1039,140 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
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)
// Parse new_services
var newServices []string
if len(newServicesJSON) > 0 {
if err := json.Unmarshal(newServicesJSON, &newServices); err != nil {
log.Printf("Failed to unmarshal new_services: %v", 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
}
// 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)
// Calculate duration for overlap check - use overrides if has_overrides is true
var durationMinutes int
if hasOverrides {
// Use the existing booking_services with overrides
err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, bookingID).Scan(&durationMinutes)
} else {
// Use standard durations or new_services if provided
if len(newServices) > 0 {
// Use new services to calculate duration
err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM services s
WHERE s.id = ANY($1)
`, newServices).Scan(&durationMinutes)
} else {
// Use existing booking services
err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, bookingID).Scan(&durationMinutes)
}
}
if err != nil {
log.Printf("Failed to update edit request %s: %v", requestID, err)
log.Printf("Failed to calculate duration: %v", err)
durationMinutes = 60 // fallback
}
// Check for overlapping bookings if start time is being changed
if newStartTime != nil {
newEndTime := newStartTime.Add(time.Duration(durationMinutes) * time.Minute)
var overlapCount int
err = tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE id != $1
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
)) > $2
`, bookingID, *newStartTime, newEndTime).Scan(&overlapCount)
if err != nil {
log.Printf("Failed to check overlap: %v", err)
}
if overlapCount > 0 {
http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict)
return
}
}
// Build update query for bookings table
if newStartTime != nil || notes != nil {
var setClauses []string
var args []interface{}
argNum := 1
if newStartTime != nil {
setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum))
args = append(args, *newStartTime)
argNum++
}
if notes != nil {
setClauses = append(setClauses, fmt.Sprintf("notes = $%d", argNum))
args = append(args, *notes)
argNum++
}
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
args = append(args, time.Now())
argNum++
args = append(args, bookingID)
query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum)
_, err = tx.Exec(r.Context(), query, args...)
if err != nil {
log.Printf("Failed to update booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Handle new_services (only if has_overrides is false)
if len(newServices) > 0 && !hasOverrides {
// Delete existing booking_services
_, err = tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID)
if err != nil {
log.Printf("Failed to delete existing services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Insert new services
for _, serviceID := range newServices {
_, err = tx.Exec(r.Context(), `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
log.Printf("Failed to insert booking service %s: %v", serviceID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
}
// Delete the edit request row (not update status)
_, err = tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", requestID)
if err != nil {
log.Printf("Failed to delete edit request %s: %v", requestID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
@@ -945,7 +1186,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// AdminRejectEditRequestHandler rejects an edit request
// AdminRejectEditRequestHandler rejects an edit request by deleting it (deny without notification)
func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
requestID := chi.URLParam(r, "request_id")
if requestID == "" || !validators.IsValidID(requestID) {
@@ -953,19 +1194,10 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
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)
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)
+7
View File
@@ -158,6 +158,9 @@ func main() {
r.Get("/{id}/calendar", bookings.GetBookingCalendarHandler)
r.Put("/{id}", bookings.EditBookingHandler)
r.Delete("/{id}", bookings.DeleteBookingHandler)
// Edit request endpoints
r.Post("/{id}/edit-request", bookings.RequestEditHandler)
r.Delete("/{id}/edit-request", bookings.DeleteEditRequestHandler)
})
})
@@ -182,6 +185,10 @@ func main() {
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
r.Post("/{id}/cancel", bookings.CancelBookingHandler)
// Edit request endpoints
r.Get("/{id}/edit-requests", bookings.AdminListEditRequestsHandler)
r.Post("/{id}/edit-requests/{request_id}/approve", bookings.AdminApproveEditRequestHandler)
r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler)
})
r.Route("/admin/users", func(r chi.Router) {