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) }