Two-tier notification system: new_booking (all public bookings) + pending_booking (notes/today). Priority-sorted queue, unread count polling, enriched responses with user_name/booking_start_time. Fix critical bug: edit_requested cleanup was broken (wrong reason string in 3 handlers). Add 15 new tests covering priority ordering, enrichment, and notification creation flows. Update Admin Manual, Technical Manual, and gap backlog docs.
310 lines
9.6 KiB
Go
310 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.Get("/unread-count", notifications.GetUnreadCount)
|
|
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")
|
|
}
|