package service import ( "context" "encoding/json" "fmt" "regexp" "strings" "time" "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"` Description string `json:"description"` Content string `json:"content"` Status model.ArticleStatus `json:"status"` FolderID *uint `json:"folder_id"` CategoryID *uint `json:"category_id"` AuthorID *uint `json:"author_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"` Slug *string `json:"slug"` Description *string `json:"description"` Content *string `json:"content"` Status *model.ArticleStatus `json:"status"` FolderID *uint `json:"folder_id"` CategoryID *uint `json:"category_id"` AuthorID *uint `json:"author_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) } if req.AuthorID != nil { authorID = *req.AuthorID } slug := req.Slug if slug == "" { slug = articleSlug(req.Title) } article := &model.Article{ AccountID: accountID, PortalID: portalID, AuthorID: &authorID, Title: req.Title, Slug: 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 s.repo.GetByPortalAndID(ctx, portalID, article.ID) } 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) GetByPortalAndID(ctx context.Context, portalID, id uint) (*model.Article, error) { article, err := s.repo.GetByPortalAndID(ctx, portalID, 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) } return s.UpdateExisting(ctx, article, req) } func (s *ArticleService) UpdateScoped(ctx context.Context, portalID, id uint, req *UpdateArticleRequest) (*model.Article, error) { article, err := s.repo.GetByPortalAndID(ctx, portalID, id) if err != nil { return nil, fmt.Errorf("find article: %w", err) } return s.UpdateExisting(ctx, article, req) } func (s *ArticleService) UpdateExisting(ctx context.Context, article *model.Article, req *UpdateArticleRequest) (*model.Article, error) { if article == nil { return nil, fmt.Errorf("find article: record not found") } // Update fields — only non-zero/non-nil values if req.Title != nil { article.Title = *req.Title } if req.Slug != nil { article.Slug = *req.Slug } if req.Description != nil { article.Description = *req.Description } if req.Content != nil { article.Content = *req.Content } if req.Status != nil { article.Status = string(*req.Status) } if req.FolderID != nil { article.FolderID = req.FolderID } if req.CategoryID != nil { article.CategoryID = req.CategoryID } if req.AuthorID != nil { article.AuthorID = req.AuthorID } if req.Position != nil { article.Position = *req.Position } if req.Locale != nil { 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 s.repo.GetByPortalAndID(ctx, article.PortalID, article.ID) } 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 } func (s *ArticleService) DeleteScoped(ctx context.Context, portalID, id uint) error { article, err := s.repo.GetByPortalAndID(ctx, portalID, 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 } type ArticleListMeta struct { AllArticlesCount int64 ArchivedArticlesCount int64 ArticlesCount int64 DraftArticlesCount int64 MineArticlesCount int64 PublishedCount int64 } func (s *ArticleService) ListMeta(ctx context.Context, params repository.ArticleSearchParams, currentUserID uint) (*ArticleListMeta, error) { allCount, err := s.repo.CountSearch(ctx, repository.ArticleSearchParams{PortalID: params.PortalID}) if err != nil { return nil, fmt.Errorf("count all articles: %w", err) } baseParams := params baseParams.Status = "" baseParams.AuthorID = nil articlesCount, err := s.repo.CountSearch(ctx, baseParams) if err != nil { return nil, fmt.Errorf("count articles: %w", err) } mineParams := baseParams if currentUserID != 0 { mineParams.AuthorID = ¤tUserID } mineCount := int64(0) if currentUserID != 0 { mineCount, err = s.repo.CountSearch(ctx, mineParams) if err != nil { return nil, fmt.Errorf("count mine articles: %w", err) } } publishedParams := baseParams publishedParams.Status = string(model.ArticleStatusPublished) publishedCount, err := s.repo.CountSearch(ctx, publishedParams) if err != nil { return nil, fmt.Errorf("count published articles: %w", err) } draftParams := baseParams draftParams.Status = string(model.ArticleStatusDraft) draftCount, err := s.repo.CountSearch(ctx, draftParams) if err != nil { return nil, fmt.Errorf("count draft articles: %w", err) } archivedParams := baseParams archivedParams.Status = string(model.ArticleStatusArchived) archivedCount, err := s.repo.CountSearch(ctx, archivedParams) if err != nil { return nil, fmt.Errorf("count archived articles: %w", err) } return &ArticleListMeta{ AllArticlesCount: allCount, ArchivedArticlesCount: archivedCount, ArticlesCount: articlesCount, DraftArticlesCount: draftCount, MineArticlesCount: mineCount, PublishedCount: publishedCount, }, 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 } func (s *ArticleService) ReorderScoped(ctx context.Context, portalID uint, positions map[uint]int) error { if err := s.repo.UpdatePositionsForPortal(ctx, portalID, 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 } func (s *ArticleService) BulkUpdateStatusScoped(ctx context.Context, portalID uint, ids []uint, status string) error { count, err := s.repo.CountByPortalAndIDs(ctx, portalID, ids) if err != nil { return fmt.Errorf("count articles: %w", err) } if count == 0 { return ErrArticleBulkNoArticles } if err := validateArticleBulkStatus(status); err != nil { return err } if err := s.repo.BulkUpdateStatusForPortal(ctx, portalID, ids, status); err != nil { return fmt.Errorf("bulk update status: %w", err) } return nil } func (s *ArticleService) BulkUpdateCategoryScoped(ctx context.Context, portalID uint, ids []uint, categoryID uint) error { count, err := s.repo.CountByPortalAndIDs(ctx, portalID, ids) if err != nil { return fmt.Errorf("count articles: %w", err) } if count == 0 { return ErrArticleBulkNoArticles } exists, err := s.repo.CategoryExistsForPortal(ctx, portalID, categoryID) if err != nil { return fmt.Errorf("find category: %w", err) } if !exists { return ErrArticleBulkCategoryNotFound } if err := s.repo.BulkUpdateCategoryForPortal(ctx, portalID, ids, categoryID); err != nil { return fmt.Errorf("bulk update category: %w", err) } 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 } func (s *ArticleService) BulkDeleteScoped(ctx context.Context, portalID uint, ids []uint) error { count, err := s.repo.CountByPortalAndIDs(ctx, portalID, ids) if err != nil { return fmt.Errorf("count articles: %w", err) } if count == 0 { return ErrArticleBulkNoArticles } if err := s.repo.BulkDeleteForPortal(ctx, portalID, ids); err != nil { return fmt.Errorf("bulk delete: %w", err) } 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) } } var ( ErrArticleBulkNoArticles = fmt.Errorf("no articles found") ErrArticleBulkInvalidStatus = fmt.Errorf("invalid status") ErrArticleBulkCategoryNotFound = fmt.Errorf("category not found") ) func validateArticleBulkStatus(status string) error { validStatuses := map[model.ArticleStatus]bool{ model.ArticleStatusDraft: true, model.ArticleStatusPublished: true, model.ArticleStatusArchived: true, } if !validStatuses[model.ArticleStatus(status)] { return ErrArticleBulkInvalidStatus } return nil } func articleSlug(title string) string { slug := strings.ToLower(strings.TrimSpace(title)) slug = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(slug, "-") slug = strings.Trim(slug, "-") if slug == "" { slug = "article" } return fmt.Sprintf("%d-%s", time.Now().UTC().Unix(), slug) }