Files
MyClub/internal/routes/routes.go
T
Tomas Dvorak 087f30e82c dev day #80
2025-11-02 21:31:00 +01:00

566 lines
25 KiB
Go

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)
contactController := controllers.NewContactController(db, emailService)
passwordController := controllers.NewPasswordController(db, emailService)
aiController := controllers.NewAIController(db)
scoreboardController := controllers.NewScoreboardController(db)
aboutController := controllers.NewAboutController(db)
galleryController := controllers.NewGalleryController(db)
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)
sweepstakesController := controllers.NewSweepstakesController(db, emailService)
clothingController := controllers.NewClothingController(db)
pageElementConfigController := controllers.NewPageElementConfigController(db)
articleController := controllers.NewArticleController(db)
myuibrixController := &controllers.MyUIbrixController{DB: db}
editorPreviewController := controllers.NewEditorPreviewController(db)
shortLinkController := controllers.NewShortLinkController(db)
commentController := controllers.NewCommentController(db)
engagementController := controllers.NewEngagementController(db, emailService)
// API v1 group
{
// Health check
api.GET("/health", baseController.HealthCheck)
// CSRF token for cookie-based clients
api.GET("/csrf-token", middleware.GetCSRFToken)
// 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)
api.GET("/clothing", clothingController.GetClothing)
// Public shortlink creation for visitors (same-site only)
api.POST("/shortlinks/public", middleware.RateLimit(30, time.Minute), shortLinkController.PublicCreateShortLink)
// 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)
}
// Comments (public list)
api.GET("/comments", commentController.GetComments)
// Engagement (public + protected)
api.GET("/engagement/rewards", engagementController.GetRewards)
// Protected routes (require authentication)
protected := api.Group("")
protected.Use(middleware.JWTAuth(db))
// CSRF protect state-changing requests when relying on cookies (Bearer tokens are auto-exempt)
protected.Use(middleware.CSRFProtection())
{
// Sweepstakes (protected)
protected.POST("/sweepstakes/:id/enter", middleware.RateLimit(30, time.Minute), sweepstakesController.Enter)
protected.POST("/sweepstakes/:id/played", sweepstakesController.MarkVisualPlayed)
protected.GET("/sweepstakes/my-winnings", sweepstakesController.MyWinnings)
// Engagement leaderboard (auth)
protected.GET("/engagement/leaderboard", engagementController.GetLeaderboard)
// Engagement profile & actions
protected.GET("/engagement/profile", engagementController.GetProfile)
protected.PATCH("/engagement/profile", engagementController.PatchProfile)
protected.PATCH("/engagement/avatar", engagementController.PatchAvatar)
protected.POST("/engagement/redeem", middleware.RateLimit(5, time.Hour), engagementController.Redeem)
protected.GET("/engagement/achievements", engagementController.GetAchievements)
protected.GET("/engagement/transactions", engagementController.GetMyTransactions)
// Engagement new actions
protected.POST("/engagement/checkin", middleware.RateLimit(10, time.Minute), engagementController.Checkin)
protected.POST("/engagement/article-read", middleware.RateLimit(60, time.Minute), engagementController.ArticleRead)
// Comments (create/update/delete)
protected.POST("/comments", middleware.RateLimit(20, time.Minute), commentController.CreateComment)
protected.PUT("/comments/:id", commentController.UpdateComment)
protected.DELETE("/comments/:id", commentController.DeleteComment)
// Comment reactions and unban request
protected.POST("/comments/:id/react", middleware.RateLimit(60, time.Minute), commentController.React)
protected.DELETE("/comments/:id/react", commentController.Unreact)
protected.POST("/comments/unban-request", middleware.RateLimit(5, time.Hour), commentController.CreateUnbanRequest)
protected.POST("/comments/:id/report", middleware.RateLimit(10, time.Hour), commentController.ReportComment)
// Editor preview endpoints (authenticated editors)
editor := protected.Group("/editor")
editor.Use(middleware.RoleAuth("editor"))
{
// Real-time preview state
editor.GET("/preview/:session_id", editorPreviewController.GetPreviewState)
editor.POST("/preview/:session_id", editorPreviewController.UpdatePreviewState)
editor.POST("/preview/:session_id/apply", editorPreviewController.ApplyPreviewChanges)
editor.DELETE("/preview/:session_id", editorPreviewController.DiscardPreviewChanges)
// Validation and variants
editor.POST("/preview/validate", editorPreviewController.ValidatePreviewConfig)
editor.GET("/variants/:element_name", editorPreviewController.GetAvailableVariants)
}
// Newsletter preferences token for current user
protected.GET("/newsletter/token/me", contactController.GetNewsletterTokenForUser)
// User profile update
protected.PUT("/me", authController.UpdateCurrentUser)
// AI endpoints (protected)
ai := protected.Group("/ai")
{
ai.POST("/blog/generate", aiController.GenerateBlog)
ai.POST("/about/generate", aiController.GenerateAboutPage)
ai.POST("/css/generate", aiController.GenerateCSS)
ai.POST("/instagram/generate", aiController.GenerateInstagram)
}
// 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.Use(middleware.RoleAuth("editor"))
{
protectedEvents.POST("", eventController.CreateEvent)
protectedEvents.PUT("/:id", eventController.UpdateEvent)
protectedEvents.DELETE("/:id", eventController.DeleteEvent)
}
// Shortlinks (protected for editors) - create/list
protectedShortlinks := protected.Group("/shortlinks")
protectedShortlinks.Use(middleware.RoleAuth("editor"))
{
protectedShortlinks.POST("", shortLinkController.CreateShortLink)
protectedShortlinks.GET("", shortLinkController.ListShortLinks)
}
// Articles (protected - accessible by editors and admins)
articles := protected.Group("/articles")
articles.Use(middleware.RoleAuth("editor"))
{
articles.POST("", articleController.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.Use(middleware.RoleAuth("admin"))
{
teams.POST("", baseController.CreateTeam)
teams.PUT("/:id", baseController.UpdateTeam)
teams.DELETE("/:id", baseController.DeleteTeam)
}
// Players (protected)
players := protected.Group("/players")
players.Use(middleware.RoleAuth("admin"))
{
players.POST("", baseController.CreatePlayer)
players.PUT("/:id", baseController.UpdatePlayer)
players.DELETE("/:id", baseController.DeletePlayer)
}
// Sponsors (protected CRUD)
sponsors := protected.Group("/sponsors")
sponsors.Use(middleware.RoleAuth("admin"))
{
sponsors.POST("", baseController.CreateSponsor)
sponsors.PUT("/:id", baseController.UpdateSponsor)
sponsors.DELETE("/:id", baseController.DeleteSponsor)
}
// Banners (protected CRUD)
banners := protected.Group("/banners")
banners.Use(middleware.RoleAuth("admin"))
{
banners.POST("", baseController.CreateBanner)
banners.PUT("/:id", baseController.UpdateBanner)
banners.DELETE("/:id", baseController.DeleteBanner)
}
// Admin routes (single consolidated group)
admin := protected.Group("/admin")
admin.Use(middleware.RoleAuth("admin"))
{
// Comments
commentsAdmin := admin.Group("/comments")
{
commentsAdmin.GET("", commentController.AdminList)
commentsAdmin.PATCH("/:id/status", commentController.AdminUpdateStatus)
commentsAdmin.POST("/ban", commentController.AdminBanUser)
commentsAdmin.GET("/bans", commentController.AdminListBans)
commentsAdmin.POST("/bans/:id/lift", commentController.AdminLiftBan)
commentsAdmin.GET("/unban-requests", commentController.AdminListUnban)
commentsAdmin.POST("/unban-requests/:id/resolve", commentController.AdminResolveUnban)
}
// 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.POST("/newsletter/smtp-test", contactController.AdminSmtpTest)
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("/usage", filesController.GetStorageUsage)
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)
polls.GET("/:id/votes", pollController.AdminListVotes)
}
// Engagement management (admin)
engagement := admin.Group("/engagement")
{
engagement.GET("/rewards", engagementController.AdminListRewards)
engagement.POST("/rewards", engagementController.AdminCreateReward)
engagement.PUT("/rewards/:id", engagementController.AdminUpdateReward)
engagement.DELETE("/rewards/:id", engagementController.AdminDeleteReward)
engagement.GET("/redemptions", engagementController.AdminListRedemptions)
engagement.PATCH("/redemptions/:id", engagementController.AdminUpdateRedemptionStatus)
engagement.GET("/leaderboard", engagementController.AdminGetLeaderboard)
engagement.GET("/transactions", engagementController.AdminListTransactions)
engagement.POST("/adjust", engagementController.AdminAdjustPoints)
engagement.GET("/profile/:user_id", engagementController.AdminGetUserProfile)
}
// 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)
}
// MyUIbrix optimization and validation endpoints (admin)
myuibrix := admin.Group("/myuibrix")
{
myuibrix.POST("/validate", myuibrixController.ValidateElementConfig)
myuibrix.POST("/validate-batch", myuibrixController.BatchValidateConfigs)
myuibrix.GET("/preview", myuibrixController.GetElementPreview)
myuibrix.GET("/optimize-layout", myuibrixController.OptimizePageLayout)
}
// Short links (admin)
shortlinks := admin.Group("/shortlinks")
{
shortlinks.POST("", shortLinkController.CreateShortLink)
shortlinks.GET("", shortLinkController.ListShortLinks)
shortlinks.GET("/:id/stats", shortLinkController.GetShortLinkStats)
}
// Sweepstakes management (admin)
sw := admin.Group("/sweepstakes")
{
sw.GET("", sweepstakesController.AdminList)
sw.POST("", sweepstakesController.AdminCreate)
sw.PUT("/:id", sweepstakesController.AdminUpdate)
sw.DELETE("/:id", sweepstakesController.AdminDelete)
sw.GET("/:id/entries", sweepstakesController.AdminEntries)
sw.GET("/:id/winners", sweepstakesController.AdminWinners)
sw.PATCH("/:id/winners/:winner_id", sweepstakesController.AdminUpdateWinner)
sw.PATCH("/:id/winners/:winner_id/prize", sweepstakesController.AdminSetWinnerPrize)
sw.GET("/:id/visual", sweepstakesController.AdminVisualData)
// Prizes
sw.GET("/:id/prizes", sweepstakesController.AdminListPrizes)
sw.POST("/:id/prizes", sweepstakesController.AdminCreatePrize)
sw.PUT("/:id/prizes/:prize_id", sweepstakesController.AdminUpdatePrize)
sw.DELETE("/:id/prizes/:prize_id", sweepstakesController.AdminDeletePrize)
sw.POST("/:id/prizes/reorder", sweepstakesController.AdminReorderPrizes)
// Finalize (draw winners)
sw.POST("/:id/finalize", sweepstakesController.AdminFinalize)
}
}
// Protected routes end
}
// Public sweepstakes endpoints
api.GET("/sweepstakes/current", sweepstakesController.GetCurrent)
api.GET("/sweepstakes/:id/visual", sweepstakesController.PublicVisualData)
}
}
// SetupRootRoutes registers endpoints at the root (no /api prefix)
func SetupRootRoutes(r *gin.Engine, db *gorm.DB) {
seoController := controllers.NewSEOController(db)
shortLinkController := controllers.NewShortLinkController(db)
r.GET("/robots.txt", seoController.GetRobotsTXT)
r.GET("/sitemap.xml", seoController.GetSitemapXML)
// Public short-link redirects and generic tracked redirect
r.GET("/s/:code", shortLinkController.RedirectShort)
r.GET("/r", shortLinkController.RedirectAndTrack)
}