feat: Authors feature backend (and upgraded deprecated mods)

This commit is contained in:
Florian Sylvain committed 2026-10-14 17:45:00 +02:00
1 parent a11338094e
commit b34a173d83
103 files changed
+6454 -240

No files matched your search

@@ -3,8 +3,10 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/site"
"context"
"errors"
)
type CreateArticleUseCase struct {
@@ -12,16 +14,18 @@ type CreateArticleUseCase struct {
bodyRenderer article.BodyRenderer
recorder audit.Recorder
sites site.Repository
authors author.Repository
}
type CreateArticleCommand struct {
Title string
Body string
Format article.BodyFormat
Title string
Body string
Format article.BodyFormat
CreatedBy uint32
}
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites}
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
}
func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
@@ -32,6 +36,17 @@ func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
return defaultSite.ID, nil
}
func (g *CreateArticleUseCase) creatorBylines(userID uint32) ([]author.Byline, error) {
found, err := g.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 (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle CreateArticleCommand) (article.Article, error) {
event := audit.NewEvent(audit.ActionArticleCreate, audit.CategoryArticles)
if err := article.ValidateTitle(createArticle.Title); err != nil {
@@ -45,11 +60,18 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return article.Article{}, err
}
creditedAuthors, err := g.creatorBylines(createArticle.CreatedBy)
if err != nil {
recordFailure(ctx, g.recorder, event, err)
return article.Article{}, err
}
created, err := g.articleRepository.Create(article.Article{
Title: createArticle.Title,
Body: createArticle.Body,
IsOnline: false,
SiteID: siteID,
Authors: creditedAuthors,
})
if err != nil {
recordFailure(ctx, g.recorder, event, err)
@@ -0,0 +1,106 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"context"
"strings"
)
type CreateAuthorUseCase struct {
authors author.Repository
users user.Repository
images image.Repository
recorder audit.Recorder
}
type CreateAuthorCommand struct {
Name string
Slug string
Bio string
AvatarID *uint32
Website *string
SocialLinks []author.SocialLink
UserID *uint32
}
func NewCreateAuthorUseCase(authors author.Repository, users user.Repository, images image.Repository, recorder audit.Recorder) *CreateAuthorUseCase {
return &CreateAuthorUseCase{authors: authors, users: users, images: images, recorder: recorder}
}
func (u *CreateAuthorUseCase) Create(ctx context.Context, command CreateAuthorCommand) (author.Author, error) {
command.Name = strings.TrimSpace(command.Name)
event := audit.NewEvent(audit.ActionAuthorCreate, audit.CategoryAuthors)
event.Target = audit.Target{Type: audit.TargetAuthor, Name: command.Name}
created, err := u.create(command)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return author.Author{}, err
}
event.Target = authorTarget(created)
u.recorder.Record(ctx, event)
return withUserEmail(u.users, created), nil
}
func (u *CreateAuthorUseCase) create(command CreateAuthorCommand) (author.Author, error) {
website, err := validateAuthorContent(command.Bio, command.Website, command.SocialLinks)
if err != nil {
return author.Author{}, err
}
if err := ensureImageExists(u.images, command.AvatarID); err != nil {
return author.Author{}, err
}
if command.UserID != nil {
if err := ensureUserCanBeLinked(u.users, u.authors, *command.UserID); err != nil {
return author.Author{}, err
}
}
slug, err := u.slugFor(command)
if err != nil {
return author.Author{}, err
}
links := command.SocialLinks
if links == nil {
links = []author.SocialLink{}
}
return u.authors.Create(author.Author{
Name: command.Name,
Slug: slug,
Bio: command.Bio,
AvatarID: command.AvatarID,
Website: website,
SocialLinks: links,
UserID: command.UserID,
IsLinked: command.UserID != nil,
})
}
func (u *CreateAuthorUseCase) slugFor(command CreateAuthorCommand) (string, error) {
slug := command.Slug
if slug == "" {
slug = author.GenerateSlug(command.Name)
}
if !author.IsValidSlug(slug) {
return "", author.ErrInvalidSlug
}
taken, err := isSlugTaken(u.authors, slug)
if err != nil {
return "", err
}
if taken {
return "", author.ErrSlugTaken
}
return slug, nil
}
func validateAuthorContent(bio string, website *string, links []author.SocialLink) (*string, error) {
if err := author.ValidateBio(bio); err != nil {
return nil, err
}
if err := author.ValidateLinks(links); err != nil {
return nil, err
}
return author.NormalizeWebsite(website)
}
@@ -0,0 +1,80 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/user"
"context"
"errors"
"strings"
)
const maxOwnSlugAttempts = 50
type CreateMyAuthorUseCase struct {
authors author.Repository
users user.Repository
recorder audit.Recorder
}
func NewCreateMyAuthorUseCase(authors author.Repository, users user.Repository, recorder audit.Recorder) *CreateMyAuthorUseCase {
return &CreateMyAuthorUseCase{authors: authors, users: users, recorder: recorder}
}
func (u *CreateMyAuthorUseCase) Create(ctx context.Context, userID uint32) (author.Author, error) {
caller, err := u.users.Get(userID)
if err != nil {
return author.Author{}, err
}
name := authorNameOf(caller)
event := audit.NewEvent(audit.ActionAuthorCreate, audit.CategoryAuthors)
event.Target = audit.Target{Type: audit.TargetAuthor, Name: name}
created, err := u.create(caller, name)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return author.Author{}, err
}
event.Target = authorTarget(created)
u.recorder.Record(ctx, event)
return created, nil
}
func authorNameOf(caller user.User) string {
name := strings.TrimSpace(caller.DisplayName)
if name == "" {
name = strings.TrimSpace(caller.Username)
}
runes := []rune(name)
return string(runes[:min(len(runes), author.MaxNameRunes)])
}
func (u *CreateMyAuthorUseCase) create(caller user.User, name string) (author.Author, error) {
_, err := u.authors.GetByUserID(caller.ID)
if err == nil {
return author.Author{}, author.ErrUserAlreadyLinked
}
if !errors.Is(err, author.ErrAuthorNotFound) {
return author.Author{}, err
}
return u.createWithFreeSlug(caller.ID, name)
}
func (u *CreateMyAuthorUseCase) createWithFreeSlug(userID uint32, name string) (author.Author, error) {
base := author.GenerateSlug(name)
for attempt := 1; attempt <= maxOwnSlugAttempts; attempt++ {
slug := author.SlugWithSuffix(base, attempt)
taken, err := isSlugTaken(u.authors, slug)
if err != nil {
return author.Author{}, err
}
if taken {
continue
}
created, err := u.authors.Create(author.Author{Name: name, Slug: slug, SocialLinks: []author.SocialLink{}, UserID: &userID, IsLinked: true})
if errors.Is(err, author.ErrSlugTaken) {
continue
}
return created, err
}
return author.Author{}, author.ErrSlugTaken
}
@@ -0,0 +1,76 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"context"
)
type ReassignMode int
const (
ReassignAbsent ReassignMode = iota
ReassignNone
ReassignToAuthor
ReassignMalformed
)
type Reassignment struct {
Mode ReassignMode
Destination uint32
}
type DeleteAuthorUseCase struct {
authors author.Repository
recorder audit.Recorder
}
func NewDeleteAuthorUseCase(authors author.Repository, recorder audit.Recorder) *DeleteAuthorUseCase {
return &DeleteAuthorUseCase{authors: authors, recorder: recorder}
}
func (u *DeleteAuthorUseCase) Delete(ctx context.Context, id uint32, reassignment Reassignment) error {
target, err := u.authors.Get(id)
if err != nil {
return err
}
event := audit.NewEvent(audit.ActionAuthorDelete, audit.CategoryAuthors)
event.Target = authorTarget(target)
if err := u.delete(target, reassignment); err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
u.recorder.Record(ctx, event)
return nil
}
func (u *DeleteAuthorUseCase) delete(target author.Author, reassignment Reassignment) error {
if reassignment.Mode == ReassignNone {
return u.authors.DeleteUncrediting(target.ID)
}
if target.ArticleCount == 0 {
return u.authors.DeleteMovingCredits(target.ID, nil)
}
destination, err := u.destination(target, reassignment)
if err != nil {
return err
}
return u.authors.DeleteMovingCredits(target.ID, &destination)
}
func (u *DeleteAuthorUseCase) destination(target author.Author, reassignment Reassignment) (uint32, error) {
switch reassignment.Mode {
case ReassignAbsent:
return 0, author.ErrAuthorHasArticles
case ReassignMalformed:
return 0, author.ErrInvalidReassign
}
if reassignment.Destination == 0 || reassignment.Destination == target.ID {
return 0, author.ErrInvalidReassign
}
if _, err := u.authors.Get(reassignment.Destination); err != nil {
return 0, err
}
return reassignment.Destination, nil
}
@@ -2,6 +2,7 @@ package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"context"
@@ -12,10 +13,11 @@ type DeleteMediaUseCase struct {
remover mediaRemover
recorder audit.Recorder
sites site.Repository
authors author.Repository
}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors}
}
func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
@@ -23,8 +25,14 @@ func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
if err != nil {
return err
}
clearedAuthors, err := u.authors.ClearAvatar(img.ID)
if err != nil {
_ = u.sites.AssignImage(clearedSites, img.ID)
return err
}
if err := u.remover.remove(img); err != nil {
_ = u.sites.AssignImage(clearedSites, img.ID)
_ = u.authors.AssignAvatar(clearedAuthors, img.ID)
return err
}
return nil
@@ -0,0 +1,25 @@
package useCases
import (
"RenewCMS/internal/domain/author"
"errors"
)
type GetMyAuthorUseCase struct {
authors author.Repository
}
func NewGetMyAuthorUseCase(authors author.Repository) *GetMyAuthorUseCase {
return &GetMyAuthorUseCase{authors: authors}
}
func (u *GetMyAuthorUseCase) Get(userID uint32) (*author.Author, error) {
found, err := u.authors.GetByUserID(userID)
if errors.Is(err, author.ErrAuthorNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &found, nil
}
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
@@ -20,6 +21,7 @@ type ListActivityUseCase struct {
apiKeys apikey.Repository
now func() time.Time
sites site.Repository
authors author.Repository
}
type ListActivityCommand struct {
@@ -39,8 +41,8 @@ type ActivityPage struct {
Size int
}
func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time, sites site.Repository) *ListActivityUseCase {
return &ListActivityUseCase{events: events, users: users, articles: articles, images: images, apiKeys: apiKeys, now: now, sites: sites}
func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time, sites site.Repository, authors author.Repository) *ListActivityUseCase {
return &ListActivityUseCase{events: events, users: users, articles: articles, images: images, apiKeys: apiKeys, now: now, sites: sites, authors: authors}
}
func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (ActivityPage, error) {
@@ -130,6 +132,9 @@ func (l *ListActivityUseCase) targetExists(target audit.Target) (bool, error) {
case audit.TargetSite:
_, err = l.sites.Get(uint32(id))
return existsUnless(err, site.ErrSiteNotFound)
case audit.TargetAuthor:
_, err = l.authors.Get(uint32(id))
return existsUnless(err, author.ErrAuthorNotFound)
default:
return true, nil
}
@@ -0,0 +1,44 @@
package useCases
import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/user"
)
type ListAuthorsUseCase struct {
authors author.Repository
users user.Repository
}
type ListAuthorsCommand struct {
Q string
Page int
Size int
CallerRole user.Role
}
type AuthorPage struct {
Items []author.Author
Total int
Page int
Size int
}
func NewListAuthorsUseCase(authors author.Repository, users user.Repository) *ListAuthorsUseCase {
return &ListAuthorsUseCase{authors: authors, users: users}
}
func (u *ListAuthorsUseCase) List(command ListAuthorsCommand) (AuthorPage, error) {
found, err := u.authors.List(author.ListQuery{Q: command.Q, Page: command.Page, Size: command.Size})
if err != nil {
return AuthorPage{}, err
}
items := make([]author.Author, 0, len(found.Items))
for _, item := range found.Items {
if isAdminRole(command.CallerRole) {
item = withUserEmail(u.users, item)
}
items = append(items, item)
}
return AuthorPage{Items: items, Total: found.Total, Page: command.Page, Size: command.Size}, nil
}
@@ -2,6 +2,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
@@ -38,10 +39,11 @@ type ListMediaUseCase struct {
userRepository user.Repository
variantFiles image.VariantFileRepository
sites site.Repository
authors author.Repository
}
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites}
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository, authors author.Repository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites, authors: authors}
}
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
@@ -108,7 +110,7 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
if err != nil {
return MediaItem{}, err
}
siteUsage, err := siteImageUsage(l.sites)
siteUsage, err := imageUsageCount(l.sites, l.authors)
if err != nil {
return MediaItem{}, err
}
@@ -143,7 +145,7 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
if err != nil {
return nil, err
}
siteUsage, err := siteImageUsage(l.sites)
siteUsage, err := imageUsageCount(l.sites, l.authors)
if err != nil {
return nil, err
}
+22 -5
View File
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"cmp"
@@ -16,6 +17,7 @@ type MergeMediaResult struct {
ArticlesUpdated int
Merged int
SitesUpdated int
AuthorsUpdated int
UpdatedArticleIDs []uint32
}
@@ -25,18 +27,27 @@ type MergeMediaUseCase struct {
remover mediaRemover
recorder audit.Recorder
sites site.Repository
authors author.Repository
}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors}
}
func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) {
func copyIdsOf(copies []image.Image) []uint32 {
copyIds := make([]uint32, 0, len(copies))
for _, duplicate := range copies {
copyIds = append(copyIds, duplicate.ID)
}
return u.sites.RepointImage(copyIds, keptId)
return copyIds
}
func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) {
return u.sites.RepointImage(copyIdsOf(copies), keptId)
}
func (u *MergeMediaUseCase) repointAuthors(copies []image.Image, keptId uint32) (int, error) {
return u.authors.RepointAvatar(copyIdsOf(copies), keptId)
}
type bodyRewrite struct {
@@ -95,13 +106,19 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
return MergeMediaResult{}, err
}
authorsUpdated, err := u.repointAuthors(copies, kept.ID)
if err != nil {
u.restoreBodies(rewrites)
return MergeMediaResult{}, err
}
for _, duplicate := range copies {
if err := u.remover.remove(duplicate); err != nil {
return MergeMediaResult{}, err
}
}
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
}
func rewrittenIds(rewrites []bodyRewrite) []uint32 {
@@ -2,6 +2,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
)
@@ -29,8 +30,8 @@ type PreviewPurgeUseCase struct {
planner *purgePlanner
}
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository) *PreviewPurgeUseCase {
return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites}}
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository, authors author.Repository) *PreviewPurgeUseCase {
return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors}}
}
func (u *PreviewPurgeUseCase) Preview() (PurgePreview, error) {
+4 -2
View File
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/domain/site"
@@ -23,9 +24,9 @@ type RunPurgeUseCase struct {
guard sync.Mutex
}
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder, sites site.Repository) *RunPurgeUseCase {
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder, sites site.Repository, authors author.Repository) *RunPurgeUseCase {
return &RunPurgeUseCase{
planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites},
planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors},
merge: merge,
repository: repository,
now: now,
@@ -88,6 +89,7 @@ func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
result.GroupsPurged++
result.Merged += merged.Merged
result.SitesUpdated += merged.SitesUpdated
result.AuthorsUpdated += merged.AuthorsUpdated
for _, id := range merged.UpdatedArticleIDs {
updated[id] = true
}
@@ -3,9 +3,12 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/site"
"context"
"slices"
"strconv"
"strings"
)
type UpdateArticleUseCase struct {
@@ -13,16 +16,18 @@ type UpdateArticleUseCase struct {
bodyRenderer article.BodyRenderer
recorder audit.Recorder
sites site.Repository
authors author.Repository
}
type UpdateArticleCommand struct {
Body *string
SiteID *uint32
Format article.BodyFormat
Body *string
SiteID *uint32
AuthorIDs *[]uint32
Format article.BodyFormat
}
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites}
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
}
func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, body string, format article.BodyFormat) (article.Article, error) {
@@ -38,7 +43,10 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command Up
if err != nil {
return article.Article{}, err
}
updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID})
if err := g.ensureAuthorsExist(command.AuthorIDs); err != nil {
return article.Article{}, err
}
updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID, AuthorIDs: command.AuthorIDs})
if err != nil {
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
return article.Article{}, err
@@ -58,11 +66,51 @@ func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*s
return &destination, nil
}
func (g *UpdateArticleUseCase) ensureAuthorsExist(ids *[]uint32) error {
if ids == nil {
return nil
}
for _, id := range *ids {
if _, err := g.authors.Get(id); err != nil {
return err
}
}
return nil
}
func authorsChange(before article.Article, after article.Article) (audit.Change, bool) {
beforeIDs := bylineIDs(before.Authors)
afterIDs := bylineIDs(after.Authors)
if slices.Equal(beforeIDs, afterIDs) {
return audit.Change{}, false
}
return audit.TextChange(audit.FieldAuthors, bylineNames(before.Authors), bylineNames(after.Authors)), true
}
func bylineIDs(bylines []author.Byline) []uint32 {
ids := make([]uint32, 0, len(bylines))
for _, byline := range bylines {
ids = append(ids, byline.ID)
}
return ids
}
func bylineNames(bylines []author.Byline) string {
names := make([]string, 0, len(bylines))
for _, byline := range bylines {
names = append(names, byline.Name)
}
return strings.Join(names, authorChangeSeparator)
}
func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Article, after article.Article, destination *site.Site) {
changes := audit.ArticleChanges(before, after)
if destination != nil && before.SiteID != after.SiteID {
changes = append(changes, audit.TextChange(audit.FieldSite, g.siteName(before.SiteID), destination.Name))
}
if change, changed := authorsChange(before, after); changed {
changes = append(changes, change)
}
if len(changes) == 0 {
return
}
@@ -0,0 +1,152 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"context"
"strings"
)
type UpdateAuthorUseCase struct {
authors author.Repository
users user.Repository
images image.Repository
recorder audit.Recorder
}
func NewUpdateAuthorUseCase(authors author.Repository, users user.Repository, images image.Repository, recorder audit.Recorder) *UpdateAuthorUseCase {
return &UpdateAuthorUseCase{authors: authors, users: users, images: images, recorder: recorder}
}
func (u *UpdateAuthorUseCase) Update(ctx context.Context, id uint32, caller AuthorCaller, patch author.Patch) (author.Author, error) {
before, err := u.authors.Get(id)
if err != nil {
return author.Author{}, err
}
event := audit.NewEvent(audit.ActionAuthorEdit, audit.CategoryAuthors)
event.Target = authorTarget(before)
after, err := u.prepare(before, caller, patch)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return author.Author{}, err
}
changes := authorChanges(u.users, u.images, before, after)
if len(changes) == 0 {
return u.present(before, caller), nil
}
saved, err := u.authors.Update(after)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return author.Author{}, err
}
event.Target = authorTarget(saved)
event.Changes = changes
u.recorder.Record(ctx, event)
return u.present(saved, caller), nil
}
func (u *UpdateAuthorUseCase) present(a author.Author, caller AuthorCaller) author.Author {
if caller.isAdmin() {
return withUserEmail(u.users, a)
}
a.UserEmail = nil
return a
}
func (u *UpdateAuthorUseCase) prepare(before author.Author, caller AuthorCaller, patch author.Patch) (author.Author, error) {
if err := authorizeEdit(before, caller, patch); err != nil {
return author.Author{}, err
}
after := before
if patch.Name != nil {
after.Name = strings.TrimSpace(*patch.Name)
}
if patch.Bio != nil {
after.Bio = *patch.Bio
}
if patch.Website.Set {
after.Website = patch.Website.Value
}
if patch.SocialLinks != nil {
after.SocialLinks = *patch.SocialLinks
}
website, err := validateAuthorContent(after.Bio, after.Website, after.SocialLinks)
if err != nil {
return author.Author{}, err
}
after.Website = website
if err := u.applySlug(&after, patch); err != nil {
return author.Author{}, err
}
if err := u.applyAvatar(&after, patch); err != nil {
return author.Author{}, err
}
return after, u.applyLink(&after, patch)
}
func authorizeEdit(before author.Author, caller AuthorCaller, patch author.Patch) error {
if caller.isAdmin() {
return nil
}
if before.UserID == nil || *before.UserID != caller.UserID || patch.UserID.Set {
return author.ErrAuthorRights
}
return nil
}
func (u *UpdateAuthorUseCase) applySlug(after *author.Author, patch author.Patch) error {
if patch.Slug == nil || *patch.Slug == after.Slug {
return nil
}
if !author.IsValidSlug(*patch.Slug) {
return author.ErrInvalidSlug
}
taken, err := isSlugTaken(u.authors, *patch.Slug)
if err != nil {
return err
}
if taken {
return author.ErrSlugTaken
}
after.Slug = *patch.Slug
return nil
}
func (u *UpdateAuthorUseCase) applyAvatar(after *author.Author, patch author.Patch) error {
if !patch.AvatarID.Set {
return nil
}
if err := ensureImageExists(u.images, patch.AvatarID.Value); err != nil {
return err
}
after.AvatarID = patch.AvatarID.Value
return nil
}
func (u *UpdateAuthorUseCase) applyLink(after *author.Author, patch author.Patch) error {
if !patch.UserID.Set {
return nil
}
requested := patch.UserID.Value
if requested == nil {
after.UserID = nil
after.IsLinked = false
return nil
}
if after.UserID != nil {
if *after.UserID == *requested {
return nil
}
return author.ErrAuthorAlreadyLinked
}
if err := ensureUserCanBeLinked(u.users, u.authors, *requested); err != nil {
return err
}
after.UserID = requested
after.IsLinked = true
return nil
}
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/purge"
@@ -64,6 +65,12 @@ var failureReasons = []failureReason{
{site.ErrInvalidLocale, "The locale is not valid."},
{site.ErrUseSetDefault, "Use the default site action to change the default."},
{site.ErrInvalidReassign, "The destination site is not valid."},
{author.ErrSlugTaken, "That slug is already used by another author."},
{author.ErrUserAlreadyLinked, "That user already has an author."},
{author.ErrAuthorAlreadyLinked, "That author is already linked to a user."},
{author.ErrAuthorRights, "Only an owner or admin can change this author."},
{author.ErrAuthorHasArticles, "The author is still credited on articles."},
{author.ErrInvalidReassign, "The destination author is not valid."},
}
var restartFailureReasons = []failureReason{
@@ -82,6 +89,11 @@ var notFoundCauses = []error{
invite.ErrInviteNotFound,
user.ErrUserNotFound,
site.ErrSiteNotFound,
author.ErrAuthorNotFound,
author.ErrBioTooLong,
author.ErrInvalidURL,
author.ErrTooManyLinks,
author.ErrInvalidSlug,
}
func reasonFor(err error, overrides ...failureReason) string {
@@ -0,0 +1,115 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"errors"
"strconv"
"strings"
)
const authorChangeSeparator = ", "
type AuthorCaller struct {
UserID uint32
Role user.Role
}
func isAdminRole(role user.Role) bool {
return role == user.RoleOwner || role == user.RoleAdmin
}
func (c AuthorCaller) isAdmin() bool {
return isAdminRole(c.Role)
}
func authorTarget(a author.Author) audit.Target {
return audit.NewTarget(audit.TargetAuthor, a.ID, a.Name)
}
func isSlugTaken(authors author.Repository, slug string) (bool, error) {
_, err := authors.GetBySlug(slug)
if errors.Is(err, author.ErrAuthorNotFound) {
return false, nil
}
return err == nil, err
}
func ensureUserCanBeLinked(users user.Repository, authors author.Repository, userID uint32) error {
if _, err := users.Get(userID); err != nil {
return err
}
_, err := authors.GetByUserID(userID)
if err == nil {
return author.ErrUserAlreadyLinked
}
if errors.Is(err, author.ErrAuthorNotFound) {
return nil
}
return err
}
func withUserEmail(users user.Repository, a author.Author) author.Author {
a.UserEmail = nil
if a.UserID == nil {
return a
}
linked, err := users.Get(*a.UserID)
if err != nil {
return a
}
email := linked.Email
a.UserEmail = &email
return a
}
func linksLabel(links []author.SocialLink) string {
labels := make([]string, 0, len(links))
for _, link := range links {
labels = append(labels, link.Type+": "+link.URL)
}
return strings.Join(labels, authorChangeSeparator)
}
func websiteLabel(website *string) string {
if website == nil {
return ""
}
return *website
}
func usernameLabel(users user.Repository, id *uint32) string {
if id == nil {
return ""
}
found, err := users.Get(*id)
if err != nil {
return strconv.FormatUint(uint64(*id), 10)
}
return found.Username
}
func sameID(a *uint32, b *uint32) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func authorChanges(users user.Repository, images image.Repository, before author.Author, after author.Author) []audit.Change {
changes := []audit.Change{}
changes = appendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = appendTextChange(changes, audit.FieldSlug, before.Slug, after.Slug)
changes = appendTextChange(changes, audit.FieldBio, before.Bio, after.Bio)
changes = appendTextChange(changes, audit.FieldWebsite, websiteLabel(before.Website), websiteLabel(after.Website))
changes = appendTextChange(changes, audit.FieldSocialLinks, linksLabel(before.SocialLinks), linksLabel(after.SocialLinks))
if !sameID(before.AvatarID, after.AvatarID) {
changes = append(changes, audit.TextChange(audit.FieldAvatar, imageLabel(images, before.AvatarID), imageLabel(images, after.AvatarID)))
}
if !sameID(before.UserID, after.UserID) {
changes = append(changes, audit.TextChange(audit.FieldUser, usernameLabel(users, before.UserID), usernameLabel(users, after.UserID)))
}
return changes
}
@@ -2,6 +2,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"crypto/sha256"
@@ -28,6 +29,7 @@ type purgePlanner struct {
imageRepository image.Repository
articleRepository article.Repository
sites site.Repository
authors author.Repository
}
func (p *purgePlanner) plan() (purgePlan, error) {
@@ -40,7 +42,7 @@ func (p *purgePlanner) plan() (purgePlan, error) {
return purgePlan{}, err
}
siteUsage, err := siteImageUsage(p.sites)
siteUsage, err := imageUsageCount(p.sites, p.authors)
if err != nil {
return purgePlan{}, err
}
@@ -1,6 +1,9 @@
package useCases
import "RenewCMS/internal/domain/site"
import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/site"
)
func siteImageUsage(sites site.Repository) (map[uint32]int, error) {
usage := map[uint32]int{}
@@ -15,3 +18,18 @@ func siteImageUsage(sites site.Repository) (map[uint32]int, error) {
}
return usage, nil
}
func imageUsageCount(sites site.Repository, authors author.Repository) (map[uint32]int, error) {
usage, err := siteImageUsage(sites)
if err != nil {
return nil, err
}
avatars, err := authors.AvatarUsage()
if err != nil {
return nil, err
}
for imageID, total := range avatars {
usage[imageID] += total
}
return usage, nil
}