Removes the inline Bearer token fallback in ServicesHandler — the OptionalAuth middleware (added to the public services route group in main.go) now handles auth context population. This eliminates duplicated token parsing logic and ensures consistent auth behavior across all routes. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
712 lines
20 KiB
Go
712 lines
20 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"github.com/jackc/pgx/v5"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"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 — 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
|
|
}
|
|
}
|