380 lines
13 KiB
Go
380 lines
13 KiB
Go
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")
|
|
}
|