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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
-- =======================================
|
||||
|
||||
Reference in New Issue
Block a user