refac: migration crash, real-database tests, site deleted during create, ruels out of the repo, lists

This commit is contained in:
Florian Sylvain committed 2026-11-03 05:06:04 +01:00
1 parent 680b9a1308
commit 9d284e65a9
62 files changed
+1858 -987

No files matched your search

@@ -24,6 +24,7 @@ type Driver interface {
Open(source string) (*gorm.DB, error)
NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup
IsLockContention(err error) bool
IsUniqueViolation(err error) bool
}
var drivers = map[string]Driver{
@@ -41,6 +42,15 @@ func IsLockContention(db *gorm.DB, err error) bool {
return IsLockContentionFor(db.Name(), err)
}
func IsUniqueViolationFor(driverName string, err error) bool {
driver, ok := drivers[driverName]
return ok && err != nil && driver.IsUniqueViolation(err)
}
func IsUniqueViolation(db *gorm.DB, err error) bool {
return IsUniqueViolationFor(db.Name(), err)
}
type Connection struct {
DB *gorm.DB
driver Driver
@@ -7,6 +7,9 @@ import (
)
func Migrate(db *gorm.DB, publicURL string) error {
if err := BackfillNullSourceRevisions(db); err != nil {
return err
}
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{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{}, &models.Story{}, &models.SiteLanguage{})
if err != nil {
return err
@@ -28,6 +28,8 @@ const (
mysqlLockWaitTimeout = 1205
mysqlDeadlock = 1213
mysqlSnapshotChanged = 1020
mysqlDuplicateEntry = 1062
mysqlDuplicateKeyed = 1586
)
type mysqlDriver struct{}
@@ -94,6 +96,16 @@ func tableOptionsFor(db *gorm.DB) *gorm.DB {
var mysqlContentionCodes = []uint16{mysqlLockNowait, mysqlLockWaitTimeout, mysqlDeadlock, mysqlSnapshotChanged}
func (mysqlDriver) IsLockContention(err error) bool {
return hasMySQLCode(err, mysqlContentionCodes)
}
var mysqlUniqueViolationCodes = []uint16{mysqlDuplicateEntry, mysqlDuplicateKeyed}
func (mysqlDriver) IsUniqueViolation(err error) bool {
return hasMySQLCode(err, mysqlUniqueViolationCodes)
}
func hasMySQLCode(err error, codes []uint16) bool {
var failure *mysqldriver.MySQLError
return errors.As(err, &failure) && slices.Contains(mysqlContentionCodes, failure.Number)
return errors.As(err, &failure) && slices.Contains(codes, failure.Number)
}
@@ -26,6 +26,7 @@ const (
postgresLockNotAvailable = "55P03"
postgresDeadlockDetected = "40P01"
postgresSerializationFailure = "40001"
postgresUniqueViolation = "23505"
)
var timeType = reflect.TypeOf(time.Time{})
@@ -115,6 +116,14 @@ func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.T
var postgresContentionCodes = []string{postgresLockNotAvailable, postgresDeadlockDetected, postgresSerializationFailure}
func (postgresDriver) IsLockContention(err error) bool {
return hasPostgresCode(err, postgresContentionCodes)
}
func (postgresDriver) IsUniqueViolation(err error) bool {
return hasPostgresCode(err, []string{postgresUniqueViolation})
}
func hasPostgresCode(err error, codes []string) bool {
var failure *pgconn.PgError
return errors.As(err, &failure) && slices.Contains(postgresContentionCodes, failure.Code)
return errors.As(err, &failure) && slices.Contains(codes, failure.Code)
}
@@ -0,0 +1,20 @@
package database
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
const (
sourceRevisionColumn = "source_revision"
untranslatedSourceValue = 0
)
func BackfillNullSourceRevisions(db *gorm.DB) error {
migrator := db.Migrator()
if !migrator.HasTable(&entity.Article{}) || !migrator.HasColumn(&entity.Article{}, sourceRevisionColumn) {
return nil
}
return db.Unscoped().Model(&entity.Article{}).Where(sourceRevisionColumn+" IS NULL").UpdateColumn(sourceRevisionColumn, untranslatedSourceValue).Error
}
@@ -19,6 +19,8 @@ const (
sqliteResultBusy = 5
sqliteResultLocked = 6
sqlitePrimaryMask = 0xff
sqliteUniqueKey = 2067
sqlitePrimaryKey = 1555
)
type sqliteDriver struct{}
@@ -39,10 +41,20 @@ func (sqliteDriver) NewBackup(db *gorm.DB, directory string, now func() time.Tim
}
func (sqliteDriver) IsLockContention(err error) bool {
code, ok := sqliteCode(err)
primary := code & sqlitePrimaryMask
return ok && (primary == sqliteResultBusy || primary == sqliteResultLocked)
}
func (sqliteDriver) IsUniqueViolation(err error) bool {
code, ok := sqliteCode(err)
return ok && (code == sqliteUniqueKey || code == sqlitePrimaryKey)
}
func sqliteCode(err error) (int, bool) {
var coded interface{ Code() int }
if !errors.As(err, &coded) {
return false
return 0, false
}
primary := coded.Code() & sqlitePrimaryMask
return primary == sqliteResultBusy || primary == sqliteResultLocked
return coded.Code(), true
}
@@ -375,10 +375,7 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
return err
}
}
if err := ensureStoryLanguageFree(tx, created.StoryID, created.Lang); err != nil {
return err
}
if err := ensureSlugFree(tx, created.SiteID, created.Lang, created.Slug, 0); err != nil {
if err := requireStory(tx, created.StoryID); err != nil {
return err
}
if err := openStory(tx, &created); err != nil {
@@ -390,12 +387,12 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors))
})
if err != nil {
return domain.Article{}, a.explainSlugFailure(created.SiteID, created.Lang, created.Slug, 0, err)
return domain.Article{}, a.explainUniqueViolation(created, 0, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
}
func ensureStoryLanguageFree(tx *gorm.DB, storyID uint32, lang string) error {
func requireStory(tx *gorm.DB, storyID uint32) error {
if storyID == 0 {
return nil
}
@@ -406,13 +403,6 @@ func ensureStoryLanguageFree(tx *gorm.DB, storyID uint32, lang string) error {
if stories == 0 {
return domain.ErrArticleNotFound
}
var versions int64
if err := tx.Model(&entity.Article{}).Where("story_id = ? AND lang = ?", storyID, lang).Count(&versions).Error; err != nil {
return err
}
if versions > 0 {
return domain.ErrLanguageTaken
}
return nil
}
@@ -434,26 +424,21 @@ func articleSlugInLanguage(db *gorm.DB, siteID uint32, lang string, slug string,
return total > 0, err
}
func ensureSlugFree(db *gorm.DB, siteID uint32, lang string, slug string, exceptID uint32) error {
if slug == "" {
return nil
}
taken, err := articleSlugInLanguage(db, siteID, lang, slug, exceptID)
if err != nil {
return err
}
if taken {
return domain.ErrSlugTaken
}
return nil
func storyLanguageInUse(db *gorm.DB, storyID uint32, lang string, exceptID uint32) (bool, error) {
var total int64
err := db.Unscoped().Model(&entity.Article{}).Where("story_id = ? AND lang = ? AND retired_id = ? AND id <> ?", storyID, lang, 0, exceptID).Count(&total).Error
return total > 0, err
}
func (a *ArticleRepository) explainSlugFailure(siteID uint32, lang string, slug string, exceptID uint32, cause error) error {
if errors.Is(cause, domain.ErrSlugTaken) {
func (a *ArticleRepository) explainUniqueViolation(written entity.Article, exceptID uint32, cause error) error {
if !database.IsUniqueViolation(a.db, cause) {
return cause
}
if err := ensureSlugFree(a.db, siteID, lang, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
return err
if taken, err := articleSlugInLanguage(a.db, written.SiteID, written.Lang, written.Slug, exceptID); err == nil && taken {
return domain.ErrSlugTaken
}
if taken, err := storyLanguageInUse(a.db, written.StoryID, written.Lang, exceptID); err == nil && taken {
return domain.ErrLanguageTaken
}
return cause
}
@@ -528,11 +513,6 @@ func (a *ArticleRepository) updateAt(id uint32, update domain.Update, probedSite
}
before := localArticle
applyUpdate(&localArticle, update)
if update.Slug != nil || update.SiteID != nil {
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil {
return err
}
}
authorsChanged, err := authorsDiffer(tx, id, update.AuthorIDs)
if err != nil {
return err
@@ -554,7 +534,7 @@ func (a *ArticleRepository) updateAt(id uint32, update domain.Update, probedSite
return domain.Article{}, err
}
if err != nil {
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err)
return domain.Article{}, a.explainUniqueViolation(localArticle, id, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
@@ -722,15 +702,13 @@ func (a *ArticleRepository) ListByStory(storyID uint32) ([]domain.Article, error
}
func (a *ArticleRepository) MoveToSite(ids []uint32, siteID uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
var moving entity.Article
err := a.db.Transaction(func(tx *gorm.DB) error {
for _, id := range ids {
var moving entity.Article
moving = entity.Article{}
if err := tx.First(&moving, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if err := ensureSlugFree(tx, siteID, moving.Lang, moving.Slug, id); err != nil {
return err
}
moved := map[string]any{"site_id": siteID, "edit_revision": gorm.Expr("edit_revision + 1")}
if err := tx.Model(&entity.Article{}).Where("id = ?", id).Updates(moved).Error; err != nil {
return err
@@ -738,6 +716,11 @@ func (a *ArticleRepository) MoveToSite(ids []uint32, siteID uint32) error {
}
return nil
})
if err != nil {
moving.SiteID = siteID
return a.explainUniqueViolation(moving, moving.ID, err)
}
return nil
}
func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
@@ -44,42 +44,76 @@ func lacksReadableVersionIn(languageExpression string, filters domain.StoryFilte
return condition
}
func (a *ArticleRepository) versionRestriction(filters domain.StoryFilters) ([]uint32, bool, error) {
restricted := filters.VersionIDs != nil
var allowed []uint32
if restricted {
allowed = *filters.VersionIDs
}
query := strings.TrimSpace(filters.Q)
if query == "" {
return allowed, restricted, nil
}
func (a *ArticleRepository) FindTitleMatches(q string, filters domain.StoryFilters) ([]uint32, error) {
readable := readableCondition("q", filters)
if filters.SiteID != nil {
readable.add("q.site_id = ?", *filters.SiteID)
}
var candidates []entity.Article
if err := a.db.Raw("SELECT q.id AS id, q.title AS title FROM articles q WHERE "+readable.where(), readable.args...).Scan(&candidates).Error; err != nil {
return nil, false, err
}
needle := foldForSearch(query)
permitted := map[uint32]bool{}
for _, id := range allowed {
permitted[id] = true
return nil, err
}
needle := foldForSearch(strings.TrimSpace(q))
matching := make([]uint32, 0)
for _, candidate := range candidates {
if restricted && !permitted[candidate.ID] {
continue
}
if strings.Contains(foldForSearch(candidate.Title), needle) {
matching = append(matching, candidate.ID)
}
}
return matching, true, nil
return matching, nil
}
func qualifyingVersions(filters domain.StoryFilters, allowed []uint32, restricted bool) storySQL {
type versionSet map[uint32]bool
func restrictionOf(filters domain.StoryFilters) versionSet {
if filters.VersionIDs == nil {
return nil
}
set := make(versionSet, len(*filters.VersionIDs))
for _, id := range *filters.VersionIDs {
set[id] = true
}
return set
}
func (set versionSet) keep(rows []storyRefRow) []storyRefRow {
kept := make([]storyRefRow, 0, len(rows))
for _, row := range rows {
if set[row.ArticleID] {
kept = append(kept, row)
}
}
return kept
}
func storiesOf(rows []storyRefRow) map[uint32]bool {
stories := make(map[uint32]bool, len(rows))
for _, row := range rows {
stories[row.StoryID] = true
}
return stories
}
func unitsIn(filters domain.StoryFilters, rows []storyRefRow) int {
if filters.PerLanguage() {
return len(rows)
}
return len(storiesOf(rows))
}
func pageOf(rows []storyRefRow, offset int, size int) []storyRefRow {
start := min(offset, len(rows))
return rows[start:min(start+size, len(rows))]
}
func (a *ArticleRepository) qualifyingRefs(filters domain.StoryFilters, restriction versionSet) ([]storyRefRow, error) {
qualifying := qualifyingVersions(filters)
var rows []storyRefRow
err := a.db.Raw("SELECT q.story_id AS story_id, q.id AS article_id FROM articles q WHERE "+qualifying.where(), qualifying.args...).Scan(&rows).Error
return restriction.keep(rows), err
}
func qualifyingVersions(filters domain.StoryFilters) storySQL {
condition := readableCondition("q", filters)
if filters.SiteID != nil {
condition.add("q.site_id = ?", *filters.SiteID)
@@ -96,13 +130,6 @@ func qualifyingVersions(filters domain.StoryFilters, allowed []uint32, restricte
if filters.AuthorID != nil {
condition.add("q.id IN (SELECT aa.article_id FROM article_authors aa WHERE aa.author_id = ?)", *filters.AuthorID)
}
if restricted {
if len(allowed) == 0 {
condition.add("1 = 0")
} else {
condition.add("q.id IN ?", allowed)
}
}
if filters.Missing {
condition.merge(lacksReadableVersionIn("?", filters, ""))
condition.merge(missingCondition(filters))
@@ -129,8 +156,12 @@ func unitExpression(filters domain.StoryFilters) string {
return "COUNT(DISTINCT q.story_id)"
}
func (a *ArticleRepository) countUnits(filters domain.StoryFilters, allowed []uint32, restricted bool) (int, error) {
qualifying := qualifyingVersions(filters, allowed, restricted)
func (a *ArticleRepository) countUnits(filters domain.StoryFilters, restriction versionSet) (int, error) {
if restriction != nil {
rows, err := a.qualifyingRefs(filters, restriction)
return unitsIn(filters, rows), err
}
qualifying := qualifyingVersions(filters)
var total int64
err := a.db.Raw("SELECT "+unitExpression(filters)+" FROM articles q WHERE "+qualifying.where(), qualifying.args...).Scan(&total).Error
return int(total), err
@@ -175,11 +206,8 @@ func toStoryRefs(rows []storyRefRow) []domain.StoryRef {
func (a *ArticleRepository) FindStoryPage(filters domain.StoryFilters) (domain.StoryPage, error) {
filters.Page = max(filters.Page, 1)
filters.Size = max(filters.Size, 0)
allowed, restricted, err := a.versionRestriction(filters)
if err != nil {
return domain.StoryPage{}, err
}
total, err := a.countUnits(filters, allowed, restricted)
restriction := restrictionOf(filters)
total, err := a.countUnits(filters, restriction)
if err != nil {
return domain.StoryPage{}, err
}
@@ -187,16 +215,13 @@ func (a *ArticleRepository) FindStoryPage(filters domain.StoryFilters) (domain.S
if total == 0 {
return page, nil
}
qualifying := qualifyingVersions(filters, allowed, restricted)
offset := (filters.Page - 1) * filters.Size
var rows []storyRefRow
if filters.PerLanguage() {
query := "SELECT q.story_id AS story_id, q.id AS article_id FROM articles q WHERE " + qualifying.where() +
" ORDER BY " + storyOrderClause(versionRowOrder, filters.Sort, "q.story_id") + " LIMIT ? OFFSET ?"
err = a.db.Raw(query, append(qualifying.args, filters.Size, offset)...).Scan(&rows).Error
if restriction != nil {
rows, err = a.restrictedStoryRows(filters, restriction)
rows = pageOf(rows, offset, filters.Size)
} else {
query, args := storyRowsQuery(filters, qualifying)
err = a.db.Raw(query, append(args, filters.Size, offset)...).Scan(&rows).Error
rows, err = a.storyRows(filters, " LIMIT ? OFFSET ?", filters.Size, offset)
}
if err != nil {
return domain.StoryPage{}, err
@@ -205,6 +230,44 @@ func (a *ArticleRepository) FindStoryPage(filters domain.StoryFilters) (domain.S
return page, nil
}
func (a *ArticleRepository) storyRows(filters domain.StoryFilters, suffix string, extra ...any) ([]storyRefRow, error) {
qualifying := qualifyingVersions(filters)
var query string
var args []any
if filters.PerLanguage() {
query = "SELECT q.story_id AS story_id, q.id AS article_id FROM articles q WHERE " + qualifying.where() +
" ORDER BY " + storyOrderClause(versionRowOrder, filters.Sort, "q.story_id")
args = qualifying.args
} else {
query, args = storyRowsQuery(filters, qualifying)
}
var rows []storyRefRow
err := a.db.Raw(query+suffix, append(args, extra...)...).Scan(&rows).Error
return rows, err
}
func (a *ArticleRepository) restrictedStoryRows(filters domain.StoryFilters, restriction versionSet) ([]storyRefRow, error) {
ordered, err := a.storyRows(filters, "")
if err != nil {
return nil, err
}
if filters.PerLanguage() {
return restriction.keep(ordered), nil
}
qualifying, err := a.qualifyingRefs(filters, restriction)
if err != nil {
return nil, err
}
stories := storiesOf(qualifying)
kept := make([]storyRefRow, 0, len(stories))
for _, row := range ordered {
if stories[row.StoryID] {
kept = append(kept, row)
}
}
return kept, nil
}
func storyRowsQuery(filters domain.StoryFilters, qualifying storySQL) (string, []any) {
readable := readableCondition("a", filters)
args := []any{true}
@@ -218,17 +281,15 @@ func storyRowsQuery(filters domain.StoryFilters, qualifying storySQL) (string, [
"ROW_NUMBER() OVER (PARTITION BY a.story_id ORDER BY CASE WHEN d.lang IS NULL THEN 1 ELSE 0 END, a.lang, a.id) AS row_rank " +
"FROM articles a LEFT JOIN site_languages d ON d.site_id = a.site_id AND d.lang = a.lang AND d.is_default = ? " +
"WHERE " + readable.where() + " AND a.story_id IN (SELECT q.story_id FROM articles q WHERE " + qualifying.where() + ")" +
") r WHERE r.row_rank = 1) s ORDER BY " + storyOrderClause(storyRowOrder, filters.Sort, "s.story_id") + " LIMIT ? OFFSET ?"
") r WHERE r.row_rank = 1) s ORDER BY " + storyOrderClause(storyRowOrder, filters.Sort, "s.story_id")
return query, args
}
func (a *ArticleRepository) CountStories(filters domain.StoryFilters) (domain.StoryCounts, error) {
filters.Status = ""
allowed, restricted, err := a.versionRestriction(filters)
if err != nil {
return domain.StoryCounts{}, err
}
restriction := restrictionOf(filters)
counts := domain.StoryCounts{}
var err error
for _, target := range []struct {
status string
into *int
@@ -236,7 +297,7 @@ func (a *ArticleRepository) CountStories(filters domain.StoryFilters) (domain.St
scoped := filters
scoped.Missing = false
scoped.Status = target.status
if *target.into, err = a.countUnits(scoped, allowed, restricted); err != nil {
if *target.into, err = a.countUnits(scoped, restriction); err != nil {
return domain.StoryCounts{}, err
}
}
@@ -245,15 +306,22 @@ func (a *ArticleRepository) CountStories(filters domain.StoryFilters) (domain.St
}
missing := filters
missing.Missing = true
counts.Missing, err = a.countUnits(missing, allowed, restricted)
counts.Missing, err = a.countUnits(missing, restriction)
return counts, err
}
func (a *ArticleRepository) ListByStories(storyIDs []uint32) ([]domain.Article, error) {
func (a *ArticleRepository) ListByStories(storyIDs []uint32, filters domain.StoryFilters) ([]domain.Article, error) {
var found []entity.Article
for _, chunk := range chunkIDs(storyIDs) {
var loaded []entity.Article
if err := a.db.Model(&entity.Article{}).Where("story_id IN ?", chunk).Order("id ASC").Find(&loaded).Error; err != nil {
query := a.db.Model(&entity.Article{}).Omit("body").Where("story_id IN ?", chunk)
if filters.OnlyOnline {
query = query.Where("is_online = ?", true)
}
if filters.VisibleTo != nil {
query = query.Where("(is_online = ? OR created_by = ?)", true, *filters.VisibleTo)
}
if err := query.Order("id ASC").Find(&loaded).Error; err != nil {
return nil, err
}
found = append(found, loaded...)
@@ -28,61 +28,15 @@ func (s *SiteLanguageRepository) ListBySite(siteID uint32) ([]domain.SiteLanguag
return languages, nil
}
func (s *SiteLanguageRepository) LockStartingLanguage(siteID uint32) (string, error) {
func (s *SiteLanguageRepository) LockSiteLanguages(siteID uint32) ([]domain.SiteLanguage, error) {
if _, err := lockSite(s.db, siteID); err != nil {
return "", err
return nil, err
}
defaultLanguage, found, err := findDefaultLanguage(s.db, siteID)
if err != nil || !found || !defaultLanguage.Confirmed {
return domain.Unassigned, err
}
return defaultLanguage.Lang, nil
return s.ListBySite(siteID)
}
func (s *SiteLanguageRepository) ConfirmDefault(siteID uint32) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if _, err := lockSite(tx, siteID); err != nil {
return err
}
defaultLanguage, err := requireDefaultLanguage(tx, siteID)
if err != nil {
return err
}
if defaultLanguage.Confirmed {
return nil
}
if err := markConfirmed(tx, defaultLanguage); err != nil {
return err
}
return assignUnassignedArticles(tx, defaultLanguage)
})
}
func findDefaultLanguage(db *gorm.DB, siteID uint32) (entity.SiteLanguage, bool, error) {
var defaults []entity.SiteLanguage
if err := db.Where("site_id = ? AND is_default = ?", siteID, true).Limit(1).Find(&defaults).Error; err != nil || len(defaults) == 0 {
return entity.SiteLanguage{}, false, err
}
return defaults[0], true, nil
}
func requireDefaultLanguage(db *gorm.DB, siteID uint32) (entity.SiteLanguage, error) {
defaultLanguage, found, err := findDefaultLanguage(db, siteID)
if err != nil {
return entity.SiteLanguage{}, err
}
if !found {
return entity.SiteLanguage{}, domain.ErrNoDefaultLanguage
}
return defaultLanguage, nil
}
func markConfirmed(tx *gorm.DB, defaultLanguage entity.SiteLanguage) error {
return tx.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", defaultLanguage.SiteID, defaultLanguage.Lang).UpdateColumn("confirmed", true).Error
}
func assignUnassignedArticles(tx *gorm.DB, defaultLanguage entity.SiteLanguage) error {
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", defaultLanguage.SiteID, domain.Unassigned).UpdateColumn("lang", defaultLanguage.Lang).Error
func (s *SiteLanguageRepository) MarkConfirmed(siteID uint32, tag string) error {
return s.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", siteID, tag).UpdateColumn("confirmed", true).Error
}
var _ domain.Repository = &SiteLanguageRepository{}
@@ -0,0 +1,36 @@
package useCases
import (
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/transaction"
"context"
)
type ConfirmDefaultLanguageUseCase struct {
transactor transaction.Transactor
}
func NewConfirmDefaultLanguageUseCase(transactor transaction.Transactor) *ConfirmDefaultLanguageUseCase {
return &ConfirmDefaultLanguageUseCase{transactor}
}
func (u *ConfirmDefaultLanguageUseCase) Confirm(ctx context.Context, siteID uint32) error {
return u.transactor.Run(ctx, func(tx transaction.Repositories) error {
languages, err := tx.Languages.LockSiteLanguages(siteID)
if err != nil {
return err
}
defaultLanguage, found := language.DefaultOf(languages)
if !found {
return language.ErrNoDefaultLanguage
}
if defaultLanguage.Confirmed {
return nil
}
if err := tx.Languages.MarkConfirmed(siteID, defaultLanguage.Tag); err != nil {
return err
}
_, err = tx.Articles.AssignUnassignedLanguage(siteID, defaultLanguage.Tag)
return err
})
}
@@ -124,10 +124,14 @@ func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, requested
func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, requested string, draft article.Article) (article.Article, error) {
var created article.Article
err := g.transactor.Run(ctx, func(tx transaction.Repositories) error {
lang, err := tx.Languages.LockStartingLanguage(draft.SiteID)
languages, err := tx.Languages.LockSiteLanguages(draft.SiteID)
if errors.Is(err, site.ErrSiteNotFound) {
return article.ErrTargetSiteDeleted
}
if err != nil {
return err
}
lang := language.StartingTag(languages)
if requested != language.Unassigned {
lang, err = chooseLanguage(tx.Localization, draft.SiteID, requested)
if err != nil {
@@ -8,6 +8,7 @@ import (
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"slices"
"strings"
"time"
)
@@ -64,12 +65,8 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
if err != nil {
return StoryRowsPage{}, err
}
if command.SEO != "" {
allowed, err := u.versionsWithSEOStatus(command, filters, summaries)
if err != nil {
return StoryRowsPage{}, err
}
filters.VersionIDs = &allowed
if filters.VersionIDs, err = u.versionRestriction(command, filters, summaries); err != nil {
return StoryRowsPage{}, err
}
counts, err := u.articleRepository.CountStories(filters)
if err != nil {
@@ -88,7 +85,7 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
if len(found.Refs) == 0 {
return empty, nil
}
rows, err := u.storyRows(command, found.Refs, creatable, owners)
rows, err := u.storyRows(command, filters, found.Refs, creatable, storyVersionsByID(visibleVersions), summaries)
if err != nil {
return StoryRowsPage{}, err
}
@@ -105,7 +102,6 @@ func storyFilters(command ListStoriesCommand, visibility articleVisibility, skip
Missing: command.Tab == article.TabMissing,
Status: command.Status,
AuthorID: command.Author,
Q: command.Q,
Sort: command.Sort,
Page: command.Page,
Size: command.Size,
@@ -121,23 +117,39 @@ func (u *ListArticlesUseCase) summarizeVisibleVersions(visibility articleVisibil
return summaries, found.Items, err
}
func (u *ListArticlesUseCase) versionsWithSEOStatus(command ListStoriesCommand, filters article.StoryFilters, summaries map[uint32]article.SEOSummary) ([]uint32, error) {
narrowed := article.Filters{VisibleTo: filters.VisibleTo, SiteID: filters.SiteID, AuthorID: filters.AuthorID, Q: filters.Q, Unpaged: true}
if filters.OnlyOnline {
online := true
narrowed.IsOnline = &online
func (u *ListArticlesUseCase) versionRestriction(command ListStoriesCommand, filters article.StoryFilters, summaries map[uint32]article.SEOSummary) (*[]uint32, error) {
var allowed *[]uint32
if query := strings.TrimSpace(command.Q); query != "" {
matching, err := u.articleRepository.FindTitleMatches(query, filters)
if err != nil {
return nil, err
}
allowed = &matching
}
found, err := u.articleRepository.FindPage(narrowed)
if err != nil {
return nil, err
if command.SEO == "" {
return allowed, nil
}
allowed := []uint32{}
for _, version := range found.Items {
if summaries[version.ID].Status == command.SEO {
allowed = append(allowed, version.ID)
matchingTitle := map[uint32]bool{}
if allowed != nil {
for _, id := range *allowed {
matchingTitle[id] = true
}
}
return allowed, nil
withStatus := []uint32{}
for id, summary := range summaries {
if summary.Status == command.SEO && (allowed == nil || matchingTitle[id]) {
withStatus = append(withStatus, id)
}
}
return &withStatus, nil
}
func storyVersionsByID(versions []article.Article) map[uint32]article.Article {
byID := make(map[uint32]article.Article, len(versions))
for _, version := range versions {
byID[version.ID] = version
}
return byID
}
func countSuggestedStories(versions []article.Article, summaries map[uint32]article.SEOSummary) int {
@@ -156,12 +168,12 @@ func countSuggestedStories(versions []article.Article, summaries map[uint32]arti
return len(stories) + standalone
}
func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, refs []article.StoryRef, creatable bool, owners *siteOwners) ([]StoryRow, error) {
func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters article.StoryFilters, refs []article.StoryRef, creatable bool, visible map[uint32]article.Article, summaries map[uint32]article.SEOSummary) ([]StoryRow, error) {
storyIDs := make([]uint32, 0, len(refs))
for _, ref := range refs {
storyIDs = append(storyIDs, ref.StoryID)
}
loaded, err := u.articleRepository.ListByStories(storyIDs)
loaded, err := u.articleRepository.ListByStories(storyIDs, filters)
if err != nil {
return nil, err
}
@@ -174,8 +186,9 @@ func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, refs []artic
representatives := make([]article.Article, 0, len(refs))
for _, ref := range refs {
versions := byStory[ref.StoryID]
representative, found := versionByID(versions, ref.ArticleID)
if !found {
_, listed := versionByID(versions, ref.ArticleID)
representative, found := visible[ref.ArticleID]
if !listed || !found {
continue
}
languages, cached := siteRows[representative.SiteID]
@@ -188,10 +201,6 @@ func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, refs []artic
rows = append(rows, buildStoryRow(command.Caller, representative, versions, languages, creatable))
representatives = append(representatives, representative)
}
summaries, err := u.summarize(representatives, owners)
if err != nil {
return nil, err
}
rendered, err := renderArticles(u.bodyRenderer, representatives, command.Format)
if err != nil {
return nil, err
@@ -37,6 +37,7 @@ var failureReasons = []failureReason{
{article.ErrSourceOtherStory, "The chosen source version belongs to another article."},
{article.ErrStoryMoveRequired, "The article has other versions, so the whole story must be moved."},
{article.ErrSlugConflict, "An address of the article is already used on the destination site."},
{article.ErrTargetSiteDeleted, "The site was deleted while the article was being created."},
{image.ErrImageTypeNotAllowed, "The file must be a PNG, JPEG, WEBP or SVG image."},
{image.ErrImageTooLarge, "The file is too large to upload."},
{image.ErrImageUnreadable, "The image could not be read."},