services WIP
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+47
-47
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user