Files
Crussell/backend/handlers/services/services.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

730 lines
20 KiB
Go

package services
import (
"context"
"crussell/auth"
"crussell/clock"
"crussell/db"
"github.com/jackc/pgx/v5"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"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 tx.Rollback(r.Context())
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)
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Service toggled successfully",
"id": serviceID,
})
}
// POST /api/admin/services
func CreateServiceHandler(w http.ResponseWriter, r *http.Request) {
// Parse and validate request
var req CreateServiceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), 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 tx.Rollback(r.Context())
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
}
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
}
// 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 tx.Rollback(r.Context())
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)
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Service deleted successfully",
"id": serviceID,
})
}
// 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 - try context first, then optional token
userID, hasUser := r.Context().Value(mw.UserIDKey).(string)
role, _ := r.Context().Value(mw.UserRoleKey).(string)
// If no user in context, try to parse token from header
if !hasUser || userID == "" {
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
var err error
userID, role, _, err = auth.VerifyToken(tokenString, r.Context())
if err != nil {
// Invalid token - treat as unauthenticated
userID = ""
role = ""
}
hasUser = userID != ""
}
}
// 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
}
}