feat: editorial article list

This commit is contained in:
Florian Sylvain committed 2026-11-02 07:28:44 +01:00
1 parent a0b3c58c52
commit a671b81f80
103 files changed
+6493 -1805

No files matched your search

@@ -61,6 +61,12 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
case domain.StatusDraft:
query = query.Where("is_online = ?", false)
}
if filters.Lang != nil {
query = query.Where("lang = ?", *filters.Lang)
}
if filters.AuthorID != nil {
query = query.Where("id IN (SELECT article_id FROM article_authors WHERE author_id = ?)", *filters.AuthorID)
}
return query
}
@@ -188,6 +194,22 @@ func (a *ArticleRepository) CountVisibleTo(userID uint32) (domain.Counts, error)
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
}
func (a *ArticleRepository) CountVersions(filters domain.Filters) (domain.Counts, error) {
narrowed := domain.Filters{Lang: filters.Lang}
var online, draft int64
if err := a.scoped(narrowed).Where("is_online = ?", true).Count(&online).Error; err != nil {
return domain.Counts{}, err
}
onlineOnly := filters.IsOnline != nil && *filters.IsOnline
if !onlineOnly {
narrowed.VisibleTo = filters.VisibleTo
if err := a.scoped(narrowed).Where("is_online = ?", false).Count(&draft).Error; err != nil {
return domain.Counts{}, err
}
}
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
}
func toDomainArticles(entityArticles []entity.Article) []domain.Article {
domainArticles := make([]domain.Article, 0, len(entityArticles))
for _, entityArticle := range entityArticles {
@@ -0,0 +1,262 @@
package persistence
import (
domain "RenewCMS/internal/domain/article"
entity "RenewCMS/internal/infrastructure/persistence/models"
"strings"
)
type storySQL struct {
conditions []string
args []any
}
func (s *storySQL) add(condition string, args ...any) {
s.conditions = append(s.conditions, condition)
s.args = append(s.args, args...)
}
func (s storySQL) where() string {
return strings.Join(s.conditions, " AND ")
}
func readableCondition(alias string, filters domain.StoryFilters) storySQL {
condition := storySQL{}
condition.add(alias + ".deleted_at IS NULL")
if filters.OnlyOnline {
condition.add(alias+".is_online = ?", true)
}
if filters.VisibleTo != nil {
condition.add("("+alias+".is_online = ? OR "+alias+".created_by = ?)", true, *filters.VisibleTo)
}
return condition
}
func (s *storySQL) merge(other storySQL) {
s.conditions = append(s.conditions, other.conditions...)
s.args = append(s.args, other.args...)
}
func lacksReadableVersionIn(languageExpression string, filters domain.StoryFilters, languageArgs ...any) storySQL {
readable := readableCondition("w", filters)
condition := storySQL{}
condition.add("NOT EXISTS (SELECT 1 FROM articles w WHERE w.story_id = q.story_id AND "+readable.where()+" AND w.lang = "+languageExpression+")", append(readable.args, languageArgs...)...)
return condition
}
func (a *ArticleRepository) versionRestriction(filters domain.StoryFilters) ([]uint32, bool, error) {
restricted := filters.VersionIDs != nil
var allowed []uint32
if restricted {
allowed = *filters.VersionIDs
}
query := strings.TrimSpace(filters.Q)
if query == "" {
return allowed, restricted, nil
}
readable := readableCondition("q", filters)
if filters.SiteID != nil {
readable.add("q.site_id = ?", *filters.SiteID)
}
var candidates []entity.Article
if err := a.db.Raw("SELECT q.id AS id, q.title AS title FROM articles q WHERE "+readable.where(), readable.args...).Scan(&candidates).Error; err != nil {
return nil, false, err
}
needle := foldForSearch(query)
permitted := map[uint32]bool{}
for _, id := range allowed {
permitted[id] = true
}
matching := make([]uint32, 0)
for _, candidate := range candidates {
if restricted && !permitted[candidate.ID] {
continue
}
if strings.Contains(foldForSearch(candidate.Title), needle) {
matching = append(matching, candidate.ID)
}
}
return matching, true, nil
}
func qualifyingVersions(filters domain.StoryFilters, allowed []uint32, restricted bool) storySQL {
condition := readableCondition("q", filters)
if filters.SiteID != nil {
condition.add("q.site_id = ?", *filters.SiteID)
}
if filters.PerLanguage() {
condition.add("q.lang = ?", filters.Lang)
}
switch filters.Status {
case domain.StatusOnline:
condition.add("q.is_online = ?", true)
case domain.StatusDraft:
condition.add("q.is_online = ?", false)
}
if filters.AuthorID != nil {
condition.add("q.id IN (SELECT aa.article_id FROM article_authors aa WHERE aa.author_id = ?)", *filters.AuthorID)
}
if restricted {
if len(allowed) == 0 {
condition.add("1 = 0")
} else {
condition.add("q.id IN ?", allowed)
}
}
if filters.Missing {
condition.merge(lacksReadableVersionIn("?", filters, ""))
condition.merge(missingCondition(filters))
}
return condition
}
func missingCondition(filters domain.StoryFilters) storySQL {
condition := storySQL{}
if filters.Lang != "" {
condition.add("EXISTS (SELECT 1 FROM site_languages sl WHERE sl.site_id = q.site_id AND sl.lang = ? AND sl.enabled = ?)", filters.Lang, true)
condition.merge(lacksReadableVersionIn("?", filters, filters.Lang))
return condition
}
absent := lacksReadableVersionIn("sl.lang", filters)
condition.add("EXISTS (SELECT 1 FROM site_languages sl WHERE sl.site_id = q.site_id AND sl.enabled = ? AND "+strings.Join(absent.conditions, " AND ")+")", append([]any{true}, absent.args...)...)
return condition
}
func unitExpression(filters domain.StoryFilters) string {
if filters.PerLanguage() {
return "COUNT(*)"
}
return "COUNT(DISTINCT q.story_id)"
}
func (a *ArticleRepository) countUnits(filters domain.StoryFilters, allowed []uint32, restricted bool) (int, error) {
qualifying := qualifyingVersions(filters, allowed, restricted)
var total int64
err := a.db.Raw("SELECT "+unitExpression(filters)+" FROM articles q WHERE "+qualifying.where(), qualifying.args...).Scan(&total).Error
return int(total), err
}
var storyRowOrder = map[domain.SortField]string{
domain.SortByTitle: "s.sort_title",
domain.SortByStatus: "s.sort_online",
domain.SortByCreatedAt: "s.sort_created",
domain.SortByUpdatedAt: "s.sort_updated",
}
var versionRowOrder = map[domain.SortField]string{
domain.SortByTitle: "LOWER(q.title)",
domain.SortByStatus: "q.is_online",
domain.SortByCreatedAt: "q.created_at",
domain.SortByUpdatedAt: "q.updated_at",
}
func storyOrderClause(columns map[domain.SortField]string, sort domain.Sort, storyColumn string) string {
column, ok := columns[sort.Field]
direction, directionOk := sortDirections[sort.Direction]
if !ok || !directionOk {
column, direction = columns[domain.SortByUpdatedAt], sortDirections[domain.SortDesc]
}
return column + " " + direction + ", " + storyColumn + " DESC"
}
type storyRefRow struct {
StoryID uint32
ArticleID uint32
}
func toStoryRefs(rows []storyRefRow) []domain.StoryRef {
refs := make([]domain.StoryRef, 0, len(rows))
for _, row := range rows {
refs = append(refs, domain.StoryRef{StoryID: row.StoryID, ArticleID: row.ArticleID})
}
return refs
}
func (a *ArticleRepository) FindStoryPage(filters domain.StoryFilters) (domain.StoryPage, error) {
filters.Page = max(filters.Page, 1)
filters.Size = max(filters.Size, 0)
allowed, restricted, err := a.versionRestriction(filters)
if err != nil {
return domain.StoryPage{}, err
}
total, err := a.countUnits(filters, allowed, restricted)
if err != nil {
return domain.StoryPage{}, err
}
page := domain.StoryPage{Refs: []domain.StoryRef{}, Total: total}
if total == 0 {
return page, nil
}
qualifying := qualifyingVersions(filters, allowed, restricted)
offset := (filters.Page - 1) * filters.Size
var rows []storyRefRow
if filters.PerLanguage() {
query := "SELECT q.story_id AS story_id, q.id AS article_id FROM articles q WHERE " + qualifying.where() +
" ORDER BY " + storyOrderClause(versionRowOrder, filters.Sort, "q.story_id") + " LIMIT ? OFFSET ?"
err = a.db.Raw(query, append(qualifying.args, filters.Size, offset)...).Scan(&rows).Error
} else {
query, args := storyRowsQuery(filters, qualifying)
err = a.db.Raw(query, append(args, filters.Size, offset)...).Scan(&rows).Error
}
if err != nil {
return domain.StoryPage{}, err
}
page.Refs = toStoryRefs(rows)
return page, nil
}
func storyRowsQuery(filters domain.StoryFilters, qualifying storySQL) (string, []any) {
readable := readableCondition("a", filters)
args := []any{true}
args = append(args, readable.args...)
args = append(args, qualifying.args...)
query := "SELECT s.story_id AS story_id, s.article_id AS article_id FROM (" +
"SELECT r.story_id AS story_id, r.id AS article_id, r.sort_title AS sort_title, r.sort_online AS sort_online, r.sort_updated AS sort_updated, r.sort_created AS sort_created FROM (" +
"SELECT a.id AS id, a.story_id AS story_id, LOWER(a.title) AS sort_title, a.is_online AS sort_online, " +
"MAX(a.updated_at) OVER (PARTITION BY a.story_id) AS sort_updated, " +
"MIN(a.created_at) OVER (PARTITION BY a.story_id) AS sort_created, " +
"ROW_NUMBER() OVER (PARTITION BY a.story_id ORDER BY CASE WHEN d.lang IS NULL THEN 1 ELSE 0 END, a.lang, a.id) AS row_rank " +
"FROM articles a LEFT JOIN site_languages d ON d.site_id = a.site_id AND d.lang = a.lang AND d.is_default = ? " +
"WHERE " + readable.where() + " AND a.story_id IN (SELECT q.story_id FROM articles q WHERE " + qualifying.where() + ")" +
") r WHERE r.row_rank = 1) s ORDER BY " + storyOrderClause(storyRowOrder, filters.Sort, "s.story_id") + " LIMIT ? OFFSET ?"
return query, args
}
func (a *ArticleRepository) CountStories(filters domain.StoryFilters) (domain.StoryCounts, error) {
filters.Status = ""
allowed, restricted, err := a.versionRestriction(filters)
if err != nil {
return domain.StoryCounts{}, err
}
counts := domain.StoryCounts{}
for _, target := range []struct {
status string
into *int
}{{"", &counts.All}, {domain.StatusOnline, &counts.Online}, {domain.StatusDraft, &counts.Draft}} {
scoped := filters
scoped.Missing = false
scoped.Status = target.status
if *target.into, err = a.countUnits(scoped, allowed, restricted); err != nil {
return domain.StoryCounts{}, err
}
}
if filters.SkipMissing {
return counts, nil
}
missing := filters
missing.Missing = true
counts.Missing, err = a.countUnits(missing, allowed, restricted)
return counts, err
}
func (a *ArticleRepository) ListByStories(storyIDs []uint32) ([]domain.Article, error) {
var found []entity.Article
for _, chunk := range chunkIDs(storyIDs) {
var loaded []entity.Article
if err := a.db.Model(&entity.Article{}).Where("story_id IN ?", chunk).Order("id ASC").Find(&loaded).Error; err != nil {
return nil, err
}
found = append(found, loaded...)
}
return a.withAuthors(a.db, toDomainArticles(found))
}