feat: enriched edit request system with side-by-side snapshots, calendar preloading, and admin review UI
Backend: - Add enriched response types (EditSnapshot, EnrichedEditRequest) with original vs proposed snapshots - Add 4 new GET endpoints for viewing edit requests (user and admin scoped) - Remove github.com/lib/pq dependency — use native PostgreSQL array scanning - Clean up edit requests, time blockers, and notifications on booking cancellation - Validate exceptional closed hours on admin approve (409 Conflict) - Notification upsert on edit request replace (no duplicate admin notifications) Frontend: - New user EditRequestModal with time/services/both modes and lunch protection - New admin EditRequestModal with side-by-side diff (date/time, services, notes) - Integrate edit requests into PendingApprovals card and notifications page - Preload 3 months of availability to prevent calendar snap-back - Apply lunch protection to isDateUnavailable in BookingFlow and BookingCreateModal - Fix accessibility: card list items use <button> instead of <div> Dev & Docs: - Seed edit requests in local-dev-2.sh - Update all Obsidian manuals with enriched edit request documentation - 42 new tests (438/441 passing)
This commit is contained in:
@@ -35,7 +35,6 @@ import (
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
@@ -1511,7 +1510,7 @@ func TestAdminBookings_ListEditRequests(t *testing.T) {
|
||||
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
bookingID, userID, time.Now().Add(time.Duration(i)*24*time.Hour),
|
||||
pq.Array(&emptyServices), fmt.Sprintf("Edit request %d", i), false)
|
||||
emptyServices, fmt.Sprintf("Edit request %d", i), false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create edit request %d: %v", i, err)
|
||||
}
|
||||
@@ -1714,7 +1713,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) {
|
||||
`INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes)
|
||||
VALUES ($1, $2, $3, $4, 'Please change time')
|
||||
RETURNING id`,
|
||||
bookingID, userID, newStartTime, pq.Array(&emptyServices)).Scan(&editRequestID)
|
||||
bookingID, userID, newStartTime, emptyServices).Scan(&editRequestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create edit request: %v", err)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
func resetTestData(t *testing.T) {
|
||||
@@ -2860,7 +2859,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
|
||||
t.Fatalf("failed to confirm booking: %v", err)
|
||||
}
|
||||
|
||||
// Create edit request directly in DB (need pq.Array for PostgreSQL array)
|
||||
// Create edit request directly in DB
|
||||
var editRequestID string
|
||||
newTime := time.Now().Add(24 * time.Hour).Truncate(time.Minute)
|
||||
var emptyServices []string
|
||||
@@ -2868,7 +2867,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
|
||||
`INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes)
|
||||
VALUES ($1, $2, $3, $4, 'Please change time')
|
||||
RETURNING id`,
|
||||
bookingID, userID, newTime, pq.Array(&emptyServices)).Scan(&editRequestID)
|
||||
bookingID, userID, newTime, emptyServices).Scan(&editRequestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create edit request: %v", err)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// UserCancelBookingHandler allows an authenticated user to cancel a booking they own.
|
||||
@@ -78,6 +77,16 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM booking_edit_requests WHERE booking_id = $1
|
||||
`, bookingID)
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM time_blockers WHERE description = $1
|
||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'
|
||||
`, bookingID)
|
||||
|
||||
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
|
||||
if originalStatus != "pending" {
|
||||
notificationQuery := `
|
||||
@@ -393,6 +402,8 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Don't fail the request, just log the error
|
||||
}
|
||||
|
||||
// TODO: Notify user that their edit request was superseded by admin direct edit (blocked on E5 SMTP)
|
||||
|
||||
// Return warnings if any
|
||||
if len(warnings) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -853,6 +864,208 @@ type BookingEditRequest struct {
|
||||
User *UserSummary `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// Enriched response types for edit request detail views
|
||||
type EditServiceDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
}
|
||||
|
||||
type EditSnapshot struct {
|
||||
StartTime *time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time"`
|
||||
Services []EditServiceDetail `json:"services"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
type EditUserSummary struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"full_name"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
}
|
||||
|
||||
type EnrichedEditRequest struct {
|
||||
ID string `json:"id"`
|
||||
BookingID string `json:"booking_id"`
|
||||
RequestedBy string `json:"requested_by"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
Notes *string `json:"notes"`
|
||||
Original *EditSnapshot `json:"original"`
|
||||
Proposed *EditSnapshot `json:"proposed"`
|
||||
User *EditUserSummary `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// buildEnrichedEditRequest builds a full enriched response from a pending edit request.
|
||||
// It queries the database for original booking details, services, and user info.
|
||||
func buildEnrichedEditRequest(ctx context.Context, editReq *BookingEditRequest) (*EnrichedEditRequest, error) {
|
||||
var bStartTime time.Time
|
||||
var bNotes *string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT start_time, notes FROM bookings WHERE id = $1
|
||||
`, editReq.BookingID).Scan(&bStartTime, &bNotes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get booking %s: %w", editReq.BookingID, err)
|
||||
}
|
||||
|
||||
origServices, err := queryBookingServicesWithDetails(ctx, editReq.BookingID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get booking services for %s: %w", editReq.BookingID, err)
|
||||
}
|
||||
|
||||
var proposedServices []EditServiceDetail
|
||||
if len(editReq.NewServices) > 0 && !editReq.HasOverrides {
|
||||
proposedServices, err = queryServiceDetailsByIDs(ctx, editReq.NewServices)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get service details: %w", err)
|
||||
}
|
||||
} else {
|
||||
proposedServices = origServices
|
||||
}
|
||||
|
||||
var proposedStartTime *time.Time
|
||||
if editReq.NewStartTime != nil {
|
||||
proposedStartTime = editReq.NewStartTime
|
||||
} else {
|
||||
proposedStartTime = &bStartTime
|
||||
}
|
||||
|
||||
var proposedNotes *string
|
||||
if editReq.Notes != nil {
|
||||
proposedNotes = editReq.Notes
|
||||
} else {
|
||||
proposedNotes = bNotes
|
||||
}
|
||||
|
||||
origDuration := sumServiceDurations(origServices)
|
||||
proposedDuration := sumServiceDurations(proposedServices)
|
||||
|
||||
origEndTime := bStartTime.Add(time.Duration(origDuration) * time.Minute)
|
||||
var proposedEndTime *time.Time
|
||||
if editReq.NewStartTime != nil {
|
||||
et := editReq.NewStartTime.Add(time.Duration(proposedDuration) * time.Minute)
|
||||
proposedEndTime = &et
|
||||
} else {
|
||||
proposedEndTime = &origEndTime
|
||||
}
|
||||
|
||||
// Non-fatal: still return the request without user details
|
||||
userSummary, err := queryUserSummary(ctx, editReq.RequestedBy)
|
||||
if err != nil {
|
||||
// Non-fatal: still return the request without user details
|
||||
log.Printf("Failed to get user summary for %s: %v", editReq.RequestedBy, err)
|
||||
}
|
||||
|
||||
result := &EnrichedEditRequest{
|
||||
ID: editReq.ID,
|
||||
BookingID: editReq.BookingID,
|
||||
RequestedBy: editReq.RequestedBy,
|
||||
RequestedAt: editReq.UpdatedAt,
|
||||
Notes: editReq.Notes,
|
||||
Original: &EditSnapshot{
|
||||
StartTime: &bStartTime,
|
||||
EndTime: &origEndTime,
|
||||
Services: origServices,
|
||||
Notes: bNotes,
|
||||
},
|
||||
Proposed: &EditSnapshot{
|
||||
StartTime: proposedStartTime,
|
||||
EndTime: proposedEndTime,
|
||||
Services: proposedServices,
|
||||
Notes: proposedNotes,
|
||||
},
|
||||
User: userSummary,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// queryBookingServicesWithDetails returns service details for a booking, respecting overrides.
|
||||
func queryBookingServicesWithDetails(ctx context.Context, bookingID string) ([]EditServiceDetail, error) {
|
||||
rows, err := db.DB.Query(ctx, `
|
||||
SELECT s.id, s.name,
|
||||
COALESCE(bs.override_price, s.price) as price,
|
||||
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = $1
|
||||
ORDER BY s.name
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []EditServiceDetail
|
||||
for rows.Next() {
|
||||
var svc EditServiceDetail
|
||||
if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services = append(services, svc)
|
||||
}
|
||||
if services == nil {
|
||||
services = []EditServiceDetail{}
|
||||
}
|
||||
return services, rows.Err()
|
||||
}
|
||||
|
||||
// queryServiceDetailsByIDs returns service details for the given service IDs.
|
||||
func queryServiceDetailsByIDs(ctx context.Context, serviceIDs []string) ([]EditServiceDetail, error) {
|
||||
if len(serviceIDs) == 0 {
|
||||
return []EditServiceDetail{}, nil
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(ctx, `
|
||||
SELECT id, name, price, duration_minutes
|
||||
FROM services
|
||||
WHERE id = ANY($1)
|
||||
ORDER BY name
|
||||
`, serviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []EditServiceDetail
|
||||
for rows.Next() {
|
||||
var svc EditServiceDetail
|
||||
if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services = append(services, svc)
|
||||
}
|
||||
if services == nil {
|
||||
services = []EditServiceDetail{}
|
||||
}
|
||||
return services, rows.Err()
|
||||
}
|
||||
|
||||
// sumServiceDurations returns the total duration in minutes from a slice of EditServiceDetail.
|
||||
func sumServiceDurations(services []EditServiceDetail) int {
|
||||
total := 0
|
||||
for _, s := range services {
|
||||
total += s.DurationMinutes
|
||||
}
|
||||
if total == 0 {
|
||||
return 60 // fallback
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// queryUserSummary fetches user details for the enriched edit request response.
|
||||
func queryUserSummary(ctx context.Context, userID string) (*EditUserSummary, error) {
|
||||
var summary EditUserSummary
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT id, fn, email, phone FROM users WHERE id = $1
|
||||
`, userID).Scan(&summary.ID, &summary.FullName, &summary.Email, &summary.Phone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// DeleteEditRequestHandler allows a user to delete/cancel their pending edit request
|
||||
func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
@@ -1039,12 +1252,12 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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, pq.Array(req.NewServices), req.Notes, false).Scan(
|
||||
`, bookingID, userID, req.NewStartTime, req.NewServices, req.Notes, false).Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.NewStartTime,
|
||||
pq.Array(&editReq.NewServices),
|
||||
&editReq.NewServices,
|
||||
&editReq.Notes,
|
||||
&editReq.HasOverrides,
|
||||
&editReq.UpdatedAt,
|
||||
@@ -1187,12 +1400,12 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
&req.ID,
|
||||
&req.BookingID,
|
||||
&req.RequestedBy,
|
||||
&req.NewStartTime,
|
||||
pq.Array(&newServices),
|
||||
&req.Notes,
|
||||
&req.HasOverrides,
|
||||
&req.UpdatedAt,
|
||||
&origStartTime,
|
||||
&req.NewStartTime,
|
||||
&newServices,
|
||||
&req.Notes,
|
||||
&req.HasOverrides,
|
||||
&req.UpdatedAt,
|
||||
&origStartTime,
|
||||
&bookingStatus,
|
||||
&userName,
|
||||
)
|
||||
@@ -1253,7 +1466,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT booking_id, new_start_time, new_services, notes, has_overrides
|
||||
FROM booking_edit_requests
|
||||
WHERE id = $1
|
||||
`, requestID).Scan(&bookingID, &newStartTime, pq.Array(&newServices), ¬es, &hasOverrides)
|
||||
`, requestID).Scan(&bookingID, &newStartTime, &newServices, ¬es, &hasOverrides)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "Edit request not found", http.StatusNotFound)
|
||||
@@ -1336,6 +1549,36 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, fmt.Sprintf("This edit would overlap with a time blocker: %s", blockerDesc), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Check working hours (admin gets warning)
|
||||
weekday := int((newStartTime.Weekday() + 6) % 7)
|
||||
bookingTime := newStartTime.Format("15:04:05")
|
||||
daysToMonday := int(newStartTime.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
weekStart := newStartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
||||
|
||||
var isClosed bool
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM exceptional_working_hours ewh
|
||||
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
|
||||
WHERE ega.week_start = $1
|
||||
AND ewh.weekday = $2
|
||||
AND ewh.is_open = false
|
||||
AND ewh.start_time <= $3
|
||||
AND ewh.end_time >= $3
|
||||
)
|
||||
`, weekStart, weekday, bookingTime).Scan(&isClosed)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check exceptional hours: %v", err)
|
||||
}
|
||||
|
||||
if isClosed {
|
||||
http.Error(w, "Cannot approve: the proposed time falls during a period when the salon is closed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build update query for bookings table
|
||||
@@ -1418,10 +1661,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Notify user that their edit request was approved.
|
||||
// Options: (a) INSERT into user_notifications table (needs schema), (b) send email via SMTP provider.
|
||||
// The user_notification_preferences table exists but no delivery mechanism is wired yet.
|
||||
// See: obsidian/Crussell/Future Work - Gap Backlog.md → E5 (Email/SMS notification system).
|
||||
// TODO: Notify user that their edit request was approved (blocked on E5 SMTP)
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit: %v", err)
|
||||
@@ -1492,8 +1732,7 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Notify user that their edit request was denied.
|
||||
// Same as approve TODO above — needs user_notifications table or email delivery (E5).
|
||||
// TODO: Notify user that their edit request was denied with option to cancel (blocked on E5 SMTP)
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit reject edit request: %v", err)
|
||||
@@ -1504,6 +1743,238 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetMyEditRequestHandler returns the pending edit request for a specific booking the user owns.
|
||||
// GET /api/bookings/{id}/edit-request
|
||||
func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
err := db.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
|
||||
}
|
||||
|
||||
var editReq BookingEditRequest
|
||||
var newServices []string
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
||||
FROM booking_edit_requests
|
||||
WHERE booking_id = $1 AND requested_by = $2
|
||||
`, bookingID, userID).Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.NewStartTime,
|
||||
&newServices,
|
||||
&editReq.Notes,
|
||||
&editReq.HasOverrides,
|
||||
&editReq.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "No edit request pending for this booking", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
editReq.NewServices = newServices
|
||||
|
||||
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to build enriched edit request: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"edit_request": enriched,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyEditRequestsHandler returns all pending edit requests for the current user across all bookings.
|
||||
// GET /api/bookings/edit-requests
|
||||
func GetMyEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(r.Context(), `
|
||||
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
||||
FROM booking_edit_requests
|
||||
WHERE requested_by = $1
|
||||
ORDER BY updated_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch edit requests for user %s: %v", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var enrichedRequests []*EnrichedEditRequest
|
||||
for rows.Next() {
|
||||
var editReq BookingEditRequest
|
||||
var newServices []string
|
||||
if err := rows.Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.NewStartTime,
|
||||
&newServices,
|
||||
&editReq.Notes,
|
||||
&editReq.HasOverrides,
|
||||
&editReq.UpdatedAt,
|
||||
); err != nil {
|
||||
log.Printf("Failed to scan edit request: %v", err)
|
||||
continue
|
||||
}
|
||||
editReq.NewServices = newServices
|
||||
|
||||
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
|
||||
continue
|
||||
}
|
||||
enrichedRequests = append(enrichedRequests, enriched)
|
||||
}
|
||||
|
||||
if enrichedRequests == nil {
|
||||
enrichedRequests = []*EnrichedEditRequest{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"edit_requests": enrichedRequests,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminListAllEditRequestsHandler returns ALL pending edit requests across all bookings.
|
||||
// GET /api/admin/bookings/edit-requests
|
||||
func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := db.DB.Query(r.Context(), `
|
||||
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
||||
FROM booking_edit_requests
|
||||
ORDER BY updated_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch all edit requests: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var enrichedRequests []*EnrichedEditRequest
|
||||
for rows.Next() {
|
||||
var editReq BookingEditRequest
|
||||
var newServices []string
|
||||
if err := rows.Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.NewStartTime,
|
||||
&newServices,
|
||||
&editReq.Notes,
|
||||
&editReq.HasOverrides,
|
||||
&editReq.UpdatedAt,
|
||||
); err != nil {
|
||||
log.Printf("Failed to scan edit request: %v", err)
|
||||
continue
|
||||
}
|
||||
editReq.NewServices = newServices
|
||||
|
||||
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
|
||||
continue
|
||||
}
|
||||
enrichedRequests = append(enrichedRequests, enriched)
|
||||
}
|
||||
|
||||
if enrichedRequests == nil {
|
||||
enrichedRequests = []*EnrichedEditRequest{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"edit_requests": enrichedRequests,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminGetBookingEditRequestHandler returns the pending edit request for a specific booking.
|
||||
// GET /api/admin/bookings/{id}/edit-request
|
||||
func AdminGetBookingEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var editReq BookingEditRequest
|
||||
var newServices []string
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
||||
FROM booking_edit_requests
|
||||
WHERE booking_id = $1
|
||||
`, bookingID).Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.NewStartTime,
|
||||
&newServices,
|
||||
&editReq.Notes,
|
||||
&editReq.HasOverrides,
|
||||
&editReq.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "No edit request pending for this booking", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
editReq.NewServices = newServices
|
||||
|
||||
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to build enriched edit request: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"edit_request": enriched,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// NO-SHOW HELPER FUNCTIONS
|
||||
// ========================================
|
||||
|
||||
Reference in New Issue
Block a user