feat: Sites feature backend
This commit is contained in:
1 parent
45fcbd890d
commit
a11338094e
98 files changed
+5365
-214
No files matched your search
@@ -6,15 +6,18 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB) 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{})
|
||||
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{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureImagePathIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return BackfillUserRoles(db)
|
||||
if err := BackfillUserRoles(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return SeedDefaultSite(db, publicURL)
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
domainSite "RenewCMS/internal/domain/site"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SeedDefaultSite(db *gorm.DB, publicURL string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
defaultSite, err := ensureDefaultSite(tx, publicURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Model(&entity.Article{}).Where("site_id IS NULL OR site_id = 0").UpdateColumn("site_id", defaultSite.ID).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureDefaultSite(tx *gorm.DB, publicURL string) (entity.Site, error) {
|
||||
var total int64
|
||||
if err := tx.Model(&entity.Site{}).Count(&total).Error; err != nil {
|
||||
return entity.Site{}, err
|
||||
}
|
||||
if total > 0 {
|
||||
return firstDefaultSite(tx)
|
||||
}
|
||||
baseURL, err := domainSite.SeedBaseURL(publicURL)
|
||||
if err != nil {
|
||||
return entity.Site{}, fmt.Errorf("PUBLIC_URL %q cannot seed the first site: %w", publicURL, err)
|
||||
}
|
||||
seeded := entity.Site{
|
||||
Name: domainSite.DefaultName,
|
||||
Key: domainSite.DefaultKey,
|
||||
BaseURL: baseURL,
|
||||
DefaultRobots: domainSite.RobotsIndex,
|
||||
IsDefault: true,
|
||||
}
|
||||
if err := tx.Create(&seeded).Error; err != nil {
|
||||
return entity.Site{}, err
|
||||
}
|
||||
return seeded, nil
|
||||
}
|
||||
|
||||
func firstDefaultSite(tx *gorm.DB) (entity.Site, error) {
|
||||
var found entity.Site
|
||||
if err := tx.Order("is_default DESC, id ASC").First(&found).Error; err != nil {
|
||||
return entity.Site{}, err
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ func ArticleToDomain(m models.Article) article.Article {
|
||||
Title: m.Title,
|
||||
Body: m.Body,
|
||||
IsOnline: m.IsOnline,
|
||||
SiteID: m.SiteID,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
@@ -21,6 +22,7 @@ func ArticleToModel(a article.Article) models.Article {
|
||||
Title: a.Title,
|
||||
Body: a.Body,
|
||||
IsOnline: a.IsOnline,
|
||||
SiteID: a.SiteID,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func SiteToDomain(m models.Site, articleCount int) site.Site {
|
||||
return site.Site{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Key: m.Key,
|
||||
BaseURL: m.BaseURL,
|
||||
DefaultSocialImageID: m.DefaultSocialImageID,
|
||||
DefaultRobots: m.DefaultRobots,
|
||||
Locale: m.Locale,
|
||||
IsDefault: m.IsDefault,
|
||||
ArticleCount: articleCount,
|
||||
}
|
||||
}
|
||||
|
||||
func SiteToModel(s site.Site) models.Site {
|
||||
return models.Site{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
Key: s.Key,
|
||||
BaseURL: s.BaseURL,
|
||||
DefaultSocialImageID: s.DefaultSocialImageID,
|
||||
DefaultRobots: s.DefaultRobots,
|
||||
Locale: s.Locale,
|
||||
IsDefault: s.IsDefault,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type Article struct {
|
||||
Title string
|
||||
Body string
|
||||
IsOnline bool `gorm:"not_null;default:false"`
|
||||
SiteID uint32 `gorm:"index"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Site struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"size:100;not null"`
|
||||
Key string `gorm:"column:site_key;size:32;not null;uniqueIndex"`
|
||||
BaseURL string `gorm:"size:255;not null"`
|
||||
DefaultSocialImageID *uint32
|
||||
DefaultRobots string `gorm:"size:16;not null"`
|
||||
Locale *string `gorm:"size:35"`
|
||||
IsDefault bool `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -40,6 +40,9 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
|
||||
if filters.IsOnline != nil {
|
||||
query = query.Where("is_online = ?", *filters.IsOnline)
|
||||
}
|
||||
if filters.SiteID != nil {
|
||||
query = query.Where("site_id = ?", *filters.SiteID)
|
||||
}
|
||||
switch filters.Status {
|
||||
case domain.StatusOnline:
|
||||
query = query.Where("is_online = ?", true)
|
||||
@@ -177,19 +180,24 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
|
||||
creationResult := a.db.Create(&entity.Article{
|
||||
created := entity.Article{
|
||||
Title: article.Title,
|
||||
Body: article.Body,
|
||||
IsOnline: article.IsOnline,
|
||||
})
|
||||
if creationResult.Error != nil {
|
||||
return domain.Article{}, creationResult.Error
|
||||
SiteID: article.SiteID,
|
||||
}
|
||||
|
||||
var createdArticle entity.Article
|
||||
creationResult.Scan(&createdArticle)
|
||||
|
||||
return mappers.ArticleToDomain(createdArticle), nil
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if article.SiteID != 0 {
|
||||
if _, err := lockSite(tx, article.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&created).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
return mappers.ArticleToDomain(created), nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) GetAll() []domain.Article {
|
||||
@@ -225,6 +233,31 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article,
|
||||
return newArticle, nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) {
|
||||
var localArticle entity.Article
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if update.SiteID != nil {
|
||||
if _, err := lockSite(tx, *update.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil {
|
||||
return mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||
}
|
||||
if update.Body != nil {
|
||||
localArticle.Body = *update.Body
|
||||
}
|
||||
if update.SiteID != nil {
|
||||
localArticle.SiteID = *update.SiteID
|
||||
}
|
||||
return tx.Save(&localArticle).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
return mappers.ArticleToDomain(localArticle), nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
|
||||
var localArticle entity.Article
|
||||
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type SiteRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSiteRepository(db *gorm.DB) *SiteRepository {
|
||||
return &SiteRepository{db}
|
||||
}
|
||||
|
||||
type articleTotal struct {
|
||||
SiteID uint32
|
||||
Total int
|
||||
}
|
||||
|
||||
func (s *SiteRepository) articleCounts(db *gorm.DB) (map[uint32]int, error) {
|
||||
var totals []articleTotal
|
||||
err := db.Model(&entity.Article{}).Select("site_id, COUNT(*) AS total").Group("site_id").Scan(&totals).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[uint32]int, len(totals))
|
||||
for _, total := range totals {
|
||||
counts[total.SiteID] = total.Total
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) articleCount(db *gorm.DB, id uint32) (int, error) {
|
||||
var total int64
|
||||
if err := db.Model(&entity.Article{}).Where("site_id = ?", id).Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) List() ([]domain.Site, error) {
|
||||
var found []entity.Site
|
||||
if err := s.db.Order("is_default DESC, LOWER(name) ASC, id ASC").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, err := s.articleCounts(s.db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sites := make([]domain.Site, 0, len(found))
|
||||
for _, model := range found {
|
||||
sites = append(sites, mappers.SiteToDomain(model, counts[model.ID]))
|
||||
}
|
||||
return sites, nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) load(db *gorm.DB, model entity.Site) (domain.Site, error) {
|
||||
total, err := s.articleCount(db, model.ID)
|
||||
if err != nil {
|
||||
return domain.Site{}, err
|
||||
}
|
||||
return mappers.SiteToDomain(model, total), nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) Get(id uint32) (domain.Site, error) {
|
||||
var model entity.Site
|
||||
if err := s.db.First(&model, id).Error; err != nil {
|
||||
return domain.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
|
||||
}
|
||||
return s.load(s.db, model)
|
||||
}
|
||||
|
||||
func (s *SiteRepository) GetByKey(key string) (domain.Site, error) {
|
||||
var model entity.Site
|
||||
if err := s.db.Where("site_key = ?", key).First(&model).Error; err != nil {
|
||||
return domain.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
|
||||
}
|
||||
return s.load(s.db, model)
|
||||
}
|
||||
|
||||
func (s *SiteRepository) GetDefault() (domain.Site, error) {
|
||||
var model entity.Site
|
||||
if err := s.db.Where("is_default = ?", true).Order("id ASC").First(&model).Error; err != nil {
|
||||
return domain.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
|
||||
}
|
||||
return s.load(s.db, model)
|
||||
}
|
||||
|
||||
func (s *SiteRepository) Create(site domain.Site) (domain.Site, error) {
|
||||
model := mappers.SiteToModel(site)
|
||||
model.ID = 0
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if model.IsDefault {
|
||||
if err := lockAllSites(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unflagDefaults(tx, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Site{}, err
|
||||
}
|
||||
return mappers.SiteToDomain(model, 0), nil
|
||||
}
|
||||
|
||||
func lockAllSites(tx *gorm.DB) error {
|
||||
var locked []entity.Site
|
||||
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&locked).Error
|
||||
}
|
||||
|
||||
func lockSite(tx *gorm.DB, id uint32) (entity.Site, error) {
|
||||
var locked entity.Site
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
|
||||
return entity.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
|
||||
}
|
||||
return locked, nil
|
||||
}
|
||||
|
||||
func unflagDefaults(tx *gorm.DB, exceptID uint32) error {
|
||||
return tx.Model(&entity.Site{}).Where("is_default = ? AND id <> ?", true, exceptID).UpdateColumn("is_default", false).Error
|
||||
}
|
||||
|
||||
func (s *SiteRepository) Update(site domain.Site) (domain.Site, error) {
|
||||
changes := map[string]any{
|
||||
"name": site.Name,
|
||||
"base_url": site.BaseURL,
|
||||
"default_social_image_id": site.DefaultSocialImageID,
|
||||
"default_robots": site.DefaultRobots,
|
||||
"locale": site.Locale,
|
||||
}
|
||||
if _, err := s.Get(site.ID); err != nil {
|
||||
return domain.Site{}, err
|
||||
}
|
||||
if err := s.db.Model(&entity.Site{}).Where("id = ?", site.ID).Updates(changes).Error; err != nil {
|
||||
return domain.Site{}, err
|
||||
}
|
||||
return s.Get(site.ID)
|
||||
}
|
||||
|
||||
func (s *SiteRepository) SetDefault(id uint32) (domain.Site, error) {
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockAllSites(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := lockSite(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unflagDefaults(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&entity.Site{}).Where("id = ?", id).UpdateColumn("is_default", true).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Site{}, err
|
||||
}
|
||||
return s.Get(id)
|
||||
}
|
||||
|
||||
func (s *SiteRepository) DeleteMovingArticles(id uint32, reassignTo *uint32) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
target, err := lockSite(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.IsDefault {
|
||||
return domain.ErrDefaultSite
|
||||
}
|
||||
remaining, err := s.articleCount(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining > 0 && reassignTo == nil {
|
||||
return domain.ErrSiteHasArticles
|
||||
}
|
||||
if reassignTo != nil {
|
||||
if err := moveArticles(tx, id, *reassignTo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return deleteOrNotFound(tx.Delete(&entity.Site{}, id), domain.ErrSiteNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func moveArticles(tx *gorm.DB, from uint32, to uint32) error {
|
||||
if _, err := lockSite(tx, to); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ?", from).UpdateColumn("site_id", to).Error
|
||||
}
|
||||
|
||||
func (s *SiteRepository) ClearImage(imageID uint32) ([]uint32, error) {
|
||||
ids := []uint32{}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&entity.Site{}).Where("default_social_image_id = ?", imageID).Order("id ASC").Pluck("id", &ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&entity.Site{}).Where("id IN ?", ids).UpdateColumn("default_social_image_id", nil).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) AssignImage(siteIDs []uint32, imageID uint32) error {
|
||||
if len(siteIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.Model(&entity.Site{}).Where("id IN ?", siteIDs).UpdateColumn("default_social_image_id", imageID).Error
|
||||
}
|
||||
|
||||
func (s *SiteRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) (int, error) {
|
||||
if len(fromImageIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
result := s.db.Model(&entity.Site{}).Where("default_social_image_id IN ?", fromImageIDs).UpdateColumn("default_social_image_id", toImageID)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return int(result.RowsAffected), nil
|
||||
}
|
||||
|
||||
var _ domain.Repository = &SiteRepository{}
|
||||
Reference in new issue
Block a user