package entry import ( "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/response" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/contenttype" domain "RenewCMS/internal/domain/entry" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "errors" "net/http" "net/url" "strconv" "time" "github.com/go-chi/chi/v5" ) type Handler struct { CreateUseCase *useCases.CreateEntryUseCase GetUseCase *useCases.GetEntryUseCase ListUseCase *useCases.ListEntriesUseCase UpdateUseCase *useCases.UpdateEntryUseCase DeleteUseCase *useCases.DeleteEntryUseCase } func NewHandler(create *useCases.CreateEntryUseCase, get *useCases.GetEntryUseCase, list *useCases.ListEntriesUseCase, update *useCases.UpdateEntryUseCase, deleteUseCase *useCases.DeleteEntryUseCase) *Handler { return &Handler{CreateUseCase: create, GetUseCase: get, ListUseCase: list, UpdateUseCase: update, DeleteUseCase: deleteUseCase} } type EntryBody struct { Data map[string]any `json:"data" validate:"required"` } type entryResponse struct { ID uint32 `json:"id"` ContentType string `json:"content_type"` Data map[string]any `json:"data"` CreatedBy uint32 `json:"created_by"` UpdatedBy uint32 `json:"updated_by"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Media map[string]any `json:"media"` Relations map[string]any `json:"relations"` } type firstFieldBody struct { Key string `json:"key"` Type contenttype.FieldType `json:"type"` Value any `json:"value"` } type relationBody struct { ID uint32 `json:"id"` ContentType string `json:"content_type"` FirstField firstFieldBody `json:"first_field"` } type missingRelationBody struct { ID uint32 `json:"id"` Missing bool `json:"missing"` } func toRelationItem(item useCases.EntryRelation) any { if item.Missing { return missingRelationBody{ID: item.ID, Missing: true} } return relationBody{ID: item.ID, ContentType: item.ContentType, FirstField: firstFieldBody{Key: item.FirstField.Key, Type: item.FirstField.Type, Value: item.FirstField.Value}} } func toRelationsResponse(relations map[string]useCases.EntryRelations) map[string]any { body := make(map[string]any, len(relations)) for key, held := range relations { if !held.Many { if len(held.Items) > 0 { body[key] = toRelationItem(held.Items[0]) } continue } items := make([]any, 0, len(held.Items)) for _, item := range held.Items { items = append(items, toRelationItem(item)) } body[key] = items } return body } type mediaBody struct { ID uint32 `json:"id"` Filename string `json:"filename"` Width *int `json:"width"` Height *int `json:"height"` Size int64 `json:"size"` ThumbnailURL string `json:"thumbnail_url"` } type missingMediaBody struct { ID uint32 `json:"id"` Missing bool `json:"missing"` } func toMediaResponse(media map[string]useCases.EntryMedia) map[string]any { body := make(map[string]any, len(media)) for key, item := range media { if item.Missing { body[key] = missingMediaBody{ID: item.ID, Missing: true} continue } body[key] = mediaBody{ID: item.ID, Filename: item.Filename, Width: item.Width, Height: item.Height, Size: item.Size, ThumbnailURL: item.ThumbnailURL} } return body } func toResponse(typed useCases.TypedEntry) entryResponse { return entryResponse{ ID: typed.ID, ContentType: typed.ContentTypeKey, Data: typed.Data, CreatedBy: typed.CreatedBy, UpdatedBy: typed.UpdatedBy, CreatedAt: typed.CreatedAt, UpdatedAt: typed.UpdatedAt, Media: toMediaResponse(typed.Media), Relations: toRelationsResponse(typed.Relations), } } type pageResponse struct { Items []entryResponse `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` } const ( defaultListPage = 1 defaultListSize = 25 ) type listQuery struct { Page int `validate:"gte=1"` Size int `validate:"oneof=5 25 50 100"` Sort domain.Sort } 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 } order := domain.DefaultSort() if values.Has("sort") { order, err = domain.ParseSort(values.Get("sort")) if err != nil { return listQuery{}, err } } return listQuery{Page: page, Size: size, Sort: order}, nil } func parseEntryId(r *http.Request) (uint32, error) { id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) return uint32(id), err } func writeEntryError(w http.ResponseWriter, err error) { var fieldError domain.FieldError switch { case errors.Is(err, contenttype.ErrContentTypeNotFound): http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound) case errors.Is(err, domain.ErrEntryNotFound): http.Error(w, apperr.EntryNotFoundErrorMessage, http.StatusNotFound) case errors.As(err, &fieldError): http.Error(w, apperr.InvalidEntryErrorMessage+": "+fieldError.Error(), fieldErrorStatus(err)) case errors.Is(err, domain.ErrInvalidPage), errors.Is(err, domain.ErrInvalidSort): http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) case errors.Is(err, domain.ErrInvalidEntry): http.Error(w, apperr.InvalidEntryErrorMessage, http.StatusUnprocessableEntity) default: http.Error(w, "Internal server error", http.StatusInternalServerError) } } func fieldErrorStatus(err error) int { if errors.Is(err, domain.ErrUniqueViolation) { return http.StatusConflict } return http.StatusUnprocessableEntity } func readBody(w http.ResponseWriter, r *http.Request) (EntryBody, bool) { var body EntryBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil || validator.Validate.Struct(body) != nil { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) return EntryBody{}, false } return body, true } func currentUserId(w http.ResponseWriter, r *http.Request) (uint32, bool) { userId, ok := claims.UserId(r) if !ok { http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) } return userId, ok } 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 } page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort, Query: r.URL.Query().Get("q")}) if err != nil { writeEntryError(w, err) return } items := make([]entryResponse, 0, len(page.Items)) for _, typed := range page.Items { items = append(items, toResponse(typed)) } response.WriteJSON(w, http.StatusOK, pageResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size}) } func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { id, err := parseEntryId(r) if err != nil { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } found, err := h.GetUseCase.Get(chi.URLParam(r, "key"), id) if err != nil { writeEntryError(w, err) return } response.WriteJSON(w, http.StatusOK, toResponse(found)) } func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { userId, ok := currentUserId(w, r) if !ok { return } body, ok := readBody(w, r) if !ok { return } created, err := h.CreateUseCase.Create(useCases.CreateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), Data: body.Data, UserID: userId}) if err != nil { writeEntryError(w, err) return } response.WriteJSON(w, http.StatusCreated, toResponse(created)) } func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { id, err := parseEntryId(r) if err != nil { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } userId, ok := currentUserId(w, r) if !ok { return } body, ok := readBody(w, r) if !ok { return } updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, UserID: userId}) if err != nil { writeEntryError(w, err) return } response.WriteJSON(w, http.StatusOK, toResponse(updated)) } func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { id, err := parseEntryId(r) if err != nil { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key"), id); err != nil { writeEntryError(w, err) return } w.WriteHeader(http.StatusNoContent) }