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