feat: custom content type icons, built-in types, user lookup
This commit is contained in:
1 parent
e6e088c963
commit
73a75d5cb9
31 files changed
+1611
-36
No files matched your search
@@ -0,0 +1,141 @@
|
||||
package builtintype
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("built-in type not found")
|
||||
|
||||
const (
|
||||
KeyArticle = "article"
|
||||
KeyAuthor = "author"
|
||||
KeySite = "site"
|
||||
KeyMedia = "media"
|
||||
KeyUser = "user"
|
||||
)
|
||||
|
||||
type Type struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Fields []contenttype.Field `json:"fields"`
|
||||
}
|
||||
|
||||
func Catalogue() []Type {
|
||||
return []Type{
|
||||
{Key: KeyArticle, Name: "Articles", Fields: articleFields()},
|
||||
{Key: KeyAuthor, Name: "Authors", Fields: authorFields()},
|
||||
{Key: KeySite, Name: "Sites", Fields: siteFields()},
|
||||
{Key: KeyMedia, Name: "Media", Fields: mediaFields()},
|
||||
{Key: KeyUser, Name: "Users", Fields: userFields()},
|
||||
}
|
||||
}
|
||||
|
||||
func Find(key string) (Type, error) {
|
||||
for _, candidate := range Catalogue() {
|
||||
if candidate.Key == key {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return Type{}, ErrNotFound
|
||||
}
|
||||
|
||||
func field(key string, label string, fieldType contenttype.FieldType) contenttype.Field {
|
||||
return contenttype.Field{Key: key, Label: label, Type: fieldType, Options: []string{}}
|
||||
}
|
||||
|
||||
func (b builder) required() builder {
|
||||
b.Required = true
|
||||
return b
|
||||
}
|
||||
|
||||
func (b builder) min(bound int) builder {
|
||||
b.Min = bound
|
||||
return b
|
||||
}
|
||||
|
||||
func (b builder) max(bound int) builder {
|
||||
b.Max = bound
|
||||
return b
|
||||
}
|
||||
|
||||
func (b builder) options(options ...string) builder {
|
||||
b.Options = options
|
||||
return b
|
||||
}
|
||||
|
||||
type builder contenttype.Field
|
||||
|
||||
func text(key string, label string) builder {
|
||||
return builder(field(key, label, contenttype.TypeText))
|
||||
}
|
||||
|
||||
func of(key string, label string, fieldType contenttype.FieldType) builder {
|
||||
return builder(field(key, label, fieldType))
|
||||
}
|
||||
|
||||
func build(builders ...builder) []contenttype.Field {
|
||||
fields := make([]contenttype.Field, 0, len(builders))
|
||||
for _, b := range builders {
|
||||
fields = append(fields, contenttype.Field(b))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func articleFields() []contenttype.Field {
|
||||
return build(
|
||||
text("title", "Title").required().min(article.MinTitleRunes).max(article.MaxTitleRunes),
|
||||
of("body", "Body", contenttype.TypeRichText),
|
||||
text("slug", "Slug"),
|
||||
text("excerpt", "Excerpt").max(300),
|
||||
text("seo_title", "SEO title").max(120),
|
||||
text("seo_description", "SEO description").max(320),
|
||||
of("is_online", "Online", contenttype.TypeBoolean),
|
||||
text("canonical_url", "Canonical URL").max(article.MaxCanonicalURLRunes),
|
||||
of("noindex", "No index", contenttype.TypeBoolean),
|
||||
)
|
||||
}
|
||||
|
||||
func authorFields() []contenttype.Field {
|
||||
return build(
|
||||
text("name", "Name").required(),
|
||||
text("slug", "Slug"),
|
||||
text("bio", "Bio"),
|
||||
text("website", "Website"),
|
||||
)
|
||||
}
|
||||
|
||||
func siteFields() []contenttype.Field {
|
||||
return build(
|
||||
text("name", "Name").required(),
|
||||
text("key", "Key").required(),
|
||||
text("base_url", "Base URL").required(),
|
||||
of("default_robots", "Default robots", contenttype.TypeSelect).options("index", "noindex"),
|
||||
text("locale", "Locale"),
|
||||
of("is_default", "Default site", contenttype.TypeBoolean),
|
||||
text("site_name", "Site name").max(100),
|
||||
text("title_template", "Title template").max(200),
|
||||
text("default_description", "Default description").max(320),
|
||||
)
|
||||
}
|
||||
|
||||
func mediaFields() []contenttype.Field {
|
||||
return build(
|
||||
text("filename", "Filename"),
|
||||
of("size", "Size", contenttype.TypeNumber),
|
||||
of("width", "Width", contenttype.TypeNumber),
|
||||
of("height", "Height", contenttype.TypeNumber),
|
||||
text("content_type", "Content type"),
|
||||
)
|
||||
}
|
||||
|
||||
func userFields() []contenttype.Field {
|
||||
return build(
|
||||
text("username", "Username").required(),
|
||||
text("display_name", "Display name"),
|
||||
text("email", "Email").required(),
|
||||
of("role", "Role", contenttype.TypeSelect).required().options(string(user.RoleOwner), string(user.RoleAdmin)),
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ var (
|
||||
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")
|
||||
ErrInvalidIcon = errors.New("invalid content type icon")
|
||||
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")
|
||||
@@ -58,6 +59,7 @@ type ContentType struct {
|
||||
ID uint32 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Icon *string `json:"icon"`
|
||||
Fields []Field `json:"fields"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var iconPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
|
||||
|
||||
var fieldKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,39}$`)
|
||||
|
||||
func ValidateKey(key string) error {
|
||||
@@ -24,6 +26,16 @@ func ValidateName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateIcon(icon *string) (*string, error) {
|
||||
if icon == nil || *icon == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !iconPattern.MatchString(*icon) {
|
||||
return nil, ErrInvalidIcon
|
||||
}
|
||||
return icon, nil
|
||||
}
|
||||
|
||||
func ValidateFields(fields []Field) ([]Field, error) {
|
||||
if len(fields) < MinFields || len(fields) > MaxFields {
|
||||
return nil, ErrInvalidField
|
||||
|
||||
@@ -2,6 +2,7 @@ package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,8 +16,11 @@ var ErrInvalidPasswordResetCode = errors.New("invalid password reset code")
|
||||
var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
|
||||
var ErrInvalidDisplayName = errors.New("invalid display name")
|
||||
var ErrUsernameTaken = errors.New("username taken")
|
||||
var ErrInvalidLookup = errors.New("invalid user lookup")
|
||||
var ErrVerificationEmailNotSent = errors.New("verification email not sent")
|
||||
|
||||
const MaxLookupIDs = 100
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
@@ -40,3 +44,10 @@ type User struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (u User) NameOrUsername() string {
|
||||
if trimmed := strings.TrimSpace(u.DisplayName); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
@@ -7,6 +7,7 @@ type Repository interface {
|
||||
GetByUsername(username string) (User, error)
|
||||
GetByEmail(email string) (User, error)
|
||||
GetAll() []User
|
||||
FindByIDs(ids []uint32) ([]User, error)
|
||||
Create(user User) (User, error)
|
||||
Delete(id uint32) error
|
||||
UpdateVerificationStatus(userId uint32, isVerified bool) (User, error)
|
||||
|
||||
@@ -15,6 +15,7 @@ func ContentTypeToDomain(m models.ContentType) (contenttype.ContentType, error)
|
||||
ID: m.ID,
|
||||
Key: m.Key,
|
||||
Name: m.Name,
|
||||
Icon: m.Icon,
|
||||
Fields: fields,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
@@ -30,6 +31,7 @@ func ContentTypeToModel(c contenttype.ContentType) (models.ContentType, error) {
|
||||
ID: c.ID,
|
||||
Key: c.Key,
|
||||
Name: c.Name,
|
||||
Icon: c.Icon,
|
||||
Fields: string(fields),
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"`
|
||||
Icon *string `gorm:"size:40"`
|
||||
Fields string `gorm:"type:text;not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
|
||||
@@ -60,7 +60,7 @@ func (c *ContentTypeRepository) Update(contentType domain.ContentType) (domain.C
|
||||
if err != nil {
|
||||
return domain.ContentType{}, err
|
||||
}
|
||||
result := c.db.Model(&entity.ContentType{}).Where("id = ?", model.ID).Select("Name", "Fields", "UpdatedAt").Updates(&model)
|
||||
result := c.db.Model(&entity.ContentType{}).Where("id = ?", model.ID).Select("Name", "Icon", "Fields", "UpdatedAt").Updates(&model)
|
||||
if err := deleteOrNotFound(result, domain.ErrContentTypeNotFound); err != nil {
|
||||
return domain.ContentType{}, mapKeyTakenErr(err)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,18 @@ func (u *UserRepository) GetAll() []domainUser.User {
|
||||
return domainUsers
|
||||
}
|
||||
|
||||
func (u *UserRepository) FindByIDs(ids []uint32) ([]domainUser.User, error) {
|
||||
var users []entity.User
|
||||
if err := u.db.Select("id", "username", "display_name").Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := make([]domainUser.User, 0, len(users))
|
||||
for _, localUser := range users {
|
||||
found = append(found, mappers.UserToDomain(localUser))
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (u *UserRepository) GetByUsername(username string) (domainUser.User, error) {
|
||||
var localUser entity.User
|
||||
err := u.db.Model(&entity.User{}).Where("username = ?", username).First(&localUser).Error
|
||||
|
||||
@@ -14,6 +14,7 @@ type CreateContentTypeUseCase struct {
|
||||
type CreateContentTypeCommand struct {
|
||||
Key string
|
||||
Name string
|
||||
Icon *string
|
||||
Fields []contenttype.Field
|
||||
}
|
||||
|
||||
@@ -28,6 +29,10 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
|
||||
if err := contenttype.ValidateName(command.Name); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
icon, err := contenttype.ValidateIcon(command.Icon)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
fields, err := contenttype.ValidateFields(command.Fields)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
@@ -39,6 +44,7 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
|
||||
return u.contentTypes.Create(contenttype.ContentType{
|
||||
Key: command.Key,
|
||||
Name: command.Name,
|
||||
Icon: icon,
|
||||
Fields: fields,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/builtintype"
|
||||
|
||||
type GetBuiltinTypeUseCase struct{}
|
||||
|
||||
func NewGetBuiltinTypeUseCase() *GetBuiltinTypeUseCase {
|
||||
return &GetBuiltinTypeUseCase{}
|
||||
}
|
||||
|
||||
func (u *GetBuiltinTypeUseCase) Get(key string) (builtintype.Type, error) {
|
||||
return builtintype.Find(key)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -62,7 +61,7 @@ func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.
|
||||
if requester.Role != user.RoleOwner {
|
||||
return names, nil
|
||||
}
|
||||
names[requester.ID] = displayNameOrUsername(requester)
|
||||
names[requester.ID] = requester.NameOrUsername()
|
||||
for _, key := range keys {
|
||||
if _, resolved := names[key.UserID]; resolved {
|
||||
continue
|
||||
@@ -75,18 +74,11 @@ func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names[key.UserID] = displayNameOrUsername(creator)
|
||||
names[key.UserID] = creator.NameOrUsername()
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func displayNameOrUsername(account user.User) string {
|
||||
if trimmed := strings.TrimSpace(account.DisplayName); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return account.Username
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) {
|
||||
if requester.Role == user.RoleOwner {
|
||||
return l.apiKeyRepository.ListAll()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/builtintype"
|
||||
|
||||
type ListBuiltinTypesUseCase struct{}
|
||||
|
||||
func NewListBuiltinTypesUseCase() *ListBuiltinTypesUseCase {
|
||||
return &ListBuiltinTypesUseCase{}
|
||||
}
|
||||
|
||||
func (u *ListBuiltinTypesUseCase) List() []builtintype.Type {
|
||||
return builtintype.Catalogue()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type LookupUserNamesUseCase struct {
|
||||
users user.Repository
|
||||
}
|
||||
|
||||
type UserName struct {
|
||||
ID uint32
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
func NewLookupUserNamesUseCase(users user.Repository) *LookupUserNamesUseCase {
|
||||
return &LookupUserNamesUseCase{users}
|
||||
}
|
||||
|
||||
func (u *LookupUserNamesUseCase) Lookup(ids []uint32) ([]UserName, error) {
|
||||
distinct := distinctIDs(ids)
|
||||
if len(distinct) == 0 || len(distinct) > user.MaxLookupIDs {
|
||||
return nil, user.ErrInvalidLookup
|
||||
}
|
||||
found, err := u.users.FindByIDs(distinct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool { return found[i].ID < found[j].ID })
|
||||
names := make([]UserName, 0, len(found))
|
||||
for _, record := range found {
|
||||
names = append(names, UserName{ID: record.ID, DisplayName: record.NameOrUsername()})
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func distinctIDs(ids []uint32) []uint32 {
|
||||
seen := make(map[uint32]bool, len(ids))
|
||||
distinct := make([]uint32, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
distinct = append(distinct, id)
|
||||
}
|
||||
}
|
||||
return distinct
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type UpdateContentTypeUseCase struct {
|
||||
type UpdateContentTypeCommand struct {
|
||||
Key string
|
||||
Name string
|
||||
Icon *string
|
||||
Fields []contenttype.Field
|
||||
}
|
||||
|
||||
@@ -30,6 +31,10 @@ func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (con
|
||||
if err := contenttype.ValidateName(command.Name); err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
icon, err := contenttype.ValidateIcon(command.Icon)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
}
|
||||
fields, err := contenttype.ValidateFields(command.Fields)
|
||||
if err != nil {
|
||||
return contenttype.ContentType{}, err
|
||||
@@ -44,6 +49,7 @@ func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (con
|
||||
}
|
||||
}
|
||||
current.Name = command.Name
|
||||
current.Icon = icon
|
||||
current.Fields = fields
|
||||
current.UpdatedAt = u.now()
|
||||
return u.contentTypes.Update(current)
|
||||
|
||||
Reference in new issue
Block a user