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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user