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:
2026-05-26 11:59:07 +01:00
parent 3ccc017716
commit 8574bf2221
19 changed files with 5270 additions and 599 deletions
+2 -3
View File
@@ -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
+487 -16
View File
@@ -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), &notes, &hasOverrides)
`, requestID).Scan(&bookingID, &newStartTime, &newServices, &notes, &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
// ========================================