package routes import ( "fotbal-club/internal/config" "fotbal-club/internal/controllers" "fotbal-club/internal/middleware" "fotbal-club/internal/services" "fotbal-club/pkg/email" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // Global newsletter automation instance var newsletterAutomation *services.NewsletterAutomation // SetNewsletterAutomation stores the newsletter automation instance func SetNewsletterAutomation(na *services.NewsletterAutomation) { newsletterAutomation = na } // GetNewsletterAutomation returns the newsletter automation instance func GetNewsletterAutomation() *services.NewsletterAutomation { return newsletterAutomation } // SetupRoutes configures all the routes for the application func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) { emailService := email.NewEmailService(config.AppConfig, db) // Initialize controllers baseController := &controllers.BaseController{DB: db} authController := controllers.NewAuthController(db) facrController := controllers.NewFACRController(db) contactController := controllers.NewContactController(db, emailService) passwordController := controllers.NewPasswordController(db, emailService) aiController := controllers.NewAIController(db) scoreboardController := controllers.NewScoreboardController(db) youtubeController := controllers.NewYouTubeController(db) aboutController := controllers.NewAboutController(db) galleryController := controllers.NewGalleryController(db) umamiController := controllers.NewUmamiController() filesController := &controllers.FilesController{DB: db} notificationsController := controllers.NewNotificationsController(db, emailService) emailController := controllers.NewEmailController(db) prefetchController := controllers.NewPrefetchController() seoController := controllers.NewSEOController(db) navigationController := controllers.NewNavigationController(db) pollController := controllers.NewPollController(db) clothingController := controllers.NewClothingController(db) pageElementConfigController := controllers.NewPageElementConfigController(db) // API v1 group { // Health check api.GET("/health", baseController.HealthCheck) // Image proxy (public) to work around CORS when reading images in Canvas on the frontend api.GET("/proxy/image", baseController.ProxyImage) // Public SEO: metadata api.GET("/seo", seoController.GetPublicSEO) // Public navigation endpoints api.GET("/navigation", navigationController.GetNavigationItems) api.GET("/social-links", navigationController.GetSocialLinks) // Public page element configurations api.GET("/page-elements", pageElementConfigController.GetPageElementConfigs) // Email tracking (public) api.GET("/email/open.gif", emailController.OpenPixel) api.GET("/email/click", emailController.ClickRedirect) api.GET("/email/spam", emailController.MarkSpam) api.GET("/email/unsubscribe", emailController.Unsubscribe) // Initial setup (public) api.GET("/setup/status", baseController.SetupStatus) api.POST("/setup/initialize", baseController.SetupInitialize) // SMTP validation (public during setup; does not send email, only connects) api.POST("/setup/validate-smtp", baseController.ValidateSMTP) // Auth routes auth := api.Group("/auth") { // Limit login attempts to mitigate brute force auth.POST("/login", middleware.RateLimit(15, time.Minute), authController.Login) // Logout clears auth cookie (stateless) auth.POST("/logout", authController.Logout) // Limit registration bursts auth.POST("/register", middleware.RateLimit(5, time.Hour), authController.Register) auth.GET("/check-email", authController.CheckEmail) // Password reset (public) auth.POST("/initiate-password-reset", passwordController.InitiatePasswordReset) auth.POST("/verify-reset-code", passwordController.VerifyResetCode) auth.POST("/complete-password-reset", passwordController.CompletePasswordReset) // Legacy password reset endpoints (deprecated) auth.POST("/forgot-password", passwordController.ForgotPassword) auth.POST("/forgot-password-admin", passwordController.AdminSendReset) auth.POST("/reset-password", passwordController.ResetPassword) auth.GET("/me", middleware.JWTAuth(db), authController.GetCurrentUser) // Initial admin setup helpers auth.GET("/admin/exists", authController.AdminExists) auth.POST("/make-admin", middleware.JWTAuth(db), authController.MakeAdmin) } // Event routes (public) eventController := &controllers.EventController{DB: db} events := api.Group("/events") { events.GET("", eventController.GetEvents) events.GET("/upcoming", eventController.GetUpcomingEvents) events.GET("/:id", eventController.GetEventByID) } // Protected routes (require authentication) protected := api.Group("") protected.Use(middleware.JWTAuth(db)) { // AI endpoints (protected) ai := protected.Group("/ai") { ai.POST("/blog/generate", aiController.GenerateBlog) ai.POST("/about/generate", aiController.GenerateAboutPage) } // User profile protected.GET("/me", authController.GetCurrentUser) // Uploads are registered as a public endpoint below so the handler can // allow unauthenticated uploads during initial setup (when no admin exists). // The protected group remains for other authenticated endpoints. // Events (protected) protectedEvents := protected.Group("/events") { protectedEvents.POST("", eventController.CreateEvent) protectedEvents.PUT("/:id", eventController.UpdateEvent) protectedEvents.DELETE("/:id", eventController.DeleteEvent) } // Articles (protected - accessible by editors and admins) articles := protected.Group("/articles") { articles.POST("", baseController.CreateArticle) articles.PUT("/:id", baseController.UpdateArticle) articles.DELETE("/:id", baseController.DeleteArticle) // Link article to FACR match articles.POST("/:id/match-link", baseController.PutArticleMatchLink) articles.DELETE("/:id/match-link", baseController.DeleteArticleMatchLink) } // Teams (protected) teams := protected.Group("/teams") { teams.POST("", baseController.CreateTeam) teams.PUT("/:id", baseController.UpdateTeam) teams.DELETE("/:id", baseController.DeleteTeam) } // Players (protected) players := protected.Group("/players") { players.POST("", baseController.CreatePlayer) players.PUT("/:id", baseController.UpdatePlayer) players.DELETE("/:id", baseController.DeletePlayer) } // Sponsors (protected CRUD) sponsors := protected.Group("/sponsors") { sponsors.POST("", baseController.CreateSponsor) sponsors.PUT("/:id", baseController.UpdateSponsor) sponsors.DELETE("/:id", baseController.DeleteSponsor) } // Admin routes (single consolidated group) admin := protected.Group("/admin") admin.Use(middleware.RoleAuth("admin")) { // Admin-only endpoints for managing sponsors, etc. (user CRUD removed; no handlers defined) // Competition aliases (admin) admin.GET("/competition-aliases", baseController.GetCompetitionAliases) admin.PUT("/competition-aliases/:code", baseController.PutCompetitionAlias) admin.DELETE("/competition-aliases/:code", baseController.DeleteCompetitionAlias) admin.POST("/competition-aliases/reorder", baseController.ReorderCompetitionAliases) // Categories (admin) admin.POST("/categories", baseController.CreateCategory) admin.PUT("/categories/:id", baseController.UpdateCategory) admin.DELETE("/categories/:id", baseController.DeleteCategory) // Settings (singleton) admin.GET("/settings", baseController.GetSettings) admin.PUT("/settings", baseController.UpdateSettings) // About page (singleton) admin.GET("/about", aboutController.GetAdminAboutPage) admin.PUT("/about", aboutController.UpsertAboutPage) admin.DELETE("/about", aboutController.DeleteAboutPage) // Scoreboard (singleton) admin.GET("/scoreboard", scoreboardController.GetAdmin) admin.PUT("/scoreboard", scoreboardController.PutAdmin) // Scoreboard timer controls admin.POST("/scoreboard/timer/start", scoreboardController.StartTimer) admin.POST("/scoreboard/timer/pause", scoreboardController.PauseTimer) admin.POST("/scoreboard/timer/reset", scoreboardController.ResetTimer) // Scoreboard advanced actions admin.POST("/scoreboard/swap-sides", scoreboardController.SwapSides) admin.POST("/scoreboard/second-half", scoreboardController.StartSecondHalf) // Presets: save/list/load admin.POST("/scoreboard/save", scoreboardController.SaveState) admin.GET("/scoreboard/saves", scoreboardController.ListSaves) admin.POST("/scoreboard/load", scoreboardController.LoadSaved) // Scoreboard sponsors & QR (admin-only) admin.GET("/scoreboard/sponsors", scoreboardController.ListSponsors) admin.POST("/scoreboard/sponsors/upload", scoreboardController.UploadSponsors) admin.DELETE("/scoreboard/sponsors", scoreboardController.DeleteSponsor) admin.GET("/scoreboard/qr", scoreboardController.GetQR) admin.POST("/scoreboard/qr", scoreboardController.UploadQR) // Users (admin) admin.GET("/users", authController.ListUsers) // Create/Update/Delete users admin.POST("/users", authController.AdminCreateUser) admin.PUT("/users/:id", authController.AdminUpdateUser) admin.DELETE("/users/:id", authController.AdminDeleteUser) // Admin: send password reset email using special SMTP override admin.POST("/users/send-reset", passwordController.AdminSendReset) // Admin: reset password for a specific user ID admin.POST("/users/:id/reset-password", passwordController.AdminSendResetByID) // Admin matches merged with overrides admin.GET("/matches", baseController.GetAdminMatches) // Match & Team Logo Overrides overrides := admin.Group("") { // Match overrides overrides.GET("/match-overrides", baseController.GetMatchOverrides) overrides.PUT("/match-overrides/:external_match_id", baseController.PutMatchOverride) overrides.PATCH("/match-overrides/:external_match_id", baseController.PatchMatchOverride) // Team logo overrides overrides.GET("/team-logo-overrides", baseController.GetTeamLogoOverrides) overrides.PUT("/team-logo-overrides/:external_team_id", baseController.PutTeamLogoOverride) overrides.PATCH("/team-logo-overrides/:external_team_id", baseController.PatchTeamLogoOverride) } // Contact messages management contactMessages := admin.Group("/contact-messages") { contactMessages.GET("", contactController.GetContactMessages) contactMessages.GET("/:id", contactController.GetContactMessage) contactMessages.PATCH("/:id/read", contactController.MarkMessageAsRead) contactMessages.POST("/:id/forward", contactController.ForwardContactMessage) contactMessages.POST("/forward-all", contactController.ForwardAllContactMessages) contactMessages.DELETE("/:id", contactController.DeleteContactMessage) contactMessages.DELETE("", contactController.DeleteContactMessages) // Bulk delete } // Newsletter management admin.GET("/newsletter/subscribers", contactController.GetNewsletterSubscribers) admin.POST("/newsletter/send", contactController.SendNewsletter) admin.POST("/newsletter/preview", contactController.PreviewNewsletter) admin.POST("/newsletter/test", contactController.SendNewsletterTest) // New: send prebuilt digest by type and toggle automation admin.POST("/newsletter/send-digest", contactController.SendNewsletterDigest) admin.PATCH("/newsletter/enable", contactController.UpdateNewsletterAutomation) // Removed deprecated SMTP test route (use /newsletter/test instead) admin.GET("/newsletter/status", contactController.GetNewsletterStatus) admin.GET("/newsletter/stats/recent", emailController.GetRecentEmailStats) admin.GET("/newsletter/stats/:id/events", emailController.GetEmailEventsForLog) admin.PATCH("/newsletter/subscribers/:id/preferences", contactController.UpdateNewsletterSubscriberPreferences) admin.DELETE("/newsletter/subscribers/:id", contactController.DeleteNewsletterSubscriber) admin.PATCH("/newsletter/subscribers/:id/status", contactController.UpdateNewsletterSubscriberStatus) // Notifications (admin) notifications := admin.Group("/notifications") { notifications.POST("/competition", notificationsController.SendCompetitionNotification) notifications.POST("/match", notificationsController.SendMatchNotification) } // Prefetch management (admin) prefetch := admin.Group("/prefetch") { prefetch.GET("/status", prefetchController.Status) prefetch.POST("/trigger", prefetchController.Trigger) } // Cache RAW viewer (admin) cache := admin.Group("/cache") { cache.GET("/list", baseController.GetAdminCacheList) cache.GET("/file", baseController.GetAdminCacheFile) } // Gallery management (admin) gallery := admin.Group("/gallery") { gallery.GET("/profile", galleryController.GetGalleryProfile) // Get Zonerama profile gallery.POST("/albums/fetch", galleryController.FetchAlbum) // Fetch single album gallery.DELETE("/albums/:id", galleryController.DeleteAlbum) // Delete album gallery.POST("/refresh", galleryController.RefreshFromZonerama) // Refresh from Zonerama } // Alias endpoint for saving a single Zonerama album (keeps older frontend code working) admin.POST("/zonerama/save-album", galleryController.FetchAlbum) // Save or update a chosen Zonerama pick (photo) in unified cache admin.POST("/zonerama/pick", baseController.PutZoneramaPick) // SEO admin admin.GET("/seo", seoController.GetSEOSettings) admin.PUT("/seo", seoController.UpdateSEOSettings) // Files management (admin) files := admin.Group("/files") { files.GET("", filesController.GetAllFiles) files.GET("/unused", filesController.GetUnusedFiles) files.GET("/duplicates", filesController.GetDuplicateFiles) files.GET("/:id/usages", filesController.GetFileUsages) files.DELETE("/:id", filesController.DeleteFile) files.POST("/scan", filesController.ScanAndSyncFiles) files.POST("/refresh-tracking", filesController.RefreshFileTracking) } // Navigation management (admin) navigation := admin.Group("/navigation") { navigation.GET("", navigationController.GetAllNavigationItems) navigation.POST("", navigationController.CreateNavigationItem) navigation.PUT("/:id", navigationController.UpdateNavigationItem) navigation.DELETE("/:id", navigationController.DeleteNavigationItem) navigation.POST("/reorder", navigationController.ReorderNavigationItems) navigation.POST("/seed", navigationController.SeedDefaultNavigation) } // Social links management (admin) socialLinks := admin.Group("/social-links") { socialLinks.GET("", navigationController.GetAllSocialLinks) socialLinks.POST("", navigationController.CreateSocialLink) socialLinks.PUT("/:id", navigationController.UpdateSocialLink) socialLinks.DELETE("/:id", navigationController.DeleteSocialLink) socialLinks.POST("/reorder", navigationController.ReorderSocialLinks) } // Clothing management (admin) clothing := admin.Group("/clothing") { clothing.GET("", clothingController.GetClothingAdmin) clothing.GET("/:id", clothingController.GetClothingByID) clothing.POST("", clothingController.CreateClothing) clothing.PUT("/:id", clothingController.UpdateClothing) clothing.DELETE("/:id", clothingController.DeleteClothing) clothing.POST("/reorder", clothingController.UpdateClothingOrder) } // Polls management (admin) polls := admin.Group("/polls") { polls.GET("", pollController.GetPolls) polls.GET("/:id", pollController.GetPoll) polls.POST("", pollController.CreatePoll) polls.PUT("/:id", pollController.UpdatePoll) polls.DELETE("/:id", pollController.DeletePoll) polls.GET("/:id/stats", pollController.GetPollStats) } // Page element configurations management (admin) pageElements := admin.Group("/page-elements") { pageElements.GET("", pageElementConfigController.GetAllPageElementConfigs) pageElements.POST("", pageElementConfigController.CreateOrUpdatePageElementConfig) pageElements.PUT("/:id", pageElementConfigController.UpdatePageElementConfig) pageElements.DELETE("/:id", pageElementConfigController.DeletePageElementConfig) pageElements.POST("/batch", pageElementConfigController.BatchUpdatePageElementConfigs) } } } // Register analytics routes (public tracking + admin endpoints) RegisterAnalyticsRoutes(api, db) // Umami analytics routes api.GET("/umami/config", umamiController.GetUmamiConfig) // Public setup endpoint (no auth required - called during initial setup) api.POST("/umami/initialize-setup", umamiController.InitializeUmamiSetup) umami := api.Group("/admin/umami") umami.Use(middleware.JWTAuth(db)) umami.Use(middleware.RoleAuth("admin")) { umami.POST("/initialize", umamiController.InitializeUmami) umami.GET("/stats", umamiController.GetStats) umami.GET("/metrics/:type", umamiController.GetMetrics) umami.GET("/pageviews", umamiController.GetPageviews) } // Register contact info routes (public + admin endpoints) RegisterContactInfoRoutes(api, db) // Public API routes // Allow uploads publicly so initial setup can upload a club logo before an admin exists. api.POST("/upload", middleware.RateLimit(30, time.Minute), baseController.UploadImage) // Public scoreboard api.GET("/scoreboard", scoreboardController.GetPublic) api.GET("/scoreboard/colors/derive", scoreboardController.DeriveColors) // Public core endpoints api.GET("/settings", baseController.GetPublicSettings) api.GET("/competition-aliases", baseController.GetPublicCompetitionAliases) api.GET("/public/team-logo-overrides", baseController.GetPublicTeamLogoOverrides) // Articles (public) api.GET("/articles/featured", baseController.GetFeaturedArticles) api.GET("/articles", baseController.GetArticles) api.GET("/articles/:id", baseController.GetArticle) api.GET("/articles/slug/:slug", baseController.GetArticleBySlug) api.POST("/articles/:id/read", baseController.IncrementArticleRead) api.POST("/articles/:id/track-view", baseController.TrackArticleView) // Public read-only access to article-match link api.GET("/articles/:id/match-link", baseController.GetArticleMatchLink) // Public categories api.GET("/categories", baseController.GetCategories) // Public YouTube cached videos api.GET("/youtube/videos", youtubeController.GetYouTubeVideos) // Public About page api.GET("/about", aboutController.GetPublicAboutPage) api.GET("/teams", baseController.GetTeams) api.GET("/teams/:id", baseController.GetTeam) api.GET("/players", baseController.GetPlayers) api.GET("/players/:id", baseController.GetPlayer) api.GET("/sponsors", baseController.GetSponsors) api.GET("/matches", baseController.GetMatches) api.GET("/matches/history", baseController.GetMatchesHistory) api.GET("/standings", baseController.GetStandings) // Gallery (public): albums and photos api.GET("/gallery/albums", galleryController.GetGalleryAlbums) // Get all albums api.GET("/gallery/albums/:id", galleryController.GetGalleryAlbum) // Get single album with photos api.GET("/gallery/proxy-image", galleryController.ProxyImage) // Proxy Zonerama images to avoid CORS // Legacy Zonerama endpoints (keep for backwards compatibility) api.GET("/zonerama/album", baseController.GetZoneramaAlbum) // Alias to support hyphenated path used by some frontend calls api.GET("/zonerama-album", baseController.GetZoneramaAlbum) api.GET("/zonerama/picks", baseController.GetZoneramaPicks) // Clothing (public) - active items only api.GET("/clothing", clothingController.GetClothing) // Polls (public) api.GET("/polls", pollController.GetPolls) api.GET("/polls/:id", pollController.GetPoll) api.POST("/polls/:id/vote", middleware.RateLimit(10, time.Minute), pollController.Vote) api.GET("/polls/:id/results", pollController.GetPollResults) // Contact form and newsletter endpoints (public) – rate limited to prevent abuse api.POST("/contact", middleware.RateLimit(10, time.Minute), contactController.SubmitContactForm) api.POST("/newsletter/subscribe", middleware.RateLimit(30, time.Minute), contactController.SubscribeToNewsletter) api.POST("/newsletter/setup", middleware.RateLimit(30, time.Minute), contactController.SetupNewsletterPreferences) api.POST("/newsletter/unsubscribe/:email", middleware.RateLimit(30, time.Minute), contactController.UnsubscribeFromNewsletter) // Token-based management (no auth) api.GET("/newsletter/preferences", contactController.GetNewsletterPreferencesByToken) api.POST("/newsletter/preferences", contactController.SaveNewsletterPreferencesByToken) api.POST("/newsletter/unsubscribe-token", contactController.UnsubscribeByToken) } // FACR scraper endpoints (integrated): /api/v1/facr/* facr := api.Group("/facr") { facr.GET("/club/search", facrController.SearchClubs) facr.GET("/club/:type/:id", facrController.GetClubInfo) facr.GET("/club/:type/:id/table", facrController.GetClubTables) } } // SetupRootRoutes registers endpoints at the root (no /api prefix) func SetupRootRoutes(r *gin.Engine, db *gorm.DB) { seoController := controllers.NewSEOController(db) r.GET("/robots.txt", seoController.GetRobotsTXT) r.GET("/sitemap.xml", seoController.GetSitemapXML) }