Compare commits

...
3 Commits
Author SHA1 Message Date
popertotsandSisyphus 1c1fc6a921 docs: update README and obsidian docs with reservation improvements
CI / Go vulnerabilities (push) Successful in 34s
CI / Tests (push) Successful in 1m20s
CI / Frontend lint & types (push) Successful in 1m42s
CI / Race detector (push) Successful in 3m33s
Document self-blocking prevention (excludeUserID), explicit reservation cancellation endpoint, background cleanup goroutine, and edit_request reservation scrubbing. Bump test counts from 1,169 to 1,180 and package count from 19 to 20. Add race detector command to README.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-05 20:26:47 +01:00
popertotsandSisyphus be22710f7b test: add scheduling excludeUserID integration test
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-05 20:26:40 +01:00
popertotsandSisyphus 2e0760f083 feat: add IP-based anon reservation cleanup to admin reserve handler
Extend AdminReserveSlotHandler's pre-overlap DELETE to also clean up anonymous RESERVATION:anon entries matching the admin's IP address. This handles the edge case where an admin previously reserved a slot without authentication. Also reorganise imports to follow goimports conventions.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-05 20:26:33 +01:00
8 changed files with 274 additions and 15 deletions
+3 -2
View File
@@ -4,7 +4,7 @@ Nail salon booking platform — Go 1.25 backend + SvelteKit 5 SPA + PostgreSQL 1
## Features
**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). Guest accounts with GDPR-compliant anonymization. Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation extracted into a reusable `closing_time` helper.
**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user's reservation. **Background cleanup**: 5-minute goroutine clears expired reservations. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation extracted into a reusable `closing_time` helper.
**Payments**: Square Terminal (in-person) + Web Payments SDK (online). Cash with change calculation. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A PostgreSQL `pg_advisory_lock` serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases now insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record.
@@ -76,7 +76,8 @@ Default logins (password: `password`):
```bash
cd backend && go build -o bin/backend ./main.go
cd frontend && npm ci && npm run build
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 1,169 tests, 0 failures, 4 skipped (~14s)
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 1,180 tests, 0 failures, 4 skipped (~14s)
cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min)
cd backend && go test -tags "test,dev" -count=10 -parallel 8 ./... # thorough verification (~2-3min)
```
+17 -5
View File
@@ -2,15 +2,17 @@ package bookings
import (
"context"
"crussell/db"
"crypto/md5"
"crussell/clock"
"github.com/jackc/pgx/v5"
"crussell/db"
"crussell/handlers/scheduling"
"crussell/mw"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"log"
"net"
"net/http"
"time"
)
@@ -140,11 +142,21 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
// using db.Conn.Exec so the delete is visible to the separate connection
// used by CheckTimeBlockerOverlap. The in-transaction DELETE is kept
// as a safety net for the insert-phase.
// Also clean up anonymous reservations matching this admin's IP
// (edge case: admin previously reserved without authentication).
ip := r.Header.Get("CF-Connecting-IP")
if ip == "" {
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
if ip == "" {
ip = r.RemoteAddr
}
}
ipHash := fmt.Sprintf("%x", md5.Sum([]byte(ip)))[:8]
if _, delErr := db.Conn.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description LIKE 'RESERVATION:admin:%'
AND created_by = $1
`, adminID); delErr != nil {
WHERE (description LIKE 'RESERVATION:admin:%' AND created_by = $1)
OR (description LIKE 'RESERVATION:anon:' || $2 || ':%')
`, adminID, ipHash); delErr != nil {
log.Printf("Failed to delete existing admin reservation: %v", delErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
+201
View File
@@ -1997,3 +1997,204 @@ func TestReserveSlot_CleansUpAnonReservation(t *testing.T) {
t.Errorf("expected anonymous reservation to be cleaned up by pre-overlap DELETE, got %d remaining", remaining)
}
}
// TestEditBooking_DoesNotSelfBlock_OwnReservation verifies that a user's own
// RESERVATION does not block EditBookingHandler — the excludeUserID parameter
// prevents the reservation from appearing as a time blocker conflict.
func TestEditBooking_DoesNotSelfBlock_OwnReservation(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
dur := durationMinutes(t, ctx, tx, serviceID)
token := jwt.GenerateUserToken(userID)
baseTime := weekdayTime(time.Wednesday, 10)
// Create a confirmed booking
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a RESERVATION for this user at a time that overlaps with the edit target
reservationTime := baseTime.Add(time.Duration(dur) * time.Minute)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':' || EXTRACT(epoch FROM NOW())::bigint::text, $2)
`, reservationTime, userID)
if err != nil {
t.Fatalf("failed to create reservation: %v", err)
}
// Edit the booking to a time that overlaps the reservation.
// Without excludeUserID, this would return 409.
overlapTime := reservationTime.Add(-time.Duration(dur/2) * time.Minute)
handler := http.HandlerFunc(EditBookingHandler)
w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, map[string]interface{}{
"start_time": overlapTime.Format(time.RFC3339),
"service_ids": []string{serviceID},
}, token, ctx)
if w.Code == http.StatusConflict {
t.Fatalf("own reservation should NOT self-block EditBookingHandler: got 409. body: %s", w.Body.String())
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for edit (own reservation excluded), got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminRescheduleBooking_DoesNotSelfBlock verifies that an admin's own
// RESERVATION does not block AdminRescheduleBookingHandler.
func TestAdminRescheduleBooking_DoesNotSelfBlock(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
baseTime := weekdayTime(time.Wednesday, 10)
// Create a booking belonging to a regular user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create an admin RESERVATION at a time that would overlap the reschedule target
reservationTime := baseTime.Add(2 * time.Hour)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:' || EXTRACT(epoch FROM NOW())::bigint::text, $2)
`, reservationTime, adminID)
if err != nil {
t.Fatalf("failed to create admin reservation: %v", err)
}
// Reschedule to a time overlapping the admin's own reservation
overlapTime := reservationTime.Add(-30 * time.Minute)
handler := http.HandlerFunc(AdminRescheduleBookingHandler)
w := makeAuthRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/reschedule", map[string]interface{}{
"start_time": overlapTime.Format(time.RFC3339),
}, token, "", ctx)
if w.Code == http.StatusConflict {
t.Fatalf("admin's own reservation should NOT self-block AdminRescheduleBookingHandler: got 409. body: %s", w.Body.String())
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for reschedule (own reservation excluded), got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminReserveSlot_CleansUpAnonReservation verifies that AdminReserveSlotHandler
// cleans up anonymous RESERVATION:anon entries matching the admin's IP
// when the admin is authenticated.
func TestAdminReserveSlot_CleansUpAnonReservation(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
future := weekdayTime(time.Monday, 10)
testIP := "192.0.2.2"
ipHash := fmt.Sprintf("%x", md5.Sum([]byte(testIP)))[:8]
// Create an anonymous reservation matching this IP
var blockerID string
err = tx.QueryRow(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, $2, NULL)
RETURNING id
`, future, fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, clock.Now().UnixNano())).Scan(&blockerID)
if err != nil {
t.Fatalf("failed to create anon reservation: %v", err)
}
makeIPRequest := func(handler http.Handler, method, path string, body interface{}, token, ip, userID string, requestCtx ...context.Context) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("CF-Connecting-IP", ip)
baseCtx := req.Context()
if len(requestCtx) > 0 {
baseCtx = requestCtx[0]
}
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, userID)
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
rctx := chi.NewRouteContext()
ctx := context.WithValue(baseCtx, chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
w := makeIPRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve",
&AdminReserveSlotRequest{
StartTime: future,
ServiceIDs: []string{serviceID},
DurationMinutes: 60,
ReservationType: "walkin",
}, token, testIP, adminID, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("admin reserve: expected 201, got %d. body: %s", w.Code, w.Body.String())
}
var remaining int
tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining)
if remaining != 0 {
t.Errorf("expected anonymous reservation to be cleaned up by admin pre-overlap DELETE, got %d remaining", remaining)
}
}
@@ -2882,3 +2882,48 @@ func TestScheduling_DST_AutumnBack_BookingAt0130GMT(t *testing.T) {
t.Error("expected 01:30 GMT slot to be blocked (booking at 01:30 GMT = 01:30 UTC)")
}
}
// TestScheduling_GetAvailableHours_ExcludesOwnReservation verifies that an
// authenticated user's own RESERVATION entry is excluded from time blockers
// via the excludeUserID parameter in GetTimeBlockersInRange.
func TestScheduling_GetAvailableHours_ExcludesOwnReservation(t *testing.T) {
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':' || EXTRACT(epoch FROM NOW())::bigint::text, $2)
`, reservationTime, userID)
if err != nil {
t.Fatalf("failed to create reservation: %v", err)
}
// Request available hours as THIS user — their own reservation should be excluded
handler := http.HandlerFunc(GetAvailableHours)
req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
reqCtx := context.WithValue(ctx, mw.UserIDKey, userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var response []DayAvailableHours
json.Unmarshal(w.Body.Bytes(), &response)
targetDay := findDayByDate(response, "2026-03-17")
if targetDay == nil {
t.Fatal("expected 2026-03-17 in response")
}
// The user's own reservation at 10:00 should NOT block the slot
if !slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 to be available (own reservation excluded)")
}
}
@@ -14,7 +14,7 @@ These are blockers: missing functionality that prevents daily operations, legal
| # | Gap | Effort | Area | Notes |
|---|---|---|---|---|
| 1 | **CurrentAppointment action stubs** | M (1d) | Frontend | `Extend` and `Cancel` buttons on Today page are dead. Staff cannot cancel or extend an in-progress appointment from the Today page. Edit, Take Payment, and Reschedule are already wired. |
| 2 | **Reservation/anonymization background cron** | S (2-3h) | Backend | `CleanupOldReservations()`, `AnonymizeStaleGuestAccounts()`, `CleanupExpiredGiftCards()`, `CleanupIdleAccounts()`, `CleanupExpiredFinancialRecords()`, `CleanupOldNameHistory()` all run on `GET /api/availability`. If no one fetches availability for days, expired reservations persist and stale guest data isn't anonymized. Should be a background ticker in `main.go` (or a lightweight cron job). |
| 2 | **~~Reservation/anonymization background cron~~** | S (2-3h) | Backend | **DONE**: `CleanupOldReservations()` now runs in a background goroutine (5-minute ticker, 30s timeout) with graceful shutdown via SIGTERM/SIGINT. `AnonymizeStaleGuestAccounts()`, `CleanupExpiredGiftCards()`, `CleanupIdleAccounts()`, `CleanupExpiredFinancialRecords()`, `CleanupOldNameHistory()` still only run on `GET /api/availability`. |
| 3 | **VAT/Tax export endpoints** | M (1-2d) | Backend | `get_vat_return_data()` and `export_sales_transactions()` SQL functions exist. No admin API to trigger them. Needed for HMRC Making Tax Digital compliance. |
| 4 | **Password reset flow** | S (2-3h) | Frontend | Backend has `/api/verify/generate` and `/api/verify/check`. Login page has no "forgot password" link or form. Customers who forget their password must call the salon. |
| 5 | **Email verification flow** | S (2-3h) | Frontend | Users register with `unverified_email` role. No UI to enter verification code or resend. `+layout.svelte` has an alert-based prototype that needs to be wired properly. |
+3 -3
View File
@@ -59,7 +59,7 @@ VAT treatment: gift cards are Single-Purpose Vouchers (SPVs) by default — VAT
Default weekly hours stored in `working_hours` table. Exceptional groups use a three-table design: group metadata, 7-day hours per group, and week-range applications. Merged via `GetWorkingHours()` with `source` field ("default" or "exceptional").
Available hours calculated by loading working hours, subtracting existing bookings (with gap logic), subtracting time blockers (including reservations). Late-night lock: after 22:00, blocks next morning 00:0011:00 for non-admin users.
Available hours calculated by loading working hours, subtracting existing bookings (with gap logic), subtracting time blockers (including reservations). **Self-blocking prevention**: `GetAvailableHours` passes `excludeUserID` (from `OptionalAuth` context) to `GetTimeBlockersInRange`, excluding the user's own `RESERVATION` entries so their existing hold doesn't hide the slot. All booking/reservation handlers also pass `excludeUserID` to `CheckTimeBlockerOverlap`. Late-night lock: after 22:00, blocks next morning 00:0011:00 for non-admin users.
Time blockers: one-off (no cron) or recurring (cron expression via `robfig/cron/v3`). Created from Admin dashboard with overlap detection against existing bookings. Visible on Today page calendar grid as red/hatched bars.
@@ -87,7 +87,7 @@ Campaign lifecycle: `draft → active → completed` (or any → `cancelled`, `a
**GDPR Article 15**: Full data export via `/gdpr` frontend. Async Go endpoint (`GET /api/user/gdpr-export`) with 12h in-memory cache and background generation (navigation away doesn't cancel). 21-section JSON export: user profile, bookings with overrides, payments, refunds, saved cards, social logins, loyalty redemptions, booking discounts, edit requests, affiliate payouts, forgiven no-shows, patch tests, referrals, referral discounts, notification preferences, gift_card_balance, gift_card_transactions, gift_cards, admin_audit_log, login_audit, refresh_tokens, name_history, export metadata. **Verification codes excluded** (authentication tokens are not personal data under GDPR Art 15). Frontend: skeleton loading, 2s polling, styled report cards/tables, PDF export (print CSS hides navbar + verification banner), raw JSON download.
**Account deletion**: Registered users → `anonymize_user()` SQL function extended with child table PII scrubbing (social logins deleted, saved cards soft-deleted with PCI data cleared, verification codes expired, time blocker reservations scrubbed, edit request notes nulled, notification preferences deleted). External system scrubbing: S3 profile picture, Square saved cards. Guests → `delete_guest_user()` for full removal.
**Account deletion**: Registered users → `anonymize_user()` SQL function extended with child table PII scrubbing (social logins deleted, saved cards soft-deleted with PCI data cleared, verification codes expired, time blocker reservations scrubbed including `RESERVATION:edit_request:%` entries, edit request notes nulled, notification preferences deleted). External system scrubbing: S3 profile picture, Square saved cards. Guests → `delete_guest_user()` for full removal.
**Data retention**: Guest PII scrubbed 6 months post-appointment via `AnonymizeStaleGuestAccounts()`. Payment records retained 7 years (HMRC + Limitation Act), then aggregated into `financial_aggregates` (monthly totals, no PII) and deleted. Gift card dormant balances retained indefinitely in `gift_card_expired_balances` (no PII).
@@ -215,7 +215,7 @@ npm run dev # Dev server with HMR
```bash
cd backend
go test -tags "test,dev" ./... # 1,169 tests, 0 failures, 4 skipped
go test -tags "test,dev" ./... # 1,180 tests, 0 failures, 4 skipped
go test -tags "test,dev" -v -run TestName ./... # Single test
```
+3 -3
View File
@@ -62,14 +62,14 @@ Backend (:8080)
| Package | File(s) | Purpose |
|---------|---------|---------|
| `handlers/auth` | local.go, social.go | Registration (with referral code validation), login, refresh, email verification |
| `handlers/bookings` | bookings.go, reserve.go, manage.go, admin_reserve.go | Booking CRUD, reservations, admin management, edit requests, discounts, closing hours validation, active booking limits, GetBookingsByCreatedRange, created_by_name resolution |
| `handlers/bookings` | bookings.go, reserve.go, manage.go, admin_reserve.go, cancel_reservation.go | Booking CRUD, reservations with **self-blocking prevention** (`excludeUserID` parameter on `CheckTimeBlockerOverlap` + pre-overlap DELETE with IP hash anon cleanup), admin management, edit requests, discounts, closing hours validation, active booking limits, GetBookingsByCreatedRange, created_by_name resolution, **explicit reservation cancellation** (`DELETE /api/bookings/reserve`) |
| `handlers/payments` | handlers.go, service.go, validators.go, giftcards.go, till.go, refunds.go, refund_policy.go | Square payments: terminal, online, refunds, tips, saved cards, gift cards (CRUD, topup, transfer, redeem, buy, expired balances, till sales). Refund calculation with notice-period tiers and deposit protection |
| `handlers/webhooks` | square.go | Square webhook handler for payment status updates. **Fail-closed signature check** — rejects requests with 403 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is set but header is missing. Dev mode: skips verification when env var is empty. Still uses hex-encoding stub (`verifySquareSignature`) — production requires HMAC-SHA256 with base64 output, `x-square-hmacsha256-signature` header. See `TODO(PROD)` in source. |
| `handlers/admin` | users.go, analytics.go, custom_services.go, discount_campaigns.go, settings.go | Admin user management, custom services CRUD (list/create/get/update/promote/delete), discount campaigns, analytics (stub), business settings (GET/PUT with VAT, gift card config) |
| `handlers/today` | today.go | Current/next appointment, today's grid, pending approvals, `DoneForDay` state with daily/weekly summary (`DailySummary` with `total_bookings`, `customers_served`, `summary_scope`), auto-status transitions, closed-day aggregation via `findWeekSummaryRange` + `computeAggregateSummary`. Exceptional hours lookup uses `exceptional_group_applications.week_start` (0=Monday). |
| `handlers/user` | profile.go, account.go, guest.go, loyalty.go, customer_relationship.go, gdpr_export.go | User profile, guest creation (with CheckEmailHandler for registered-email detection), loyalty, contact info, GDPR export (async with 12h cache) |
| `handlers/services` | services.go | Service catalog, eligibility filtering, patch_test_duration_hours auto-creates patch test records |
| `handlers/scheduling` | default-hours.go, exceptional-hours.go, time-blockers.go | Working hours, exceptional groups, time blockers, gift card expiry cleanup (24-month rolling), idle account cleanup (2yr/5yr) |
| `handlers/scheduling` | default-hours.go, exceptional-hours.go, time-blockers.go | Working hours, exceptional groups, time blockers with **excludeUserID filtering** (user's own `RESERVATION` entries excluded from blocker results when authenticated), gift card expiry cleanup (24-month rolling), idle account cleanup (2yr/5yr) |
| `handlers/portfolio` | images.go | Image CRUD, cursor-paginated listing with fuzzy tag search & exact category filters, relevance-sorted tag results |
| `handlers/notifications` | notifications.go | Admin notifications (GET, acknowledge) |
@@ -1242,7 +1242,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
### Test Coverage
**1,169 tests run** across all packages (4 skipped, 0 failures). Recent additions: closing_time tests (3), content-type middleware tests (2), new booking handler tests (FOR UPDATE overlap checks, admin reserve with closing_time, gift card buy with VAT). Booking integration tests continue to expand: duplicate completion guard, daily stamp cap (handler + SQL subquery), invalid status transitions, sequential edit, timezone independence, and past-booking no-show guard. The `clock` package itself has tests for Now() and clock interface correctness.
**1,180 tests run** across all packages (4 skipped, 0 failures). Recent additions: self-blocking prevention tests (excludeUserID coverage for GetAvailableHours, EditBookingHandler, AdminRescheduleBookingHandler, ReserveSlotHandler anon IP cleanup, AdminReserveSlotHandler anon IP cleanup), closing_time tests (3), content-type middleware tests (2), booking handler tests (FOR UPDATE overlap checks, admin reserve with closing_time, gift card buy with VAT). Booking integration tests continue to expand: duplicate completion guard, daily stamp cap (handler + SQL subquery), invalid status transitions, sequential edit, timezone independence, and past-booking no-show guard. The `clock` package itself has tests for Now() and clock interface correctness.
| Package | Coverage Area |
|---------|--------------|
@@ -634,7 +634,7 @@ This shouldn't appear anymore — the auth package's TestMain was updated to use
### Q: What's the total test count?
1,169 tests run across all packages (4 skipped). 0 failures across 19 packages.
1,180 tests run across all packages (4 skipped). 0 failures across 20 packages.
**Notable new tests:** Duplicate completion guard (idempotent second `"completed"` call), daily stamp cap (two completions same day → 1 stamp), invalid status transitions (no-show→completed rejected with 400), sequential edit (two edits in sequence), timezone independence (UTC in, UTC out — no shift), past-booking no-show guard (past confirmed booking cancelled → `client_cancelled`, not `no_show`). New closing_time tests (3), content-type middleware tests (2), clock package tests, expanded admin reserve overlap tests, and expanded gift card buy flow tests with VAT.