feat: custom content types backend

This commit is contained in:
Florian Sylvain committed 2026-10-19 17:01:37 +02:00
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
}
+80
View File
@@ -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
}
+101
View File
@@ -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) == ""
}
+41
View File
@@ -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)
}
+90
View File
@@ -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
}
+64
View File
@@ -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
}