Files
gochat/internal/service/article_service.go
T

508 lines
17 KiB
Go

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 = &currentUserID
}
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)
}