Files
gochat/internal/service/portal_service.go
T

392 lines
12 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"gorm.io/gorm"
)
// 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"`
Config json.RawMessage `json:"config"`
}
// 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"`
Config json.RawMessage `json:"config"`
}
type PatchPortalRequest struct {
Name *string `json:"name"`
Slug *string `json:"slug"`
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"`
Config *json.RawMessage `json:"config"`
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,
}
if len(req.Config) > 0 {
portal.PortalConfiguration = req.Config
}
// 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) ResolvePublicBySlug(ctx context.Context, slug string) (*model.Portal, error) {
if slug == "" {
return nil, fmt.Errorf("portal not found")
}
portal, err := s.repo.FindPublicBySlug(ctx, slug)
if err != nil {
return nil, fmt.Errorf("portal not found: %w", err)
}
if portal.Archived {
return nil, fmt.Errorf("portal not found: %w", gorm.ErrRecordNotFound)
}
return portal, nil
}
func (s *PortalService) ResolveByAccountAndRouteID(ctx context.Context, accountID uint, routeID string) (*model.Portal, error) {
if routeID == "" {
return nil, fmt.Errorf("portal not found")
}
portal, err := s.repo.FindByAccountAndSlug(ctx, accountID, routeID)
if err == nil {
return portal, nil
}
if numericID, parseErr := strconv.ParseUint(routeID, 10, 32); parseErr == nil && numericID != 0 {
portal, idErr := s.repo.GetByAccountAndID(ctx, accountID, uint(numericID))
if idErr == nil {
return portal, nil
}
}
return nil, fmt.Errorf("portal not found: %w", err)
}
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 len(req.Config) > 0 {
portal.PortalConfiguration = req.Config
}
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) UpdatePatch(ctx context.Context, portal *model.Portal, req *PatchPortalRequest) (*model.Portal, error) {
if portal == nil {
return nil, fmt.Errorf("portal not found")
}
if req.Name != nil {
portal.Name = *req.Name
}
if req.Slug != nil {
portal.Slug = *req.Slug
}
if req.Description != nil {
portal.Description = *req.Description
}
if req.LogoURL != nil {
portal.LogoURL = *req.LogoURL
}
if req.HeaderText != nil {
portal.HeaderText = *req.HeaderText
}
if req.HomepageLink != nil {
portal.HomepageLink = *req.HomepageLink
}
if req.PageTitle != nil {
portal.PageTitle = *req.PageTitle
}
if req.Color != nil {
portal.Color = *req.Color
}
if req.Archived != nil {
portal.Archived = *req.Archived
}
if req.CustomDomain != nil {
portal.CustomDomain = *req.CustomDomain
}
if req.Locale != nil {
portal.Locale = *req.Locale
}
if req.PortalConfiguration != nil {
portal.PortalConfiguration = *req.PortalConfiguration
}
if req.Config != nil {
portal.PortalConfiguration = *req.Config
}
if req.SSLSettings != nil {
portal.SSLSettings = *req.SSLSettings
}
if req.HomepageContent != nil {
portal.HomepageContent = *req.HomepageContent
}
if err := s.repo.Update(ctx, portal); err != nil {
return nil, fmt.Errorf("update portal: %w", err)
}
return s.repo.GetByAccountAndID(ctx, portal.AccountID, portal.ID)
}
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 := 0
if page > 0 && perPage > 0 {
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
}
func (s *PortalService) ListByAccountIDWithAssociations(ctx context.Context, accountID uint, page, perPage int) ([]model.Portal, int64, error) {
offset := 0
if page > 0 && perPage > 0 {
offset = (page - 1) * perPage
}
portals, count, err := s.repo.FindByAccountIDWithAssociations(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
}