From f59595eeec811c794e1d14a49c36e020508cc96f Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Tue, 24 Feb 2026 17:23:28 +0000 Subject: [PATCH] Refactor patch test system and add booking edit requests - Replace patch_test_duration_hours on services with separate patch_tests table - Add user_patch_tests table to track user patch test records - Add booking edit request system: users can request time changes - Add admin handlers to list, approve, and reject edit requests - Add validation to prevent editing completed/cancelled bookings - Add overlap and closed-day checks for booking edits --- backend/handlers/admin/services_test.go | 42 +- backend/handlers/admin/users_test.go | 118 ++++-- backend/handlers/auth/auth_test.go | 2 +- backend/handlers/bookings/bookings.go | 88 ++++- backend/handlers/bookings/manage.go | 422 ++++++++++++++++++++- backend/handlers/services/services.go | 226 +++++------ backend/handlers/services/services_test.go | 56 ++- backend/handlers/user/profile.go | 91 +++-- backend/testutils/fixtures/fixtures.go | 66 +++- backend/testutils/testdb/testdb.go | 7 +- init-scripts/init-script.sql | 43 ++- 11 files changed, 906 insertions(+), 255 deletions(-) diff --git a/backend/handlers/admin/services_test.go b/backend/handlers/admin/services_test.go index 9475f26..f164a4d 100644 --- a/backend/handlers/admin/services_test.go +++ b/backend/handlers/admin/services_test.go @@ -30,12 +30,11 @@ func TestAdminServices_Create(t *testing.T) { handler := http.HandlerFunc(services.CreateServiceHandler) createReq := services.CreateServiceRequest{ - Name: "Test Manicure", - Description: stringPtr("A test manicure service"), - Price: 35.00, - DurationMinutes: 45, - PatchTestDurationHours: 0, - MinimumAgeRequired: 16, + Name: "Test Manicure", + Description: stringPtr("A test manicure service"), + Price: 35.00, + DurationMinutes: 45, + MinimumAgeRequired: 16, } w := makeAdminRequest(handler, "POST", "/api/admin/services", createReq) @@ -66,11 +65,11 @@ func TestAdminServices_List(t *testing.T) { // Insert test services _, err := db.DB.Exec(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES - ('Manicure', 'Basic manicure', 25.00, 30, true, 0, 0), - ('Pedicure', 'Basic pedicure', 30.00, 45, false, 0, 0), - ('Gel Polish', 'Gel polish service', 40.00, 60, true, 48, 16) + ('Manicure', 'Basic manicure', 25.00, 30, true, 0), + ('Pedicure', 'Basic pedicure', 30.00, 45, false, 0), + ('Gel Polish', 'Gel polish service', 40.00, 60, true, 16) `) if err != nil { t.Fatalf("failed to create services: %v", err) @@ -116,8 +115,8 @@ func TestAdminServices_Toggle(t *testing.T) { // Create a service var serviceID string err := db.DB.QueryRow(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) - VALUES ('Test Service', 'A test service', 50.00, 60, true, 0, 16) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ('Test Service', 'A test service', 50.00, 60, true, 16) RETURNING id `).Scan(&serviceID) if err != nil { @@ -164,8 +163,8 @@ func TestAdminServices_Delete(t *testing.T) { // Create a service var serviceID string err := db.DB.QueryRow(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) - VALUES ('Test Service', 'A test service', 50.00, 60, true, 0, 16) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ('Test Service', 'A test service', 50.00, 60, true, 16) RETURNING id `).Scan(&serviceID) if err != nil { @@ -206,12 +205,11 @@ func TestAdminServices_NonAdmin(t *testing.T) { // Test CREATE - should get 403 when using middleware createHandler := mw.RequireAdmin(http.HandlerFunc(services.CreateServiceHandler)) createReq := services.CreateServiceRequest{ - Name: "Test Service", - Description: stringPtr("Test"), - Price: 50.00, - DurationMinutes: 60, - PatchTestDurationHours: 0, - MinimumAgeRequired: 16, + Name: "Test Service", + Description: stringPtr("Test"), + Price: 50.00, + DurationMinutes: 60, + MinimumAgeRequired: 16, } w := makeUserRequest(createHandler, "POST", "/api/admin/services", createReq) if w.Code != http.StatusForbidden { @@ -228,8 +226,8 @@ func TestAdminServices_NonAdmin(t *testing.T) { // Test TOGGLE - should get 403 when using middleware var serviceID string err = db.DB.QueryRow(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) - VALUES ('Test Service', 'A test service', 50.00, 60, true, 0, 16) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ('Test Service', 'A test service', 50.00, 60, true, 16) RETURNING id `).Scan(&serviceID) if err != nil { diff --git a/backend/handlers/admin/users_test.go b/backend/handlers/admin/users_test.go index 76f3478..471b3de 100644 --- a/backend/handlers/admin/users_test.go +++ b/backend/handlers/admin/users_test.go @@ -117,17 +117,45 @@ func TestAdminUsers_PatchTests_Eligible(t *testing.T) { // Create services - some with patch test, some without _, err = db.DB.Exec(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES - ('Basic Manicure', 'Basic manicure', 25.00, 30, true, 0, 0), - ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 48, 16), - ('Luxury Gel Manicure', 'Luxury gel', 55.00, 75, true, 48, 16), - ('Inactive Service', 'Inactive', 30.00, 30, false, 48, 16) + ('Basic Manicure', 'Basic manicure', 25.00, 30, true, 0), + ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16), + ('Luxury Gel Manicure', 'Luxury gel', 55.00, 75, true, 16), + ('Inactive Service', 'Inactive', 30.00, 30, false, 16) `) if err != nil { t.Fatalf("failed to create services: %v", err) } + // Get service IDs for patch test services + var gelPolishID, luxuryGelID string + err = db.DB.QueryRow(context.Background(), "SELECT id FROM services WHERE name = 'Gel Polish Full Set'").Scan(&gelPolishID) + if err != nil { + t.Fatalf("failed to get gel polish service ID: %v", err) + } + err = db.DB.QueryRow(context.Background(), "SELECT id FROM services WHERE name = 'Luxury Gel Manicure'").Scan(&luxuryGelID) + if err != nil { + t.Fatalf("failed to get luxury gel service ID: %v", err) + } + + // Create patch tests that link to these services + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) + VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1) + `, []string{gelPolishID}) + if err != nil { + t.Fatalf("failed to create patch test for gel polish: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) + VALUES ('Luxury Gel Test', 'Patch test for luxury gel', 24, 6, $1) + `, []string{luxuryGelID}) + if err != nil { + t.Fatalf("failed to create patch test for luxury gel: %v", err) + } + handler := http.HandlerFunc(user.GetEligiblePatchTestServicesHandler) w := makeAdminRequest(handler, "GET", "/api/admin/users/"+userID+"/patch-tests/eligible", nil) @@ -140,7 +168,7 @@ func TestAdminUsers_PatchTests_Eligible(t *testing.T) { t.Fatalf("failed to unmarshal response: %v", err) } - // Should return 2 services (the two with patch test duration > 0 that are active) + // Should return 2 services (the two with patch tests that are active) if len(response) != 2 { t.Errorf("expected 2 eligible services, got %d. body: %s", len(response), w.Body.String()) } @@ -161,11 +189,11 @@ func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { t.Fatalf("failed to create user: %v", err) } - // Create services with patch test + // Create services var serviceID1, serviceID2 string err = db.DB.QueryRow(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) - VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 48, 16) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16) RETURNING id `).Scan(&serviceID1) if err != nil { @@ -173,19 +201,38 @@ func TestAdminUsers_PatchTests_Eligible_WithExisting(t *testing.T) { } err = db.DB.QueryRow(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) - VALUES ('Luxury Gel Manicure', 'Luxury gel', 55.00, 75, true, 48, 16) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ('Luxury Gel Manicure', 'Luxury gel', 55.00, 75, true, 16) RETURNING id `).Scan(&serviceID2) if err != nil { t.Fatalf("failed to create service 2: %v", err) } - // Add one patch test for the user + // Create patch tests + var patchTestID1 string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) + VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1) + RETURNING id + `, []string{serviceID1}).Scan(&patchTestID1) + if err != nil { + t.Fatalf("failed to create patch test 1: %v", err) + } + _, err = db.DB.Exec(context.Background(), ` - INSERT INTO user_service_patch_tests (user_id, service_id, last_time) - VALUES ($1, $2, NOW()) - `, userID, serviceID1) + INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) + VALUES ('Luxury Gel Test', 'Patch test for luxury gel', 24, 6, $1) + `, []string{serviceID2}) + if err != nil { + t.Fatalf("failed to create patch test 2: %v", err) + } + + // Add one patch test for the user (valid - within expiry) + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) + VALUES ($1, $2, NOW() - INTERVAL '2 months') + `, userID, patchTestID1) if err != nil { t.Fatalf("failed to add patch test: %v", err) } @@ -227,20 +274,31 @@ func TestAdminUsers_AddPatchTest(t *testing.T) { t.Fatalf("failed to create user: %v", err) } - // Create service with patch test + // Create a service var serviceID string err = db.DB.QueryRow(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) - VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 48, 16) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16) RETURNING id `).Scan(&serviceID) if err != nil { t.Fatalf("failed to create service: %v", err) } + // Create a patch test that links to this service + var patchTestID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) + VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1) + RETURNING id + `, []string{serviceID}).Scan(&patchTestID) + if err != nil { + t.Fatalf("failed to create patch test: %v", err) + } + handler := http.HandlerFunc(user.AddPatchTestHandler) - reqBody := user.AddPatchTestRequest{ServiceID: serviceID} + reqBody := user.AddPatchTestRequest{PatchTestID: patchTestID} w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody) if w.Code != http.StatusCreated { @@ -250,8 +308,8 @@ func TestAdminUsers_AddPatchTest(t *testing.T) { // Verify patch test was added var count int err = db.DB.QueryRow(context.Background(), ` - SELECT COUNT(*) FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2 - `, userID, serviceID).Scan(&count) + SELECT COUNT(*) FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2 + `, userID, patchTestID).Scan(&count) if err != nil { t.Fatalf("failed to check patch test: %v", err) } @@ -261,7 +319,7 @@ func TestAdminUsers_AddPatchTest(t *testing.T) { } } -func TestAdminUsers_AddPatchTest_InvalidService(t *testing.T) { +func TestAdminUsers_AddPatchTest_InvalidPatchTest(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() @@ -276,20 +334,10 @@ func TestAdminUsers_AddPatchTest_InvalidService(t *testing.T) { t.Fatalf("failed to create user: %v", err) } - // Create service without patch test requirement - var serviceID string - err = db.DB.QueryRow(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) - VALUES ('Basic Manicure', 'Basic manicure', 25.00, 30, true, 0, 0) - RETURNING id - `).Scan(&serviceID) - if err != nil { - t.Fatalf("failed to create service: %v", err) - } - handler := http.HandlerFunc(user.AddPatchTestHandler) - reqBody := user.AddPatchTestRequest{ServiceID: serviceID} + // Try to add a non-existent patch test + reqBody := user.AddPatchTestRequest{PatchTestID: "nonexist123"} w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody) if w.Code != http.StatusBadRequest { @@ -344,7 +392,7 @@ func TestAdminUsers_NonAdmin(t *testing.T) { // Test add patch test - should get 403 when using middleware addHandler := mw.RequireAdmin(http.HandlerFunc(user.AddPatchTestHandler)) - w = makeUserRequest(addHandler, "POST", "/api/admin/users/"+targetUserID+"/patch-tests", map[string]string{"service_id": "some-service-id"}) + w = makeUserRequest(addHandler, "POST", "/api/admin/users/"+targetUserID+"/patch-tests", map[string]string{"patch_test_id": "some-test-id"}) if w.Code != http.StatusForbidden { t.Errorf("ADD: expected status 403, got %d", w.Code) } diff --git a/backend/handlers/auth/auth_test.go b/backend/handlers/auth/auth_test.go index 4e30cd9..9220ee3 100644 --- a/backend/handlers/auth/auth_test.go +++ b/backend/handlers/auth/auth_test.go @@ -291,7 +291,7 @@ func TestRegister_InvalidInput_Under16(t *testing.T) { handler := http.HandlerFunc(RegisterHandler) // Calculate a date that makes them under 16 - under16DOB := time.Now().AddDate(-15, 0, 0).Format("2006-01-02") + under16DOB := time.Now().AddDate(-15, NULL, 0).Format("2006-01-02") body := RegisterRequest{ FirstName: "Young", diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index cf7cb6b..f99aee9 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -1350,6 +1350,92 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Check if user owns this booking and get current status + var currentStatus string + err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(¤tStatus) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "Booking not found or access denied", http.StatusNotFound) + return + } + log.Printf("Failed to get booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Users cannot edit completed or cancelled bookings + if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" { + http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden) + return + } + + // Get booking duration for overlap check + var durationMinutes int + err = db.DB.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + `, bookingID).Scan(&durationMinutes) + if err != nil { + log.Printf("Failed to get booking duration %s: %v", bookingID, err) + durationMinutes = 60 + } + + // Check for overlapping bookings (user is blocked if overlap exists) + var overlapCount int + newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute) + err = db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM bookings + WHERE id != $1 + AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled') + AND start_time < $3 + AND start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = bookings.id + )) > $2 + `, bookingID, req.StartTime, newEndTime).Scan(&overlapCount) + if err != nil { + log.Printf("Failed to check overlap %s: %v", bookingID, err) + } + if overlapCount > 0 { + http.Error(w, "This time slot overlaps with an existing booking", http.StatusConflict) + return + } + + // Check if salon is closed (exceptional hours) - user is blocked on closed days + weekday := int(req.StartTime.Weekday()) + bookingTime := req.StartTime.Format("15:04:05") + daysToMonday := weekday + if daysToMonday == 0 { + daysToMonday = 7 + } + weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) + + // Check if salon is closed (exceptional hours) + var isClosed bool + err = db.DB.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 book on a closed day", http.StatusBadRequest) + return + } + // Update booking start time (only for user's own bookings) query := ` UPDATE bookings @@ -1360,7 +1446,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { var booking Booking booking.User = &UserSummary{} - err := db.DB.QueryRow(r.Context(), + err = db.DB.QueryRow(r.Context(), query, req.StartTime, bookingID, diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index afb8908..d611462 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -8,6 +8,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "log" "net/http" "time" @@ -239,7 +240,9 @@ func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) { } // AdminEditBookingHandler allows an admin to modify the start time of any booking. -// It validates the new start time and returns 404 if the booking does not exist. +// Admin can edit any booking EXCEPT completed or cancelled bookings. +// Admin can create/edit bookings outside working hours (with warning). +// Admin can create/edit bookings that overlap with existing bookings (with warning). func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { @@ -258,6 +261,98 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Check if booking exists and is not completed/cancelled + var currentStatus string + err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + log.Printf("Failed to get booking status %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Block edits on completed or cancelled bookings + if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" { + http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden) + return + } + + // Get booking duration for overlap check + var durationMinutes int + err = db.DB.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + `, bookingID).Scan(&durationMinutes) + if err != nil { + log.Printf("Failed to get booking duration %s: %v", bookingID, err) + durationMinutes = 60 // fallback + } + + // Check for overlapping bookings (excluding the current booking) + var overlapCount int + newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute) + err = db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM bookings + WHERE id != $1 + AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled') + AND start_time < $3 + AND start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = bookings.id + )) > $2 + `, bookingID, req.StartTime, newEndTime).Scan(&overlapCount) + if err != nil { + log.Printf("Failed to check overlap %s: %v", bookingID, err) + } + + // Check if salon is closed (exceptional hours) - admin gets warning but can proceed + weekday := int(req.StartTime.Weekday()) + bookingTime := req.StartTime.Format("15:04:05") + daysToMonday := weekday + if daysToMonday == 0 { + daysToMonday = 7 + } + weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) + + // Check if salon is closed (exceptional hours) + var isClosed bool + err = db.DB.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) + } + + isOutsideWorkingHours := isClosed + + // Prevent overlap - block admin + if overlapCount > 0 { + http.Error(w, "This booking overlaps with an existing booking", http.StatusConflict) + return + } + + // Build warning for outside working hours (admin can proceed with warning) + var warnings []string + if isOutsideWorkingHours { + warnings = append(warnings, "Warning: This booking is outside standard working hours") + } + + // Perform the update res, err := db.DB.Exec(r.Context(), ` UPDATE bookings SET start_time = $1, updated_at = $2 @@ -274,6 +369,26 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Clear any pending edit requests for this booking (admin edit takes priority) + _, err = db.DB.Exec(r.Context(), ` + DELETE FROM booking_edit_requests + WHERE booking_id = $1 AND status = 'pending' + `, bookingID) + if err != nil { + log.Printf("Failed to clear edit requests for booking %s: %v", bookingID, err) + // Don't fail the request, just log the error + } + + // Return warnings if any + if len(warnings) > 0 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Booking updated", + "warnings": warnings, + }) + return + } + w.WriteHeader(http.StatusNoContent) } @@ -331,8 +446,13 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { } // Check if booking time falls within a closed exceptional hours period - bookingDate := req.StartTime.Truncate(24 * time.Hour) + // Calculate the Monday of the week containing the booking date weekday := int(req.StartTime.Weekday()) + daysToMonday := weekday + if daysToMonday == 0 { + daysToMonday = 7 // Sunday -> next Monday + } + weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) bookingTime := req.StartTime.Format("15:04:05") // Check if there's an exceptional hours entry that makes this time unavailable @@ -348,7 +468,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { AND ewh.start_time <= $3 AND ewh.end_time >= $3 ) - `, bookingDate, weekday, bookingTime).Scan(&isClosed) + `, weekStart, weekday, bookingTime).Scan(&isClosed) if checkErr != nil { log.Printf("Failed to check exceptional hours: %v", checkErr) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -498,3 +618,299 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) } } + +// ============================================================================= +// Booking Edit Request Handlers +// ============================================================================= + +// BookingEditRequest represents a user's request to edit a booking +type BookingEditRequest struct { + ID string `json:"id"` + BookingID string `json:"booking_id"` + RequestedStartTime time.Time `json:"requested_start_time"` + Status string `json:"status"` + AdminNotes *string `json:"admin_notes,omitempty"` + RequestedBy string `json:"requested_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + // Joined fields + Booking *Booking `json:"booking,omitempty"` + User *UserSummary `json:"user,omitempty"` +} + +// RequestEditHandler allows a user to request an edit to their booking's start time +func RequestEditHandler(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 req struct { + RequestedStartTime time.Time `json:"requested_start_time" validate:"required"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Verify user owns this booking + 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 + } + + // Check booking is not already completed/cancelled + var currentStatus string + err = db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus) + if err != nil { + log.Printf("Failed to get booking status %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" { + http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden) + return + } + + // Create edit request + var editReq BookingEditRequest + err = db.DB.QueryRow(r.Context(), ` + INSERT INTO booking_edit_requests (booking_id, requested_start_time, requested_by) + VALUES ($1, $2, $3) + RETURNING id, booking_id, requested_start_time, status, requested_by, created_at, updated_at + `, bookingID, req.RequestedStartTime, userID).Scan( + &editReq.ID, + &editReq.BookingID, + &editReq.RequestedStartTime, + &editReq.Status, + &editReq.RequestedBy, + &editReq.CreatedAt, + &editReq.UpdatedAt, + ) + if err != nil { + log.Printf("Failed to create edit request for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(editReq) +} + +// AdminListEditRequestsHandler returns all pending edit requests +func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + status := query.Get("status") + + baseQuery := ` + SELECT ber.id, ber.booking_id, ber.requested_start_time, ber.status, + ber.admin_notes, ber.requested_by, ber.created_at, ber.updated_at, + b.start_time as original_start_time, b.status as booking_status, + u.fn as user_name + FROM booking_edit_requests ber + JOIN bookings b ON ber.booking_id = b.id + JOIN users u ON ber.requested_by = u.id + ` + + countQuery := `SELECT COUNT(*) FROM booking_edit_requests ber` + + var args []interface{} + paramCount := 1 + + if status != "" { + baseQuery += fmt.Sprintf(" WHERE ber.status = $%d", paramCount) + countQuery += fmt.Sprintf(" WHERE ber.status = $%d", paramCount) + args = append(args, status) + paramCount++ + } + + baseQuery += " ORDER BY ber.created_at DESC" + + // Get total count + var total int + err := db.DB.QueryRow(r.Context(), countQuery, args...).Scan(&total) + if err != nil { + log.Printf("Failed to count edit requests: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + rows, err := db.DB.Query(r.Context(), baseQuery, args...) + if err != nil { + log.Printf("Failed to fetch edit requests: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var requests []BookingEditRequest + for rows.Next() { + var req BookingEditRequest + var origStartTime time.Time + var bookingStatus string + var userName string + + err := rows.Scan( + &req.ID, + &req.BookingID, + &req.RequestedStartTime, + &req.Status, + &req.AdminNotes, + &req.RequestedBy, + &req.CreatedAt, + &req.UpdatedAt, + &origStartTime, + &bookingStatus, + &userName, + ) + if err != nil { + log.Printf("Failed to scan edit request: %v", err) + continue + } + + req.Booking = &Booking{ + ID: req.BookingID, + StartTime: origStartTime, + Status: bookingStatus, + } + req.User = &UserSummary{ + ID: req.RequestedBy, + FullName: userName, + } + + requests = append(requests, req) + } + + if requests == nil { + requests = []BookingEditRequest{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "requests": requests, + "total": total, + }) +} + +// AdminApproveEditRequestHandler approves an edit request and updates the booking +func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) { + requestID := chi.URLParam(r, "request_id") + if requestID == "" || !validators.IsValidID(requestID) { + http.Error(w, "Edit request not found", http.StatusNotFound) + return + } + + var req struct { + AdminNotes *string `json:"admin_notes,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + log.Printf("Failed to start transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + // Get the edit request + var bookingID string + var newStartTime time.Time + err = tx.QueryRow(r.Context(), ` + SELECT booking_id, requested_start_time FROM booking_edit_requests + WHERE id = $1 AND status = 'pending' + `, requestID).Scan(&bookingID, &newStartTime) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "Edit request not found or already processed", http.StatusNotFound) + return + } + log.Printf("Failed to get edit request %s: %v", requestID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Update the booking + _, err = tx.Exec(r.Context(), ` + UPDATE bookings SET start_time = $1, updated_at = NOW() WHERE id = $2 + `, newStartTime, bookingID) + if err != nil { + log.Printf("Failed to update booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Mark request as approved + _, err = tx.Exec(r.Context(), ` + UPDATE booking_edit_requests + SET status = 'approved', admin_notes = $1, updated_at = NOW() + WHERE id = $2 + `, req.AdminNotes, requestID) + if err != nil { + log.Printf("Failed to update edit request %s: %v", requestID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// AdminRejectEditRequestHandler rejects an edit request +func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) { + requestID := chi.URLParam(r, "request_id") + if requestID == "" || !validators.IsValidID(requestID) { + http.Error(w, "Edit request not found", http.StatusNotFound) + return + } + + var req struct { + AdminNotes string `json:"admin_notes" validate:"required"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + _, err := db.DB.Exec(r.Context(), ` + UPDATE booking_edit_requests + SET status = 'rejected', admin_notes = $1, updated_at = NOW() + WHERE id = $2 AND status = 'pending' + `, req.AdminNotes, requestID) + if err != nil { + log.Printf("Failed to reject edit request %s: %v", requestID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/handlers/services/services.go b/backend/handlers/services/services.go index cd3dc7a..fab5e7b 100644 --- a/backend/handlers/services/services.go +++ b/backend/handlers/services/services.go @@ -1,6 +1,7 @@ package services import ( + "context" "crussell/auth" "crussell/db" "crussell/internal/validators" @@ -18,38 +19,35 @@ import ( // Service represents a service in the system type Service struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Price float64 `json:"price"` - DurationMinutes int `json:"duration_minutes"` - IsActive bool `json:"is_active"` - PatchTestDurationHours int `json:"patch_test_duration_hours"` - MinimumAgeRequired int `json:"minimum_age_required"` - CreatedAt time.Time `json:"created_at"` - CreatedBy *string `json:"created_by,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + DurationMinutes int `json:"duration_minutes"` + IsActive bool `json:"is_active"` + MinimumAgeRequired int `json:"minimum_age_required"` + CreatedAt time.Time `json:"created_at"` + CreatedBy *string `json:"created_by,omitempty"` } type ServiceResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Price float64 `json:"price"` - DurationMinutes int `json:"duration_minutes"` - PatchTestDurationHours int `json:"patch_test_duration_hours"` - MinimumAgeRequired int `json:"minimum_age_required"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + DurationMinutes int `json:"duration_minutes"` + MinimumAgeRequired int `json:"minimum_age_required"` // Patch test status for non-admin users PatchTestStatus *string `json:"patch_test_status,omitempty"` // nil = not checked, "ok" = valid, "required" = no record, "expired" = record too old } // 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"` - PatchTestDurationHours int `json:"patch_test_duration_hours" validate:"gte=0"` - MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"` + 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 @@ -106,10 +104,6 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Duration must be between 1 and 480 minutes", http.StatusBadRequest) return } - if req.PatchTestDurationHours < 0 || req.PatchTestDurationHours > 168 { - http.Error(w, "Patch test duration must be between 0 and 168 hours", http.StatusBadRequest) - return - } if req.MinimumAgeRequired < 0 || req.MinimumAgeRequired > 100 { http.Error(w, "Minimum age must be between 0 and 100", http.StatusBadRequest) return @@ -121,17 +115,16 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) { createdBy = &userID } - // Insert new service + // Insert new service (no patch test columns) query := ` INSERT INTO services ( name, description, price, duration_minutes, - patch_test_duration_hours, minimum_age_required, created_by + minimum_age_required, created_by ) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, description, price, duration_minutes, is_active, - patch_test_duration_hours, minimum_age_required, created_at, - created_by + minimum_age_required, created_at, created_by ` var service Service @@ -143,7 +136,6 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) { req.Description, req.Price, req.DurationMinutes, - req.PatchTestDurationHours, req.MinimumAgeRequired, createdBy, ).Scan( @@ -153,7 +145,6 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) { &service.Price, &service.DurationMinutes, &service.IsActive, - &service.PatchTestDurationHours, &service.MinimumAgeRequired, &service.CreatedAt, &createdByDB, @@ -240,7 +231,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { if !hasUser || userID == "" || role == "admin" { query := ` SELECT id, name, description, price, duration_minutes, - patch_test_duration_hours, minimum_age_required + minimum_age_required FROM services WHERE is_active = TRUE ORDER BY name @@ -264,7 +255,6 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { &service.Description, &service.Price, &service.DurationMinutes, - &service.PatchTestDurationHours, &service.MinimumAgeRequired, ) if err != nil { @@ -312,7 +302,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { // Get all active services query := ` SELECT id, name, description, price, duration_minutes, - patch_test_duration_hours, minimum_age_required + minimum_age_required FROM services WHERE is_active = TRUE ORDER BY name @@ -337,7 +327,6 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { &service.Description, &service.Price, &service.DurationMinutes, - &service.PatchTestDurationHours, &service.MinimumAgeRequired, ) if err != nil { @@ -350,42 +339,17 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { continue } - // Check patch test if required - GRAY OUT if not valid - if service.PatchTestDurationHours > 0 { - var lastTime time.Time - err := db.DB.QueryRow(r.Context(), - `SELECT last_time FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2`, - userID, service.ID).Scan(&lastTime) - - if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { - // No patch test record - status := "required" - service.PatchTestStatus = &status - ineligibleServices = append(ineligibleServices, service) - continue - } else if err != nil { - http.Error(w, "Failed to check patch test: "+err.Error(), http.StatusInternalServerError) - return - } - - // Check if patch test is still valid - requiredSince := lastTime.Add(time.Duration(service.PatchTestDurationHours) * time.Hour) - if now.After(requiredSince) { - // Patch test expired - gray out - status := "expired" - service.PatchTestStatus = &status - ineligibleServices = append(ineligibleServices, service) - continue - } - - // Patch test is valid - include normally - status := "ok" - service.PatchTestStatus = &status - services = append(services, service) - } else { - // No patch test required - include normally - services = append(services, service) + // Check patch test requirement using new schema + patchTestStatus := checkPatchTestStatus(r.Context(), userID, service.ID) + if patchTestStatus != nil { + // Patch test required - add status and put in ineligible list + service.PatchTestStatus = patchTestStatus + ineligibleServices = append(ineligibleServices, service) + continue } + + // No patch test required or valid - include normally + services = append(services, service) } if err = rows.Err(); err != nil { @@ -440,7 +404,7 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { // Get all active services query := ` SELECT id, name, description, price, duration_minutes, - patch_test_duration_hours, minimum_age_required + minimum_age_required FROM services WHERE is_active = TRUE ORDER BY name @@ -465,7 +429,6 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { &service.Description, &service.Price, &service.DurationMinutes, - &service.PatchTestDurationHours, &service.MinimumAgeRequired, ) if err != nil { @@ -478,42 +441,17 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { continue } - // Check patch test if required - if service.PatchTestDurationHours > 0 { - var lastTime time.Time - err := db.DB.QueryRow(r.Context(), - `SELECT last_time FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2`, - userID, service.ID).Scan(&lastTime) - - if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { - // No patch test record - gray out - status := "required" - service.PatchTestStatus = &status - grayedOutServices = append(grayedOutServices, service) - continue - } else if err != nil { - http.Error(w, "Failed to check patch test: "+err.Error(), http.StatusInternalServerError) - return - } - - // Check if patch test is still valid - requiredSince := lastTime.Add(time.Duration(service.PatchTestDurationHours) * time.Hour) - if now.After(requiredSince) { - // Patch test expired - gray out - status := "expired" - service.PatchTestStatus = &status - grayedOutServices = append(grayedOutServices, service) - continue - } - - // Patch test is valid - status := "ok" - service.PatchTestStatus = &status - services = append(services, service) - } else { - // No patch test required - services = append(services, service) + // Check patch test requirement using new schema + patchTestStatus := checkPatchTestStatus(r.Context(), userID, service.ID) + if patchTestStatus != nil { + // Patch test required - add status and put in grayed out list + service.PatchTestStatus = patchTestStatus + grayedOutServices = append(grayedOutServices, service) + continue } + + // No patch test required or valid - include normally + services = append(services, service) } if err = rows.Err(); err != nil { @@ -536,12 +474,77 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { } } +// checkPatchTestStatus checks if a service requires a patch test and if the user has a valid one +// Returns: nil = no patch test required, "required" = no record, "expired" = record too old +func checkPatchTestStatus(ctx context.Context, userID, serviceID string) *string { + // Find patch tests that include this service + var patchTestID string + var noticeDurationHours int + var expiryMonths int + + err := db.DB.QueryRow(ctx, ` + SELECT id, notice_duration_hours, expiry_months + FROM patch_tests + WHERE $1 = ANY(service_ids) + LIMIT 1 + `, serviceID).Scan(&patchTestID, ¬iceDurationHours, &expiryMonths) + + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { + // No patch test required for this service + return nil + } + if err != nil { + // Database error - don't fail the whole request, just assume patch test required + status := "required" + return &status + } + + // Check if user has a valid patch test record + var testedAt time.Time + err = db.DB.QueryRow(ctx, ` + SELECT tested_at + FROM user_patch_tests + WHERE user_id = $1 AND patch_test_id = $2 + `, userID, patchTestID).Scan(&testedAt) + + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { + // No patch test record - required + status := "required" + return &status + } + if err != nil { + // Database error + status := "required" + return &status + } + + // Check if notice period has passed (can only book after this time) + eligibleFrom := testedAt.Add(time.Duration(noticeDurationHours) * time.Hour) + if time.Now().Before(eligibleFrom) { + // Not yet eligible (within notice period) + status := "required" + return &status + } + + // Check if patch test has expired + expiresAt := testedAt.AddDate(0, expiryMonths, 0) + if time.Now().After(expiresAt) { + // Patch test expired + status := "expired" + return &status + } + + // Patch test is valid + status := "ok" + return &status +} + // AllServicesHandler returns all services including inactive ones (useful for admin) func AllServicesHandler(w http.ResponseWriter, r *http.Request) { // Query all services including inactive ones query := ` SELECT id, name, description, price, duration_minutes, is_active, - patch_test_duration_hours, minimum_age_required, created_at, created_by + minimum_age_required, created_at, created_by FROM services ORDER BY is_active DESC, name ` @@ -566,7 +569,6 @@ func AllServicesHandler(w http.ResponseWriter, r *http.Request) { &service.Price, &service.DurationMinutes, &service.IsActive, - &service.PatchTestDurationHours, &service.MinimumAgeRequired, &service.CreatedAt, &createdBy, diff --git a/backend/handlers/services/services_test.go b/backend/handlers/services/services_test.go index 1a82781..0804b73 100644 --- a/backend/handlers/services/services_test.go +++ b/backend/handlers/services/services_test.go @@ -94,11 +94,11 @@ func TestServices_ListAll(t *testing.T) { defer cleanup() _, err := db.DB.Exec(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES - ('Manicure', 'Basic manicure', 25.00, 30, true, 0, 0), - ('Pedicure', 'Basic pedicure', 30.00, 45, true, 0, 0), - ('Inactive Service', 'Should not appear', 50.00, 60, false, 0, 0) + ('Manicure', 'Basic manicure', 25.00, 30, true, 0), + ('Pedicure', 'Basic pedicure', 30.00, 45, true, 0), + ('Inactive Service', 'Should not appear', 50.00, 60, false, 0) `) if err != nil { t.Fatalf("failed to create services: %v", err) @@ -146,11 +146,11 @@ func TestServices_EligibleForUser_AgeFilter(t *testing.T) { } _, err = db.DB.Exec(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) VALUES - ('Under 18 Service', 'For minors', 20.00, 30, true, 0, 16), - ('Adult Only Service', 'For adults only', 50.00, 60, true, 0, 21), - ('No Age Restriction', 'Everyone welcome', 30.00, 45, true, 0, 0) + ('Under 18 Service', 'For minors', 20.00, 30, true, 16), + ('Adult Only Service', 'For adults only', 50.00, 60, true, 21), + ('No Age Restriction', 'Everyone welcome', 30.00, 45, true, 0) `) if err != nil { t.Fatalf("failed to create services: %v", err) @@ -198,28 +198,44 @@ func TestServices_EligibleForUser_PatchTest(t *testing.T) { t.Fatalf("failed to create user: %v", err) } + // Create a regular service (no patch test required) _, err = db.DB.Exec(context.Background(), ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) - VALUES - ('Regular Service', 'No patch test needed', 30.00, 30, true, 0, 0), - ('Patch Test Required', 'Requires patch test', 75.00, 60, true, 48, 0) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ('Regular Service', 'No patch test needed', 30.00, 30, true, 0) `) if err != nil { - t.Fatalf("failed to create services: %v", err) + t.Fatalf("failed to create regular service: %v", err) } + // Create a service that will require a patch test var patchTestSvcID string - err = db.DB.QueryRow(context.Background(), "SELECT id FROM services WHERE name = 'Patch Test Required'").Scan(&patchTestSvcID) + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ('Patch Test Required', 'Requires patch test', 75.00, 60, true, 0) + RETURNING id + `).Scan(&patchTestSvcID) if err != nil { - t.Fatalf("failed to get patch test service: %v", err) + t.Fatalf("failed to create patch test service: %v", err) } - _, err = db.DB.Exec(context.Background(), ` - INSERT INTO user_service_patch_tests (user_id, service_id, last_time) - VALUES ($1, $2, NOW() - INTERVAL '24 hours') - `, userID, patchTestSvcID) + // Create a patch test that links to this service + var patchTestID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) + VALUES ('Allergy Test', 'Patch test for gel products', 24, 6, $1) + RETURNING id + `, []string{patchTestSvcID}).Scan(&patchTestID) if err != nil { - t.Fatalf("failed to create patch test record: %v", err) + t.Fatalf("failed to create patch test: %v", err) + } + + // Create a valid user patch test record (tested 24+ hours ago, within expiry) + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) + VALUES ($1, $2, NOW() - INTERVAL '48 hours') + `, userID, patchTestID) + if err != nil { + t.Fatalf("failed to create user patch test record: %v", err) } handler := http.HandlerFunc(ServicesEligibleForUserHandler) diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go index 60844fa..cd76ab2 100644 --- a/backend/handlers/user/profile.go +++ b/backend/handlers/user/profile.go @@ -3,8 +3,8 @@ package user import ( "bytes" "database/sql" - "errors" "encoding/json" + "errors" "fmt" "io" "log" @@ -576,13 +576,17 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +// ServiceForPatchTest represents a service that requires a patch test type ServiceForPatchTest struct { - ID string `json:"id"` - Name string `json:"name"` - PatchTestDurationHours int `json:"patchTestDurationHours"` + ID string `json:"id"` + Name string `json:"name"` + PatchTestID string `json:"patchTestId"` + NoticeDurationHours int `json:"noticeDurationHours"` + ExpiryMonths int `json:"expiryMonths"` } // GET /api/admin/users/{id}/patch-tests/eligible +// Returns services that require a patch test which the user hasn't completed yet func GetEligiblePatchTestServicesHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "id") if userID == "" || !validators.IsValidID(userID) { @@ -590,20 +594,20 @@ func GetEligiblePatchTestServicesHandler(w http.ResponseWriter, r *http.Request) return } - // Debug: count services with patch test - var totalWithPatchTest int - err := db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM services WHERE is_active = true AND patch_test_duration_hours > 0`).Scan(&totalWithPatchTest) - if err != nil { - log.Printf("Debug: failed to count patch test services: %v", err) - } - log.Printf("Debug: userID=%s, services with patch_test=%d", userID, totalWithPatchTest) - + // Get services that require a patch test but user hasn't completed + // This queries patch_tests to find which services require patch tests, + // then excludes those the user already has valid records for rows, err := db.DB.Query(r.Context(), ` - SELECT s.id, s.name, s.patch_test_duration_hours + SELECT DISTINCT s.id, s.name, pt.id, pt.notice_duration_hours, pt.expiry_months FROM services s - WHERE s.is_active = true AND s.patch_test_duration_hours > 0 - AND s.id NOT IN ( - SELECT service_id FROM user_service_patch_tests WHERE user_id = $1 + JOIN patch_tests pt ON s.id = ANY(pt.service_ids) + WHERE s.is_active = true + AND pt.id NOT IN ( + SELECT pt_inner.id + FROM patch_tests pt_inner + JOIN user_patch_tests upt ON pt_inner.id = upt.patch_test_id + WHERE upt.user_id = $1 + AND upt.tested_at + (pt_inner.expiry_months || ' months')::interval > NOW() ) ORDER BY s.name ASC `, userID) @@ -617,7 +621,7 @@ func GetEligiblePatchTestServicesHandler(w http.ResponseWriter, r *http.Request) var services []ServiceForPatchTest for rows.Next() { var s ServiceForPatchTest - if err := rows.Scan(&s.ID, &s.Name, &s.PatchTestDurationHours); err != nil { + if err := rows.Scan(&s.ID, &s.Name, &s.PatchTestID, &s.NoticeDurationHours, &s.ExpiryMonths); err != nil { log.Printf("Failed to scan service: %v", err) continue } @@ -633,10 +637,11 @@ func GetEligiblePatchTestServicesHandler(w http.ResponseWriter, r *http.Request) } type AddPatchTestRequest struct { - ServiceID string `json:"service_id"` + PatchTestID string `json:"patch_test_id"` } // POST /api/admin/users/{id}/patch-tests +// Records that a user has taken a patch test func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "id") if userID == "" || !validators.IsValidID(userID) { @@ -650,28 +655,30 @@ func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) { return } - if req.ServiceID == "" { - http.Error(w, "service_id is required", http.StatusBadRequest) + if req.PatchTestID == "" { + http.Error(w, "patch_test_id is required", http.StatusBadRequest) return } - var patchTestHours int - err := db.DB.QueryRow(r.Context(), `SELECT patch_test_duration_hours FROM services WHERE id = $1 AND is_active = true AND patch_test_duration_hours > 0`, req.ServiceID).Scan(&patchTestHours) + // Verify patch test exists + var patchTestID string + err := db.DB.QueryRow(r.Context(), `SELECT id FROM patch_tests WHERE id = $1`, req.PatchTestID).Scan(&patchTestID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - http.Error(w, "service not found or does not require patch test", http.StatusBadRequest) + http.Error(w, "patch test not found", http.StatusBadRequest) return } - log.Printf("Failed to verify service: %v", err) + log.Printf("Failed to verify patch test: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } + // Insert or update user_patch_tests record _, err = db.DB.Exec(r.Context(), ` - INSERT INTO user_service_patch_tests (user_id, service_id, last_time) + INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) VALUES ($1, $2, NOW()) - ON CONFLICT (user_id, service_id) DO UPDATE SET last_time = NOW() - `, userID, req.ServiceID) + ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW() + `, userID, req.PatchTestID) if err != nil { log.Printf("Failed to add patch test: %v", err) http.Error(w, "failed to add patch test", http.StatusInternalServerError) @@ -681,13 +688,17 @@ func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +// UserPatchTest represents a user's patch test record type UserPatchTest struct { - ID string `json:"id"` - ServiceID string `json:"serviceId"` - ServiceName string `json:"serviceName"` - LastTime time.Time `json:"lastTime"` + ID string `json:"id"` + PatchTestID string `json:"patchTestId"` + PatchTestName string `json:"patchTestName"` + TestedAt time.Time `json:"testedAt"` + ValidUntil time.Time `json:"validUntil"` } +// GET /api/admin/users/{user_id}/patch-tests +// Returns all patch test records for a user func GetUserPatchTestsHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "user_id") if userID == "" || !validators.IsValidID(userID) { @@ -696,11 +707,11 @@ func GetUserPatchTestsHandler(w http.ResponseWriter, r *http.Request) { } rows, err := db.DB.Query(r.Context(), ` - SELECT p.id, p.service_id, s.name, p.last_time - FROM user_service_patch_tests p - JOIN services s ON p.service_id = s.id - WHERE p.user_id = $1 - ORDER BY p.last_time DESC + SELECT upt.id, upt.patch_test_id, pt.name, upt.tested_at, upt.tested_at + (pt.expiry_months || ' months')::interval as valid_until + FROM user_patch_tests upt + JOIN patch_tests pt ON upt.patch_test_id = pt.id + WHERE upt.user_id = $1 + ORDER BY upt.tested_at DESC `, userID) if err != nil { log.Printf("Failed to get patch tests: %v", err) @@ -712,7 +723,7 @@ func GetUserPatchTestsHandler(w http.ResponseWriter, r *http.Request) { var tests []UserPatchTest for rows.Next() { var t UserPatchTest - if err := rows.Scan(&t.ID, &t.ServiceID, &t.ServiceName, &t.LastTime); err != nil { + if err := rows.Scan(&t.ID, &t.PatchTestID, &t.PatchTestName, &t.TestedAt, &t.ValidUntil); err != nil { log.Printf("Failed to scan patch test: %v", err) continue } @@ -723,6 +734,7 @@ func GetUserPatchTestsHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(tests) } +// DELETE /api/admin/users/{user_id}/patch-tests/{test_id} func DeletePatchTestHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "user_id") testID := chi.URLParam(r, "test_id") @@ -735,8 +747,9 @@ func DeletePatchTestHandler(w http.ResponseWriter, r *http.Request) { return } + // testID in this context is the user_patch_tests.id (BIGSERIAL) result, err := db.DB.Exec(r.Context(), ` - DELETE FROM user_service_patch_tests WHERE id = $1 AND user_id = $2 + DELETE FROM user_patch_tests WHERE id = $1 AND user_id = $2 `, testID, userID) if err != nil { log.Printf("Failed to delete patch test: %v", err) @@ -789,7 +802,7 @@ func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) { fileBytes, err := io.ReadAll(file) if err != nil { log.Printf("Failed to read file: %v", err) - http.Error(w, "Failed to read file", http.StatusInternalServerError) + http.Error(w, "Failed to read file", http.StatusBadRequest) return } diff --git a/backend/testutils/fixtures/fixtures.go b/backend/testutils/fixtures/fixtures.go index 9457123..079910d 100644 --- a/backend/testutils/fixtures/fixtures.go +++ b/backend/testutils/fixtures/fixtures.go @@ -57,10 +57,10 @@ func CreateTestService(pool *pgxpool.Pool) (string, error) { ctx := context.Background() var serviceID string err := pool.QueryRow(ctx, ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id - `, "Test Service", "A test service for unit tests", 50.00, 60, true, 0, 16).Scan(&serviceID) + `, "Test Service", "A test service for unit tests", 50.00, 60, true, 16).Scan(&serviceID) if err != nil { return "", fmt.Errorf("failed to create service: %w", err) @@ -69,20 +69,68 @@ func CreateTestService(pool *pgxpool.Pool) (string, error) { return serviceID, nil } -func CreateTestServiceWithPatchTest(pool *pgxpool.Pool) (string, error) { +// CreateTestServiceWithPatchTest creates a service and a patch test that links to it +// Returns serviceID, patchTestID +func CreateTestServiceWithPatchTest(pool *pgxpool.Pool) (string, string, error) { ctx := context.Background() + + // First create the service var serviceID string err := pool.QueryRow(ctx, ` - INSERT INTO services (name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id - `, "Test Patch Test Service", "A test service requiring patch test", 75.00, 90, true, 48, 18).Scan(&serviceID) + `, "Test Patch Test Service", "A test service requiring patch test", 75.00, 90, true, 18).Scan(&serviceID) if err != nil { - return "", fmt.Errorf("failed to create service: %w", err) + return "", "", fmt.Errorf("failed to create service: %w", err) } - return serviceID, nil + // Now create a patch test that links to this service + var patchTestID string + err = pool.QueryRow(ctx, ` + INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, "Test Patch Test", "A patch test for testing", 24, 6, []string{serviceID}).Scan(&patchTestID) + + if err != nil { + return "", "", fmt.Errorf("failed to create patch test: %w", err) + } + + return serviceID, patchTestID, nil +} + +// CreateTestPatchTest creates a patch test definition +func CreateTestPatchTest(pool *pgxpool.Pool, serviceIDs []string) (string, error) { + ctx := context.Background() + var patchTestID string + err := pool.QueryRow(ctx, ` + INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, "Test Patch Test", "A patch test for testing", 24, 6, serviceIDs).Scan(&patchTestID) + + if err != nil { + return "", fmt.Errorf("failed to create patch test: %w", err) + } + + return patchTestID, nil +} + +// CreateUserPatchTest creates a user patch test record +func CreateUserPatchTest(pool *pgxpool.Pool, userID, patchTestID string, testedAt string) error { + ctx := context.Background() + _, err := pool.Exec(ctx, ` + INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) + VALUES ($1, $2, $3) + `, userID, patchTestID, testedAt) + + if err != nil { + return fmt.Errorf("failed to create user patch test: %w", err) + } + + return nil } func CreateTestBooking(pool *pgxpool.Pool, userID, serviceID string) (string, error) { diff --git a/backend/testutils/testdb/testdb.go b/backend/testutils/testdb/testdb.go index 203756e..9a8eb98 100644 --- a/backend/testutils/testdb/testdb.go +++ b/backend/testutils/testdb/testdb.go @@ -76,7 +76,9 @@ func Migrate(t *testing.T, pool *pgxpool.Pool) { "booking_services", "payments", "bookings", - "user_service_patch_tests", + "user_patch_tests", + "patch_tests", + "booking_edit_requests", "services", "verification_codes", "user_social_logins", @@ -196,7 +198,8 @@ func TruncateTables(t *testing.T, pool *pgxpool.Pool) { "booking_services", "payments", "bookings", - "user_service_patch_tests", + "user_patch_tests", + "patch_tests", "services", "admin_notifications", "user_referrals", diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index e30b49a..556623a 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -150,6 +150,38 @@ CREATE INDEX idx_verification_codes_code ON verification_codes (code); CREATE INDEX idx_verification_codes_user_purpose ON verification_codes (user_id, purpose) WHERE used_at IS NULL; CREATE INDEX idx_verification_codes_expires ON verification_codes (expires_at) WHERE used_at IS NULL; +-- ======================================= +-- PATCH TESTS TABLE +-- ======================================= + +CREATE TABLE patch_tests ( + id CHAR(12) PRIMARY KEY DEFAULT generate_service_id(), + name VARCHAR(100) NOT NULL, + description TEXT, + notice_duration_hours INT NOT NULL DEFAULT 24, + expiry_months INT NOT NULL DEFAULT 6, + service_ids CHAR(12)[] DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_patch_tests_name ON patch_tests(name); + +-- ======================================= +-- USER PATCH TESTS TABLE +-- ======================================= + +CREATE TABLE user_patch_tests ( + id BIGSERIAL PRIMARY KEY, + user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + patch_test_id CHAR(12) NOT NULL REFERENCES patch_tests(id) ON DELETE CASCADE, + tested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + notes TEXT, + UNIQUE (user_id, patch_test_id) +); + +CREATE INDEX idx_user_patch_tests_user ON user_patch_tests(user_id); +CREATE INDEX idx_user_patch_tests_tested_at ON user_patch_tests(tested_at); + -- ======================================= -- SERVICES TABLE -- ======================================= @@ -161,7 +193,6 @@ CREATE TABLE services ( price NUMERIC(10,2) NOT NULL, duration_minutes INT NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, - patch_test_duration_hours INT NOT NULL DEFAULT 0, minimum_age_required INT NOT NULL DEFAULT 16, requires_manual_pricing BOOLEAN NOT NULL DEFAULT FALSE, requires_manual_duration BOOLEAN NOT NULL DEFAULT FALSE, @@ -171,16 +202,6 @@ CREATE TABLE services ( CREATE INDEX idx_services_name ON services(name); -CREATE TABLE user_service_patch_tests ( - id BIGSERIAL PRIMARY KEY, - user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - service_id CHAR(12) NOT NULL REFERENCES services(id) ON DELETE CASCADE, - last_time TIMESTAMPTZ NOT NULL, - UNIQUE (user_id, service_id) -); - -CREATE INDEX idx_service_patch_tests_userid ON user_service_patch_tests(user_id); - -- ======================================= -- BOOKINGS TABLE -- =======================================