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

+164 -123
View File
@@ -3,11 +3,10 @@ package article
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/articleerr"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
@@ -26,25 +25,40 @@ import (
)
type Handler struct {
CreateUseCase *useCases.CreateArticleUseCase
GetUseCase *useCases.GetArticleUseCase
ListUseCase *useCases.ListArticlesUseCase
DeleteUseCase *useCases.DeleteArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
createUseCase *useCases.CreateArticleUseCase
getUseCase *useCases.GetArticleUseCase
listUseCase *useCases.ListArticlesUseCase
deleteUseCase *useCases.DeleteArticleUseCase
updateUseCase *useCases.UpdateArticleUseCase
translationUseCase *useCases.CreateTranslationUseCase
describeUseCase *useCases.DescribeArticleUseCase
}
type PostArticle struct {
Title string `json:"title" validate:"required,min=3,max=200"`
Body string `json:"body"`
Title string `json:"title" validate:"required,min=3,max=200"`
Body string `json:"body"`
Lang *string `json:"lang"`
StoryID *uint32 `json:"story_id" validate:"omitempty,gte=1"`
SourceArticleID *uint32 `json:"source_article_id" validate:"omitempty,gte=1"`
Copy *bool `json:"copy"`
}
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler {
type PostTranslation struct {
Lang string `json:"lang" validate:"required"`
Title string `json:"title" validate:"required,min=3,max=200"`
SourceArticleID *uint32 `json:"source_article_id" validate:"omitempty,gte=1"`
Copy *bool `json:"copy" validate:"required"`
}
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, translation *useCases.CreateTranslationUseCase, describe *useCases.DescribeArticleUseCase) *Handler {
return &Handler{
CreateUseCase: create,
GetUseCase: get,
ListUseCase: list,
DeleteUseCase: delete,
UpdateUseCase: update,
createUseCase: create,
getUseCase: get,
listUseCase: list,
deleteUseCase: delete,
updateUseCase: update,
translationUseCase: translation,
describeUseCase: describe,
}
}
@@ -61,22 +75,26 @@ type baseArticleResponse struct {
UpdatedAt time.Time `json:"updated_at"`
Lang string `json:"lang"`
StoryID uint32 `json:"story_id"`
Revision uint32 `json:"revision"`
}
type articleResponse struct {
baseArticleResponse
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
SEOTitle string `json:"seo_title"`
SEODescription string `json:"seo_description"`
CoverImageID *uint32 `json:"cover_image_id"`
CoverImageAlt string `json:"cover_image_alt"`
SocialTitle string `json:"social_title"`
SocialDescription string `json:"social_description"`
SocialImageID *uint32 `json:"social_image_id"`
CanonicalURL string `json:"canonical_url"`
Noindex bool `json:"noindex"`
SEODismissed []string `json:"seo_dismissed"`
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
SEOTitle string `json:"seo_title"`
SEODescription string `json:"seo_description"`
CoverImageID *uint32 `json:"cover_image_id"`
CoverImageAlt string `json:"cover_image_alt"`
SocialTitle string `json:"social_title"`
SocialDescription string `json:"social_description"`
SocialImageID *uint32 `json:"social_image_id"`
CanonicalURL string `json:"canonical_url"`
Noindex bool `json:"noindex"`
SEODismissed []string `json:"seo_dismissed"`
Capabilities []string `json:"capabilities"`
Translations []domain.Translation `json:"translations"`
Source *domain.Source `json:"source"`
}
type listItemResponse struct {
@@ -121,10 +139,11 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer acc
UpdatedAt: article.UpdatedAt,
Lang: article.Lang,
StoryID: article.StoryID,
Revision: article.EditRevision,
}, nil
}
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) {
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller, described useCases.ArticleContext) (articleResponse, error) {
base, err := toBaseResponse(article, format, viewer)
if err != nil {
return articleResponse{}, err
@@ -147,6 +166,9 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer
CanonicalURL: article.CanonicalURL,
Noindex: article.Noindex,
SEODismissed: dismissed,
Capabilities: described.Capabilities,
Translations: described.Translations,
Source: described.Source,
}, nil
}
@@ -158,8 +180,8 @@ func toListItemResponse(article domain.Article, summary domain.SEOSummary, forma
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller) ([]byte, error) {
response, err := toArticleResponse(article, format, viewer)
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller, described useCases.ArticleContext) ([]byte, error) {
response, err := toArticleResponse(article, format, viewer, described)
if err != nil {
return nil, err
}
@@ -192,25 +214,13 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return
}
article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r))
article, err := h.getUseCase.GetArticle(id, format, access.CallerOf(r))
if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
writeArticleErrorResponse(w, err)
return
}
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(articleJson); err != nil {
fmt.Printf("Failed to write response: %v", err)
}
h.writeArticle(w, r, http.StatusOK, article, format, printToStdout)
}
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
@@ -230,24 +240,89 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
return
}
createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
if localArticle.StoryID != nil {
h.postTranslation(w, r, format, localArticle)
return
}
if localArticle.SourceArticleID != nil || localArticle.Copy != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
lang := ""
if localArticle.Lang != nil {
lang = *localArticle.Lang
}
createdArticle, err := h.createUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
Title: localArticle.Title,
Body: localArticle.Body,
Format: format,
Lang: lang,
Caller: access.CallerOf(r),
})
if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) {
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
return
}
if !writeKnownArticleError(w, err) {
http.Error(w, "Failed to create article", http.StatusInternalServerError)
}
return
}
writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format)
h.writeArticle(w, r, http.StatusCreated, createdArticle, format, log.Printf)
}
func (h *Handler) postTranslation(w http.ResponseWriter, r *http.Request, format domain.BodyFormat, request PostArticle) {
if request.Lang == nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
h.createTranslation(w, r, useCases.CreateTranslationCommand{
StoryID: *request.StoryID,
Lang: *request.Lang,
Title: request.Title,
Copy: request.Copy,
SourceArticleID: request.SourceArticleID,
Format: format,
Caller: access.CallerOf(r),
})
}
func (h *Handler) PostTranslation(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var request PostTranslation
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
h.createTranslation(w, r, useCases.CreateTranslationCommand{
ArticleID: id,
Lang: request.Lang,
Title: request.Title,
Copy: request.Copy,
SourceArticleID: request.SourceArticleID,
Format: format,
Caller: access.CallerOf(r),
})
}
func (h *Handler) createTranslation(w http.ResponseWriter, r *http.Request, command useCases.CreateTranslationCommand) {
created, err := h.translationUseCase.CreateTranslation(r.Context(), command)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
h.writeArticle(w, r, http.StatusCreated, created, command.Format, log.Printf)
}
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
@@ -257,46 +332,13 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
}
func writeKnownArticleError(w http.ResponseWriter, err error) bool {
if access.WriteIfMissingPermission(w, err) {
return true
}
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, site.ErrSiteNotFound) {
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, author.ErrAuthorNotFound) {
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, domain.ErrSlugTaken) {
writeSlugTaken(w, err)
return true
}
if errors.Is(err, domain.ErrInvalidSlug) {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return true
}
if errors.Is(err, domain.ErrInvalidCanonicalURL) {
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
return true
}
if errors.Is(err, image.ErrImageNotFound) {
http.Error(w, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest)
return true
}
return false
return access.WriteIfMissingPermission(w, err) || articleerr.Write(w, err)
}
func writeSlugTaken(w http.ResponseWriter, err error) {
var conflict *domain.SlugTakenError
if errors.As(err, &conflict) && conflict.Suggestion != "" {
w.Header().Set(apperr.SlugSuggestionHeader, conflict.Suggestion)
}
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
var bodyMismatchFailures = map[string]string{
"Copy": "required",
"IfRevision": "min",
"AuthorIDs": "unique",
}
func writeValidationError(w http.ResponseWriter, err error) {
@@ -308,12 +350,26 @@ func writeValidationError(w http.ResponseWriter, err error) {
return
}
}
if len(failures) > 0 && bodyMismatchFailures[failures[0].Field()] == failures[0].Tag() {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
}
http.Error(w, err.Error(), http.StatusBadRequest)
}
func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
func printToStdout(format string, args ...any) {
fmt.Printf(format, args...)
}
func (h *Handler) writeArticle(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat, reportFailure func(string, ...any)) {
caller := access.CallerOf(r)
described, err := h.describeUseCase.Describe(article, caller)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
articleJson, err := encodeArticle(article, format, caller, described)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -323,7 +379,7 @@ func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int
w.WriteHeader(status)
}
if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err)
reportFailure("Failed to write response: %v", err)
}
}
@@ -339,13 +395,13 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return
}
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
updatedArticle, err := h.updateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
h.writeArticle(w, r, http.StatusOK, updatedArticle, format, log.Printf)
}
type optionalImageID struct {
@@ -372,9 +428,11 @@ func (o optionalImageID) toNullable() domain.Nullable[uint32] {
}
type UpdateArticleBody struct {
Title *string `json:"title"`
IfRevision *uint32 `json:"if_revision" validate:"omitempty,min=1"`
Body *string `json:"body"`
SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"`
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,dive,gte=1"`
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,unique,dive,gte=1"`
Slug *string `json:"slug" validate:"omitempty,articleslug"`
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
@@ -386,24 +444,13 @@ type UpdateArticleBody struct {
SocialImageID optionalImageID `json:"social_image_id"`
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
Noindex *bool `json:"noindex"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,seodismissed"`
}
var nonNullableArticleFields = []string{"body", "site_id", "author_ids", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
var nonNullableArticleFields = []string{"title", "body", "site_id", "author_ids", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
var nullableArticleFields = []string{"cover_image_id", "social_image_id"}
func hasDuplicates(ids []uint32) bool {
seen := make(map[uint32]bool, len(ids))
for _, id := range ids {
if seen[id] {
return true
}
seen[id] = true
}
return false
}
func countKnownFields(fields map[string]json.RawMessage, nonNullable []string) (int, bool) {
known := 0
for name, value := range fields {
@@ -430,16 +477,13 @@ func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
return UpdateArticleBody{}, false
}
known, ok := countKnownFields(fields, nonNullableArticleFields)
if !ok || known == 0 {
if !ok || known == 0 || string(fields["if_revision"]) == "null" {
return UpdateArticleBody{}, false
}
var request UpdateArticleBody
if err := json.Unmarshal(raw, &request); err != nil {
return UpdateArticleBody{}, false
}
if request.AuthorIDs != nil && hasDuplicates(*request.AuthorIDs) {
return UpdateArticleBody{}, false
}
return request, true
}
@@ -466,7 +510,9 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return
}
updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
updatedArticle, err := h.updateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
Title: request.Title,
IfRevision: request.IfRevision,
Body: request.Body,
SiteID: request.SiteID,
AuthorIDs: request.AuthorIDs,
@@ -489,7 +535,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return
}
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
h.writeArticle(w, r, http.StatusOK, updatedArticle, format, log.Printf)
}
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
@@ -581,7 +627,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
return
}
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
page, err := h.listUseCase.ListArticlesPage(useCases.ListArticlesCommand{
Format: format,
Caller: access.CallerOf(r),
Page: query.Page,
@@ -625,15 +671,10 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
if err := h.deleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
if !writeKnownArticleError(w, err) {
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
}
if access.WriteIfMissingPermission(w, err) {
return
}
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
return
}
+1 -1
View File
@@ -34,7 +34,7 @@ type CheckArticleSEOBody struct {
SocialImageID optionalImageID `json:"social_image_id"`
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
Noindex *bool `json:"noindex"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,seodismissed"`
}
var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
+69
View File
@@ -0,0 +1,69 @@
package story
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/articleerr"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"log"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type Handler struct {
MoveUseCase *useCases.MoveStoryUseCase
}
func NewHandler(move *useCases.MoveStoryUseCase) *Handler {
return &Handler{MoveUseCase: move}
}
type moveRequest struct {
Site *string `json:"site"`
}
type moveResponse struct {
StoryID uint32 `json:"story_id"`
Site string `json:"site"`
SiteID uint32 `json:"site_id"`
ArticleIDs []uint32 `json:"article_ids"`
}
func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
var request moveRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Site == nil || *request.Site == "" {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
result, err := h.MoveUseCase.MoveStory(r.Context(), useCases.MoveStoryCommand{StoryID: uint32(id), SiteKey: *request.Site, Caller: access.CallerOf(r)})
if err != nil {
writeError(w, err)
return
}
body, err := json.Marshal(moveResponse{StoryID: result.StoryID, Site: result.Site.Key, SiteID: result.Site.ID, ArticleIDs: result.ArticleIDs})
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func writeError(w http.ResponseWriter, err error) {
if access.WriteIfMissingPermission(w, err) {
return
}
if articleerr.Write(w, err) {
return
}
http.Error(w, "Failed to move story", http.StatusInternalServerError)
}