392 lines
12 KiB
Go
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
|
|
}
|