refactor: migrate patch test schema from service-level to dedicated tables

- Remove patch_test_duration_hours from services table
- Add new patch_tests table with service_ids array, notice_duration_hours, expiry_months
- Add new user_patch_tests table linking users to patch_tests with tested_at
- Update services handler to check patch_tests.service_ids for eligibility
- Update booking creation to validate patch test requirements (24h notice, 6mo expiry)
- Update booking completion to extend patch test validity (reset tested_at)
- Update admin handlers for new patch test CRUD operations
- Update test fixtures and test cases for new schema
- Update seeding script to create patch_tests and link to gel services
This commit is contained in:
2026-02-24 22:17:50 +00:00
parent f59595eeec
commit c6fe9e92a7
5 changed files with 173 additions and 443 deletions
+77 -13
View File
@@ -1179,6 +1179,63 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Check patch test requirements for all services
for _, serviceID := range req.ServiceIDs {
// Find patch test for this service
var patchTestID string
var noticeHours int
err := db.DB.QueryRow(r.Context(), `
SELECT id, notice_duration_hours
FROM patch_tests
WHERE $1 = ANY(service_ids)
`, serviceID).Scan(&patchTestID, &noticeHours)
if err == nil {
// Service requires a patch test - check if user has valid record
var testedAt time.Time
err = db.DB.QueryRow(r.Context(), `
SELECT tested_at
FROM user_patch_tests
WHERE user_id = $1 AND patch_test_id = $2
`, userID, patchTestID).Scan(&testedAt)
if err != nil {
// No valid patch test record
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
return
}
// Check if notice period has passed
eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
if time.Now().Before(eligibleFrom) {
hoursLeft := time.Until(eligibleFrom).Hours()
http.Error(w, fmt.Sprintf("You must wait %.0f hours after your patch test before booking this service.", hoursLeft), http.StatusBadRequest)
return
}
// Check if patch test has expired
var expiryMonths int
err = db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths)
if err == nil {
expiresAt := testedAt.AddDate(0, expiryMonths, 0)
if time.Now().After(expiresAt) {
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
return
}
}
}
}
// Validate start time is not in the past
if req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
// Validate start time is not in the past
if req.StartTime.Before(time.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
@@ -1543,30 +1600,36 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
}
if req.Status == "completed" {
// When a booking is completed, extend patch test validity for any related patch tests
// Get all services in this booking
rows, err := db.DB.Query(r.Context(), `
SELECT bs.service_id, s.patch_test_duration_hours
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 AND s.patch_test_duration_hours > 0
SELECT DISTINCT pt.id
FROM patch_tests pt
JOIN booking_services bs ON bs.booking_id = $1
WHERE pt.id IN (
SELECT pt_inner.id
FROM patch_tests pt_inner
WHERE bs.service_id = ANY(pt_inner.service_ids)
)
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services for patch test: %v", err)
log.Printf("Failed to fetch patch tests for booking %s: %v", bookingID, err)
} else {
defer rows.Close()
for rows.Next() {
var serviceID string
var patchTestHours int
if err := rows.Scan(&serviceID, &patchTestHours); err != nil {
log.Printf("Failed to scan service: %v", err)
var patchTestID string
if err := rows.Scan(&patchTestID); err != nil {
log.Printf("Failed to scan patch test: %v", err)
continue
}
// Update or insert 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()
`, booking.User.ID, serviceID)
ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW()
`, booking.User.ID, patchTestID)
if err != nil {
log.Printf("Failed to record patch test: %v", err)
log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, patchTestID, err)
}
}
}
@@ -1599,6 +1662,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
// Return updated booking
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode booking response: %v", err)
+61
View File
@@ -429,6 +429,67 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Check patch test requirements for all services
for _, serviceID := range req.ServiceIDs {
// Find patch test for this service
var patchTestID string
var noticeHours int
err := db.DB.QueryRow(r.Context(), `
SELECT id, notice_duration_hours
FROM patch_tests
WHERE $1 = ANY(service_ids)
`, serviceID).Scan(&patchTestID, &noticeHours)
if err == nil {
// Service requires a patch test - check if user has valid record
var testedAt time.Time
err = db.DB.QueryRow(r.Context(), `
SELECT tested_at
FROM user_patch_tests
WHERE user_id = $1 AND patch_test_id = $2
`, req.UserID, patchTestID).Scan(&testedAt)
if err != nil {
// No valid patch test record
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
return
}
// Check if notice period has passed
eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
if req.StartTime.Before(eligibleFrom) {
hoursNeeded := time.Until(eligibleFrom).Hours()
http.Error(w, fmt.Sprintf("Booking time is before the %.0f hour notice period after patch test. Earliest booking: %s", hoursNeeded, eligibleFrom.Format("2006-01-02 15:04")), http.StatusBadRequest)
return
}
// Check if patch test has expired
var expiryMonths int
err = db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths)
if err == nil {
expiresAt := testedAt.AddDate(0, expiryMonths, 0)
if req.StartTime.After(expiresAt) {
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
return
}
}
}
}
// Validate overrides
if req.UserID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
if req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
// Validate overrides
for _, override := range req.ServiceOverrides {
if override.ServiceID == "" {