feat: custom content types backend
This commit is contained in:
1 parent
ed67b7e70d
commit
58d58f5a04
41 files changed
+4086
-2
No files matched your search
@@ -0,0 +1,64 @@
|
||||
package contenttype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrContentTypeNotFound = errors.New("content type not found")
|
||||
ErrKeyTaken = errors.New("a content type with that key already exists")
|
||||
ErrInvalidName = errors.New("invalid content type name")
|
||||
ErrInvalidKey = errors.New("invalid content type key")
|
||||
ErrInvalidField = errors.New("invalid field")
|
||||
ErrHasEntries = errors.New("the content type still has entries")
|
||||
ErrSchemaIncompatible = errors.New("the schema is incompatible with the stored entries")
|
||||
)
|
||||
|
||||
type FieldType string
|
||||
|
||||
const (
|
||||
TypeText FieldType = "text"
|
||||
TypeRichText FieldType = "richtext"
|
||||
TypeNumber FieldType = "number"
|
||||
TypeBoolean FieldType = "boolean"
|
||||
TypeDate FieldType = "date"
|
||||
TypeSelect FieldType = "select"
|
||||
)
|
||||
|
||||
const (
|
||||
RuleType = "type"
|
||||
RuleMin = "min"
|
||||
RuleMax = "max"
|
||||
RuleOptions = "options"
|
||||
)
|
||||
|
||||
const (
|
||||
MinKeyRunes = 2
|
||||
MaxKeyRunes = 40
|
||||
MaxNameRunes = 100
|
||||
MinFields = 1
|
||||
MaxFields = 50
|
||||
DateLayout = "2006-01-02"
|
||||
)
|
||||
|
||||
type Field struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type FieldType `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Unique bool `json:"unique"`
|
||||
Default any `json:"default"`
|
||||
Min any `json:"min"`
|
||||
Max any `json:"max"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
type ContentType struct {
|
||||
ID uint32 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Fields []Field `json:"fields"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package contenttype
|
||||
|
||||
type Repository interface {
|
||||
Create(contentType ContentType) (ContentType, error)
|
||||
GetByKey(key string) (ContentType, error)
|
||||
List() ([]ContentType, error)
|
||||
Update(contentType ContentType) (ContentType, error)
|
||||
Delete(id uint32) error
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package contenttype
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type kind struct {
|
||||
accepts func(value any) bool
|
||||
measure func(value any) (float64, bool)
|
||||
bound func(bound any) (float64, bool)
|
||||
unique bool
|
||||
}
|
||||
|
||||
var kinds = map[FieldType]kind{
|
||||
TypeText: {accepts: isString, measure: runeCount, bound: wholeLength, unique: true},
|
||||
TypeRichText: {accepts: isString, measure: runeCount, bound: wholeLength},
|
||||
TypeNumber: {accepts: isNumber, measure: asNumber, bound: asNumber, unique: true},
|
||||
TypeBoolean: {accepts: isBoolean},
|
||||
TypeDate: {accepts: isDate, measure: dateDays, bound: dateDays, unique: true},
|
||||
TypeSelect: {accepts: isString, unique: true},
|
||||
}
|
||||
|
||||
func isString(value any) bool {
|
||||
_, ok := value.(string)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isBoolean(value any) bool {
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isNumber(value any) bool {
|
||||
_, ok := asNumber(value)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isDate(value any) bool {
|
||||
_, ok := dateDays(value)
|
||||
return ok
|
||||
}
|
||||
|
||||
func runeCount(value any) (float64, bool) {
|
||||
text, ok := value.(string)
|
||||
return float64(utf8.RuneCountInString(text)), ok
|
||||
}
|
||||
|
||||
func wholeLength(bound any) (float64, bool) {
|
||||
number, ok := asNumber(bound)
|
||||
return number, ok && number >= 0 && number == math.Trunc(number)
|
||||
}
|
||||
|
||||
func asNumber(value any) (float64, bool) {
|
||||
var number float64
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
number = typed
|
||||
case int:
|
||||
number = float64(typed)
|
||||
case int64:
|
||||
number = float64(typed)
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
return number, !math.IsNaN(number) && !math.IsInf(number, 0)
|
||||
}
|
||||
|
||||
func dateDays(value any) (float64, bool) {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := time.Parse(DateLayout, text)
|
||||
if err != nil || parsed.Format(DateLayout) != text {
|
||||
return 0, false
|
||||
}
|
||||
return float64(parsed.Unix()), true
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package contenttype
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/slug"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var fieldKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,39}$`)
|
||||
|
||||
func ValidateKey(key string) error {
|
||||
length := utf8.RuneCountInString(key)
|
||||
if length < MinKeyRunes || length > MaxKeyRunes || !slug.IsValid(key) {
|
||||
return ErrInvalidKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateName(name string) error {
|
||||
if strings.TrimSpace(name) == "" || utf8.RuneCountInString(name) > MaxNameRunes {
|
||||
return ErrInvalidName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateFields(fields []Field) ([]Field, error) {
|
||||
if len(fields) < MinFields || len(fields) > MaxFields {
|
||||
return nil, ErrInvalidField
|
||||
}
|
||||
seen := make(map[string]bool, len(fields))
|
||||
normalized := make([]Field, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if seen[field.Key] || !field.validSchema() {
|
||||
return nil, ErrInvalidField
|
||||
}
|
||||
seen[field.Key] = true
|
||||
if field.Options == nil {
|
||||
field.Options = []string{}
|
||||
}
|
||||
normalized = append(normalized, field)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (f Field) validSchema() bool {
|
||||
fieldKind, known := kinds[f.Type]
|
||||
if !known || !fieldKeyPattern.MatchString(f.Key) {
|
||||
return false
|
||||
}
|
||||
if f.Unique && !fieldKind.unique {
|
||||
return false
|
||||
}
|
||||
return f.validOptions() && f.validBounds(fieldKind) && f.validDefault()
|
||||
}
|
||||
|
||||
func (f Field) validOptions() bool {
|
||||
if f.Type != TypeSelect {
|
||||
return len(f.Options) == 0
|
||||
}
|
||||
if len(f.Options) == 0 {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]bool, len(f.Options))
|
||||
for _, option := range f.Options {
|
||||
if option == "" || seen[option] {
|
||||
return false
|
||||
}
|
||||
seen[option] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (f Field) validBounds(fieldKind kind) bool {
|
||||
if f.Min == nil && f.Max == nil {
|
||||
return true
|
||||
}
|
||||
if fieldKind.bound == nil {
|
||||
return false
|
||||
}
|
||||
lower, lowerOk := f.optionalBound(fieldKind, f.Min)
|
||||
upper, upperOk := f.optionalBound(fieldKind, f.Max)
|
||||
if !lowerOk || !upperOk {
|
||||
return false
|
||||
}
|
||||
return f.Min == nil || f.Max == nil || lower <= upper
|
||||
}
|
||||
|
||||
func (f Field) optionalBound(fieldKind kind, bound any) (float64, bool) {
|
||||
if bound == nil {
|
||||
return 0, true
|
||||
}
|
||||
return fieldKind.bound(bound)
|
||||
}
|
||||
|
||||
func (f Field) validDefault() bool {
|
||||
if f.Default == nil {
|
||||
return true
|
||||
}
|
||||
return !f.Unique && f.Violation(f.Default) == ""
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package contenttype
|
||||
|
||||
import "slices"
|
||||
|
||||
func (f Field) Violation(value any) string {
|
||||
fieldKind, known := kinds[f.Type]
|
||||
if !known || !fieldKind.accepts(value) {
|
||||
return RuleType
|
||||
}
|
||||
if rule := f.boundViolation(fieldKind, value); rule != "" {
|
||||
return rule
|
||||
}
|
||||
if len(f.Options) > 0 {
|
||||
text, _ := value.(string)
|
||||
if !slices.Contains(f.Options, text) {
|
||||
return RuleOptions
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f Field) boundViolation(fieldKind kind, value any) string {
|
||||
if fieldKind.measure == nil {
|
||||
return ""
|
||||
}
|
||||
measured, _ := fieldKind.measure(value)
|
||||
if lower, set := f.measuredBound(fieldKind, f.Min); set && measured < lower {
|
||||
return RuleMin
|
||||
}
|
||||
if upper, set := f.measuredBound(fieldKind, f.Max); set && measured > upper {
|
||||
return RuleMax
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f Field) measuredBound(fieldKind kind, bound any) (float64, bool) {
|
||||
if bound == nil {
|
||||
return 0, false
|
||||
}
|
||||
return fieldKind.bound(bound)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package entry
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"reflect"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func SameValue(left any, right any) bool {
|
||||
return reflect.DeepEqual(left, right)
|
||||
}
|
||||
|
||||
type UniqueValue struct {
|
||||
Key string
|
||||
Value any
|
||||
}
|
||||
|
||||
func ApplyDefaults(fields []contenttype.Field, data map[string]any) map[string]any {
|
||||
filled := make(map[string]any, len(data))
|
||||
for key, value := range data {
|
||||
filled[key] = value
|
||||
}
|
||||
for _, field := range fields {
|
||||
if _, present := filled[field.Key]; !present && field.Default != nil {
|
||||
filled[field.Key] = field.Default
|
||||
}
|
||||
}
|
||||
return filled
|
||||
}
|
||||
|
||||
func Project(fields []contenttype.Field, data map[string]any) map[string]any {
|
||||
projected := make(map[string]any, len(data))
|
||||
for _, field := range fields {
|
||||
if value, present := data[field.Key]; present {
|
||||
projected[field.Key] = value
|
||||
}
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
func Validate(fields []contenttype.Field, data map[string]any) error {
|
||||
for _, field := range fields {
|
||||
if err := validateField(field, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return firstUnknownField(fields, data)
|
||||
}
|
||||
|
||||
func validateField(field contenttype.Field, data map[string]any) error {
|
||||
value := data[field.Key]
|
||||
if value == nil {
|
||||
if field.Required {
|
||||
return invalidField(field.Key, RuleRequired)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rule := field.Violation(value); rule != "" {
|
||||
return invalidField(field.Key, rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstUnknownField(fields []contenttype.Field, data map[string]any) error {
|
||||
known := make(map[string]bool, len(fields))
|
||||
for _, field := range fields {
|
||||
known[field.Key] = true
|
||||
}
|
||||
var unknown []string
|
||||
for key := range data {
|
||||
if !known[key] {
|
||||
unknown = append(unknown, key)
|
||||
}
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
return invalidField(unknown[0], RuleUnknownField)
|
||||
}
|
||||
|
||||
func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue {
|
||||
var values []UniqueValue
|
||||
for _, field := range fields {
|
||||
if value := data[field.Key]; field.Unique && value != nil {
|
||||
values = append(values, UniqueValue{Key: field.Key, Value: value})
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package entry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEntryNotFound = errors.New("entry not found")
|
||||
ErrInvalidEntry = errors.New("invalid entry")
|
||||
ErrUniqueViolation = errors.New("a unique value is already used")
|
||||
ErrInvalidSort = errors.New("invalid sort")
|
||||
ErrInvalidPage = errors.New("invalid page")
|
||||
)
|
||||
|
||||
const (
|
||||
MaxPage = 1000000
|
||||
MaxPageSize = 100
|
||||
)
|
||||
|
||||
func ValidatePage(page int, size int) error {
|
||||
if page > MaxPage || size > MaxPageSize {
|
||||
return ErrInvalidPage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
RuleRequired = "required"
|
||||
RuleUnknownField = "unknown_field"
|
||||
RuleUnique = "unique"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID uint32 `json:"id"`
|
||||
ContentTypeID uint32 `json:"content_type_id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type FieldError struct {
|
||||
Key string
|
||||
Rule string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e FieldError) Error() string {
|
||||
return e.Key + ": " + e.Rule
|
||||
}
|
||||
|
||||
func (e FieldError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func invalidField(key string, rule string) error {
|
||||
return FieldError{Key: key, Rule: rule, Cause: ErrInvalidEntry}
|
||||
}
|
||||
|
||||
func UniqueViolation(key string) error {
|
||||
return FieldError{Key: key, Rule: RuleUnique, Cause: ErrUniqueViolation}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package entry
|
||||
|
||||
import "strings"
|
||||
|
||||
type SortField string
|
||||
|
||||
type SortDirection string
|
||||
|
||||
const (
|
||||
SortByID SortField = "id"
|
||||
SortByCreatedAt SortField = "created_at"
|
||||
SortByUpdatedAt SortField = "updated_at"
|
||||
|
||||
SortAsc SortDirection = "asc"
|
||||
SortDesc SortDirection = "desc"
|
||||
)
|
||||
|
||||
type Sort struct {
|
||||
Field SortField
|
||||
Direction SortDirection
|
||||
}
|
||||
|
||||
func (s Sort) Valid() bool {
|
||||
switch s.Field {
|
||||
case SortByID, SortByCreatedAt, SortByUpdatedAt:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return s.Direction == SortAsc || s.Direction == SortDesc
|
||||
}
|
||||
|
||||
func (s Sort) String() string {
|
||||
return string(s.Field) + ":" + string(s.Direction)
|
||||
}
|
||||
|
||||
func ParseSort(value string) (Sort, error) {
|
||||
field, direction, found := strings.Cut(value, ":")
|
||||
parsed := Sort{Field: SortField(field), Direction: SortDirection(direction)}
|
||||
if !found || !parsed.Valid() {
|
||||
return Sort{}, ErrInvalidSort
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func DefaultSort() Sort {
|
||||
return Sort{Field: SortByUpdatedAt, Direction: SortDesc}
|
||||
}
|
||||
|
||||
type Filters struct {
|
||||
ContentTypeID uint32
|
||||
Page int
|
||||
Size int
|
||||
Sort Sort
|
||||
Unpaged bool
|
||||
}
|
||||
|
||||
type PageResult struct {
|
||||
Items []Entry
|
||||
Total int
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Create(entry Entry) (Entry, error)
|
||||
Get(contentTypeID uint32, id uint32) (Entry, error)
|
||||
ListPage(filters Filters) (PageResult, error)
|
||||
Update(entry Entry) (Entry, error)
|
||||
Delete(contentTypeID uint32, id uint32) error
|
||||
DeleteByContentType(contentTypeID uint32) error
|
||||
CountByContentType(contentTypeID uint32) (int, error)
|
||||
FindByFieldValue(contentTypeID uint32, fieldKey string, value any, excludeID uint32) ([]Entry, error)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB, publicURL string) error {
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{})
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func ContentTypeToDomain(m models.ContentType) (contenttype.ContentType, error) {
|
||||
var fields []contenttype.Field
|
||||
if err := json.Unmarshal([]byte(m.Fields), &fields); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
return contenttype.ContentType{
|
||||
ID: m.ID,
|
||||
Key: m.Key,
|
||||
Name: m.Name,
|
||||
Fields: fields,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ContentTypeToModel(c contenttype.ContentType) (models.ContentType, error) {
|
||||
fields, err := json.Marshal(c.Fields)
|
||||
if err != nil {
|
||||
return models.ContentType{}, err
|
||||
}
|
||||
return models.ContentType{
|
||||
ID: c.ID,
|
||||
Key: c.Key,
|
||||
Name: c.Name,
|
||||
Fields: string(fields),
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func EntryToDomain(m models.Entry) (entry.Entry, error) {
|
||||
data := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(m.Data), &data); err != nil {
|
||||
return entry.Entry{}, err
|
||||
}
|
||||
return entry.Entry{
|
||||
ID: m.ID,
|
||||
ContentTypeID: m.ContentTypeID,
|
||||
Data: data,
|
||||
CreatedBy: m.CreatedBy,
|
||||
UpdatedBy: m.UpdatedBy,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func EntryToModel(e entry.Entry) (models.Entry, error) {
|
||||
data, err := json.Marshal(e.Data)
|
||||
if err != nil {
|
||||
return models.Entry{}, err
|
||||
}
|
||||
return models.Entry{
|
||||
ID: e.ID,
|
||||
ContentTypeID: e.ContentTypeID,
|
||||
Data: string(data),
|
||||
CreatedBy: e.CreatedBy,
|
||||
UpdatedBy: e.UpdatedBy,
|
||||
CreatedAt: e.CreatedAt,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type ContentType struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Key string `gorm:"column:type_key;size:40;not null;uniqueIndex"`
|
||||
Name string `gorm:"size:100;not null"`
|
||||
Fields string `gorm:"type:text;not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Entry struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
ContentTypeID uint32 `gorm:"not null;index"`
|
||||
Data string `gorm:"type:text;not null"`
|
||||
CreatedBy uint32 `gorm:"not null"`
|
||||
UpdatedBy uint32 `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ domain.Repository = &ContentTypeRepository{}
|
||||
|
||||
type ContentTypeRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewContentTypeRepository(db *gorm.DB) *ContentTypeRepository {
|
||||
return &ContentTypeRepository{db}
|
||||
}
|
||||
|
||||
func (c *ContentTypeRepository) Create(contentType domain.ContentType) (domain.ContentType, error) {
|
||||
model, err := mappers.ContentTypeToModel(contentType)
|
||||
if err != nil {
|
||||
return domain.ContentType{}, err
|
||||
}
|
||||
if err := c.db.Create(&model).Error; err != nil {
|
||||
return domain.ContentType{}, mapKeyTakenErr(err)
|
||||
}
|
||||
return mappers.ContentTypeToDomain(model)
|
||||
}
|
||||
|
||||
func (c *ContentTypeRepository) GetByKey(key string) (domain.ContentType, error) {
|
||||
var model entity.ContentType
|
||||
if err := c.db.Where("type_key = ?", key).First(&model).Error; err != nil {
|
||||
return domain.ContentType{}, mapNotFoundErr(err, domain.ErrContentTypeNotFound)
|
||||
}
|
||||
return mappers.ContentTypeToDomain(model)
|
||||
}
|
||||
|
||||
func (c *ContentTypeRepository) List() ([]domain.ContentType, error) {
|
||||
var found []entity.ContentType
|
||||
if err := c.db.Order("LOWER(name) ASC, id ASC").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentTypes := make([]domain.ContentType, 0, len(found))
|
||||
for _, model := range found {
|
||||
contentType, err := mappers.ContentTypeToDomain(model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentTypes = append(contentTypes, contentType)
|
||||
}
|
||||
return contentTypes, nil
|
||||
}
|
||||
|
||||
func (c *ContentTypeRepository) Update(contentType domain.ContentType) (domain.ContentType, error) {
|
||||
model, err := mappers.ContentTypeToModel(contentType)
|
||||
if err != nil {
|
||||
return domain.ContentType{}, err
|
||||
}
|
||||
result := c.db.Model(&entity.ContentType{}).Where("id = ?", model.ID).Select("Name", "Fields", "UpdatedAt").Updates(&model)
|
||||
if err := deleteOrNotFound(result, domain.ErrContentTypeNotFound); err != nil {
|
||||
return domain.ContentType{}, mapKeyTakenErr(err)
|
||||
}
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
func (c *ContentTypeRepository) Delete(id uint32) error {
|
||||
return deleteOrNotFound(c.db.Delete(&entity.ContentType{}, id), domain.ErrContentTypeNotFound)
|
||||
}
|
||||
|
||||
var uniqueViolationMarkers = []string{
|
||||
"unique constraint failed",
|
||||
"duplicate entry",
|
||||
"duplicate key value violates unique constraint",
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return true
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
for _, marker := range uniqueViolationMarkers {
|
||||
if strings.Contains(message, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mapKeyTakenErr(err error) error {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrKeyTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ domain.Repository = &EntryRepository{}
|
||||
|
||||
type EntryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewEntryRepository(db *gorm.DB) *EntryRepository {
|
||||
return &EntryRepository{db}
|
||||
}
|
||||
|
||||
var entrySortColumns = map[domain.SortField]string{
|
||||
domain.SortByID: "id",
|
||||
domain.SortByCreatedAt: "created_at",
|
||||
domain.SortByUpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
var entrySortDirections = map[domain.SortDirection]string{
|
||||
domain.SortAsc: "ASC",
|
||||
domain.SortDesc: "DESC",
|
||||
}
|
||||
|
||||
func EntryOrderClause(sort domain.Sort) (string, error) {
|
||||
column, columnKnown := entrySortColumns[sort.Field]
|
||||
direction, directionKnown := entrySortDirections[sort.Direction]
|
||||
if !columnKnown || !directionKnown || !sort.Valid() {
|
||||
return "", domain.ErrInvalidSort
|
||||
}
|
||||
if sort.Field == domain.SortByID {
|
||||
return column + " " + direction, nil
|
||||
}
|
||||
return column + " " + direction + ", id DESC", nil
|
||||
}
|
||||
|
||||
func (e *EntryRepository) Create(record domain.Entry) (domain.Entry, error) {
|
||||
model, err := mappers.EntryToModel(record)
|
||||
if err != nil {
|
||||
return domain.Entry{}, err
|
||||
}
|
||||
if err := e.db.Create(&model).Error; err != nil {
|
||||
return domain.Entry{}, err
|
||||
}
|
||||
return mappers.EntryToDomain(model)
|
||||
}
|
||||
|
||||
func (e *EntryRepository) Get(contentTypeID uint32, id uint32) (domain.Entry, error) {
|
||||
var model entity.Entry
|
||||
if err := e.db.Where("content_type_id = ? AND id = ?", contentTypeID, id).First(&model).Error; err != nil {
|
||||
return domain.Entry{}, mapNotFoundErr(err, domain.ErrEntryNotFound)
|
||||
}
|
||||
return mappers.EntryToDomain(model)
|
||||
}
|
||||
|
||||
func (e *EntryRepository) ListPage(filters domain.Filters) (domain.PageResult, error) {
|
||||
order, err := EntryOrderClause(filters.Sort)
|
||||
if err != nil {
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
if err := domain.ValidatePage(filters.Page, filters.Size); err != nil {
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
filters.Page = max(filters.Page, 1)
|
||||
filters.Size = max(filters.Size, 0)
|
||||
scoped := func() *gorm.DB {
|
||||
return e.db.Model(&entity.Entry{}).Where("content_type_id = ?", filters.ContentTypeID)
|
||||
}
|
||||
var total int64
|
||||
if err := scoped().Count(&total).Error; err != nil {
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
ordered := scoped().Order(order)
|
||||
if !filters.Unpaged {
|
||||
ordered = ordered.Limit(filters.Size).Offset((filters.Page - 1) * filters.Size)
|
||||
}
|
||||
var models []entity.Entry
|
||||
if err := ordered.Find(&models).Error; err != nil {
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
items, err := entriesToDomain(models)
|
||||
if err != nil {
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
return domain.PageResult{Items: items, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func (e *EntryRepository) Update(record domain.Entry) (domain.Entry, error) {
|
||||
model, err := mappers.EntryToModel(record)
|
||||
if err != nil {
|
||||
return domain.Entry{}, err
|
||||
}
|
||||
result := e.db.Model(&entity.Entry{}).Where("id = ?", model.ID).Select("Data", "UpdatedBy", "UpdatedAt").Updates(&model)
|
||||
if err := deleteOrNotFound(result, domain.ErrEntryNotFound); err != nil {
|
||||
return domain.Entry{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (e *EntryRepository) Delete(contentTypeID uint32, id uint32) error {
|
||||
result := e.db.Where("content_type_id = ? AND id = ?", contentTypeID, id).Delete(&entity.Entry{})
|
||||
return deleteOrNotFound(result, domain.ErrEntryNotFound)
|
||||
}
|
||||
|
||||
func (e *EntryRepository) DeleteByContentType(contentTypeID uint32) error {
|
||||
return e.db.Where("content_type_id = ?", contentTypeID).Delete(&entity.Entry{}).Error
|
||||
}
|
||||
|
||||
func (e *EntryRepository) CountByContentType(contentTypeID uint32) (int, error) {
|
||||
var total int64
|
||||
if err := e.db.Model(&entity.Entry{}).Where("content_type_id = ?", contentTypeID).Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
func (e *EntryRepository) FindByFieldValue(contentTypeID uint32, fieldKey string, value any, excludeID uint32) ([]domain.Entry, error) {
|
||||
var models []entity.Entry
|
||||
if err := e.db.Where("content_type_id = ? AND id <> ?", contentTypeID, excludeID).Find(&models).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates, err := entriesToDomain(models)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matching := []domain.Entry{}
|
||||
for _, candidate := range candidates {
|
||||
if domain.SameValue(candidate.Data[fieldKey], value) {
|
||||
matching = append(matching, candidate)
|
||||
}
|
||||
}
|
||||
return matching, nil
|
||||
}
|
||||
|
||||
func entriesToDomain(models []entity.Entry) ([]domain.Entry, error) {
|
||||
items := make([]domain.Entry, 0, len(models))
|
||||
for _, model := range models {
|
||||
item, err := mappers.EntryToDomain(model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateContentTypeUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type CreateContentTypeCommand struct {
|
||||
Key string
|
||||
Name string
|
||||
Fields []contenttype.Field
|
||||
}
|
||||
|
||||
func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, now func() time.Time) *CreateContentTypeUseCase {
|
||||
return &CreateContentTypeUseCase{contentTypes, now}
|
||||
}
|
||||
|
||||
func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (contenttype.ContentType, error) {
|
||||
if err := contenttype.ValidateKey(command.Key); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
if err := contenttype.ValidateName(command.Name); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
fields, err := contenttype.ValidateFields(command.Fields)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
if err := u.ensureKeyFree(command.Key); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
created := u.now()
|
||||
return u.contentTypes.Create(contenttype.ContentType{
|
||||
Key: command.Key,
|
||||
Name: command.Name,
|
||||
Fields: fields,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *CreateContentTypeUseCase) ensureKeyFree(key string) error {
|
||||
_, err := u.contentTypes.GetByKey(key)
|
||||
if errors.Is(err, contenttype.ErrContentTypeNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return contenttype.ErrKeyTaken
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateEntryUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type CreateEntryCommand struct {
|
||||
ContentTypeKey string
|
||||
Data map[string]any
|
||||
UserID uint32
|
||||
}
|
||||
|
||||
func NewCreateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, now func() time.Time) *CreateEntryUseCase {
|
||||
return &CreateEntryUseCase{contentTypes, entries, now}
|
||||
}
|
||||
|
||||
func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, error) {
|
||||
owner, err := u.contentTypes.GetByKey(command.ContentTypeKey)
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
data := entry.ApplyDefaults(owner.Fields, command.Data)
|
||||
if err := entry.Validate(owner.Fields, data); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
if err := ensureUnique(u.entries, owner, data, 0); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
created := u.now()
|
||||
stored, err := u.entries.Create(entry.Entry{
|
||||
ContentTypeID: owner.ID,
|
||||
Data: data,
|
||||
CreatedBy: command.UserID,
|
||||
UpdatedBy: command.UserID,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
})
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
return typedEntry(owner, stored), nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
)
|
||||
|
||||
type DeleteContentTypeUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
}
|
||||
|
||||
func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository) *DeleteContentTypeUseCase {
|
||||
return &DeleteContentTypeUseCase{contentTypes, entries}
|
||||
}
|
||||
|
||||
func (u *DeleteContentTypeUseCase) Delete(key string) error {
|
||||
target, err := u.contentTypes.GetByKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := u.entries.CountByContentType(target.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return contenttype.ErrHasEntries
|
||||
}
|
||||
return u.contentTypes.Delete(target.ID)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
)
|
||||
|
||||
type DeleteEntryUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
}
|
||||
|
||||
func NewDeleteEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository) *DeleteEntryUseCase {
|
||||
return &DeleteEntryUseCase{contentTypes, entries}
|
||||
}
|
||||
|
||||
func (u *DeleteEntryUseCase) Delete(contentTypeKey string, id uint32) error {
|
||||
owner, err := u.contentTypes.GetByKey(contentTypeKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.entries.Delete(owner.ID, id)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/contenttype"
|
||||
|
||||
type GetContentTypeUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
}
|
||||
|
||||
func NewGetContentTypeUseCase(contentTypes contenttype.Repository) *GetContentTypeUseCase {
|
||||
return &GetContentTypeUseCase{contentTypes}
|
||||
}
|
||||
|
||||
func (u *GetContentTypeUseCase) Get(key string) (contenttype.ContentType, error) {
|
||||
return u.contentTypes.GetByKey(key)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
)
|
||||
|
||||
type GetEntryUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
}
|
||||
|
||||
func NewGetEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository) *GetEntryUseCase {
|
||||
return &GetEntryUseCase{contentTypes, entries}
|
||||
}
|
||||
|
||||
func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, error) {
|
||||
owner, err := u.contentTypes.GetByKey(contentTypeKey)
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
found, err := u.entries.Get(owner.ID, id)
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
return typedEntry(owner, found), nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/contenttype"
|
||||
|
||||
type ListContentTypesUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
}
|
||||
|
||||
func NewListContentTypesUseCase(contentTypes contenttype.Repository) *ListContentTypesUseCase {
|
||||
return &ListContentTypesUseCase{contentTypes}
|
||||
}
|
||||
|
||||
func (u *ListContentTypesUseCase) List() ([]contenttype.ContentType, error) {
|
||||
found, err := u.contentTypes.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found == nil {
|
||||
return []contenttype.ContentType{}, nil
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
)
|
||||
|
||||
type ListEntriesUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
}
|
||||
|
||||
type ListEntriesCommand struct {
|
||||
ContentTypeKey string
|
||||
Page int
|
||||
Size int
|
||||
Sort entry.Sort
|
||||
}
|
||||
|
||||
type EntryPage struct {
|
||||
Items []TypedEntry
|
||||
Total int
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
func NewListEntriesUseCase(contentTypes contenttype.Repository, entries entry.Repository) *ListEntriesUseCase {
|
||||
return &ListEntriesUseCase{contentTypes, entries}
|
||||
}
|
||||
|
||||
func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, error) {
|
||||
if err := entry.ValidatePage(command.Page, command.Size); err != nil {
|
||||
return EntryPage{}, err
|
||||
}
|
||||
if !command.Sort.Valid() {
|
||||
return EntryPage{}, entry.ErrInvalidSort
|
||||
}
|
||||
owner, err := u.contentTypes.GetByKey(command.ContentTypeKey)
|
||||
if err != nil {
|
||||
return EntryPage{}, err
|
||||
}
|
||||
found, err := u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Page: command.Page, Size: command.Size, Sort: command.Sort})
|
||||
if err != nil {
|
||||
return EntryPage{}, err
|
||||
}
|
||||
items := make([]TypedEntry, 0, len(found.Items))
|
||||
for _, record := range found.Items {
|
||||
items = append(items, typedEntry(owner, record))
|
||||
}
|
||||
return EntryPage{Items: items, Total: found.Total, Page: command.Page, Size: command.Size}, nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdateContentTypeUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type UpdateContentTypeCommand struct {
|
||||
Key string
|
||||
Name string
|
||||
Fields []contenttype.Field
|
||||
}
|
||||
|
||||
func NewUpdateContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository, now func() time.Time) *UpdateContentTypeUseCase {
|
||||
return &UpdateContentTypeUseCase{contentTypes, entries, now}
|
||||
}
|
||||
|
||||
func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (contenttype.ContentType, error) {
|
||||
current, err := u.contentTypes.GetByKey(command.Key)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
if err := contenttype.ValidateName(command.Name); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
fields, err := contenttype.ValidateFields(command.Fields)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
stored, err := u.storedEntries(current.ID)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
if len(stored) > 0 {
|
||||
if err := checkCompatibility(current.Fields, fields, stored); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
}
|
||||
current.Name = command.Name
|
||||
current.Fields = fields
|
||||
current.UpdatedAt = u.now()
|
||||
return u.contentTypes.Update(current)
|
||||
}
|
||||
|
||||
func (u *UpdateContentTypeUseCase) storedEntries(contentTypeID uint32) ([]entry.Entry, error) {
|
||||
found, err := u.entries.ListPage(entry.Filters{ContentTypeID: contentTypeID, Unpaged: true, Sort: entry.DefaultSort()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return found.Items, nil
|
||||
}
|
||||
|
||||
func checkCompatibility(previous []contenttype.Field, next []contenttype.Field, stored []entry.Entry) error {
|
||||
if fieldTypeChanged(previous, next) {
|
||||
return contenttype.ErrInvalidField
|
||||
}
|
||||
seen := make(map[string][]any)
|
||||
for _, record := range stored {
|
||||
data := entry.ApplyDefaults(next, entry.Project(next, record.Data))
|
||||
if entry.Validate(next, data) != nil {
|
||||
return contenttype.ErrSchemaIncompatible
|
||||
}
|
||||
if collides(seen, entry.UniqueValues(next, data)) {
|
||||
return contenttype.ErrSchemaIncompatible
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fieldTypeChanged(previous []contenttype.Field, next []contenttype.Field) bool {
|
||||
types := make(map[string]contenttype.FieldType, len(previous))
|
||||
for _, field := range previous {
|
||||
types[field.Key] = field.Type
|
||||
}
|
||||
for _, field := range next {
|
||||
if before, existed := types[field.Key]; existed && before != field.Type {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collides(seen map[string][]any, values []entry.UniqueValue) bool {
|
||||
for _, candidate := range values {
|
||||
for _, earlier := range seen[candidate.Key] {
|
||||
if entry.SameValue(earlier, candidate.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
seen[candidate.Key] = append(seen[candidate.Key], candidate.Value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdateEntryUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type UpdateEntryCommand struct {
|
||||
ContentTypeKey string
|
||||
ID uint32
|
||||
Data map[string]any
|
||||
UserID uint32
|
||||
}
|
||||
|
||||
func NewUpdateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, now func() time.Time) *UpdateEntryUseCase {
|
||||
return &UpdateEntryUseCase{contentTypes, entries, now}
|
||||
}
|
||||
|
||||
func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, error) {
|
||||
owner, err := u.contentTypes.GetByKey(command.ContentTypeKey)
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
current, err := u.entries.Get(owner.ID, command.ID)
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
if err := entry.Validate(owner.Fields, command.Data); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
if err := ensureUnique(u.entries, owner, command.Data, current.ID); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
current.Data = command.Data
|
||||
current.UpdatedBy = command.UserID
|
||||
current.UpdatedAt = u.now()
|
||||
stored, err := u.entries.Update(current)
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
return typedEntry(owner, stored), nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
)
|
||||
|
||||
type TypedEntry struct {
|
||||
entry.Entry
|
||||
ContentTypeKey string
|
||||
}
|
||||
|
||||
func typedEntry(owner contenttype.ContentType, record entry.Entry) TypedEntry {
|
||||
record.Data = entry.Project(owner.Fields, record.Data)
|
||||
return TypedEntry{Entry: record, ContentTypeKey: owner.Key}
|
||||
}
|
||||
|
||||
func ensureUnique(entries entry.Repository, owner contenttype.ContentType, data map[string]any, excludeID uint32) error {
|
||||
for _, candidate := range entry.UniqueValues(owner.Fields, data) {
|
||||
taken, err := entries.FindByFieldValue(owner.ID, candidate.Key, candidate.Value, excludeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(taken) > 0 {
|
||||
return entry.UniqueViolation(candidate.Key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user