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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user