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)
}