package service import ( "context" "encoding/json" "fmt" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // ArticleService implements business logic for Article CRUD. type ArticleService struct { repo *repository.ArticleRepo searchIndexer SearchIndexer } func NewArticleService(repo *repository.ArticleRepo) *ArticleService { return &ArticleService{repo: repo} } func (s *ArticleService) SetSearchIndexer(indexer SearchIndexer) { s.searchIndexer = indexer } func (s *ArticleService) indexArticle(ctx context.Context, article *model.Article) { if s.searchIndexer != nil { logSearchIndexError("article", article.ID, s.searchIndexer.IndexArticle(ctx, article)) } } func (s *ArticleService) deleteArticleIndex(ctx context.Context, accountID uint, id uint) { if s.searchIndexer != nil { logSearchIndexError("article", id, s.searchIndexer.DeleteArticle(ctx, accountID, id)) } } // CreateArticleRequest is the DTO for creating an article. type CreateArticleRequest struct { Title string `json:"title" validate:"required,min=2"` Slug string `json:"slug" validate:"required"` Description string `json:"description"` Content string `json:"content"` Status model.ArticleStatus `json:"status"` FolderID *uint `json:"folder_id"` CategoryID *uint `json:"category_id"` Position int `json:"position"` Locale string `json:"locale"` AssociatedArticleID *uint `json:"associated_article_id"` Meta json.RawMessage `json:"meta"` CustomAttributes json.RawMessage `json:"custom_attributes"` } // UpdateArticleRequest is the DTO for updating an article. type UpdateArticleRequest struct { Title string `json:"title"` Description string `json:"description"` Content string `json:"content"` Status model.ArticleStatus `json:"status"` FolderID *uint `json:"folder_id"` CategoryID *uint `json:"category_id"` Position *int `json:"position"` Locale string `json:"locale"` AssociatedArticleID *uint `json:"associated_article_id"` Meta json.RawMessage `json:"meta"` CustomAttributes json.RawMessage `json:"custom_attributes"` } func (s *ArticleService) Create(ctx context.Context, portalID uint, authorID uint, req *CreateArticleRequest) (*model.Article, error) { return s.CreateWithAccount(ctx, 0, portalID, authorID, req) } func (s *ArticleService) CreateWithAccount(ctx context.Context, accountID uint, portalID uint, authorID uint, req *CreateArticleRequest) (*model.Article, error) { // Default status is draft if not specified status := string(req.Status) if status == "" { status = string(model.ArticleStatusDraft) } article := &model.Article{ AccountID: accountID, PortalID: portalID, AuthorID: &authorID, Title: req.Title, Slug: req.Slug, Description: req.Description, Content: req.Content, Status: status, FolderID: req.FolderID, CategoryID: req.CategoryID, Position: req.Position, Locale: req.Locale, AssociatedArticleID: req.AssociatedArticleID, Meta: req.Meta, CustomAttributes: req.CustomAttributes, } if err := s.repo.Create(ctx, article); err != nil { return nil, fmt.Errorf("create article: %w", err) } // If associated_article_id is provided, link this article to the root article chain if req.AssociatedArticleID != nil { applogger.L().Debugf("Article %d linked to root article %d", article.ID, *req.AssociatedArticleID) } s.indexArticle(ctx, article) return article, nil } func (s *ArticleService) GetByID(ctx context.Context, id uint) (*model.Article, error) { article, err := s.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("get article: %w", err) } return article, nil } func (s *ArticleService) Update(ctx context.Context, id uint, req *UpdateArticleRequest) (*model.Article, error) { article, err := s.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("find article: %w", err) } // Update fields — only non-zero/non-nil values if req.Title != "" { article.Title = req.Title } if req.Description != "" { article.Description = req.Description } if req.Content != "" { article.Content = req.Content } if req.Status != "" { article.Status = string(req.Status) } if req.FolderID != nil { article.FolderID = req.FolderID } if req.CategoryID != nil { article.CategoryID = req.CategoryID } if req.Position != nil { article.Position = *req.Position } if req.Locale != "" { article.Locale = req.Locale } if req.AssociatedArticleID != nil { article.AssociatedArticleID = req.AssociatedArticleID } if req.Meta != nil { article.Meta = req.Meta } if req.CustomAttributes != nil { article.CustomAttributes = req.CustomAttributes } if err := s.repo.Update(ctx, article); err != nil { return nil, fmt.Errorf("update article: %w", err) } s.indexArticle(ctx, article) return article, nil } func (s *ArticleService) Delete(ctx context.Context, id uint) error { // Check existence first — GORM Delete() returns nil even for non-existent IDs article, err := s.repo.GetByID(ctx, id) if err != nil { return fmt.Errorf("find article for delete: %w", err) } if err := s.repo.Delete(ctx, id); err != nil { return fmt.Errorf("delete article: %w", err) } s.deleteArticleIndex(ctx, article.AccountID, id) return nil } // ListByPortalID returns articles for a portal with pagination. func (s *ArticleService) ListByPortalID(ctx context.Context, portalID uint, page, perPage int) ([]model.Article, int64, error) { offset := (page - 1) * perPage articles, count, err := s.repo.FindByPortalID(ctx, portalID, offset, perPage) if err != nil { return nil, 0, fmt.Errorf("list articles: %w", err) } return articles, count, nil } // ListByCategoryID returns articles for a category with pagination. func (s *ArticleService) ListByCategoryID(ctx context.Context, categoryID uint, page, perPage int) ([]model.Article, int64, error) { offset := (page - 1) * perPage articles, count, err := s.repo.FindByCategoryID(ctx, categoryID, offset, perPage) if err != nil { return nil, 0, fmt.Errorf("list articles by category: %w", err) } return articles, count, nil } // ListByStatus returns articles by status for a portal. func (s *ArticleService) ListByStatus(ctx context.Context, portalID uint, status string, page, perPage int) ([]model.Article, int64, error) { offset := (page - 1) * perPage articles, count, err := s.repo.FindByStatus(ctx, portalID, model.ArticleStatus(status), offset, perPage) if err != nil { return nil, 0, fmt.Errorf("list articles by status: %w", err) } return articles, count, nil } // Search returns filtered + searched articles with pagination. // Implements Article.search(params) from M9 spec. func (s *ArticleService) Search(ctx context.Context, params repository.ArticleSearchParams) ([]model.Article, int64, error) { articles, count, err := s.repo.Search(ctx, params) if err != nil { return nil, 0, fmt.Errorf("search articles: %w", err) } return articles, count, nil } // StatusCounts returns article counts by status for a portal. func (s *ArticleService) StatusCounts(ctx context.Context, portalID uint) (map[string]int64, error) { counts, err := s.repo.StatusCounts(ctx, portalID) if err != nil { return nil, fmt.Errorf("status counts: %w", err) } return counts, nil } // Reorder batch-updates article positions. func (s *ArticleService) Reorder(ctx context.Context, positions map[uint]int) error { if err := s.repo.UpdatePositions(ctx, positions); err != nil { return fmt.Errorf("reorder articles: %w", err) } return nil } // BulkUpdateStatus updates status for multiple articles. func (s *ArticleService) BulkUpdateStatus(ctx context.Context, ids []uint, status string) error { // Validate status validStatuses := map[model.ArticleStatus]bool{ model.ArticleStatusDraft: true, model.ArticleStatusPublished: true, model.ArticleStatusArchived: true, } if !validStatuses[model.ArticleStatus(status)] { return fmt.Errorf("invalid status: %s", status) } if err := s.repo.BulkUpdateStatus(ctx, ids, status); err != nil { return fmt.Errorf("bulk update status: %w", err) } for _, id := range ids { article, err := s.repo.GetByID(ctx, id) if err == nil { s.indexArticle(ctx, article) } } return nil } // BulkDelete deletes multiple articles. func (s *ArticleService) BulkDelete(ctx context.Context, ids []uint) error { articles := make([]*model.Article, 0, len(ids)) for _, id := range ids { article, err := s.repo.GetByID(ctx, id) if err == nil { articles = append(articles, article) } } if err := s.repo.BulkDelete(ctx, ids); err != nil { return fmt.Errorf("bulk delete: %w", err) } for _, article := range articles { s.deleteArticleIndex(ctx, article.AccountID, article.ID) } return nil } // IncrementViews increments the view count for an article by 1. func (s *ArticleService) IncrementViews(ctx context.Context, id uint) error { if err := s.repo.IncrementViews(ctx, id); err != nil { return fmt.Errorf("increment views: %w", err) } return nil } // BulkActionsRequest is the DTO for performing bulk operations on articles. // Action can be "publish", "archive", or "delete". type BulkActionsRequest struct { Action string `json:"action" validate:"required,oneof=publish archive delete"` IDs []uint `json:"ids" validate:"required,min=1"` } // BulkActions dispatches a bulk operation (publish/archive/delete) on the given article IDs. // Reference: Chatwoot articles_controller#bulk_actions func (s *ArticleService) BulkActions(ctx context.Context, req *BulkActionsRequest) error { switch req.Action { case "publish": return s.BulkUpdateStatus(ctx, req.IDs, string(model.ArticleStatusPublished)) case "archive": return s.BulkUpdateStatus(ctx, req.IDs, string(model.ArticleStatusArchived)) case "delete": return s.BulkDelete(ctx, req.IDs) default: return fmt.Errorf("unsupported action: %s — must be publish, archive, or delete", req.Action) } }