Replace bare http.FileServer with an SPA-aware handler that tries to serve the requested static file and falls back to index.html when the path doesn't match a real file. This lets React Router handle client-side routes like /schedules on page reload. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
119 lines
4.2 KiB
Go
119 lines
4.2 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
|
|
"git.unsupervised.ca/walkies/internal/auth"
|
|
"git.unsupervised.ca/walkies/internal/checkin"
|
|
"git.unsupervised.ca/walkies/internal/notification"
|
|
"git.unsupervised.ca/walkies/internal/schedule"
|
|
"git.unsupervised.ca/walkies/internal/server/middleware"
|
|
"git.unsupervised.ca/walkies/internal/setup"
|
|
"git.unsupervised.ca/walkies/internal/timeoff"
|
|
"git.unsupervised.ca/walkies/internal/volunteer"
|
|
)
|
|
|
|
func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
|
authSvc := auth.NewService(db, jwtSecret)
|
|
|
|
volunteerStore := volunteer.NewStore(db)
|
|
volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc)
|
|
|
|
notificationStore := notification.NewStore(db)
|
|
notificationHandler := notification.NewHandler(notificationStore)
|
|
|
|
scheduleStore := schedule.NewStore(db)
|
|
scheduleHandler := schedule.NewHandler(scheduleStore, notificationStore)
|
|
|
|
timeoffStore := timeoff.NewStore(db)
|
|
timeoffHandler := timeoff.NewHandler(timeoffStore)
|
|
|
|
checkinStore := checkin.NewStore(db)
|
|
checkinHandler := checkin.NewHandler(checkinStore)
|
|
|
|
setupStore := setup.NewStore(db)
|
|
setupHandler := setup.NewHandler(setupStore, authSvc)
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(chimiddleware.Logger)
|
|
r.Use(chimiddleware.Recoverer)
|
|
r.Use(chimiddleware.RealIP)
|
|
|
|
// API routes
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
// Public auth endpoints
|
|
r.Post("/auth/login", volunteerHandler.Login)
|
|
r.Post("/auth/activate", volunteerHandler.Activate)
|
|
|
|
// Public setup endpoints (self-disabling once first user exists)
|
|
r.Get("/setup/status", setupHandler.Status)
|
|
r.Post("/setup/admin", setupHandler.CreateAdmin)
|
|
|
|
// Protected routes
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.Authenticate(authSvc))
|
|
|
|
// Volunteers
|
|
r.With(middleware.RequireAdmin).Post("/volunteers", volunteerHandler.Create)
|
|
r.Get("/volunteers", volunteerHandler.List)
|
|
r.Get("/volunteers/{id}", volunteerHandler.Get)
|
|
r.Put("/volunteers/{id}", volunteerHandler.Update)
|
|
r.With(middleware.RequireAdmin).Post("/volunteers/{id}/invite", volunteerHandler.ResendInvite)
|
|
|
|
// Shift templates (admin only)
|
|
r.Get("/shift-templates", scheduleHandler.ListTemplates)
|
|
r.With(middleware.RequireAdmin).Post("/shift-templates", scheduleHandler.CreateTemplate)
|
|
r.With(middleware.RequireAdmin).Put("/shift-templates/{id}", scheduleHandler.UpdateTemplate)
|
|
r.With(middleware.RequireAdmin).Delete("/shift-templates/{id}", scheduleHandler.DeleteTemplate)
|
|
|
|
// Shift instances
|
|
r.Get("/shifts", scheduleHandler.ListInstances)
|
|
r.With(middleware.RequireAdmin).Post("/shifts/generate", scheduleHandler.GenerateInstances)
|
|
r.With(middleware.RequireAdmin).Post("/shifts/publish", scheduleHandler.PublishMonth)
|
|
r.With(middleware.RequireAdmin).Post("/shifts/unpublish", scheduleHandler.UnpublishMonth)
|
|
r.With(middleware.RequireAdmin).Put("/shifts/{id}", scheduleHandler.UpdateInstance)
|
|
r.Post("/shifts/{id}/confirm", scheduleHandler.ConfirmShift)
|
|
|
|
// Time off
|
|
r.Get("/timeoff", timeoffHandler.List)
|
|
r.Post("/timeoff", timeoffHandler.Create)
|
|
r.With(middleware.RequireAdmin).Put("/timeoff/{id}/review", timeoffHandler.Review)
|
|
|
|
// Check-in / check-out
|
|
r.Post("/checkin", checkinHandler.CheckIn)
|
|
r.Post("/checkout", checkinHandler.CheckOut)
|
|
r.Get("/checkin/history", checkinHandler.History)
|
|
|
|
// Notifications
|
|
r.Get("/notifications", notificationHandler.List)
|
|
r.Put("/notifications/{id}/read", notificationHandler.MarkRead)
|
|
})
|
|
})
|
|
|
|
// Serve static React app for all other routes, with SPA fallback
|
|
r.Handle("/*", spaHandler(staticDir))
|
|
|
|
return r
|
|
}
|
|
|
|
// spaHandler serves static files from dir, falling back to index.html for
|
|
// paths that don't match a file on disk (so client-side routing works).
|
|
func spaHandler(dir string) http.HandlerFunc {
|
|
fs := http.Dir(dir)
|
|
fileServer := http.FileServer(fs)
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Try to open the requested path as a static file.
|
|
if f, err := fs.Open(r.URL.Path); err == nil {
|
|
f.Close()
|
|
fileServer.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// Not a real file — serve index.html and let React Router handle it.
|
|
http.ServeFile(w, r, dir+"/index.html")
|
|
}
|
|
}
|