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
This commit is contained in:
2026-02-24 17:23:28 +00:00
parent 89d848ee72
commit f59595eeec
11 changed files with 906 additions and 255 deletions
+10 -12
View File
@@ -34,7 +34,6 @@ func TestAdminServices_Create(t *testing.T) {
Description: stringPtr("A test manicure service"),
Price: 35.00,
DurationMinutes: 45,
PatchTestDurationHours: 0,
MinimumAgeRequired: 16,
}
@@ -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 {
@@ -210,7 +209,6 @@ func TestAdminServices_NonAdmin(t *testing.T) {
Description: stringPtr("Test"),
Price: 50.00,
DurationMinutes: 60,
PatchTestDurationHours: 0,
MinimumAgeRequired: 16,
}
w := makeUserRequest(createHandler, "POST", "/api/admin/services", createReq)
@@ -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 {
+83 -35
View File
@@ -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)
}
+1 -1
View File
@@ -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",
+87 -1
View File
@@ -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(&currentStatus)
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,
+419 -3
View File
@@ -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(&currentStatus)
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(&currentStatus)
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)
}
+86 -84
View File
@@ -1,6 +1,7 @@
package services
import (
"context"
"crussell/auth"
"crussell/db"
"crussell/internal/validators"
@@ -24,7 +25,6 @@ type Service struct {
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"`
@@ -36,7 +36,6 @@ type ServiceResponse struct {
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"`
// 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
@@ -48,7 +47,6 @@ type CreateServiceRequest struct {
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"`
}
@@ -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
// 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
}
// Patch test is valid - include normally
status := "ok"
service.PatchTestStatus = &status
// No patch test required or valid - include normally
services = append(services, service)
} else {
// No patch test required - 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
// 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
}
// Patch test is valid
status := "ok"
service.PatchTestStatus = &status
// No patch test required or valid - include normally
services = append(services, service)
} else {
// No patch test required
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, &noticeDurationHours, &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,
+36 -20
View File
@@ -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)
+49 -36
View File
@@ -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"`
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"`
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
}
+57 -9
View File
@@ -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) {
+5 -2
View File
@@ -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",
+32 -11
View File
@@ -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
-- =======================================