package controllers import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "os" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type DirectoryController struct { DB *gorm.DB request *gin.Context } func NewDirectoryController(db *gorm.DB) *DirectoryController { return &DirectoryController{DB: db} } type InstanceRegistrationPayload struct { ClubID string `json:"club_id"` ClubName string `json:"club_name"` APIBaseURL string `json:"api_base_url"` LogoURL string `json:"logo_url"` City string `json:"city"` Country string `json:"country"` IsActive bool `json:"is_active"` Version string `json:"version"` Tags map[string]string `json:"tags"` Features []string `json:"features"` LastSeen time.Time `json:"last_seen"` } // RegisterInstance registers this club instance with the central directory func (dc *DirectoryController) RegisterInstance(c *gin.Context) { // Store request context for helper methods dc.request = c var s models.Settings if err := dc.DB.First(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"}) return } // Build registration payload payload := InstanceRegistrationPayload{ ClubID: strings.TrimSpace(s.ClubID), ClubName: strings.TrimSpace(s.ClubName), APIBaseURL: dc.getPublicURL(), LogoURL: strings.TrimSpace(s.ClubLogoURL), City: strings.TrimSpace(s.ContactCity), Country: strings.TrimSpace(s.ContactCountry), IsActive: true, Version: strings.TrimSpace(os.Getenv("APP_VERSION")), LastSeen: time.Now(), Tags: map[string]string{ "instance_host": dc.getHostname(), "environment": config.AppConfig.AppEnv, "instance_id": dc.getInstanceID(), }, Features: dc.getEnabledFeatures(), } // Validate required fields if payload.ClubID == "" || payload.ClubName == "" || payload.APIBaseURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields: club_id, club_name, api_base_url"}) return } // Send to central directory if err := dc.sendToCentralDirectory("/api/v1/directory/register", payload); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register with central directory", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "status": "registered", "club_id": payload.ClubID, "timestamp": payload.LastSeen, }) } // Heartbeat sends a heartbeat to central directory func (dc *DirectoryController) Heartbeat(c *gin.Context) { var s models.Settings if err := dc.DB.First(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"}) return } payload := map[string]interface{}{ "club_id": strings.TrimSpace(s.ClubID), "last_seen": time.Now(), "status": "active", "tags": map[string]string{ "instance_host": dc.getHostname(), "environment": config.AppConfig.AppEnv, }, } if err := dc.sendToCentralDirectory("/api/v1/directory/heartbeat", payload); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send heartbeat", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok", "timestamp": time.Now()}) } // GetInstanceInfo returns this instance's information func (dc *DirectoryController) GetInstanceInfo(c *gin.Context) { // Store request context for helper methods dc.request = c var s models.Settings if err := dc.DB.First(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"}) return } info := InstanceRegistrationPayload{ ClubID: strings.TrimSpace(s.ClubID), ClubName: strings.TrimSpace(s.ClubName), APIBaseURL: dc.getPublicURL(), LogoURL: strings.TrimSpace(s.ClubLogoURL), City: strings.TrimSpace(s.ContactCity), Country: strings.TrimSpace(s.ContactCountry), IsActive: true, Version: strings.TrimSpace(os.Getenv("APP_VERSION")), LastSeen: time.Now(), Tags: map[string]string{ "instance_host": dc.getHostname(), "environment": config.AppConfig.AppEnv, "instance_id": dc.getInstanceID(), }, Features: dc.getEnabledFeatures(), } c.JSON(http.StatusOK, info) } // Helper methods func (dc *DirectoryController) getPublicURL() string { // Try to get the public URL from settings or environment if config.AppConfig != nil && config.AppConfig.FrontendBaseURL != "" { return strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") + "/api/v1" } // Fallback to constructing from request scheme := "https" if dc.request == nil || dc.request.Request.TLS == nil { scheme = "http" } host := "localhost" if dc.request != nil { host = dc.request.Request.Host if idx := strings.Index(host, ":"); idx >= 0 { host = host[:idx] } } return scheme + "://" + host + "/api/v1" } func (dc *DirectoryController) getHostname() string { host := "localhost" if dc.request != nil { host = dc.request.Request.Host if idx := strings.Index(host, ":"); idx >= 0 { host = host[:idx] } } return host } func (dc *DirectoryController) getInstanceID() string { // Try to get instance ID from environment or generate from hostname if id := strings.TrimSpace(os.Getenv("INSTANCE_ID")); id != "" { return id } hostname := dc.getHostname() if hostname != "" { return strings.ReplaceAll(hostname, ".", "-") } return "unknown" } func (dc *DirectoryController) getEnabledFeatures() []string { var features []string // Always add basic features features = append(features, "dashboard", "news", "auth") // Check which features are enabled based on settings var s models.Settings if err := dc.DB.First(&s).Error; err == nil { if s.VideosModuleEnabled { features = append(features, "videos") } if s.MerchModuleEnabled { features = append(features, "merch") } if s.NewsletterEnabled { features = append(features, "newsletter") } if s.ShowMapOnHomepage { features = append(features, "map") } if s.GalleryURL != "" { features = append(features, "gallery") } // Add matches feature (always enabled for now) features = append(features, "matches") // Add blog feature (always enabled for now) features = append(features, "blog") } return features } func (dc *DirectoryController) sendToCentralDirectory(endpoint string, payload interface{}) error { // Get central directory URL from environment baseURL := strings.TrimSpace(os.Getenv("DIRECTORY_INGEST_URL")) if baseURL == "" { return nil // Silently skip if not configured } // Build full URL fullURL := strings.TrimSuffix(baseURL, "/") + endpoint // Marshal payload b, err := json.Marshal(payload) if err != nil { return err } // Send request with timeout post := func(u string) bool { req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b)) if err != nil { return false } req.Header.Set("Content-Type", "application/json") // Use same token pattern as error system token := strings.TrimSpace(os.Getenv("DIRECTORY_INGEST_TOKEN")) if token != "" { req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("X-Ingest-Token", token) } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return false } defer resp.Body.Close() return resp.StatusCode >= 200 && resp.StatusCode < 300 } if post(fullURL) { return nil } // Try Docker host fallback for local development if u, err := url.Parse(fullURL); err == nil { h := u.Hostname() if h == "127.0.0.1" || h == "localhost" { u.Host = strings.Replace(u.Host, h, "host.docker.internal", 1) if post(u.String()) { return nil } } } return fmt.Errorf("failed to send to central directory") } // StartDirectoryHeartbeat starts the background heartbeat process func (dc *DirectoryController) StartDirectoryHeartbeat() { // Check if directory registration is enabled if strings.TrimSpace(os.Getenv("DIRECTORY_INGEST_URL")) == "" { return } ticker := time.NewTicker(5 * time.Minute) // Heartbeat every 5 minutes go func() { for range ticker.C { // Send heartbeat in background go func() { payload := map[string]interface{}{ "club_id": dc.getClubID(), "last_seen": time.Now(), "status": "active", "tags": map[string]string{ "instance_host": dc.getHostname(), "environment": config.AppConfig.AppEnv, }, } dc.sendToCentralDirectory("/api/v1/directory/heartbeat", payload) }() } }() // Also register immediately on startup go func() { time.Sleep(2 * time.Second) // Wait for server to be ready dc.registerOnStartup() }() } func (dc *DirectoryController) getClubID() string { var s models.Settings if err := dc.DB.First(&s).Error; err != nil { return "" } return strings.TrimSpace(s.ClubID) } func (dc *DirectoryController) registerOnStartup() { var s models.Settings if err := dc.DB.First(&s).Error; err != nil { return } payload := InstanceRegistrationPayload{ ClubID: strings.TrimSpace(s.ClubID), ClubName: strings.TrimSpace(s.ClubName), APIBaseURL: dc.getPublicURL(), LogoURL: strings.TrimSpace(s.ClubLogoURL), City: strings.TrimSpace(s.ContactCity), Country: strings.TrimSpace(s.ContactCountry), IsActive: true, Version: strings.TrimSpace(os.Getenv("APP_VERSION")), LastSeen: time.Now(), Tags: map[string]string{ "instance_host": dc.getHostname(), "environment": config.AppConfig.AppEnv, "instance_id": dc.getInstanceID(), }, Features: dc.getEnabledFeatures(), } dc.sendToCentralDirectory("/api/v1/directory/register", payload) }