package author import ( "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" validator "RenewCMS/api/pkg/validator" domain "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "errors" "io" "log" "net/http" "net/url" "strconv" "github.com/go-chi/chi/v5" playground "github.com/go-playground/validator/v10" ) const ( defaultListPage = 1 defaultListSize = 25 reassignParam = "reassign_to" reassignNone = "none" ) type Handler struct { ListUseCase *useCases.ListAuthorsUseCase CreateUseCase *useCases.CreateAuthorUseCase CreateMyUseCase *useCases.CreateMyAuthorUseCase UpdateUseCase *useCases.UpdateAuthorUseCase DeleteUseCase *useCases.DeleteAuthorUseCase GetUserUseCase *useCases.GetUserUseCase } func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase, getUser *useCases.GetUserUseCase) *Handler { return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase, GetUserUseCase: getUser} } type socialLinkRequest struct { Type string `json:"type" validate:"socialtype"` URL string `json:"url"` } func toLinks(requests []socialLinkRequest) []domain.SocialLink { links := make([]domain.SocialLink, 0, len(requests)) for _, request := range requests { links = append(links, domain.SocialLink{Type: request.Type, URL: request.URL}) } return links } type PostAuthor struct { Name string `json:"name" validate:"authorname"` Slug string `json:"slug" validate:"omitempty,authorslug"` Bio string `json:"bio"` AvatarID *uint32 `json:"avatar_id" validate:"omitempty,gte=1"` Website *string `json:"website"` SocialLinks []socialLinkRequest `json:"social_links" validate:"omitempty,dive"` UserID *uint32 `json:"user_id" validate:"omitempty,gte=1"` } type optional[T any] struct { Set bool Value *T } func (o *optional[T]) UnmarshalJSON(data []byte) error { o.Set = true if string(data) == "null" { o.Value = nil return nil } var value T if err := json.Unmarshal(data, &value); err != nil { return err } o.Value = &value return nil } type PatchAuthor struct { Name *string `json:"name" validate:"authorname"` Slug *string `json:"slug" validate:"authorslug"` Bio *string `json:"bio"` AvatarID optional[uint32] `json:"avatar_id"` Website optional[string] `json:"website"` SocialLinks *[]socialLinkRequest `json:"social_links" validate:"omitempty,dive"` UserID optional[uint32] `json:"user_id"` } func (p PatchAuthor) toPatch() domain.Patch { patch := domain.Patch{ Name: p.Name, Slug: p.Slug, Bio: p.Bio, AvatarID: domain.Nullable[uint32]{Set: p.AvatarID.Set, Value: p.AvatarID.Value}, Website: domain.Nullable[string]{Set: p.Website.Set, Value: p.Website.Value}, UserID: domain.Nullable[uint32]{Set: p.UserID.Set, Value: p.UserID.Value}, } if p.SocialLinks != nil { links := toLinks(*p.SocialLinks) patch.SocialLinks = &links } return patch } var requiredNonNullFields = []string{"name", "slug", "bio", "social_links"} var nullableFields = []string{"avatar_id", "website", "user_id"} func readPatchAuthor(r *http.Request) (PatchAuthor, bool) { raw, err := io.ReadAll(r.Body) if err != nil { return PatchAuthor{}, false } var fields map[string]json.RawMessage if err := json.Unmarshal(raw, &fields); err != nil { return PatchAuthor{}, false } known := 0 for _, name := range requiredNonNullFields { value, present := fields[name] if !present { continue } if string(value) == "null" { return PatchAuthor{}, false } known++ } for _, name := range nullableFields { if _, present := fields[name]; present { known++ } } if known == 0 { return PatchAuthor{}, false } var request PatchAuthor if err := json.Unmarshal(raw, &request); err != nil { return PatchAuthor{}, false } return request, true } func parseAuthorId(r *http.Request) (uint32, error) { id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) return uint32(id), err } type ownAuthorResponse struct { domain.Author SlugAdjusted bool `json:"slug_adjusted"` } func presentAuthor(a domain.Author) domain.Author { if a.SocialLinks == nil { a.SocialLinks = []domain.SocialLink{} } return a } func writeJSON(w http.ResponseWriter, status int, payload any) { body, err := json.Marshal(payload) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } if status != http.StatusOK { w.WriteHeader(status) } if _, err := w.Write(body); err != nil { log.Printf("Failed to write response: %v", err) } } func writeAuthorError(w http.ResponseWriter, err error) { switch { case errors.Is(err, domain.ErrAuthorNotFound): http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound) case errors.Is(err, domain.ErrSlugTaken): http.Error(w, apperr.AuthorSlugTakenErrorMessage, http.StatusConflict) case errors.Is(err, domain.ErrAuthorHasArticles): http.Error(w, apperr.AuthorHasArticlesErrorMessage, http.StatusConflict) case errors.Is(err, domain.ErrUserAlreadyLinked): http.Error(w, apperr.UserAlreadyLinkedErrorMessage, http.StatusConflict) case errors.Is(err, domain.ErrAuthorAlreadyLinked): http.Error(w, apperr.AuthorAlreadyLinkedErrorMessage, http.StatusConflict) case errors.Is(err, user.ErrUserNotFound): http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound) case errors.Is(err, domain.ErrAuthorRights): http.Error(w, apperr.AuthorRightsErrorMessage, http.StatusForbidden) case errors.Is(err, domain.ErrBioTooLong): http.Error(w, apperr.BioTooLongErrorMessage, http.StatusUnprocessableEntity) case errors.Is(err, domain.ErrInvalidURL): http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity) case errors.Is(err, domain.ErrTooManyLinks): http.Error(w, apperr.TooManyLinksErrorMessage, http.StatusUnprocessableEntity) case errors.Is(err, domain.ErrInvalidSocialType): http.Error(w, apperr.InvalidSocialTypeErrorMessage, http.StatusBadRequest) case errors.Is(err, domain.ErrInvalidSlug): http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest) case errors.Is(err, domain.ErrInvalidReassign): http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest) case errors.Is(err, image.ErrImageNotFound): http.Error(w, apperr.AuthorImageNotFoundErrorMessage, http.StatusBadRequest) default: http.Error(w, "Internal server error", http.StatusInternalServerError) } } func writeValidationError(w http.ResponseWriter, err error) { var failures playground.ValidationErrors if errors.As(err, &failures) { for _, failure := range failures { if failure.Tag() == validator.AuthorSlugTag { http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest) return } } } http.Error(w, err.Error(), http.StatusBadRequest) } func (h *Handler) currentUser(r *http.Request) (user.User, bool) { userId, ok := claims.UserId(r) if !ok { return user.User{}, false } current, err := h.GetUserUseCase.GetUser(userId) if err != nil { return user.User{}, false } return current, true } type listQuery struct { Page int `validate:"gte=1"` Size int `validate:"oneof=5 25 50 100"` Q string `validate:"max=200"` } type listResponse struct { Items []domain.Author `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` } func intQueryParam(values url.Values, name string, fallback int) (int, error) { if !values.Has(name) { return fallback, nil } return strconv.Atoi(values.Get(name)) } func readListQuery(values url.Values) (listQuery, error) { page, err := intQueryParam(values, "page", defaultListPage) if err != nil { return listQuery{}, err } size, err := intQueryParam(values, "size", defaultListSize) if err != nil { return listQuery{}, err } return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil } func (h *Handler) List(w http.ResponseWriter, r *http.Request) { query, err := readListQuery(r.URL.Query()) if err != nil || validator.Validate.Struct(query) != nil { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) return } current, _ := h.currentUser(r) page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, CallerRole: current.Role}) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } items := make([]domain.Author, 0, len(page.Items)) for _, item := range page.Items { items = append(items, presentAuthor(item)) } writeJSON(w, http.StatusOK, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size}) } func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { var request PostAuthor 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 } created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateAuthorCommand{ Name: request.Name, Slug: request.Slug, Bio: request.Bio, AvatarID: request.AvatarID, Website: request.Website, SocialLinks: toLinks(request.SocialLinks), UserID: request.UserID, }) if err != nil { writeAuthorError(w, err) return } writeJSON(w, http.StatusCreated, presentAuthor(created)) } func (h *Handler) CreateMine(w http.ResponseWriter, r *http.Request) { userId, ok := claims.UserId(r) if !ok { http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) return } created, err := h.CreateMyUseCase.Create(r.Context(), userId) if err != nil { writeAuthorError(w, err) return } writeJSON(w, http.StatusCreated, ownAuthorResponse{Author: presentAuthor(created.Author), SlugAdjusted: created.SlugAdjusted}) } func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { id, err := parseAuthorId(r) if err != nil { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } request, ok := readPatchAuthor(r) if !ok { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) return } if err := validator.Validate.Struct(request); err != nil { writeValidationError(w, err) return } current, ok := h.currentUser(r) if !ok { http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) return } updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: current.ID, Role: current.Role}, request.toPatch()) if err != nil { writeAuthorError(w, err) return } writeJSON(w, http.StatusOK, presentAuthor(updated)) } func readReassignment(r *http.Request) useCases.Reassignment { query := r.URL.Query() if !query.Has(reassignParam) { return useCases.Reassignment{Mode: useCases.ReassignAbsent} } value := query.Get(reassignParam) if value == reassignNone { return useCases.Reassignment{Mode: useCases.ReassignNone} } parsed, err := strconv.ParseUint(value, 10, 32) if err != nil { return useCases.Reassignment{Mode: useCases.ReassignMalformed} } return useCases.Reassignment{Mode: useCases.ReassignToAuthor, Destination: uint32(parsed)} } func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { id, err := parseAuthorId(r) if err != nil { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } if err := h.DeleteUseCase.Delete(r.Context(), id, readReassignment(r)); err != nil { writeAuthorError(w, err) return } w.WriteHeader(http.StatusNoContent) }