package repository import ( "context" "strings" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" "gorm.io/gorm" ) // ArticleRepo provides data access for Article. type ArticleRepo struct { db *gorm.DB } func NewArticleRepo(db *gorm.DB) *ArticleRepo { return &ArticleRepo{db: db} } func (r *ArticleRepo) DB() *gorm.DB { return r.db } func (r *ArticleRepo) Create(ctx context.Context, article *model.Article) error { return r.db.WithContext(ctx).Create(article).Error } func (r *ArticleRepo) GetByID(ctx context.Context, id uint) (*model.Article, error) { var article model.Article if err := r.articlePreloads(r.db.WithContext(ctx)).First(&article, id).Error; err != nil { return nil, err } return &article, nil } func (r *ArticleRepo) GetByPortalAndID(ctx context.Context, portalID, id uint) (*model.Article, error) { var article model.Article if err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ?", portalID).First(&article, id).Error; err != nil { return nil, err } return &article, nil } func (r *ArticleRepo) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.Article, error) { var article model.Article if err := r.articlePreloads(r.db.WithContext(ctx)).Where("account_id = ?", accountID).First(&article, id).Error; err != nil { return nil, err } return &article, nil } func (r *ArticleRepo) Update(ctx context.Context, article *model.Article) error { return r.db.WithContext(ctx).Save(article).Error } func (r *ArticleRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.Article{}, id).Error } // FindByPortalID returns articles for a portal with pagination. func (r *ArticleRepo) FindByPortalID(ctx context.Context, portalID uint, offset, limit int) ([]model.Article, int64, error) { var articles []model.Article var count int64 db := r.db.WithContext(ctx).Model(&model.Article{}).Where("portal_id = ?", portalID) db.Count(&count) query := r.articlePreloads(db).Offset(offset).Limit(limit).Order("position ASC") if limit <= 0 { query = r.articlePreloads(db).Order("position ASC") } if err := query.Find(&articles).Error; err != nil { return nil, 0, err } return articles, count, nil } // FindByCategoryID returns articles for a category with pagination. func (r *ArticleRepo) FindByCategoryID(ctx context.Context, categoryID uint, offset, limit int) ([]model.Article, int64, error) { var articles []model.Article var count int64 db := r.db.WithContext(ctx).Model(&model.Article{}).Where("category_id = ?", categoryID) db.Count(&count) if err := r.articlePreloads(db).Offset(offset).Limit(limit).Order("position ASC").Find(&articles).Error; err != nil { return nil, 0, err } return articles, count, nil } // FindByFolderID returns articles for a folder with pagination. func (r *ArticleRepo) FindByFolderID(ctx context.Context, folderID uint, offset, limit int) ([]model.Article, int64, error) { var articles []model.Article var count int64 db := r.db.WithContext(ctx).Model(&model.Article{}).Where("folder_id = ?", folderID) db.Count(&count) if err := r.articlePreloads(db).Offset(offset).Limit(limit).Order("position ASC").Find(&articles).Error; err != nil { return nil, 0, err } return articles, count, nil } // FindByStatus returns articles by status for a portal with pagination. func (r *ArticleRepo) FindByStatus(ctx context.Context, portalID uint, status model.ArticleStatus, offset, limit int) ([]model.Article, int64, error) { var articles []model.Article var count int64 db := r.db.WithContext(ctx).Model(&model.Article{}).Where("portal_id = ? AND status = ?", portalID, status) db.Count(&count) if err := r.articlePreloads(db).Offset(offset).Limit(limit).Order("updated_at DESC").Find(&articles).Error; err != nil { return nil, 0, err } return articles, count, nil } // FindBySlug returns an article by its slug. func (r *ArticleRepo) FindBySlug(ctx context.Context, slug string) (*model.Article, error) { var article model.Article if err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&article).Error; err != nil { return nil, err } return &article, nil } func (r *ArticleRepo) FindByPortalAndSlug(ctx context.Context, portalID uint, slug string) (*model.Article, error) { var article model.Article if err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND slug = ?", portalID, slug).First(&article).Error; err != nil { return nil, err } return &article, nil } // ArticleSearchParams holds all filtering parameters for article search. type ArticleSearchParams struct { PortalID uint Query string // full-text search term SearchMode search.SearchMode // ilike (default) or trigram (pg_trgm fuzzy) CategorySlug string // filter by category slug Locale string // filter by locale AuthorID *uint // filter by author Status string // filter by status SortBy string // "newest", "popular", "position" Offset int Limit int } // Search returns filtered + searched articles with pagination. // Implements Article.search(params) from M9 spec: combines query + category/locale/author/status filters. func (r *ArticleRepo) Search(ctx context.Context, params ArticleSearchParams) ([]model.Article, int64, error) { var articles []model.Article var count int64 db := r.applyArticleSearchParams(r.db.WithContext(ctx).Model(&model.Article{}).Where("articles.portal_id = ?", params.PortalID), params) // Count before pagination db.Count(&count) // Sorting switch params.SortBy { case "newest": db = db.Order("articles.updated_at DESC") case "popular", "views": db = db.Order("articles.views DESC") default: if params.CategorySlug != "" || params.Status != "" { db = db.Order("articles.position ASC") } else { db = db.Order("articles.updated_at DESC") } } query := r.articlePreloads(db) if params.Limit > 0 { query = query.Offset(params.Offset).Limit(params.Limit) } if err := query.Find(&articles).Error; err != nil { return nil, 0, err } return articles, count, nil } func (r *ArticleRepo) applyArticleSearchParams(db *gorm.DB, params ArticleSearchParams) *gorm.DB { // Filter by category slug — join categories table if params.CategorySlug != "" { db = db.Joins("LEFT JOIN categories ON categories.id = articles.category_id"). Where("categories.slug = ?", params.CategorySlug) } // Filter by locale if params.Locale != "" { db = db.Where("articles.locale = ?", params.Locale) } // Filter by author if params.AuthorID != nil { db = db.Where("articles.author_id = ?", *params.AuthorID) } // Filter by status if params.Status != "" { db = db.Where("articles.status = ?", params.Status) } // Full-text search (supports ILIKE and pg_trgm similarity modes) if params.Query != "" { if params.SearchMode == search.SearchModeTrigram { // pg_trgm fuzzy match on article fields db = db.Where("articles.title % ? OR articles.description % ? OR articles.content % ?", params.Query, params.Query, params.Query) } else { // ILIKE substring match (default) searchPattern := "%" + params.Query + "%" operator := "ILIKE" if strings.EqualFold(db.Dialector.Name(), "sqlite") { operator = "LIKE" } db = db.Where("articles.title "+operator+" ? OR articles.description "+operator+" ? OR articles.content "+operator+" ?", searchPattern, searchPattern, searchPattern) } } return db } func (r *ArticleRepo) CountSearch(ctx context.Context, params ArticleSearchParams) (int64, error) { var count int64 db := r.applyArticleSearchParams(r.db.WithContext(ctx).Model(&model.Article{}).Where("articles.portal_id = ?", params.PortalID), params) if err := db.Count(&count).Error; err != nil { return 0, err } return count, nil } // StatusCounts returns the count of articles by status for a portal. // Implements ArticlesController#set_article_count from M9 spec. func (r *ArticleRepo) StatusCounts(ctx context.Context, portalID uint) (map[string]int64, error) { type statusCount struct { Status string Count int64 } var results []statusCount err := r.db.WithContext(ctx).Model(&model.Article{}). Select("status, COUNT(*) as count"). Where("portal_id = ?", portalID). Group("status"). Find(&results).Error if err != nil { return nil, err } counts := map[string]int64{ "all_count": 0, "mine_count": 0, "published_count": 0, "draft_count": 0, "archived_count": 0, } var total int64 for _, r := range results { counts[r.Status+"_count"] = r.Count total += r.Count } counts["all_count"] = total return counts, nil } // UpdatePositions batch-updates article positions for ordering. // Implements Article.update_positions from M9 spec. func (r *ArticleRepo) UpdatePositions(ctx context.Context, positions map[uint]int) error { for id, pos := range positions { if err := r.db.WithContext(ctx).Model(&model.Article{}).Where("id = ?", id).Update("position", pos).Error; err != nil { return err } } return nil } func (r *ArticleRepo) UpdatePositionsForPortal(ctx context.Context, portalID uint, positions map[uint]int) error { for id, pos := range positions { if err := r.db.WithContext(ctx).Model(&model.Article{}).Where("id = ? AND portal_id = ?", id, portalID).Update("position", pos).Error; err != nil { return err } } return nil } // BulkUpdateStatus updates status for multiple articles. func (r *ArticleRepo) BulkUpdateStatus(ctx context.Context, ids []uint, status string) error { return r.db.WithContext(ctx).Model(&model.Article{}). Where("id IN ?", ids). Update("status", status).Error } func (r *ArticleRepo) BulkUpdateStatusForPortal(ctx context.Context, portalID uint, ids []uint, status string) error { return r.db.WithContext(ctx).Model(&model.Article{}). Where("portal_id = ? AND id IN ?", portalID, ids). Update("status", status).Error } func (r *ArticleRepo) BulkUpdateCategoryForPortal(ctx context.Context, portalID uint, ids []uint, categoryID uint) error { return r.db.WithContext(ctx).Model(&model.Article{}). Where("portal_id = ? AND id IN ?", portalID, ids). Update("category_id", categoryID).Error } // BulkDelete deletes multiple articles. func (r *ArticleRepo) BulkDelete(ctx context.Context, ids []uint) error { return r.db.WithContext(ctx).Where("id IN ?", ids).Delete(&model.Article{}).Error } func (r *ArticleRepo) BulkDeleteForPortal(ctx context.Context, portalID uint, ids []uint) error { return r.db.WithContext(ctx).Where("portal_id = ? AND id IN ?", portalID, ids).Delete(&model.Article{}).Error } func (r *ArticleRepo) CountByPortalAndIDs(ctx context.Context, portalID uint, ids []uint) (int64, error) { var count int64 if len(ids) == 0 { return 0, nil } if err := r.db.WithContext(ctx).Model(&model.Article{}).Where("portal_id = ? AND id IN ?", portalID, ids).Count(&count).Error; err != nil { return 0, err } return count, nil } func (r *ArticleRepo) FindByPortalAndIDs(ctx context.Context, portalID uint, ids []uint) ([]model.Article, error) { var articles []model.Article if len(ids) == 0 { return articles, nil } err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND id IN ?", portalID, ids).Order("id ASC").Find(&articles).Error return articles, err } func (r *ArticleRepo) CategoryExistsForPortalAndLocale(ctx context.Context, portalID uint, categoryID uint, locale string) (bool, error) { var count int64 db := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ? AND id = ?", portalID, categoryID) if locale != "" { db = db.Where("locale = ?", locale) } if err := db.Count(&count).Error; err != nil { return false, err } return count > 0, nil } func (r *ArticleRepo) FindTranslationsByRootIDsAndLocale(ctx context.Context, portalID uint, rootIDs []uint, locale string) ([]model.Article, error) { var articles []model.Article if len(rootIDs) == 0 || locale == "" { return articles, nil } err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND associated_article_id IN ? AND locale = ?", portalID, rootIDs, locale).Order("id ASC").Find(&articles).Error return articles, err } func (r *ArticleRepo) FindTranslationByRootAndLocale(ctx context.Context, portalID uint, rootID uint, locale string) (*model.Article, error) { var article model.Article if err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND associated_article_id = ? AND locale = ?", portalID, rootID, locale).First(&article).Error; err != nil { return nil, err } return &article, nil } func (r *ArticleRepo) CategoryExistsForPortal(ctx context.Context, portalID uint, categoryID uint) (bool, error) { var count int64 if err := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ? AND id = ?", portalID, categoryID).Count(&count).Error; err != nil { return false, err } return count > 0, nil } // IncrementViews increments the view count for an article by 1. func (r *ArticleRepo) IncrementViews(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Model(&model.Article{}). Where("id = ?", id). UpdateColumn("views", gorm.Expr("views + 1")).Error } func (r *ArticleRepo) articlePreloads(db *gorm.DB) *gorm.DB { return db.Preload("Category").Preload("Folder").Preload("Author").Preload("Portal").Preload("AssociatedArticles.Category").Preload("AssociatedArticles.Author").Preload("AssociatedArticles.Portal") }