PostgreSQL ORDER BY ... DESC puts NULLs first by default, so services with no bookings appeared at the top instead of the bottom. Also removes the booking count column from SELECT entirely — the sort is done purely in the ORDER BY.
782 lines
22 KiB
Go
782 lines
22 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// 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"`
|
|
MinimumAgeRequired int `json:"minimum_age_required"`
|
|
PatchTestDurationHours int `json:"patch_test_duration_hours"`
|
|
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"`
|
|
MinimumAgeRequired int `json:"minimum_age_required"`
|
|
PatchTestDurationHours int `json:"patch_test_duration_hours"`
|
|
// Patch test status for non-admin logged-in users
|
|
PatchTestStatus *string `json:"patch_test_status,omitempty"`
|
|
}
|
|
|
|
// 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"`
|
|
MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"`
|
|
}
|
|
|
|
// ToggleServiceHandler handles toggling a service's active status
|
|
func ToggleService(w http.ResponseWriter, r *http.Request) {
|
|
serviceID := chi.URLParam(r, "id")
|
|
if serviceID == "" || !validators.IsValidID(serviceID) {
|
|
http.Error(w, "Service not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
query := "UPDATE services SET is_active = NOT is_active WHERE id = $1"
|
|
result, err := tx.Exec(r.Context(), query, serviceID)
|
|
if err != nil {
|
|
http.Error(w, "Failed to toggle service: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if result.RowsAffected() == 0 {
|
|
http.Error(w, "Service not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
|
"message": "Service toggled successfully",
|
|
"id": serviceID,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
// POST /api/admin/services
|
|
func CreateServiceHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateServiceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := validators.Validate.Struct(&req); err != nil {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Basic validation
|
|
if req.Name == "" {
|
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.Name) > 100 {
|
|
http.Error(w, "Name must be 100 characters or less", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Price <= 0 {
|
|
http.Error(w, "Price must be greater than 0", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.DurationMinutes <= 0 || req.DurationMinutes > 480 {
|
|
http.Error(w, "Duration must be between 1 and 480 minutes", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.MinimumAgeRequired < 0 || req.MinimumAgeRequired > 100 {
|
|
http.Error(w, "Minimum age must be between 0 and 100", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get user ID from context (if authentication is added later)
|
|
var createdBy *string
|
|
if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
|
|
createdBy = &userID
|
|
}
|
|
|
|
// Insert new service
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
query := `
|
|
INSERT INTO services (
|
|
name, description, price, duration_minutes,
|
|
minimum_age_required, created_by
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING
|
|
id, name, description, price, duration_minutes, is_active,
|
|
minimum_age_required, created_at, created_by
|
|
`
|
|
|
|
var service Service
|
|
var createdByDB sql.NullString
|
|
|
|
err = tx.QueryRow(r.Context(),
|
|
query,
|
|
req.Name,
|
|
req.Description,
|
|
req.Price,
|
|
req.DurationMinutes,
|
|
req.MinimumAgeRequired,
|
|
createdBy,
|
|
).Scan(
|
|
&service.ID,
|
|
&service.Name,
|
|
&service.Description,
|
|
&service.Price,
|
|
&service.DurationMinutes,
|
|
&service.IsActive,
|
|
&service.MinimumAgeRequired,
|
|
&service.CreatedAt,
|
|
&createdByDB,
|
|
)
|
|
|
|
if err != nil {
|
|
// Check for duplicate name or other constraints
|
|
if err.Error() == "pq: duplicate key value violates unique constraint" {
|
|
http.Error(w, "A service with this name already exists", http.StatusConflict)
|
|
return
|
|
}
|
|
http.Error(w, "Failed to create service: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Convert nullable fields to pointers
|
|
if createdByDB.Valid {
|
|
service.CreatedBy = &createdByDB.String
|
|
}
|
|
|
|
// Return created service
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
if err := json.NewEncoder(w).Encode(service); err != nil {
|
|
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// DeleteServiceHandler handles soft deleting a service (setting is_active to false)
|
|
func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) {
|
|
serviceID := chi.URLParam(r, "id")
|
|
if serviceID == "" || !validators.IsValidID(serviceID) {
|
|
http.Error(w, "Service not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Use soft delete - set is_active to FALSE instead of hard delete
|
|
// This preserves referential integrity with booking_services
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
query := "UPDATE services SET is_active = FALSE WHERE id = $1"
|
|
result, err := tx.Exec(r.Context(), query, serviceID)
|
|
if err != nil {
|
|
http.Error(w, "Failed to delete service: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if result.RowsAffected() == 0 {
|
|
http.Error(w, "Service not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
|
"message": "Service deleted successfully",
|
|
"id": serviceID,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
// PopularServicesHandler returns services sorted by booking popularity (most bookings
|
|
// in the last 6 months first), with ties broken by price (highest first).
|
|
func PopularServicesHandler(w http.ResponseWriter, r *http.Request) {
|
|
query := `
|
|
SELECT s.id, s.name, s.description, s.price, s.duration_minutes,
|
|
s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0)
|
|
FROM services s
|
|
LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids)
|
|
LEFT JOIN (
|
|
SELECT bsvc.service_id, COUNT(*) AS cnt
|
|
FROM booking_services bsvc
|
|
JOIN bookings b ON b.id = bsvc.booking_id
|
|
WHERE b.start_time >= NOW() - INTERVAL '6 months'
|
|
GROUP BY bsvc.service_id
|
|
) booking_counts ON s.id = booking_counts.service_id
|
|
WHERE s.is_active = TRUE
|
|
ORDER BY booking_counts.cnt DESC NULLS LAST, s.price DESC, s.name
|
|
`
|
|
|
|
rows, err := db.Conn.Query(r.Context(), query)
|
|
if err != nil {
|
|
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var services []ServiceResponse
|
|
|
|
for rows.Next() {
|
|
var service ServiceResponse
|
|
|
|
err := rows.Scan(
|
|
&service.ID,
|
|
&service.Name,
|
|
&service.Description,
|
|
&service.Price,
|
|
&service.DurationMinutes,
|
|
&service.MinimumAgeRequired,
|
|
&service.PatchTestDurationHours,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
services = append(services, service)
|
|
}
|
|
|
|
if err = rows.Err(); err != nil {
|
|
http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
if services == nil {
|
|
services = []ServiceResponse{}
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(services); err != nil {
|
|
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// ServicesHandler returns all services from the database
|
|
// For non-admin logged-in users, filters based on age and patch test eligibility
|
|
func ServicesHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Check if user is authenticated — context is set by OptionalAuth middleware
|
|
userID, hasUser := r.Context().Value(mw.UserIDKey).(string)
|
|
role, _ := r.Context().Value(mw.UserRoleKey).(string)
|
|
|
|
// If not logged in or admin, return all services (current behavior)
|
|
if !hasUser || userID == "" || role == "admin" {
|
|
query := `
|
|
SELECT s.id, s.name, s.description, s.price, s.duration_minutes,
|
|
s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0)
|
|
FROM services s
|
|
LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids)
|
|
WHERE s.is_active = TRUE
|
|
ORDER BY s.name
|
|
`
|
|
|
|
rows, err := db.Conn.Query(r.Context(), query)
|
|
if err != nil {
|
|
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var services []ServiceResponse
|
|
|
|
for rows.Next() {
|
|
var service ServiceResponse
|
|
|
|
err := rows.Scan(
|
|
&service.ID,
|
|
&service.Name,
|
|
&service.Description,
|
|
&service.Price,
|
|
&service.DurationMinutes,
|
|
&service.MinimumAgeRequired,
|
|
&service.PatchTestDurationHours,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
services = append(services, service)
|
|
}
|
|
|
|
if err = rows.Err(); err != nil {
|
|
http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
if services == nil {
|
|
services = []ServiceResponse{}
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(services); err != nil {
|
|
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
// User is logged in and not admin - check eligibility
|
|
// Get user's date of birth
|
|
var dob time.Time
|
|
err := db.Conn.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob)
|
|
if err != nil {
|
|
http.Error(w, "Failed to get user data: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Calculate age
|
|
now := clock.Now()
|
|
age := now.Year() - dob.Year()
|
|
if now.YearDay() < dob.YearDay() {
|
|
age--
|
|
}
|
|
|
|
// Preload patch test data once to avoid N+1 queries.
|
|
// Must be done BEFORE querying services so the tx connection isn't busy.
|
|
patchTests := loadPatchTests(r.Context(), userID)
|
|
|
|
// Get all active services
|
|
query := `
|
|
SELECT s.id, s.name, s.description, s.price, s.duration_minutes,
|
|
s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0)
|
|
FROM services s
|
|
LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids)
|
|
WHERE s.is_active = TRUE
|
|
ORDER BY s.name
|
|
`
|
|
|
|
rows, err := db.Conn.Query(r.Context(), query)
|
|
if err != nil {
|
|
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var services []ServiceResponse
|
|
var ineligibleServices []ServiceResponse
|
|
|
|
for rows.Next() {
|
|
var service ServiceResponse
|
|
|
|
err := rows.Scan(
|
|
&service.ID,
|
|
&service.Name,
|
|
&service.Description,
|
|
&service.Price,
|
|
&service.DurationMinutes,
|
|
&service.MinimumAgeRequired,
|
|
&service.PatchTestDurationHours,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Check age eligibility - EXCLUDE if user is too young (can't be fixed by user)
|
|
if age < service.MinimumAgeRequired {
|
|
continue
|
|
}
|
|
|
|
// Check patch test requirement using new schema
|
|
patchTestStatus := checkPatchTestStatus(r.Context(), userID, service.ID, patchTests)
|
|
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 {
|
|
http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Sort: eligible first (by name), then ineligible (by name)
|
|
// Combine: eligible + ineligible
|
|
services = append(services, ineligibleServices...)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
if services == nil {
|
|
services = []ServiceResponse{}
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(services); err != nil {
|
|
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// ServicesEligibleForUserHandler returns services with eligibility calculated for a specific user
|
|
// Used by admin booking flows when booking on behalf of a user
|
|
func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID := chi.URLParam(r, "user_id")
|
|
if userID == "" || !validators.IsValidID(userID) {
|
|
http.Error(w, "user not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Get user's date of birth
|
|
var dob time.Time
|
|
err := db.Conn.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "user not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "Failed to get user data: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Calculate age
|
|
now := clock.Now()
|
|
age := now.Year() - dob.Year()
|
|
if now.YearDay() < dob.YearDay() {
|
|
age--
|
|
}
|
|
|
|
// Preload patch test data once to avoid N+1 queries.
|
|
// Must be done BEFORE querying services so the tx connection isn't busy.
|
|
patchTests := loadPatchTests(r.Context(), userID)
|
|
|
|
// Get all active services
|
|
query := `
|
|
SELECT s.id, s.name, s.description, s.price, s.duration_minutes,
|
|
s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0)
|
|
FROM services s
|
|
LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids)
|
|
WHERE s.is_active = TRUE
|
|
ORDER BY s.name
|
|
`
|
|
|
|
rows, err := db.Conn.Query(r.Context(), query)
|
|
if err != nil {
|
|
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var services []ServiceResponse
|
|
var grayedOutServices []ServiceResponse
|
|
|
|
for rows.Next() {
|
|
var service ServiceResponse
|
|
|
|
err := rows.Scan(
|
|
&service.ID,
|
|
&service.Name,
|
|
&service.Description,
|
|
&service.Price,
|
|
&service.DurationMinutes,
|
|
&service.MinimumAgeRequired,
|
|
&service.PatchTestDurationHours,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Check age eligibility - EXCLUDE if user is too young
|
|
if age < service.MinimumAgeRequired {
|
|
continue
|
|
}
|
|
|
|
// Check patch test requirement using new schema
|
|
patchTestStatus := checkPatchTestStatus(r.Context(), userID, service.ID, patchTests)
|
|
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 {
|
|
http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Sort and combine: valid first, then grayed out
|
|
services = append(services, grayedOutServices...)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
if services == nil {
|
|
services = []ServiceResponse{}
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(services); err != nil {
|
|
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// patchTestInfo holds preloaded patch test data for a service
|
|
type patchTestInfo struct {
|
|
patchTestID string
|
|
noticeDurationHours int
|
|
expiryMonths int
|
|
testedAt *time.Time // nil if no user_patch_test record
|
|
}
|
|
|
|
// loadPatchTests preloads all patch test data for a user into a serviceID-keyed map.
|
|
// Performs exactly 2 queries total regardless of the number of services.
|
|
func loadPatchTests(ctx context.Context, userID string) map[string]*patchTestInfo {
|
|
result := make(map[string]*patchTestInfo)
|
|
|
|
// Query 1: load all patch_test records
|
|
rows, err := db.Conn.Query(ctx, `
|
|
SELECT id, notice_duration_hours, expiry_months, service_ids
|
|
FROM patch_tests
|
|
`)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
defer rows.Close()
|
|
|
|
type ptRow struct {
|
|
id string
|
|
noticeDurationHours int
|
|
expiryMonths int
|
|
serviceIDs []string
|
|
}
|
|
|
|
var patchTests []ptRow
|
|
for rows.Next() {
|
|
var pt ptRow
|
|
if err := rows.Scan(&pt.id, &pt.noticeDurationHours, &pt.expiryMonths, &pt.serviceIDs); err != nil {
|
|
continue
|
|
}
|
|
patchTests = append(patchTests, pt)
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return result
|
|
}
|
|
|
|
// Query 2: load user_patch_test records for this user
|
|
testedAtMap := make(map[string]time.Time)
|
|
if len(patchTests) > 0 {
|
|
uRows, err := db.Conn.Query(ctx, `
|
|
SELECT patch_test_id, tested_at
|
|
FROM user_patch_tests
|
|
WHERE user_id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
defer uRows.Close()
|
|
for uRows.Next() {
|
|
var ptID string
|
|
var testedAt time.Time
|
|
if err := uRows.Scan(&ptID, &testedAt); err == nil {
|
|
testedAtMap[ptID] = testedAt
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build serviceID → patchTestInfo map
|
|
for _, pt := range patchTests {
|
|
info := &patchTestInfo{
|
|
patchTestID: pt.id,
|
|
noticeDurationHours: pt.noticeDurationHours,
|
|
expiryMonths: pt.expiryMonths,
|
|
}
|
|
if testedAt, ok := testedAtMap[pt.id]; ok {
|
|
info.testedAt = &testedAt
|
|
}
|
|
for _, sid := range pt.serviceIDs {
|
|
result[sid] = info
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// checkPatchTestStatus checks if a service requires a patch test and if the user has a valid one.
|
|
// Uses preloaded patch test data to avoid per-service DB queries.
|
|
// Returns: nil = no patch test required, "required" = no record, "expired" = record too old
|
|
func checkPatchTestStatus(ctx context.Context, userID, serviceID string, patchTests map[string]*patchTestInfo) *string {
|
|
info, ok := patchTests[serviceID]
|
|
if !ok {
|
|
// No patch test required for this service
|
|
return nil
|
|
}
|
|
|
|
if info.testedAt == nil {
|
|
// No patch test record - required
|
|
status := "required"
|
|
return &status
|
|
}
|
|
|
|
// Check if notice period has passed (can only book after this time)
|
|
eligibleFrom := info.testedAt.Add(time.Duration(info.noticeDurationHours) * time.Hour)
|
|
if clock.Now().Before(eligibleFrom) {
|
|
// Not yet eligible (within notice period)
|
|
status := "required"
|
|
return &status
|
|
}
|
|
|
|
// Check if patch test has expired
|
|
expiresAt := info.testedAt.AddDate(0, info.expiryMonths, 0)
|
|
if clock.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 s.id, s.name, s.description, s.price, s.duration_minutes, s.is_active,
|
|
s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0),
|
|
s.created_at, s.created_by
|
|
FROM services s
|
|
LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids)
|
|
ORDER BY s.is_active DESC, s.name
|
|
`
|
|
|
|
rows, err := db.Conn.Query(r.Context(), query)
|
|
if err != nil {
|
|
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var services []Service
|
|
|
|
for rows.Next() {
|
|
var service Service
|
|
var createdBy sql.NullString
|
|
|
|
err := rows.Scan(
|
|
&service.ID,
|
|
&service.Name,
|
|
&service.Description,
|
|
&service.Price,
|
|
&service.DurationMinutes,
|
|
&service.IsActive,
|
|
&service.MinimumAgeRequired,
|
|
&service.PatchTestDurationHours,
|
|
&service.CreatedAt,
|
|
&createdBy,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Convert nullable fields to pointers
|
|
if createdBy.Valid {
|
|
service.CreatedBy = &createdBy.String
|
|
}
|
|
|
|
services = append(services, service)
|
|
}
|
|
|
|
if err = rows.Err(); err != nil {
|
|
http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Set response headers
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
// Return empty array instead of null if no services found
|
|
if services == nil {
|
|
services = []Service{}
|
|
}
|
|
|
|
// Encode response
|
|
if err := json.NewEncoder(w).Encode(services); err != nil {
|
|
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|