From ef650e013abd210ad94293f157c675576742689d Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 29 May 2026 20:21:26 +0100 Subject: [PATCH] feat: implement patch test management and validation improvements --- backend/handlers/admin/patch_tests.go | 2 +- backend/handlers/admin/test_helpers.go | 37 +--- .../admin/DiscountsManagement.svelte | 172 +++++++++++++----- .../lib/components/admin/HolidayHours.svelte | 46 ++--- .../admin/PatchTestsManagement.svelte | 68 +++---- .../admin/ServicesManagement.svelte | 29 ++- frontend/src/routes/admin/+page.svelte | 15 +- 7 files changed, 222 insertions(+), 147 deletions(-) diff --git a/backend/handlers/admin/patch_tests.go b/backend/handlers/admin/patch_tests.go index c1d0c3a..65c24e8 100644 --- a/backend/handlers/admin/patch_tests.go +++ b/backend/handlers/admin/patch_tests.go @@ -102,7 +102,7 @@ func CreatePatchTest(w http.ResponseWriter, r *http.Request) { func UpdatePatchTest(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if id == "" || !validators.IsValidID(id) { - http.Error(w, "Patch test not found", http.StatusNotFound) + http.Error(w, "Patch test not found (ID: " + id + ")", http.StatusNotFound) return } diff --git a/backend/handlers/admin/test_helpers.go b/backend/handlers/admin/test_helpers.go index e67057a..db519ea 100644 --- a/backend/handlers/admin/test_helpers.go +++ b/backend/handlers/admin/test_helpers.go @@ -9,6 +9,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "crussell/db" @@ -37,6 +38,7 @@ func makeUserRequest(handler http.Handler, method, path string, body interface{} } // makeRequestWithContext creates a request with specific user context + func makeRequestWithContext(handler http.Handler, method, path string, body interface{}, userID, role string) *httptest.ResponseRecorder { var req *http.Request if body != nil { @@ -47,18 +49,13 @@ func makeRequestWithContext(handler http.Handler, method, path string, body inte req = httptest.NewRequest(method, path, nil) } - // Set up chi routing context (required for chi.URLParam to work) rctx := chi.NewRouteContext() - // Parse the path to extract ID parameters for chi - // chi routes like /api/admin/users/{id} need {id} in route context if method == "GET" || method == "PUT" || method == "POST" || method == "DELETE" || method == "PATCH" { - // Extract path params from URL for chi if id, paramName := extractIDFromPath(path); id != "" { rctx.URLParams.Add(paramName, id) } } - // Set up context with user ID and role (simulating middleware) ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, userID) ctx = context.WithValue(ctx, mw.UserRoleKey, role) @@ -69,10 +66,7 @@ func makeRequestWithContext(handler http.Handler, method, path string, body inte return w } -// extractIDFromPath extracts the ID from URL paths like /api/admin/users/{id} or /api/admin/bookings/{id}/progress -// It returns only the ID segment, not any nested path parts func extractIDFromPath(path string) (string, string) { - // Define patterns with their param names: (prefix, paramName) patterns := []struct { prefix string paramName string @@ -81,16 +75,16 @@ func extractIDFromPath(path string) (string, string) { {"/api/admin/users/", "id"}, {"/api/admin/bookings/", "id"}, {"/api/admin/services/", "id"}, + {"/api/admin/patch-tests/", "id"}, {"/api/bookings/", "id"}, {"/api/services/eligible-for/", "userId"}, {"/api/services/", "id"}, } for _, p := range patterns { - if idx := findLastSegment(path, p.prefix); idx >= 0 { - // Extract only the ID segment (up to the next / or end of path) - suffix := path[idx:] - if slashIdx := findSlash(suffix); slashIdx >= 0 { + if strings.HasPrefix(path, p.prefix) { + suffix := path[len(p.prefix):] + if slashIdx := strings.Index(suffix, "/"); slashIdx >= 0 { return suffix[:slashIdx], p.paramName } return suffix, p.paramName @@ -99,25 +93,6 @@ func extractIDFromPath(path string) (string, string) { return "", "" } -// findSlash finds the position of the first / in the string -func findSlash(s string) int { - for i := 0; i < len(s); i++ { - if s[i] == '/' { - return i - } - } - return -1 -} - -func findLastSegment(path, prefix string) int { - for i := len(path) - 1; i >= len(prefix); i-- { - if len(path) > i && path[i-len(prefix):i] == prefix { - return i - } - } - return -1 -} - func parseResponseBody(w *httptest.ResponseRecorder, dest interface{}) error { return json.Unmarshal(w.Body.Bytes(), dest) } diff --git a/frontend/src/lib/components/admin/DiscountsManagement.svelte b/frontend/src/lib/components/admin/DiscountsManagement.svelte index 9c0d838..bc3fc72 100644 --- a/frontend/src/lib/components/admin/DiscountsManagement.svelte +++ b/frontend/src/lib/components/admin/DiscountsManagement.svelte @@ -125,7 +125,7 @@ name: '', description: '', campaign_type: 'time_based', - discount_percent: 5, + discount_percent: 5, scope: 'all_bookings', start_date: '', end_date: '', @@ -165,7 +165,11 @@ if (form.campaign_type === 'time_based') { if (!form.start_date) errors.start_date = 'Required'; if (!form.end_date) errors.end_date = 'Required'; - if (form.start_date && form.end_date && new Date(form.end_date) <= new Date(form.start_date)) { + if ( + form.start_date && + form.end_date && + new Date(form.end_date) <= new Date(form.start_date) + ) { errors.end_date = 'Must be after start'; } } @@ -279,7 +283,10 @@ } function statusBadge(status: string) { - const map: Record = { + const map: Record< + string, + { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' } + > = { draft: { label: 'Draft', variant: 'secondary' }, active: { label: 'Active', variant: 'default' }, completed: { label: 'Completed', variant: 'outline' }, @@ -287,10 +294,13 @@ }; const s = map[status] || map.draft; return `${s.label}`; } @@ -314,7 +324,8 @@
Discount Campaigns - Manage loyalty discounts, sales, and milestone campaigns + Manage loyalty discounts, sales, and milestone campaigns
@@ -355,31 +366,37 @@
{#if c.status === 'draft'} - {/if} {#if c.status === 'active'} - {/if} {#if c.status !== 'cancelled'} - {/if} {#if c.status === 'active' || c.status === 'completed'} - + {/if}
@@ -404,31 +421,37 @@
{#if c.status === 'draft'} - {/if} {#if c.status === 'active'} - {/if} {#if c.status !== 'cancelled'} - {/if} {#if c.status === 'active' || c.status === 'completed'} - + {/if}
@@ -457,7 +480,12 @@
Description - +
@@ -466,14 +494,26 @@
@@ -483,10 +523,20 @@
Discount Percent *
- + %
- {#if errors.discount_percent}

{errors.discount_percent}

{/if} + {#if errors.discount_percent}

+ {errors.discount_percent} +

{/if}
@@ -500,7 +550,8 @@ + class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm" + > @@ -545,8 +597,16 @@
Value * - - {#if errors.milestone_value}

{errors.milestone_value}

{/if} + + {#if errors.milestone_value}

+ {errors.milestone_value} +

{/if}
Unit @@ -554,7 +614,8 @@ @@ -570,7 +631,8 @@ {:else if form.milestone_type === 'global_booking_count'} Discount applies on the {form.milestone_value}th completed booking across all users {:else} - Discount applies on a user's first booking after {form.milestone_value} {form.milestone_unit} since their first visit + Discount applies on a user's first booking after {form.milestone_value} + {form.milestone_unit} since their first visit {/if}

@@ -579,15 +641,27 @@
Max Redemptions (campaign total) - +

0 = unlimited across all users

- + @@ -608,7 +682,9 @@

Total Discount Given

-

£{statsData.total_discount_amount.toFixed(2)}

+

+ £{statsData.total_discount_amount.toFixed(2)} +

Bookings Discounted

@@ -617,7 +693,11 @@
{/if} - + diff --git a/frontend/src/lib/components/admin/HolidayHours.svelte b/frontend/src/lib/components/admin/HolidayHours.svelte index 7132a7d..90bee8b 100644 --- a/frontend/src/lib/components/admin/HolidayHours.svelte +++ b/frontend/src/lib/components/admin/HolidayHours.svelte @@ -37,8 +37,7 @@ let savingHours = $state(false); let isFormValid = $derived( - exceptionDraft.name.trim() !== '' && - exceptionDraft.weekStarts.length > 0 + exceptionDraft.name.trim() !== '' && exceptionDraft.weekStarts.length > 0 ); let formErrors = $state({ @@ -51,7 +50,8 @@ } function validateWeeksField() { - formErrors.weeks = exceptionDraft.weekStarts.length === 0 ? 'At least one week must be selected' : ''; + formErrors.weeks = + exceptionDraft.weekStarts.length === 0 ? 'At least one week must be selected' : ''; } function validateAllFields() { @@ -552,30 +552,30 @@ - + {#each Array(24) as _, hour} + {#each TIME15 as min (min)} + + {/each} {/each} - {/each} - + - + {#each Array(24) as _, hour} + {#each TIME15 as min (min)} + + {/each} {/each} - {/each} - + {/each} diff --git a/frontend/src/lib/components/admin/PatchTestsManagement.svelte b/frontend/src/lib/components/admin/PatchTestsManagement.svelte index e45b067..d4aa86d 100644 --- a/frontend/src/lib/components/admin/PatchTestsManagement.svelte +++ b/frontend/src/lib/components/admin/PatchTestsManagement.svelte @@ -1,7 +1,7 @@