{#each notifications as n (n.id)}
+ {@const actionable = !n.acknowledged_at && (hasAction(n.reason) === 'approve' || hasAction(n.reason) === 'edit_approve')}
@@ -370,6 +438,8 @@
Approve Booking
{:else if hasAction(n.reason) === 'see_user'}
See User
+ {:else if hasAction(n.reason) === 'edit_approve'}
+ Review Change
{:else}
See Booking
{/if}
@@ -425,3 +495,12 @@
{#if showUserModal && selectedUserId}
{/if}
+
+{#if showEditRequestModal && selectedEditRequest}
+
+{/if}
diff --git a/local-dev-2.sh b/local-dev-2.sh
index 2e28c4d..23b7ec2 100755
--- a/local-dev-2.sh
+++ b/local-dev-2.sh
@@ -1007,6 +1007,81 @@ if api_post "$BASE_URL/scheduling/exceptional-groups" "$XMAS_BREAK" "Christmas
echo "${C_GREEN}✅ Created $sched_success/3 Exceptional Schedule Groups${C_RESET}"
+# ===========================================================================
+# 7b. EDIT REQUESTS (for testing the edit request UI)
+# ===========================================================================
+echo -e "\n${C_BLUE}✏️ Creating Edit Requests...${C_RESET}"
+edit_req_count=0
+
+# Create edit requests via the user API for upcoming confirmed bookings
+# Use a wider time window to find more bookings (any future confirmed booking)
+CONFIRMED_BOOKINGS=$(docker exec postgres psql -U myuser -d mydb -tAc \
+ "SELECT b.id, b.user_id, b.start_time FROM bookings b
+ WHERE b.status = 'confirmed' AND b.start_time > NOW()
+ ORDER BY b.start_time ASC LIMIT 8;" 2>/dev/null)
+
+if [[ -n "$CONFIRMED_BOOKINGS" ]]; then
+ req_idx=0
+ while IFS='|' read -r booking_id user_id start_time; do
+ [[ -z "$booking_id" ]] && continue
+ # Get user token
+ user_email=$(docker exec postgres psql -U myuser -d mydb -tAc \
+ "SELECT email FROM users WHERE id = '$user_id'" 2>/dev/null | tr -d '\r\t ')
+ [[ -z "$user_email" ]] && continue
+ user_tok=$(login "$user_email" "password")
+ [[ -z "$user_tok" ]] && continue
+
+ # Alternate between time-only and service+time requests
+ if (( req_idx % 3 == 0 )); then
+ # Time-only request
+ new_time=$(TZ=Europe/London date -d "$start_time +2 hours" +"%Y-%m-%dT%H:%M:%S%:z" 2>/dev/null)
+ [[ -z "$new_time" ]] && continue
+ resp=$(curl -s -w "\n%{http_code}" -X POST \
+ -H 'Content-Type: application/json' \
+ -H "Authorization: Bearer $user_tok" \
+ -d "{\"new_start_time\":\"$new_time\",\"notes\":\"Would like to move this appointment 2 hours later please\"}" \
+ "$BASE_URL/bookings/$booking_id/edit-request")
+ elif (( req_idx % 3 == 1 )); then
+ # Service change request (add nail art)
+ resp=$(curl -s -w "\n%{http_code}" -X POST \
+ -H 'Content-Type: application/json' \
+ -H "Authorization: Bearer $user_tok" \
+ -d "{\"new_services\":[\"$(get_svc 0)\",\"$(get_svc 5)\"],\"notes\":\"Would like to add nail art to my appointment\"}" \
+ "$BASE_URL/bookings/$booking_id/edit-request")
+ else
+ # Both time and services
+ new_time=$(TZ=Europe/London date -d "$start_time -1 hours" +"%Y-%m-%dT%H:%M:%S%:z" 2>/dev/null)
+ [[ -z "$new_time" ]] && continue
+ resp=$(curl -s -w "\n%{http_code}" -X POST \
+ -H 'Content-Type: application/json' \
+ -H "Authorization: Bearer $user_tok" \
+ -d "{\"new_start_time\":\"$new_time\",\"new_services\":[\"$(get_svc 1)\"],\"notes\":\"Need to reschedule earlier and switch to gel\"}" \
+ "$BASE_URL/bookings/$booking_id/edit-request")
+ fi
+ code=$(echo "$resp" | tail -n1)
+ if [[ "$code" =~ ^2 ]]; then
+ edit_req_count=$((edit_req_count+1))
+ fi
+ req_idx=$((req_idx+1))
+ done <<< "$CONFIRMED_BOOKINGS"
+fi
+
+echo "${C_GREEN}✅ Created $edit_req_count Edit Requests${C_RESET}"
+
+# ===========================================================================
+# 7c. MORE TIME BLOCKERS (for variety)
+# ===========================================================================
+echo -e "\n${C_BLUE}🚫 Creating Additional Time Blockers...${C_RESET}"
+extra_blockers=0
+
+tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +3 days" +%Y-%m-%d)")" "$SLOT_B")" 90 "Equipment maintenance"
+tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +10 days" +%Y-%m-%d)")" "$SLOT_C")" 60 "Training session"
+tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +14 days" +%Y-%m-%d)")" "09:00:00")" 60 "Opening delay"
+tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +5 days" +%Y-%m-%d)")" "$SLOT_D")" 45 "Supplier visit"
+tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +8 days" +%Y-%m-%d)")" "$SLOT_A")" 120 "Deep clean — morning closed"
+
+echo "${C_GREEN}✅ Created $extra_blockers Additional Time Blockers${C_RESET}"
+
# ===========================================================================
# SUMMARY
# ===========================================================================
@@ -1032,7 +1107,8 @@ echo -e " Confirmed : $confirmed_count | Still pending: $skipped_cou
echo -e " Bookings — guest : $count_guest"
echo -e " Bookings — w/ notes: $USER_NOTE_COUNT (pending notifications)"
echo -e " Payments : $payment_count completed bookings"
-echo -e " Time blockers : $count_blockers"
+echo -e " Time blockers : $((count_blockers + extra_blockers))"
+echo -e " Edit requests : $edit_req_count"
echo -e " Schedule groups : $sched_success/3"
echo ""
echo -e " Quick login creds (all pass: ${C_YELLOW}password${C_RESET})"
diff --git a/obsidian/Crussell/Admin Manual.md b/obsidian/Crussell/Admin Manual.md
index 15c39d0..11935fd 100644
--- a/obsidian/Crussell/Admin Manual.md
+++ b/obsidian/Crussell/Admin Manual.md
@@ -541,6 +541,12 @@ The request appears in the **Pending Approvals** section on the Today page. You'
- Any notes they've added
- Any services they want to change
+The system shows a **side-by-side comparison** of the original booking versus the proposed changes:
+- **Original snapshot**: current start time, end time, services (with prices and durations), and notes
+- **Proposed snapshot**: the new start time, recalculated end time, updated services, and new notes
+
+This lets you see exactly what will change before you approve or decline.
+
### What You Can Do
**Approve** — The booking is updated to the new time and services. The customer's request is cleared.
@@ -551,12 +557,17 @@ The request appears in the **Pending Approvals** section on the Today page. You'
- The new time doesn't clash with another appointment
- The new time falls within your working hours
+- **The new time doesn't fall during a holiday/closed period** — the system will block approval if the proposed time is during exceptional closed hours
- If the customer is changing services, the new total duration fits in the slot
### Can the Customer Withdraw Their Request?
Yes — a customer can cancel their own reschedule request at any time before you've reviewed it.
+### What Happens When a Booking is Cancelled
+
+If a customer cancels their booking entirely, any pending reschedule request for that booking is automatically removed, along with the associated time block and notification.
+
---
## The Deposit System (Admin View)
diff --git a/obsidian/Crussell/Overview.md b/obsidian/Crussell/Overview.md
index 5085fe9..03203b0 100644
--- a/obsidian/Crussell/Overview.md
+++ b/obsidian/Crussell/Overview.md
@@ -97,6 +97,7 @@ flowchart TD
- Anonymous reservation cap (50 per 10-minute rolling window)
- Auto-status transitions: confirmed → in_progress → completed
- Booking edit requests (customers can request reschedule, admin approves/denies)
+- **Enriched edit requests**: side-by-side original vs proposed snapshots with service details, end-time calculation, and user info
- Admin booking service editing with overlap detection and price/duration overrides
- Idempotency keys for booking deduplication
@@ -195,12 +196,12 @@ All flows integrate with holiday/exceptional hours and time blockers.
## Test Coverage
-**396/399 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments.
+**438/441 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, cash/gift card payments, and enriched edit request workflows.
| Package | Coverage Area |
|---------|--------------|
| `handlers/auth` | Authentication (login, register, refresh, verification) |
-| `handlers/bookings` | User booking flow, guest bookings, reservations, edit requests, discounts, closing hours validation, active booking limits |
+| `handlers/bookings` | User booking flow, guest bookings, reservations, edit requests (create/delete/view/enriched), approval/rejection, time-blocker lifecycle, exceptional hours validation, cancellation cleanup, cross-user isolation |
| `handlers/payments` | Square payments (terminal, online, refunds, tips, saved cards) |
| `internal/square` | Square client interface, dev mock, prod stub |
| `handlers/admin` | Admin bookings, today view, users, services |
diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md
index 2c53cf8..cf4d852 100644
--- a/obsidian/Crussell/Technical Manual.md
+++ b/obsidian/Crussell/Technical Manual.md
@@ -209,6 +209,8 @@ src/lib/components/
| DELETE | `/api/bookings/{id}` | Cancel booking (with forgiveness option) |
| POST | `/api/bookings/{id}/edit-request` | Request booking reschedule |
| DELETE | `/api/bookings/{id}/edit-request` | Cancel edit request |
+| GET | `/api/bookings/{id}/edit-request` | View own pending edit request (enriched) |
+| GET | `/api/bookings/edit-requests` | List all own pending edit requests (enriched) |
| POST | `/api/bookings/{id}/payment` | Create online payment (deposit, full, partial, balance) |
| POST | `/api/bookings/{id}/tip` | Add tip to completed booking |
| GET | `/api/bookings/{id}/payment-summary` | Get payment summary for booking |
@@ -235,7 +237,9 @@ src/lib/components/
| POST | `/api/admin/bookings/{id}/confirm` | Confirm booking |
| POST | `/api/admin/bookings/{id}/cancel` | Cancel booking |
| POST | `/api/admin/bookings/reserve` | Reserve slot (walkin=5min, callin=1h) |
-| GET | `/api/admin/bookings/{id}/edit-requests` | List edit requests |
+| GET | `/api/admin/bookings/{id}/edit-requests` | List edit requests (paginated, with total) |
+| GET | `/api/admin/bookings/edit-requests` | List ALL edit requests across all bookings (enriched) |
+| GET | `/api/admin/bookings/{id}/edit-request` | View pending edit request for specific booking (enriched) |
| POST | `/api/admin/bookings/{id}/edit-requests/{request_id}/approve` | Approve edit request |
| POST | `/api/admin/bookings/{id}/edit-requests/{request_id}/deny` | Deny edit request |
| GET | `/api/admin/users` | List users |
@@ -516,6 +520,58 @@ Users manage their preferred notification channels via `/account` → Admin tab
- `GET /api/user/notification-preferences` — Returns `{emailEnabled, smsEnabled, browserPushEnabled}`. Defaults to all `true` if no row exists.
- `PUT /api/user/notification-preferences` — Accepts partial updates (only provided fields change, unset fields retain current value). Upserts on first call.
+### Enriched Edit Request System
+
+**How it works:** When a user requests a booking edit (time change, notes, or services), the system creates a `booking_edit_requests` row and returns an **enriched response** with side-by-side `original` and `proposed` snapshots. Each snapshot includes start/end times, full service details (name, price, duration), and notes.
+
+**Enriched Response Types:**
+
+```go
+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 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"`
+}
+```
+
+**End-time calculation:** `end_time = start_time + sum(service durations)`. If total duration is 0, falls back to 60 minutes.
+
+**`has_overrides` branch:** When a booking has override prices/durations on its services, the `proposed` snapshot uses the original booking services (not the `new_services` array) since service changes are blocked for overridden bookings.
+
+**New endpoints:**
+
+| Endpoint | Auth | Response |
+|----------|------|----------|
+| `GET /api/bookings/{id}/edit-request` | User (owner only) | `{"edit_request": EnrichedEditRequest}` |
+| `GET /api/bookings/edit-requests` | User (own only) | `{"edit_requests": [EnrichedEditRequest]}` |
+| `GET /api/admin/bookings/edit-requests` | Admin | `{"edit_requests": [EnrichedEditRequest]}` |
+| `GET /api/admin/bookings/{id}/edit-request` | Admin | `{"edit_request": EnrichedEditRequest}` |
+
+**Cancellation cleanup:** When a user cancels their booking (`UserCancelBookingHandler`), any pending edit request, associated `RESERVATION:edit_request` time_blocker, and `edit_requested` admin_notification are all deleted.
+
+**Notification upsert:** When a user submits a second edit request (upsert), the old `edit_requested` notification is deleted and a fresh one is created — admins see a single refreshed notification with an updated timestamp, never duplicates.
+
+**Exceptional hours validation:** When admin approves an edit request, the proposed time is checked against `exceptional_working_hours`. If the time falls during a closed period, approval is rejected with 409 Conflict.
+
### Loyalty & Discount System
**Loyalty Stamps:**
@@ -640,7 +696,7 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection
### Test Coverage
-**396/399 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments.
+**438/441 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments.
- `handlers/auth` — Authentication
- `handlers/bookings` — User booking flow, guest bookings, reservations, edit requests, discounts, closing hours validation, active booking limits
- `handlers/payments` — Square payments (terminal, online, refunds, tips, saved cards)
diff --git a/obsidian/Crussell/User Manual.md b/obsidian/Crussell/User Manual.md
index ff334ba..ad95f4f 100644
--- a/obsidian/Crussell/User Manual.md
+++ b/obsidian/Crussell/User Manual.md
@@ -234,19 +234,23 @@ If you need to change your appointment time:
1. Go to your **Account** page and find the booking
2. Select **Reschedule**
3. Pick a new date and time (the same availability rules apply — the new slot must be open)
-4. Submit your reschedule request
+4. Add any notes about the change (optional)
+5. Submit your reschedule request
**What happens next:**
- Your request goes to the salon for review
+- The salon sees a side-by-side comparison of your original booking versus the proposed changes
- The salon can either **approve** or **decline** it
- If approved, your appointment time is updated to the new slot
- If declined, your original appointment time stays the same
- You can cancel your reschedule request at any time before the salon reviews it
+- You can view all your pending reschedule requests from your account
**Things to know:**
- You can't reschedule a completed or cancelled appointment
- The new time must not clash with any of your other existing appointments
- If the salon has already adjusted the price or duration of your booking, those adjustments are respected in the reschedule
+- If you cancel your booking entirely, any pending reschedule request is automatically removed
---