308 lines
10 KiB
Go
308 lines
10 KiB
Go
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)
|
|
}
|
|
}
|