Files
gochat/internal/service/portal_service.go
T
2026-06-04 15:44:48 +08:00

262 lines
7.8 KiB
Go

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
}