From f36497090a6e4e3e45f4b74353e0fad84978aa37 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 29 May 2026 17:19:02 +0100 Subject: [PATCH] feat: migrate to dedicated patch test management --- backend/handlers/admin/patch_tests.go | 173 ++++++++++++++++++ backend/handlers/admin/patch_tests_test.go | 71 +++++++ backend/handlers/services/services.go | 24 +-- backend/handlers/services/services_test.go | 40 ---- backend/main.go | 7 + .../admin/PatchTestsManagement.svelte | 91 +++++++++ .../admin/ServicesManagement.svelte | 166 ++++++++++------- frontend/src/routes/admin/+page.svelte | 2 + 8 files changed, 450 insertions(+), 124 deletions(-) create mode 100644 backend/handlers/admin/patch_tests.go create mode 100644 backend/handlers/admin/patch_tests_test.go create mode 100644 frontend/src/lib/components/admin/PatchTestsManagement.svelte diff --git a/backend/handlers/admin/patch_tests.go b/backend/handlers/admin/patch_tests.go new file mode 100644 index 0000000..deba7c4 --- /dev/null +++ b/backend/handlers/admin/patch_tests.go @@ -0,0 +1,173 @@ +package admin + +import ( + "crussell/db" + "crussell/internal/validators" + "crussell/mw" + "database/sql" + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// PatchTest represents a patch test definition +type PatchTest struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + NoticeDurationHours int `json:"notice_duration_hours"` + ExpiryMonths int `json:"expiry_months"` + ServiceIDs []string `json:"service_ids"` +} + +// CreatePatchTestRequest represents the request payload +type CreatePatchTestRequest struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + NoticeDurationHours int `json:"notice_duration_hours"` + ExpiryMonths int `json:"expiry_months"` + ServiceIDs []string `json:"service_ids"` +} + +// UpdatePatchTestRequest represents the request payload +type UpdatePatchTestRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + NoticeDurationHours *int `json:"notice_duration_hours,omitempty"` + ExpiryMonths *int `json:"expiry_months,omitempty"` + ServiceIDs []string `json:"service_ids,omitempty"` +} + +// GetPatchTests handles GET /api/admin/patch-tests +func GetPatchTests(w http.ResponseWriter, r *http.Request) { + query := ` + SELECT id, name, description, notice_duration_hours, expiry_months, service_ids + FROM patch_tests + ORDER BY name + ` + + rows, err := db.DB.Query(r.Context(), query) + if err != nil { + http.Error(w, "Failed to fetch patch tests: "+err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var patchTests []PatchTest + for rows.Next() { + var pt PatchTest + var desc sql.NullString + err := rows.Scan(&pt.ID, &pt.Name, &desc, &pt.NoticeDurationHours, &pt.ExpiryMonths, &pt.ServiceIDs) + if err != nil { + http.Error(w, "Failed to read patch test data: "+err.Error(), http.StatusInternalServerError) + return + } + if desc.Valid { + pt.Description = &desc.String + } + patchTests = append(patchTests, pt) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(patchTests) +} + +// CreatePatchTest handles POST /api/admin/patch-tests +func CreatePatchTest(w http.ResponseWriter, r *http.Request) { + var req CreatePatchTestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + query := ` + INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + ` + + var id string + err := db.DB.QueryRow(r.Context(), query, req.Name, req.Description, req.NoticeDurationHours, req.ExpiryMonths, req.ServiceIDs).Scan(&id) + if err != nil { + http.Error(w, "Failed to create patch test: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"id": id}) +} + +// UpdatePatchTest handles PUT /api/admin/patch-tests/{id} +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) + return + } + + var req UpdatePatchTestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + query := "UPDATE patch_tests SET " + args := []interface{}{} + i := 1 + + if req.Name != nil { + query += "name = $" + string(rune('0'+i)) + ", " + args = append(args, *req.Name) + i++ + } + if req.Description != nil { + query += "description = $" + string(rune('0'+i)) + ", " + args = append(args, *req.Description) + i++ + } + if req.NoticeDurationHours != nil { + query += "notice_duration_hours = $" + string(rune('0'+i)) + ", " + args = append(args, *req.NoticeDurationHours) + i++ + } + if req.ExpiryMonths != nil { + query += "expiry_months = $" + string(rune('0'+i)) + ", " + args = append(args, *req.ExpiryMonths) + i++ + } + if req.ServiceIDs != nil { + query += "service_ids = $" + string(rune('0'+i)) + ", " + args = append(args, req.ServiceIDs) + i++ + } + + query = query[:len(query)-2] + query += " WHERE id = $" + string(rune('0'+i)) + args = append(args, id) + + _, err := db.DB.Exec(r.Context(), query, args...) + if err != nil { + http.Error(w, "Failed to update patch test: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// DeletePatchTest handles DELETE /api/admin/patch-tests/{id} +func DeletePatchTest(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) + return + } + + _, err := db.DB.Exec(r.Context(), "DELETE FROM patch_tests WHERE id = $1", id) + if err != nil { + http.Error(w, "Failed to delete patch test: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/handlers/admin/patch_tests_test.go b/backend/handlers/admin/patch_tests_test.go new file mode 100644 index 0000000..35229ab --- /dev/null +++ b/backend/handlers/admin/patch_tests_test.go @@ -0,0 +1,71 @@ +//go:build test +// +build test + +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" + + "crussell/db" + "crussell/testutils/fixtures" +) + +func TestPatchTests_CRUD(t *testing.T) { + resetTestData(t) + + // Create a service to link + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + req := CreatePatchTestRequest{ + Name: "Test Patch Test", + Description: strPtr("Description"), + NoticeDurationHours: 24, + ExpiryMonths: 6, + ServiceIDs: []string{serviceID}, + } + body, _ := json.Marshal(req) + w := makeAdminRequest(http.HandlerFunc(CreatePatchTest), "POST", "/api/admin/patch-tests", bytes.NewReader(body)) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d", w.Code) + } + + var created struct { + ID string `json:"id"` + } + parseResponseBody(w, &created) + + w = makeAdminRequest(http.HandlerFunc(GetPatchTests), "GET", "/api/admin/patch-tests", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var list []PatchTest + parseResponseBody(w, &list) + if len(list) != 1 { + t.Errorf("expected 1 patch test, got %d", len(list)) + } + + newName := "Updated Name" + updateReq := UpdatePatchTestRequest{Name: &newName} + updateBody, _ := json.Marshal(updateReq) + w = makeAdminRequest(http.HandlerFunc(UpdatePatchTest), "PUT", "/api/admin/patch-tests/"+created.ID, bytes.NewReader(updateBody)) + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", w.Code) + } + + w = makeAdminRequest(http.HandlerFunc(DeletePatchTest), "DELETE", "/api/admin/patch-tests/"+created.ID, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", w.Code) + } +} + +func strPtr(s string) *string { + return &s +} diff --git a/backend/handlers/services/services.go b/backend/handlers/services/services.go index a2549c0..ae10752 100644 --- a/backend/handlers/services/services.go +++ b/backend/handlers/services/services.go @@ -45,12 +45,11 @@ type ServiceResponse struct { // CreateServiceRequest represents the request payload for creating a new service type CreateServiceRequest struct { - Name string `json:"name" validate:"required,min=1,max=100"` - Description *string `json:"description,omitempty"` - Price float64 `json:"price" validate:"required,gt=0"` - DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"` - MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"` - PatchTestDurationHours int `json:"patch_test_duration_hours"` + Name string `json:"name" validate:"required,min=1,max=100"` + Description *string `json:"description,omitempty"` + Price float64 `json:"price" validate:"required,gt=0"` + DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"` + MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"` } // ToggleServiceHandler handles toggling a service's active status @@ -163,19 +162,6 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) { return } - // Create patch test record if duration > 0 - if req.PatchTestDurationHours > 0 { - _, err = db.DB.Exec(r.Context(), ` - INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) - VALUES ($1, $2, $3, $4, $5) - `, req.Name+" Patch Test", "Patch test for "+req.Name, req.PatchTestDurationHours, 6, []string{service.ID}) - if err != nil { - http.Error(w, "Failed to create patch test: "+err.Error(), http.StatusInternalServerError) - return - } - service.PatchTestDurationHours = req.PatchTestDurationHours - } - if err != nil { // Check for duplicate name or other constraints if err.Error() == "pq: duplicate key value violates unique constraint" { diff --git a/backend/handlers/services/services_test.go b/backend/handlers/services/services_test.go index 684a021..5016efc 100644 --- a/backend/handlers/services/services_test.go +++ b/backend/handlers/services/services_test.go @@ -348,46 +348,6 @@ func TestContact_ReturnsInfo(t *testing.T) { } } -// TestServices_Create_PatchTest verifies that creating a service with -// patch_test_duration_hours > 0 automatically creates a patch test record. -func TestServices_Create_PatchTest(t *testing.T) { - resetTestData(t) - - adminToken := jwt.GenerateAdminToken() - handler := http.HandlerFunc(CreateServiceHandler) - - req := CreateServiceRequest{ - Name: "Lash Lift", - Description: strPtr("Lash lift service"), - Price: 50.00, - DurationMinutes: 60, - MinimumAgeRequired: 18, - PatchTestDurationHours: 48, - } - - reqBody, _ := json.Marshal(req) - httpreq := httptest.NewRequest("POST", "/api/admin/services", bytes.NewReader(reqBody)) - httpreq.Header.Set("Content-Type", "application/json") - httpreq.Header.Set("Authorization", "Bearer "+adminToken) - - w := httptest.NewRecorder() - handler.ServeHTTP(w, httpreq) - - if w.Code != http.StatusCreated { - t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) - } - - var count int - err := db.DB.QueryRow(context.Background(), - "SELECT COUNT(*) FROM patch_tests WHERE notice_duration_hours = 48").Scan(&count) - if err != nil { - t.Fatalf("failed to query patch_tests: %v", err) - } - if count != 1 { - t.Errorf("expected 1 patch test record, got %d", count) - } -} - func strPtr(s string) *string { return &s } diff --git a/backend/main.go b/backend/main.go index f1c7e42..e09c8d6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -251,6 +251,13 @@ func main() { r.Put("/{id}/toggle", services.ToggleService) }) + r.Route("/admin/patch-tests", func(r chi.Router) { + r.Get("/", admin.GetPatchTests) + r.Post("/", admin.CreatePatchTest) + r.Put("/{id}", admin.UpdatePatchTest) + r.Delete("/{id}", admin.DeletePatchTest) + }) + r.Route("/admin/bookings", func(r chi.Router) { r.Get("/", bookings.GetAllAdminBookingsHandler) r.Post("/", bookings.AdminCreateBookingForUserHandler) diff --git a/frontend/src/lib/components/admin/PatchTestsManagement.svelte b/frontend/src/lib/components/admin/PatchTestsManagement.svelte new file mode 100644 index 0000000..346aa97 --- /dev/null +++ b/frontend/src/lib/components/admin/PatchTestsManagement.svelte @@ -0,0 +1,91 @@ + + + + +
+
+ Patch Test Management + Manage patch test requirements. +
+ +
+
+ + {#if loading} + + {:else} +
+ + + + + + + + + + {#each patchTests as pt} + + + + + + {/each} + +
NameNotice (hrs)Expiry (months)
{pt.name}{pt.notice_duration_hours}{pt.expiry_months}
+
+ {/if} +
+
+ + + + + Add Patch Test + +
+

Patch test creation modal content goes here...

+
+
+
diff --git a/frontend/src/lib/components/admin/ServicesManagement.svelte b/frontend/src/lib/components/admin/ServicesManagement.svelte index 0629157..c7f8373 100644 --- a/frontend/src/lib/components/admin/ServicesManagement.svelte +++ b/frontend/src/lib/components/admin/ServicesManagement.svelte @@ -20,55 +20,27 @@ patch_test_duration_hours: number; minimum_age_required: number; created_at: string; - updated_at?: string; created_by?: string; - updated_by?: string; }; - // =============== State =============== let services = $state([]); - let servicesLoading = $state(true); - let servicesUpdating = $state>({}); - - // Service Creation State - let showServiceModal = $state(false); - let creatingService = $state(false); - let newService = $state({ + let newService = $state>({ name: '', description: '', - price: '', - duration_minutes: 60, - patch_test_duration_hours: 0, - minimum_age_required: 0 + price: 0, + duration_minutes: 0, + is_active: true, + minimum_age_required: 0, }); + let editingService = $state(null); + let loading = $state(true); + let serviceErrors = $state>({}); - let serviceErrors = $state({ - name: '', - price: '', - duration_minutes: '', - patch_test_duration_hours: '', - minimum_age_required: '' - }); + function validateDuration(val: any, field: string) { + if (typeof val !== 'number' || val < 0) return 'Duration must be a positive number'; + return ''; + } - // =============== Validation =============== - let isFormValid = $derived( - newService.name.trim() !== '' && - /^\d+(\.\d{1,2})?$/.test(newService.price) && - parseFloat(newService.price) > 0 && - Number.isInteger(newService.duration_minutes) && - newService.duration_minutes > 0 && - Number.isInteger(newService.patch_test_duration_hours) && - newService.patch_test_duration_hours >= 0 && - Number.isInteger(newService.minimum_age_required) && - newService.minimum_age_required >= 0 && - newService.minimum_age_required <= 100 - ); - - function validatePrice(price: string): string { - const validFormat = /^\d*\.?\d*$/.test(price); - if (!validFormat) { - return 'Price must be a valid number (e.g., 4.50)'; - } const numPrice = parseFloat(price); if (isNaN(numPrice)) { @@ -143,12 +115,97 @@ } function validateDurationField() { - serviceErrors.duration_minutes = validateDuration( - newService.duration_minutes, - 'duration_minutes' - ); + serviceErrors.duration_minutes = validateDuration(newService.duration_minutes, 'duration_minutes'); } + function validateMinimumAgeField() { + serviceErrors.minimum_age_required = validateDuration(newService.minimum_age_required, 'minimum_age_required'); + } + + function validateNameField() { + if (!newService.name) serviceErrors.name = 'Name is required'; + else serviceErrors.name = ''; + } + + function validatePriceField() { + if (typeof newService.price !== 'number' || newService.price <= 0) serviceErrors.price = 'Price must be greater than 0'; + else serviceErrors.price = ''; + } + + let isFormValid = $derived( + newService.name !== '' && + !serviceErrors.name && + !serviceErrors.price && + !serviceErrors.duration_minutes && + !serviceErrors.minimum_age_required + ); + + let creatingService = $state(false); + + async function createService() { + creatingService = true; + const loadingToast = toast.loading('Creating service...'); + + const payload = { + ...newService, + price: Number(newService.price), + duration_minutes: Number(newService.duration_minutes), + minimum_age_required: Number(newService.minimum_age_required) + }; + + try { + const response = await fetch('/api/admin/services', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify(payload) + }); + + if (response.ok) { + await response.json(); + toast.success('Service created successfully!', { id: loadingToast }); + + resetServiceForm(); + showServiceModal = false; + + await fetchServices(); + } else if (response.status === 409) { + toast.error('A service with this name already exists', { id: loadingToast }); + } else if (response.status === 400) { + const errorText = await response.text(); + toast.error(`Validation error: ${errorText}`, { id: loadingToast }); + } else { + const errorText = await response.text(); + toast.error(`Failed to create service: ${errorText}`, { id: loadingToast }); + } + } catch (err) { + console.error('Error creating service:', err); + toast.error('Network error creating service', { id: loadingToast }); + } finally { + creatingService = false; + } + } + + function resetServiceForm() { + newService = { + name: '', + description: '', + price: 0, + duration_minutes: 60, + is_active: true, + minimum_age_required: 0 + }; + serviceErrors = { + name: '', + price: '', + duration_minutes: '', + minimum_age_required: '' + }; + } + + function validatePatchTestField() { serviceErrors.patch_test_duration_hours = validateDuration( newService.patch_test_duration_hours, @@ -618,27 +675,6 @@
- -
- - - {#if serviceErrors.patch_test_duration_hours} -

{serviceErrors.patch_test_duration_hours}

- {/if} -

Hours required before service (0 for none)

-
-
diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 0a65196..f0a2a17 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -12,6 +12,7 @@ import TimeBlockers from '$lib/components/admin/TimeBlockers.svelte'; import WeeklySchedule from '$lib/components/admin/WeeklySchedule.svelte'; import ServicesManagement from '$lib/components/admin/ServicesManagement.svelte'; + import PatchTestsManagement from '$lib/components/admin/PatchTestsManagement.svelte'; import DiscountsManagement from '$lib/components/admin/DiscountsManagement.svelte'; import UserModal from '$lib/components/admin/UserModal.svelte'; import BookingModal from '$lib/components/admin/BookingModal.svelte'; @@ -279,6 +280,7 @@ +