From 69b8c0cbe58e5bb49aa480cd6ebce3fe871d09f5 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sat, 18 Oct 2025 00:45:57 +0100 Subject: [PATCH] services WIP --- backend/handlers/services/services.go | 339 +++++++++++++++++++++++++ backend/main.go | 94 +++---- frontend/src/routes/admin/+page.svelte | 303 ++++++++++++++++++++++ 3 files changed, 689 insertions(+), 47 deletions(-) create mode 100644 backend/handlers/services/services.go diff --git a/backend/handlers/services/services.go b/backend/handlers/services/services.go new file mode 100644 index 0000000..c34ab9e --- /dev/null +++ b/backend/handlers/services/services.go @@ -0,0 +1,339 @@ +package services + +import ( + "crussell/db" + "crussell/mw" + "database/sql" + "encoding/json" + "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"` + PatchTestDurationHours int `json:"patch_test_duration_hours"` + MinimumAgeRequired int `json:"minimum_age_required"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy *string `json:"created_by,omitempty"` + UpdatedBy *string `json:"updated_by,omitempty"` +} + +type ServiceResponse struct { + 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"` +} + +// 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"` +} + +// ToggleServiceHandler handles toggling a service's active status +func ToggleService(w http.ResponseWriter, r *http.Request) { + serviceID := chi.URLParam(r, "id") + if serviceID == "" { + http.Error(w, "Service ID is required", http.StatusBadRequest) + return + } + + query := "UPDATE services SET is_active = NOT is_active, updated_at = NOW(), updated_by = $1 WHERE id = $2" + result, err := db.DB.Exec(r.Context(), query, serviceID, r.Context().Value(mw.UserIDKey)) + 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 + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Service toggled successfully", + "id": serviceID, + }) +} + +// CreateServiceHandler handles creating a new service +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 + } + + // Basic validation + if req.Name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + if req.Price <= 0 { + http.Error(w, "Price must be greater than 0", http.StatusBadRequest) + return + } + if req.PatchTestDurationHours < 0 { + http.Error(w, "Patch test duration cannot be negative", 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 + query := ` + INSERT INTO services ( + name, description, price, duration_minutes, + patch_test_duration_hours, minimum_age_required, created_by, updated_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING + id, name, description, price, duration_minutes, is_active, + patch_test_duration_hours, minimum_age_required, created_at, + updated_at, created_by, updated_by + ` + + var service Service + var createdByDB, updatedByDB sql.NullString + + err := db.DB.QueryRow(r.Context(), + query, + req.Name, + req.Description, + req.Price, + req.DurationMinutes, + req.PatchTestDurationHours, + req.MinimumAgeRequired, + createdBy, + createdBy, // updated_by same as created_by for new records + ).Scan( + &service.ID, + &service.Name, + &service.Description, + &service.Price, + &service.DurationMinutes, + &service.IsActive, + &service.PatchTestDurationHours, + &service.MinimumAgeRequired, + &service.CreatedAt, + &service.UpdatedAt, + &createdByDB, + &updatedByDB, + ) + + 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 + } + if updatedByDB.Valid { + service.UpdatedBy = &updatedByDB.String + } + + // Return created service + w.Header().Set("Content-Type", "application/json") + 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 == "" { + http.Error(w, "Service ID is required", http.StatusBadRequest) + return + } + + query := "UPDATE services SET is_active = false, updated_at = NOW() WHERE id = $1" + result, err := db.DB.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 + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Service deleted successfully", + "id": serviceID, + }) +} + +// ServicesHandler returns all services from the database +func ServicesHandler(w http.ResponseWriter, r *http.Request) { + // Query all active services + query := ` + SELECT name, description, price, duration_minutes, + patch_test_duration_hours, minimum_age_required + FROM services + WHERE is_active = TRUE + ORDER BY name + ` + + rows, err := db.DB.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.Name, + &service.Description, + &service.Price, + &service.DurationMinutes, + &service.PatchTestDurationHours, + &service.MinimumAgeRequired, + ) + 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 + } + + // Set response headers + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + // Return empty array instead of null if no services found + if services == nil { + services = []ServiceResponse{} + } + + // Encode response + if err := json.NewEncoder(w).Encode(services); err != nil { + http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) + return + } +} + +// 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, + updated_at, created_by, updated_by + FROM services + ORDER BY is_active DESC, name + ` + + rows, err := db.DB.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, updatedBy sql.NullString + + err := rows.Scan( + &service.ID, + &service.Name, + &service.Description, + &service.Price, + &service.DurationMinutes, + &service.IsActive, + &service.PatchTestDurationHours, + &service.MinimumAgeRequired, + &service.CreatedAt, + &service.UpdatedAt, + &createdBy, + &updatedBy, + ) + 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 + } + if updatedBy.Valid { + service.UpdatedBy = &updatedBy.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.Header().Set("Content-Type", "application/json") + 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 + } +} diff --git a/backend/main.go b/backend/main.go index 5010db9..4d6615b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -17,6 +17,7 @@ import ( authHandlers "crussell/handlers/auth" "crussell/handlers/scheduling" + "crussell/handlers/services" "crussell/handlers/user" ) @@ -53,13 +54,12 @@ func main() { r := chi.NewRouter() - // --- Middleware --- - r.Use(middleware.RequestID) // Add X-Request-ID header - r.Use(middleware.RealIP) // Get real IP from headers - r.Use(middleware.Logger) // Basic logging - r.Use(middleware.Recoverer) // Panic recovery - r.Use(middleware.Timeout(15 * time.Second)) // Request timeout - + // --- Global Middleware --- + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(15 * time.Second)) r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") @@ -69,55 +69,55 @@ func main() { }) }) - // --- Public auth routes --- - r.Post("/api/register", authHandlers.RegisterHandler) - r.Post("/api/login", authHandlers.LoginHandler) + // All API routes grouped under /api for clarity + r.Route("/api", func(r chi.Router) { - // --- Protected routes - any authenticated user --- - r.Group(func(r chi.Router) { - r.Use(mw.RequireAuth) + // --- Public Routes --- + r.Get("/services", services.ServicesHandler) + r.Post("/register", authHandlers.RegisterHandler) + r.Post("/login", authHandlers.LoginHandler) - // User profile - r.Get("/api/user/profile", user.GetProfileHandler) - r.Put("/api/user/profile", user.UpdateProfileHandler) - r.Delete("/api/user/account", user.DeleteAccountHandler) + // --- Scheduling public GET routes --- + r.Route("/scheduling", func(r chi.Router) { + r.Get("/default-hours", scheduling.GetDefaultHours) + r.Get("/exceptional-groups", scheduling.ListExceptionalGroups) + r.Get("/exceptional-applications", scheduling.ListExceptionalApplications) + r.Get("/working-hours", scheduling.GetWorkingHours) + r.Get("/available-hours", scheduling.GetAvailableHours) - // Loyalty - r.Get("/api/user/loyalty", user.GetLoyaltyHandler) - }) + // Admin-only scheduling modifications + r.Group(func(r chi.Router) { + r.Use(mw.RequireAuth) + r.Use(mw.RequireAdmin) - // --- Scheduling routes --- - r.Route("/api/scheduling", func(r chi.Router) { + r.Put("/default-hours", scheduling.UpdateDefaultHours) + r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup) + r.Post("/exceptional-applications", scheduling.CreateExceptionalApplication) + }) + }) - // Default hours - r.Get("/default-hours", scheduling.GetDefaultHours) + // --- Protected routes (any authenticated user) --- + r.Group(func(r chi.Router) { + r.Use(mw.RequireAuth) + + r.Get("/user/profile", user.GetProfileHandler) + r.Put("/user/profile", user.UpdateProfileHandler) + r.Delete("/user/account", user.DeleteAccountHandler) + r.Get("/user/loyalty", user.GetLoyaltyHandler) + }) + + // --- Admin-only routes --- r.Group(func(r chi.Router) { r.Use(mw.RequireAuth) r.Use(mw.RequireAdmin) - r.Put("/default-hours", scheduling.UpdateDefaultHours) + + r.Route("/admin/services", func(r chi.Router) { + r.Post("/", services.CreateServiceHandler) + r.Delete("/{id}", services.DeleteServiceHandler) + r.Get("/", services.AllServicesHandler) + r.Put("/{id}/toggle", services.ToggleService) + }) }) - - // Exceptional groups - r.Get("/exceptional-groups", scheduling.ListExceptionalGroups) - r.Group(func(r chi.Router) { - r.Use(mw.RequireAuth) - r.Use(mw.RequireAdmin) - r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup) - }) - - // Exceptional applications (assign groups to weeks) - r.Get("/exceptional-applications", scheduling.ListExceptionalApplications) - r.Group(func(r chi.Router) { - r.Use(mw.RequireAuth) - r.Use(mw.RequireAdmin) - r.Post("/exceptional-applications", scheduling.CreateExceptionalApplication) - }) - - // Merged working hours (default + exceptional) - r.Get("/working-hours", scheduling.GetWorkingHours) - - // Fully calculated available hours (default + exceptional + bookings + lunch breaks) - r.Get("/available-hours", scheduling.GetAvailableHours) }) fmt.Println("Server is listening on :8080") diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 3da5f35..f5cb72f 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -464,6 +464,126 @@ dest.push(isoDateOf(new Date(d))); } } + + // =============== Services Management =============== + type Service = { + id: string; + name: string; + description: string; + price: number; + duration_minutes: number; + is_active: boolean; + patch_test_duration_hours: number; + minimum_age_required: number; + created_at: string; + updated_at: string; + created_by?: string; + updated_by?: string; + }; + + let services = $state([]); + let servicesLoading = $state(true); + let servicesUpdating = $state>({}); + let showServiceModal = $state(false); + + // Fetch services from API + async function fetchServices() { + servicesLoading = true; + try { + const response = await fetch('/api/admin/services', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('authToken')}` + } + }); + + if (response.ok) { + const data = await response.json(); + services = data; + } else { + console.error('Failed to fetch services:', response.status); + toast.error('Failed to load services'); + } + } catch (err) { + console.error('Error fetching services:', err); + toast.error('Network error loading services'); + } finally { + servicesLoading = false; + } + } + + // Toggle service active status + async function toggleService(serviceId: string) { + servicesUpdating[serviceId] = true; + + try { + const response = await fetch(`/api/admin/services/${serviceId}/toggle`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${localStorage.getItem('authToken')}` + } + }); + + if (response.ok) { + toast.success('Service status updated'); + // Refresh the services list + await fetchServices(); + } else { + const errorText = await response.text(); + toast.error(`Failed to update service: ${errorText}`); + } + } catch (err) { + console.error('Error toggling service:', err); + toast.error('Network error updating service'); + } finally { + servicesUpdating[serviceId] = false; + } + } + + // Delete service + async function deleteService(serviceId: string) { + if (!confirm('Are you sure you want to delete this service? This action cannot be undone.')) { + return; + } + + servicesUpdating[serviceId] = true; + + try { + const response = await fetch(`/api/admin/services/${serviceId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${localStorage.getItem('authToken')}` + } + }); + + if (response.ok) { + toast.success('Service deleted successfully'); + // Refresh the services list + await fetchServices(); + } else { + const errorText = await response.text(); + toast.error(`Failed to delete service: ${errorText}`); + } + } catch (err) { + console.error('Error deleting service:', err); + toast.error('Network error deleting service'); + } finally { + servicesUpdating[serviceId] = false; + } + } + + // Create new service + function createNewService() { + // TODO: Implement service creation modal + toast.info('Service creation modal would open here'); + // showServiceModal = true; + } + + // Fetch services on component mount + $effect(() => { + fetchServices(); + }); {#if user?.role == 'admin'} @@ -737,6 +857,189 @@ {/if} + + + + Services Management + Manage your services - add, edit, toggle availability, or delete services. + + +
+ +
+ + + + + +
+ {#if servicesLoading} + {#each Array(3) as _, i} +
+
+ + +
+ + +
+
+ + +
+
+
+ {/each} + {:else} + {#each services as service} +
+
+
+

{service.name}

+ + {service.is_active ? 'Active' : 'Inactive'} + +
+ + {#if service.description} +

{service.description}

+ {/if} + +
+
+ Price: £{service.price.toFixed(2)} +
+
+ Duration: + {service.duration_minutes} min +
+
+ +
+ + +
+
+
+ {/each} + {/if} +
+ + {#if !servicesLoading && services.length === 0} +
+ No services found. Click "Add Service" to create your first service. +
+ {/if} +
+