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

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