package service import ( "context" "encoding/json" "fmt" "log/slog" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ) // PortalService implements business logic for Portal CRUD. type PortalService struct { repo *repository.PortalRepo } func NewPortalService(repo *repository.PortalRepo) *PortalService { return &PortalService{repo: repo} } // CreatePortalRequest is the DTO for creating a portal. type CreatePortalRequest struct { Name string `json:"name" validate:"required,min=2"` Slug string `json:"slug" validate:"required"` Description string `json:"description"` LogoURL string `json:"logo_url"` HeaderText string `json:"header_text"` HomepageLink string `json:"homepage_link"` PageTitle string `json:"page_title"` Color string `json:"color"` CustomDomain string `json:"custom_domain"` Locale string `json:"locale"` PortalConfiguration json.RawMessage `json:"portal_configuration"` SSLSettings json.RawMessage `json:"ssl_settings"` HomepageContent string `json:"homepage_content"` } // UpdatePortalRequest is the DTO for updating a portal. type UpdatePortalRequest struct { Name string `json:"name"` Description string `json:"description"` LogoURL string `json:"logo_url"` HeaderText string `json:"header_text"` HomepageLink string `json:"homepage_link"` PageTitle string `json:"page_title"` Color string `json:"color"` Archived *bool `json:"archived"` CustomDomain string `json:"custom_domain"` Locale string `json:"locale"` PortalConfiguration json.RawMessage `json:"portal_configuration"` SSLSettings json.RawMessage `json:"ssl_settings"` HomepageContent string `json:"homepage_content"` } func (s *PortalService) Create(ctx context.Context, accountID uint, req *CreatePortalRequest) (*model.Portal, error) { portal := &model.Portal{ AccountID: accountID, Name: req.Name, Slug: req.Slug, Description: req.Description, LogoURL: req.LogoURL, HeaderText: req.HeaderText, HomepageLink: req.HomepageLink, PageTitle: req.PageTitle, Color: req.Color, CustomDomain: req.CustomDomain, Locale: req.Locale, PortalConfiguration: req.PortalConfiguration, SSLSettings: req.SSLSettings, HomepageContent: req.HomepageContent, } // Defaults if portal.Color == "" { portal.Color = "#1f93ff" } if portal.Locale == "" { portal.Locale = "en" } if portal.SSLSettings == nil { portal.SSLSettings = json.RawMessage(`{}`) } if err := s.repo.Create(ctx, portal); err != nil { return nil, fmt.Errorf("create portal: %w", err) } return portal, nil } func (s *PortalService) GetByID(ctx context.Context, id uint) (*model.Portal, error) { portal, err := s.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("get portal: %w", err) } return portal, nil } func (s *PortalService) Update(ctx context.Context, id uint, req *UpdatePortalRequest) (*model.Portal, error) { portal, err := s.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("find portal: %w", err) } if req.Name != "" { portal.Name = req.Name } if req.Description != "" { portal.Description = req.Description } if req.LogoURL != "" { portal.LogoURL = req.LogoURL } if req.HeaderText != "" { portal.HeaderText = req.HeaderText } if req.HomepageLink != "" { portal.HomepageLink = req.HomepageLink } if req.PageTitle != "" { portal.PageTitle = req.PageTitle } if req.Color != "" { portal.Color = req.Color } if req.Archived != nil { portal.Archived = *req.Archived } if req.CustomDomain != "" { portal.CustomDomain = req.CustomDomain } if req.Locale != "" { portal.Locale = req.Locale } if req.PortalConfiguration != nil { portal.PortalConfiguration = req.PortalConfiguration } if req.SSLSettings != nil { portal.SSLSettings = req.SSLSettings } if req.HomepageContent != "" { portal.HomepageContent = req.HomepageContent } if err := s.repo.Update(ctx, portal); err != nil { return nil, fmt.Errorf("update portal: %w", err) } return portal, nil } func (s *PortalService) Delete(ctx context.Context, id uint) error { // Check existence first — GORM Delete() returns nil even for non-existent IDs if _, err := s.repo.GetByID(ctx, id); err != nil { return fmt.Errorf("find portal for delete: %w", err) } if err := s.repo.Delete(ctx, id); err != nil { return fmt.Errorf("delete portal: %w", err) } return nil } func (s *PortalService) ListByAccountID(ctx context.Context, accountID uint, page, perPage int) ([]model.Portal, int64, error) { offset := (page - 1) * perPage portals, count, err := s.repo.FindByAccountID(ctx, accountID, offset, perPage) if err != nil { return nil, 0, fmt.Errorf("list portals: %w", err) } return portals, count, nil } // Archive sets archived=true on a portal. func (s *PortalService) Archive(ctx context.Context, id uint) (*model.Portal, error) { if err := s.repo.Archive(ctx, id); err != nil { return nil, fmt.Errorf("archive portal: %w", err) } return s.repo.GetByID(ctx, id) } // RemoveLogo clears the logo_url on a portal. func (s *PortalService) RemoveLogo(ctx context.Context, id uint) (*model.Portal, error) { if err := s.repo.RemoveLogo(ctx, id); err != nil { return nil, fmt.Errorf("remove portal logo: %w", err) } return s.repo.GetByID(ctx, id) } // SendInstructionsRequest is the DTO for sending portal instructions email. type SendInstructionsRequest struct { Email string `json:"email" validate:"required,email"` } // SendInstructions sends CNAME configuration instructions to the specified email. // The portal must have a custom_domain configured; otherwise the instruction is meaningless. // Reference: Chatwoot portals_controller#send_instructions func (s *PortalService) SendInstructions(ctx context.Context, portalID uint, req *SendInstructionsRequest) error { portal, err := s.repo.GetByID(ctx, portalID) if err != nil { return fmt.Errorf("find portal: %w", err) } if portal.CustomDomain == "" { return fmt.Errorf("portal has no custom domain configured") } // Validate email format if req.Email == "" { return fmt.Errorf("email is required") } // Simple email format check if !isValidEmail(req.Email) { return fmt.Errorf("invalid email format") } // In production, this would call an email service (e.g. PortalInstructionsMailer) // For now, we log the intent and return success slog.Info("SendInstructions: email delivery not yet wired", "portal_id", portalID, "custom_domain", portal.CustomDomain, "email", req.Email, ) return nil } // isValidEmail performs a basic email format validation. func isValidEmail(email string) bool { if len(email) < 3 || len(email) > 254 { return false } // Must contain exactly one @, with text before and after atIdx := -1 for i, ch := range email { if ch == '@' { if atIdx != -1 { return false // multiple @ } atIdx = i } } if atIdx < 1 || atIdx >= len(email)-1 { return false } // Domain part must contain at least one dot domain := email[atIdx+1:] hasDot := false for _, ch := range domain { if ch == '.' { hasDot = true } } return hasDot } // SSLStatus returns the SSL configuration status for a portal. // Reference: Chatwoot portals_controller#ssl_status func (s *PortalService) SSLStatus(ctx context.Context, portalID uint) (*model.PortalSSLStatus, error) { status, err := s.repo.GetSSLStatus(ctx, portalID) if err != nil { return nil, fmt.Errorf("get SSL status: %w", err) } return status, nil }