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

@@ -0,0 +1,262 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"slices"
"time"
)
type ListStoriesCommand struct {
Format article.BodyFormat
Caller ContentCaller
Page int
Size int
Q string
Status string
Sort article.Sort
SiteKey *string
SEO string
Lang string
Tab string
Author *uint32
}
type StoryRow struct {
Article article.Article
SEO article.SEOSummary
UpdatedAt time.Time
Authors []author.Byline
Coverage []article.Translation
MissingLangs []string
Capabilities []string
}
type StoryRowsPage struct {
Rows []StoryRow
Total int
Page int
Size int
Counts article.StoryCounts
}
func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (StoryRowsPage, error) {
visibility := visibilityFor(command.Caller)
creatable := command.Caller.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle)
empty := StoryRowsPage{Rows: []StoryRow{}, Page: command.Page, Size: command.Size}
filters := storyFilters(command, visibility, command.Lang == "" && !creatable)
owners := &siteOwners{repository: u.sites}
if command.SiteKey != nil {
siteID, known, err := u.siteIDOf(*command.SiteKey)
if err != nil {
return StoryRowsPage{}, err
}
if !known {
return empty, nil
}
filters.SiteID = &siteID
}
summaries, visibleVersions, err := u.summarizeVisibleVersions(visibility, owners)
if err != nil {
return StoryRowsPage{}, err
}
if command.SEO != "" {
allowed, err := u.versionsWithSEOStatus(command, filters, summaries)
if err != nil {
return StoryRowsPage{}, err
}
filters.VersionIDs = &allowed
}
counts, err := u.articleRepository.CountStories(filters)
if err != nil {
return StoryRowsPage{}, err
}
counts.Suggestions = countSuggestedStories(visibleVersions, summaries)
empty.Counts = counts
if command.Tab == article.TabMissing && command.Lang == "" && !creatable {
return empty, nil
}
found, err := u.articleRepository.FindStoryPage(filters)
if err != nil {
return StoryRowsPage{}, err
}
empty.Total = found.Total
if len(found.Refs) == 0 {
return empty, nil
}
rows, err := u.storyRows(command, found.Refs, creatable, owners)
if err != nil {
return StoryRowsPage{}, err
}
empty.Rows = rows
return empty, nil
}
func storyFilters(command ListStoriesCommand, visibility articleVisibility, skipMissing bool) article.StoryFilters {
return article.StoryFilters{
SkipMissing: skipMissing,
OnlyOnline: visibility.onlineOnly,
VisibleTo: visibility.userID,
Lang: command.Lang,
Missing: command.Tab == article.TabMissing,
Status: command.Status,
AuthorID: command.Author,
Q: command.Q,
Sort: command.Sort,
Page: command.Page,
Size: command.Size,
}
}
func (u *ListArticlesUseCase) summarizeVisibleVersions(visibility articleVisibility, owners *siteOwners) (map[uint32]article.SEOSummary, []article.Article, error) {
found, err := u.articleRepository.FindPage(visibility.apply(article.Filters{Unpaged: true}))
if err != nil {
return nil, nil, err
}
summaries, err := u.summarize(found.Items, owners)
return summaries, found.Items, err
}
func (u *ListArticlesUseCase) versionsWithSEOStatus(command ListStoriesCommand, filters article.StoryFilters, summaries map[uint32]article.SEOSummary) ([]uint32, error) {
narrowed := article.Filters{VisibleTo: filters.VisibleTo, SiteID: filters.SiteID, AuthorID: filters.AuthorID, Q: filters.Q, Unpaged: true}
if filters.OnlyOnline {
online := true
narrowed.IsOnline = &online
}
found, err := u.articleRepository.FindPage(narrowed)
if err != nil {
return nil, err
}
allowed := []uint32{}
for _, version := range found.Items {
if summaries[version.ID].Status == command.SEO {
allowed = append(allowed, version.ID)
}
}
return allowed, nil
}
func countSuggestedStories(versions []article.Article, summaries map[uint32]article.SEOSummary) int {
stories := map[uint32]bool{}
standalone := 0
for _, version := range versions {
if summaries[version.ID].Status != article.SEOStatusSuggestions {
continue
}
if version.StoryID == 0 {
standalone++
continue
}
stories[version.StoryID] = true
}
return len(stories) + standalone
}
func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, refs []article.StoryRef, creatable bool, owners *siteOwners) ([]StoryRow, error) {
storyIDs := make([]uint32, 0, len(refs))
for _, ref := range refs {
storyIDs = append(storyIDs, ref.StoryID)
}
loaded, err := u.articleRepository.ListByStories(storyIDs)
if err != nil {
return nil, err
}
byStory := map[uint32][]article.Article{}
for _, version := range command.Caller.readableVersions(loaded) {
byStory[version.StoryID] = append(byStory[version.StoryID], version)
}
siteRows := map[uint32][]localization.Row{}
rows := make([]StoryRow, 0, len(refs))
representatives := make([]article.Article, 0, len(refs))
for _, ref := range refs {
versions := byStory[ref.StoryID]
representative, found := versionByID(versions, ref.ArticleID)
if !found {
continue
}
languages, cached := siteRows[representative.SiteID]
if !cached {
if languages, err = u.localization.Rows(representative.SiteID); err != nil {
return nil, err
}
siteRows[representative.SiteID] = languages
}
rows = append(rows, buildStoryRow(command.Caller, representative, versions, languages, creatable))
representatives = append(representatives, representative)
}
summaries, err := u.summarize(representatives, owners)
if err != nil {
return nil, err
}
rendered, err := renderArticles(u.bodyRenderer, representatives, command.Format)
if err != nil {
return nil, err
}
for index := range rows {
rows[index].Article = rendered[index]
rows[index].SEO = summaries[rendered[index].ID]
}
return rows, nil
}
func versionByID(versions []article.Article, id uint32) (article.Article, bool) {
index := slices.IndexFunc(versions, func(version article.Article) bool { return version.ID == id })
if index < 0 {
return article.Article{}, false
}
return versions[index], true
}
func buildStoryRow(caller ContentCaller, representative article.Article, versions []article.Article, languages []localization.Row, creatable bool) StoryRow {
ordered := sortByLanguage(versions, languages)
coverage := make([]article.Translation, 0, len(ordered))
updatedAt := time.Time{}
authors := []author.Byline{}
seen := map[uint32]bool{}
for _, version := range ordered {
coverage = append(coverage, article.Translation{
ID: version.ID,
Lang: version.Lang,
Title: version.Title,
IsOnline: version.IsOnline,
LanguageEnabled: version.Lang == language.Unassigned || localization.EnabledTag(languages, version.Lang),
Capabilities: caller.capabilitiesFor(version),
})
if version.UpdatedAt.After(updatedAt) {
updatedAt = version.UpdatedAt
}
for _, byline := range version.Authors {
if !seen[byline.ID] {
seen[byline.ID] = true
authors = append(authors, byline)
}
}
}
return StoryRow{
UpdatedAt: updatedAt,
Authors: authors,
Coverage: coverage,
MissingLangs: missingLanguages(ordered, languages, creatable),
Capabilities: caller.capabilitiesFor(representative),
}
}
func missingLanguages(versions []article.Article, languages []localization.Row, creatable bool) []string {
missing := []string{}
if !creatable || slices.ContainsFunc(versions, func(version article.Article) bool { return version.Lang == language.Unassigned }) {
return missing
}
present := languagesOf(versions)
for _, row := range languages {
if row.Enabled && !slices.Contains(present, row.Tag) {
missing = append(missing, row.Tag)
}
}
defaultTag := localization.DefaultTag(languages)
slices.SortFunc(missing, func(left string, right string) int { return localization.CompareTags(defaultTag, left, right) })
return missing
}
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site"
"errors"
@@ -12,10 +13,11 @@ type ListArticlesUseCase struct {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
sites site.Repository
localization localization.Repository
}
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, sites site.Repository) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, sites: sites}
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, sites site.Repository, localizationRepository localization.Repository) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, sites: sites, localization: localizationRepository}
}
type ListArticlesCommand struct {
@@ -28,6 +30,8 @@ type ListArticlesCommand struct {
Sort article.Sort
SiteKey *string
SEO string
Lang *string
Author *uint32
}
type ArticlePage struct {
@@ -40,13 +44,17 @@ type ArticlePage struct {
}
func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) {
switch command.Caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) {
return u.listVisibleByRepository(command, visibilityFor(command.Caller))
}
func visibilityFor(caller ContentCaller) articleVisibility {
switch caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) {
case role.ScopeAll:
return u.listVisibleByRepository(command, articleVisibility{})
return articleVisibility{}
case role.ScopeOwn:
return u.listVisibleByRepository(command, articleVisibility{userID: &command.Caller.UserID})
return articleVisibility{userID: &caller.UserID}
default:
return u.listVisibleByRepository(command, articleVisibility{onlineOnly: true})
return articleVisibility{onlineOnly: true}
}
}
@@ -64,6 +72,13 @@ func (v articleVisibility) apply(filters article.Filters) article.Filters {
return filters
}
func (u *ListArticlesUseCase) countFlat(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
if command.Lang != nil {
return u.countVisibleVersions(command, visibility)
}
return u.countVisible(visibility)
}
func (u *ListArticlesUseCase) countVisible(visibility articleVisibility) (article.Counts, error) {
if visibility.userID != nil {
return u.articleRepository.CountVisibleTo(*visibility.userID)
@@ -73,7 +88,7 @@ func (u *ListArticlesUseCase) countVisible(visibility articleVisibility) (articl
func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, visibility articleVisibility) (ArticlePage, error) {
onlineOnly := visibility.onlineOnly
counts, err := u.countVisible(visibility)
counts, err := u.countFlat(command, visibility)
if err != nil {
return ArticlePage{}, err
}
@@ -149,7 +164,12 @@ func (u *ListArticlesUseCase) summarizeVisible(visibility articleVisibility) (ma
if err != nil {
return nil, err
}
return u.summarize(found.Items)
return u.summarize(found.Items, &siteOwners{repository: u.sites})
}
func (u *ListArticlesUseCase) countVisibleVersions(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
filters := visibility.apply(article.Filters{Lang: command.Lang})
return u.articleRepository.CountVersions(filters)
}
func countSuggestions(summaries map[uint32]article.SEOSummary) int {
@@ -167,19 +187,35 @@ func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSu
return pageSlice(matching, command.Page, command.Size), len(matching)
}
func (u *ListArticlesUseCase) summarize(items []article.Article) (map[uint32]article.SEOSummary, error) {
type siteOwners struct {
repository site.Repository
byID map[uint32]site.Site
}
func (o *siteOwners) get() (map[uint32]site.Site, error) {
if o.byID != nil {
return o.byID, nil
}
known, err := o.repository.List()
if err != nil {
return nil, err
}
o.byID = make(map[uint32]site.Site, len(known))
for _, owner := range known {
o.byID[owner.ID] = owner
}
return o.byID, nil
}
func (u *ListArticlesUseCase) summarize(items []article.Article, lookup *siteOwners) (map[uint32]article.SEOSummary, error) {
summaries := make(map[uint32]article.SEOSummary, len(items))
if len(items) == 0 {
return summaries, nil
}
known, err := u.sites.List()
owners, err := lookup.get()
if err != nil {
return nil, err
}
owners := make(map[uint32]site.Site, len(known))
for _, owner := range known {
owners[owner.ID] = owner
}
for _, item := range items {
summaries[item.ID] = assessSEO(item, owners[item.SiteID]).Summary()
}
@@ -198,5 +234,5 @@ func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
}
func pageFilters(command ListArticlesCommand) article.Filters {
return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort}
return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort, Lang: command.Lang, AuthorID: command.Author}
}