feat: editor write path

This commit is contained in:
Florian Sylvain committed 2026-11-02 03:24:30 +01:00
1 parent d9158025d4
commit a0b3c58c52
88 files changed
+5738 -263

No files matched your search

@@ -0,0 +1,254 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/transaction"
"context"
"errors"
"slices"
)
type CreateTranslationUseCase struct {
transactor transaction.Transactor
articles article.Repository
localization localization.Repository
bodyRenderer article.BodyRenderer
recorder audit.Recorder
}
type CreateTranslationCommand struct {
ArticleID uint32
StoryID uint32
Lang string
Title string
Copy *bool
SourceArticleID *uint32
Format article.BodyFormat
Caller ContentCaller
}
type translationPlan struct {
base article.Article
tag string
title string
sourceID uint32
copy bool
}
func NewCreateTranslationUseCase(transactor transaction.Transactor, articles article.Repository, localizationRepository localization.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder) *CreateTranslationUseCase {
return &CreateTranslationUseCase{transactor: transactor, articles: articles, localization: localizationRepository, bodyRenderer: bodyRenderer, recorder: recorder}
}
func (u *CreateTranslationUseCase) CreateTranslation(ctx context.Context, command CreateTranslationCommand) (article.Article, error) {
if err := command.Caller.requireHolding(role.KeyContentCreate, builtintype.KeyArticle); err != nil {
return article.Article{}, err
}
base, versions, err := u.resolveBase(command)
if err != nil {
return article.Article{}, err
}
event := articleEvent(audit.ActionArticleCreate, base)
plan, err := u.plan(command, base, versions)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return article.Article{}, err
}
created, err := u.createWithRetry(ctx, command.Caller, plan)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return article.Article{}, err
}
u.recordCreated(ctx, created)
return renderArticle(u.bodyRenderer, created, command.Format)
}
func (u *CreateTranslationUseCase) resolveBase(command CreateTranslationCommand) (article.Article, []article.Article, error) {
if command.StoryID == 0 {
base, err := u.articles.Get(command.ArticleID)
if err != nil {
return article.Article{}, nil, err
}
if !command.Caller.seesArticle(base) {
return article.Article{}, nil, article.ErrArticleNotFound
}
versions, err := u.storyVersions(base)
return base, versions, err
}
versions, err := u.articles.ListByStory(command.StoryID)
if err != nil {
return article.Article{}, nil, err
}
readable := sortByCreation(command.Caller.readableVersions(versions))
if len(readable) == 0 {
return article.Article{}, nil, article.ErrArticleNotFound
}
return readable[0], versions, nil
}
func (u *CreateTranslationUseCase) storyVersions(base article.Article) ([]article.Article, error) {
if base.StoryID == 0 {
return []article.Article{base}, nil
}
return u.articles.ListByStory(base.StoryID)
}
func (u *CreateTranslationUseCase) plan(command CreateTranslationCommand, base article.Article, versions []article.Article) (translationPlan, error) {
if command.Copy == nil || (!*command.Copy && command.SourceArticleID != nil) {
return translationPlan{}, article.ErrInvalidTranslation
}
tag, err := language.Normalize(command.Lang)
if err != nil {
return translationPlan{}, err
}
if base.Lang == language.Unassigned {
return translationPlan{}, localization.ErrSetupRequired
}
plan := translationPlan{base: base, tag: tag, title: command.Title, copy: *command.Copy}
if plan.copy {
plan.sourceID, err = u.sourceID(command, base)
if err != nil {
return translationPlan{}, err
}
}
return plan, u.checkLanguage(command.Caller, base, tag, versions)
}
func (u *CreateTranslationUseCase) sourceID(command CreateTranslationCommand, base article.Article) (uint32, error) {
sourceID := base.ID
if command.SourceArticleID != nil {
sourceID = *command.SourceArticleID
}
source, err := u.articles.Get(sourceID)
if errors.Is(err, article.ErrArticleNotFound) || err == nil && !command.Caller.seesArticle(source) {
return 0, article.ErrSourceUnavailable
}
if err != nil {
return 0, err
}
if source.StoryID != base.StoryID {
return 0, article.ErrSourceOtherStory
}
return sourceID, nil
}
func (u *CreateTranslationUseCase) checkLanguage(caller ContentCaller, base article.Article, tag string, versions []article.Article) error {
rows, err := u.localization.Rows(base.SiteID)
if err != nil {
return err
}
if !localization.HasRow(rows, tag) {
return &article.LanguageNotEnabledError{}
}
if existing, found := existingVersion(versions, tag); found {
if caller.seesArticle(existing) {
return &article.LanguageExistsError{ArticleID: existing.ID}
}
return article.ErrLanguageUnavailable
}
if !localization.EnabledTag(rows, tag) {
return article.ErrLanguageUnavailable
}
return nil
}
func existingVersion(versions []article.Article, tag string) (article.Article, bool) {
index := slices.IndexFunc(versions, func(version article.Article) bool { return version.Lang == tag })
if index < 0 {
return article.Article{}, false
}
return versions[index], true
}
func (u *CreateTranslationUseCase) createWithRetry(ctx context.Context, caller ContentCaller, plan translationPlan) (article.Article, error) {
var err error
for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ {
var created article.Article
created, err = u.createInTransaction(ctx, caller, plan)
if err == nil {
return created, nil
}
if !isSlugTakenError(err) && !errors.Is(err, transaction.ErrLockContention) {
break
}
}
if errors.Is(err, article.ErrLanguageTaken) {
return article.Article{}, article.ErrLanguageUnavailable
}
return article.Article{}, err
}
func (u *CreateTranslationUseCase) createInTransaction(ctx context.Context, caller ContentCaller, plan translationPlan) (article.Article, error) {
var created article.Article
err := u.transactor.Run(ctx, func(tx transaction.Repositories) error {
if err := tx.Sites.LockSite(plan.base.SiteID); err != nil {
return err
}
draft, err := u.draft(tx, caller, plan)
if err != nil {
return err
}
draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt)
if err != nil {
return err
}
created, err = tx.Articles.Create(draft)
return err
})
return created, err
}
func (u *CreateTranslationUseCase) draft(tx transaction.Repositories, caller ContentCaller, plan translationPlan) (article.Article, error) {
base, err := tx.Articles.Get(plan.base.ID)
if err != nil {
return article.Article{}, err
}
if base.SiteID != plan.base.SiteID || base.StoryID != plan.base.StoryID {
return article.Article{}, transaction.ErrLockContention
}
draft := article.Article{
Title: plan.title,
SiteID: base.SiteID,
StoryID: base.StoryID,
Lang: plan.tag,
CreatedBy: caller.UserID,
SEODismissed: []string{},
}
if !plan.copy {
draft.Authors, err = bylinesOf(tx.Authors, caller.UserID)
return draft, err
}
source, err := tx.Articles.Get(plan.sourceID)
if errors.Is(err, article.ErrArticleNotFound) || err == nil && (!caller.seesArticle(source) || source.StoryID != base.StoryID) {
return article.Article{}, article.ErrSourceUnavailable
}
if err != nil {
return article.Article{}, err
}
return source.CopiedInto(draft), nil
}
func bylinesOf(authors author.Repository, userID uint32) ([]author.Byline, error) {
found, err := authors.GetByUserID(userID)
if errors.Is(err, author.ErrAuthorNotFound) {
return []author.Byline{}, nil
}
if err != nil {
return nil, err
}
return []author.Byline{found.Byline()}, nil
}
func (u *CreateTranslationUseCase) recordCreated(ctx context.Context, created article.Article) {
event := articleEvent(audit.ActionArticleCreate, created)
event.Params[audit.ParamStoryID] = created.StoryID
if created.SourceArticleID != nil {
event.Params[audit.ParamSourceArticleID] = *created.SourceArticleID
}
u.recorder.Record(ctx, event)
}