Replace placeholder BookingModal on the Next Appointment edit button with a dedicated EditBookingModal that allows admins to add, remove, and override services on an active booking. Includes backend PUT endpoint with overlap detection and full test suite (20 tests).
309 lines
9.6 KiB
Go
309 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crussell/auth"
|
|
"crussell/internal/dav"
|
|
"crussell/internal/s3"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"crussell/db"
|
|
"crussell/mw"
|
|
|
|
authHandlers "crussell/handlers/auth"
|
|
"crussell/handlers/admin"
|
|
"crussell/handlers/bookings"
|
|
"crussell/handlers/notifications"
|
|
"crussell/handlers/portfolio"
|
|
"crussell/handlers/scheduling"
|
|
"crussell/handlers/services"
|
|
"crussell/handlers/today"
|
|
"crussell/handlers/user"
|
|
)
|
|
|
|
func init() {
|
|
jwtSecret := os.Getenv("JWT_SECRET_KEY")
|
|
if jwtSecret == "" {
|
|
log.Fatal("FATAL: JWT_SECRET_KEY environment variable not set. Application cannot start.")
|
|
}
|
|
auth.InitJWT(jwtSecret)
|
|
}
|
|
|
|
func initDB() {
|
|
if err := db.Connect(); err != nil {
|
|
log.Fatal("Failed to connect to DB:", err)
|
|
}
|
|
fmt.Println("Connected to DB successfully")
|
|
}
|
|
|
|
func initDav() {
|
|
if dav.Service == nil {
|
|
log.Fatal("Failed to initialize DAV service")
|
|
}
|
|
fmt.Println("DAV Service connected successfully")
|
|
}
|
|
|
|
func initS3() {
|
|
if err := s3.Connect(); err != nil {
|
|
log.Printf("WARNING: Failed to connect to S3: %v", err)
|
|
} else {
|
|
fmt.Println("S3 client initialized")
|
|
}
|
|
}
|
|
|
|
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
|
|
status := "ok"
|
|
services := map[string]string{
|
|
"backend": "ok",
|
|
"database": "ok",
|
|
"s3_storage": "ok",
|
|
"square_payments": "not_implemented",
|
|
"frontend": "unknown",
|
|
}
|
|
|
|
if db.DB != nil {
|
|
if err := db.DB.Ping(r.Context()); err != nil {
|
|
services["database"] = "error"
|
|
status = "degraded"
|
|
}
|
|
} else {
|
|
services["database"] = "error"
|
|
status = "degraded"
|
|
}
|
|
|
|
if s3.Client == nil {
|
|
services["s3_storage"] = "not_configured"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if status == "degraded" {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
} else {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": status,
|
|
"services": services,
|
|
})
|
|
}
|
|
|
|
func main() {
|
|
initDB()
|
|
initDav()
|
|
initS3()
|
|
|
|
r := chi.NewRouter()
|
|
|
|
// --- 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")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
|
// TODO: Enable HSTS in production
|
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
// TODO: Enable Referrer-Policy in production
|
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
})
|
|
|
|
// All API routes grouped under /api for clarity
|
|
r.Route("/api", func(r chi.Router) {
|
|
|
|
// Public read-only (but check auth context if present for eligibility)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(mw.RateLimit(120, time.Minute))
|
|
r.Get("/services", services.ServicesHandler)
|
|
r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler)
|
|
})
|
|
|
|
// Registration: 10/min to prevent spam
|
|
r.With(mw.RateLimit(10, time.Minute)).Post("/register", authHandlers.RegisterHandler)
|
|
|
|
// Login: Has its own internal rate limiting
|
|
r.Post("/login", authHandlers.LoginHandler)
|
|
|
|
// Email verification
|
|
r.Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler)
|
|
r.Post("/verify/check", authHandlers.VerifyCodeHandler)
|
|
|
|
// Health check
|
|
r.Get("/health", healthCheckHandler)
|
|
|
|
// Public contact info
|
|
r.Get("/contact", user.GetContactInfoHandler)
|
|
|
|
// Portfolio
|
|
r.Route("/portfolio", func(r chi.Router) {
|
|
r.Get("/images", portfolio.ListImages)
|
|
r.Get("/tags", portfolio.ListTags)
|
|
r.With(mw.RateLimit(60, time.Minute)).Get("/filters", portfolio.ListFilters)
|
|
r.With(mw.RateLimit(120, time.Minute)).Get("/images/{id}", portfolio.GetImage)
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(mw.RequireAuth)
|
|
r.Use(mw.RequireAdmin)
|
|
r.Use(mw.RateLimit(60, time.Minute))
|
|
r.Post("/images", portfolio.UploadImage)
|
|
r.Delete("/images/{id}", portfolio.DeleteImage)
|
|
})
|
|
})
|
|
|
|
// Scheduling
|
|
r.Route("/scheduling", func(r chi.Router) {
|
|
r.Use(mw.RateLimit(120, time.Minute))
|
|
r.Get("/default-hours", scheduling.GetDefaultHours)
|
|
r.Get("/exceptional-groups", scheduling.ListExceptionalGroups)
|
|
r.Get("/working-hours", scheduling.GetWorkingHours)
|
|
r.Get("/available-hours", scheduling.GetAvailableHours)
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(mw.RequireAuth)
|
|
r.Use(mw.RequireAdmin)
|
|
r.Use(mw.RateLimit(60, time.Minute))
|
|
|
|
r.Put("/default-hours", scheduling.UpdateDefaultHours)
|
|
r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup)
|
|
r.Delete("/exceptional-groups", scheduling.DeleteExceptionalGroup)
|
|
r.Put("/exceptional-applications", scheduling.UpdateExceptionalApplications)
|
|
})
|
|
})
|
|
|
|
// Public booking endpoints (optional auth for slot reservation and guest bookings)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(mw.RateLimit(30, time.Minute), mw.OptionalAuth)
|
|
r.Post("/bookings/reserve", bookings.ReserveSlotHandler)
|
|
r.Post("/bookings", bookings.CreateBookingHandler)
|
|
})
|
|
|
|
// Guest user creation (public, no auth required)
|
|
r.With(mw.RateLimit(10, time.Minute)).Post("/users/guest", user.CreateGuestUserHandler)
|
|
|
|
// Authenticated users
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(mw.RequireAuth)
|
|
r.Use(mw.RateLimit(120, time.Minute))
|
|
|
|
r.Post("/refresh-token", authHandlers.RefreshTokenHandler)
|
|
|
|
r.Get("/user/profile", user.GetProfileHandler)
|
|
r.Put("/user/profile", user.UpdateProfileHandler)
|
|
r.Post("/user/profile-picture", user.UploadProfilePictureHandler)
|
|
r.Put("/user/change-password", user.ChangePasswordHandler)
|
|
r.Delete("/user/account", user.DeleteAccountHandler)
|
|
r.Get("/user/loyalty", user.GetLoyaltyHandler)
|
|
|
|
r.Get("/bookings", bookings.GetAllUserBookingsHandler)
|
|
r.Get("/bookings/{id}", bookings.GetBookingHandler)
|
|
r.Get("/bookings/{id}/calendar", bookings.GetBookingCalendarHandler)
|
|
r.Put("/bookings/{id}", bookings.EditBookingHandler)
|
|
r.Delete("/bookings/{id}", bookings.DeleteBookingHandler)
|
|
r.Post("/bookings/{id}/edit-request", bookings.RequestEditHandler)
|
|
r.Delete("/bookings/{id}/edit-request", bookings.DeleteEditRequestHandler)
|
|
})
|
|
|
|
// Admin-only (no rate limit - trusted users with authenticated sessions)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(mw.RequireAuth)
|
|
r.Use(mw.RequireAdmin)
|
|
|
|
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)
|
|
})
|
|
|
|
r.Route("/admin/bookings", func(r chi.Router) {
|
|
r.Get("/", bookings.GetAllAdminBookingsHandler)
|
|
r.Post("/", bookings.AdminCreateBookingForUserHandler)
|
|
r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler)
|
|
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
|
|
r.Get("/{id}", bookings.GetAdminBookingHandler)
|
|
r.Put("/{id}", bookings.UpdateBookingServicesHandler)
|
|
r.Get("/{id}/overlapping", bookings.GetOverlappingBookingsHandler)
|
|
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
|
|
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
|
|
r.Post("/{id}/cancel", bookings.AdminCancelBookingHandler)
|
|
r.Post("/reserve", bookings.AdminReserveSlotHandler)
|
|
// Edit request endpoints
|
|
r.Get("/{id}/edit-requests", bookings.AdminListEditRequestsHandler)
|
|
r.Post("/{id}/edit-requests/{request_id}/approve", bookings.AdminApproveEditRequestHandler)
|
|
r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler)
|
|
})
|
|
|
|
r.Route("/admin/users", func(r chi.Router) {
|
|
r.Get("/", user.ListAdminUsersHandler)
|
|
r.Get("/{id}", user.GetAdminUserHandler)
|
|
r.Get("/{id}/relationship", user.GetCustomerRelationshipHandler)
|
|
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
|
|
r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
|
|
})
|
|
|
|
r.Route("/admin/today", func(r chi.Router) {
|
|
r.Get("/current-next", today.GetCurrentAndNextHandler)
|
|
r.Get("/appointments", today.GetTodayAppointmentsHandler)
|
|
r.Get("/pending-approvals", today.GetPendingApprovalsHandler)
|
|
})
|
|
|
|
r.Route("/admin/notifications", func(r chi.Router) {
|
|
r.Get("/", notifications.GetNotifications)
|
|
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
|
|
})
|
|
|
|
r.Route("/admin/time-blockers", func(r chi.Router) {
|
|
r.Get("/", scheduling.ListTimeBlockers)
|
|
r.Post("/", scheduling.CreateTimeBlocker)
|
|
r.Delete("/{id}", scheduling.DeleteTimeBlocker)
|
|
})
|
|
|
|
r.Route("/admin/discount-campaigns", func(r chi.Router) {
|
|
r.Get("/", admin.GetDiscountCampaigns)
|
|
r.Post("/", admin.CreateDiscountCampaign)
|
|
r.Put("/{id}", admin.UpdateDiscountCampaign)
|
|
r.Delete("/{id}", admin.DeleteDiscountCampaign)
|
|
r.Get("/{id}/stats", admin.GetCampaignStats)
|
|
})
|
|
})
|
|
})
|
|
|
|
srv := &http.Server{
|
|
Addr: ":8080",
|
|
Handler: r,
|
|
}
|
|
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
|
|
go func() {
|
|
<-quit
|
|
log.Println("Shutting down server...")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(ctx); err != nil {
|
|
log.Printf("Server forced to shutdown: %v", err)
|
|
}
|
|
}()
|
|
|
|
fmt.Println("Server is listening on :8080")
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("Server failed to start: %v", err)
|
|
}
|
|
log.Println("Server exited")
|
|
}
|