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
+114 -112
View File
@@ -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, &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)