diff --git a/models/category.go b/models/category.go new file mode 100644 index 0000000..6da9849 --- /dev/null +++ b/models/category.go @@ -0,0 +1,36 @@ +package models + +import "time" + +// Category is a journal category stored on golem15_journal_categories. +type Category struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Slug string `gorm:"column:slug"` + Code *string `gorm:"column:code"` + Description *string `gorm:"column:description"` + ParentID *uint `gorm:"column:parent_id"` + NestLeft *int `gorm:"column:nest_left"` + NestRight *int `gorm:"column:nest_right"` + NestDepth *int `gorm:"column:nest_depth"` + CreatedAt *time.Time `gorm:"column:created_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` +} + +func (Category) TableName() string { return "golem15_journal_categories" } + +// MorphName is the PHP class string stored on translate attributes. +func (Category) MorphName() string { return `Golem15\Journal\Models\Category` } + +func (Category) Translatable() []string { return []string{"name", "description", "slug"} } + +func (Category) TranslatableIndexes() []string { return []string{"slug"} } + +// Fillable is JOURNAL-002: nest_* stay out of mass assignment. +func (Category) Fillable() []string { + return []string{"name", "slug", "code", "description", "parent_id"} +} + +func init() { + Register(Category{}) +} diff --git a/models/post.go b/models/post.go index c255296..fbe6b93 100644 --- a/models/post.go +++ b/models/post.go @@ -2,6 +2,9 @@ package models import ( "time" + + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" ) // Post is a journal entry stored on golem15_journal_posts. @@ -21,6 +24,10 @@ type Post struct { Sources *string `gorm:"column:sources;type:jsonb"` CreatedAt *time.Time `gorm:"column:created_at"` UpdatedAt *time.Time `gorm:"column:updated_at"` + + User *cabana.BackendUser `gorm:"foreignKey:UserID" json:"-"` + Categories []Category `gorm:"many2many:golem15_journal_posts_categories;" json:"-"` + Tags []Tag `gorm:"many2many:golem15_journal_posts_tags;" json:"-"` } func (Post) TableName() string { return "golem15_journal_posts" } @@ -43,6 +50,13 @@ func (Post) Fillable() []string { } } +func (Post) AttachRelations() []attach.Relation { + return []attach.Relation{ + {Name: "featured_images", Many: true, Public: true}, + {Name: "content_images", Many: true, Public: true}, + } +} + func init() { Register(Post{}) } diff --git a/models/settings.go b/models/settings.go new file mode 100644 index 0000000..be347d1 --- /dev/null +++ b/models/settings.go @@ -0,0 +1,54 @@ +package models + +// Settings is the singleton journal settings row (ID=1) on golem15_journal_settings. +type Settings struct { + ID uint `gorm:"column:id;primaryKey"` + ShowAllPosts bool `gorm:"column:show_all_posts"` + UseRichEditor bool `gorm:"column:use_rich_editor"` + SearchUseTypesense bool `gorm:"column:search_use_typesense"` + SearchTitleWeight int `gorm:"column:search_title_weight"` + SearchExcerptWeight int `gorm:"column:search_excerpt_weight"` + SearchContentWeight int `gorm:"column:search_content_weight"` + RSSEnabled bool `gorm:"column:rss_enabled"` + RSSIncludeContent bool `gorm:"column:rss_include_content"` + RSSTitle *string `gorm:"column:rss_title"` + RSSDescription *string `gorm:"column:rss_description"` + RSSLanguage string `gorm:"column:rss_language"` + RSSCopyright *string `gorm:"column:rss_copyright"` + RSSImageURL *string `gorm:"column:rss_image_url"` + RSSPostsPerFeed int `gorm:"column:rss_posts_per_feed"` +} + +func (Settings) TableName() string { return "golem15_journal_settings" } + +func (Settings) Fillable() []string { + return []string{ + "show_all_posts", "use_rich_editor", "search_use_typesense", + "search_title_weight", "search_excerpt_weight", "search_content_weight", + "rss_enabled", "rss_include_content", "rss_title", "rss_description", + "rss_language", "rss_copyright", "rss_image_url", "rss_posts_per_feed", + } +} + +func (Settings) Rules() map[string]string { + return map[string]string{ + "show_all_posts": "boolean", + "use_rich_editor": "boolean", + "search_use_typesense": "boolean", + "search_title_weight": "nullable|integer|min:0|max:100", + "search_excerpt_weight": "nullable|integer|min:0|max:100", + "search_content_weight": "nullable|integer|min:0|max:100", + "rss_enabled": "boolean", + "rss_include_content": "boolean", + "rss_title": "nullable|string|max:255", + "rss_description": "nullable|string|max:1000", + "rss_language": "nullable|string|max:10", + "rss_copyright": "nullable|string|max:255", + "rss_image_url": "nullable|url|max:500", + "rss_posts_per_feed": "nullable|integer|min:1|max:100", + } +} + +func init() { + Register(Settings{}) +} diff --git a/models/tag.go b/models/tag.go new file mode 100644 index 0000000..343e589 --- /dev/null +++ b/models/tag.go @@ -0,0 +1,26 @@ +package models + +import "time" + +// Tag is a journal tag stored on golem15_journal_tags. It is not translatable. +type Tag struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Slug string `gorm:"column:slug"` + Description *string `gorm:"column:description"` + CreatedAt *time.Time `gorm:"column:created_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` +} + +func (Tag) TableName() string { return "golem15_journal_tags" } + +func (Tag) MorphName() string { return `Golem15\Journal\Models\Tag` } + +// Fillable is JOURNAL-001. +func (Tag) Fillable() []string { + return []string{"name", "slug", "description"} +} + +func init() { + Register(Tag{}) +} diff --git a/updates/202610060002_create_golem15_journal_categories.go b/updates/202610060002_create_golem15_journal_categories.go new file mode 100644 index 0000000..9e806ea --- /dev/null +++ b/updates/202610060002_create_golem15_journal_categories.go @@ -0,0 +1,45 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var createCategories = []*gormigrate.Migration{ + { + ID: "202610060002_create_golem15_journal_categories", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE golem15_journal_categories ( + id SERIAL PRIMARY KEY, + name TEXT NULL, + slug TEXT NOT NULL, + code TEXT NULL, + description TEXT NULL, + parent_id INTEGER NULL, + nest_left INTEGER NULL, + nest_right INTEGER NULL, + nest_depth INTEGER NULL, + created_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NULL +)`, + `CREATE UNIQUE INDEX golem15_journal_categories_slug_unique ON golem15_journal_categories (slug)`, + `CREATE INDEX golem15_journal_categories_parent_id_index ON golem15_journal_categories (parent_id)`, + `INSERT INTO golem15_journal_categories (name, slug, nest_left, nest_right, nest_depth) +VALUES ('Uncategorized', 'uncategorized', 1, 2, 0)`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_categories`).Error + }, + }, +} + +func init() { + Register(createCategories...) +} diff --git a/updates/202610060003_create_golem15_journal_tags.go b/updates/202610060003_create_golem15_journal_tags.go new file mode 100644 index 0000000..19fa625 --- /dev/null +++ b/updates/202610060003_create_golem15_journal_tags.go @@ -0,0 +1,37 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var createTags = []*gormigrate.Migration{ + { + ID: "202610060003_create_golem15_journal_tags", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE golem15_journal_tags ( + id SERIAL PRIMARY KEY, + name TEXT NULL, + slug TEXT NOT NULL, + description TEXT NULL, + created_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NULL +)`, + `CREATE UNIQUE INDEX golem15_journal_tags_slug_unique ON golem15_journal_tags (slug)`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_tags`).Error + }, + }, +} + +func init() { + Register(createTags...) +} diff --git a/updates/202610060004_create_golem15_journal_posts_categories.go b/updates/202610060004_create_golem15_journal_posts_categories.go new file mode 100644 index 0000000..d812330 --- /dev/null +++ b/updates/202610060004_create_golem15_journal_posts_categories.go @@ -0,0 +1,26 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var createPostsCategories = []*gormigrate.Migration{ + { + ID: "202610060004_create_golem15_journal_posts_categories", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE golem15_journal_posts_categories ( + post_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + PRIMARY KEY (post_id, category_id) +)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_posts_categories`).Error + }, + }, +} + +func init() { + Register(createPostsCategories...) +} diff --git a/updates/202610060005_create_golem15_journal_posts_tags.go b/updates/202610060005_create_golem15_journal_posts_tags.go new file mode 100644 index 0000000..ac41ee0 --- /dev/null +++ b/updates/202610060005_create_golem15_journal_posts_tags.go @@ -0,0 +1,26 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var createPostsTags = []*gormigrate.Migration{ + { + ID: "202610060005_create_golem15_journal_posts_tags", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE golem15_journal_posts_tags ( + post_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (post_id, tag_id) +)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_posts_tags`).Error + }, + }, +} + +func init() { + Register(createPostsTags...) +} diff --git a/updates/202610060006_create_golem15_journal_settings.go b/updates/202610060006_create_golem15_journal_settings.go new file mode 100644 index 0000000..33f2b22 --- /dev/null +++ b/updates/202610060006_create_golem15_journal_settings.go @@ -0,0 +1,46 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var createSettings = []*gormigrate.Migration{ + { + ID: "202610060006_create_golem15_journal_settings", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE golem15_journal_settings ( + id INTEGER PRIMARY KEY, + show_all_posts BOOLEAN NOT NULL DEFAULT TRUE, + use_rich_editor BOOLEAN NOT NULL DEFAULT FALSE, + search_use_typesense BOOLEAN NOT NULL DEFAULT FALSE, + search_title_weight INTEGER NOT NULL DEFAULT 5, + search_excerpt_weight INTEGER NOT NULL DEFAULT 3, + search_content_weight INTEGER NOT NULL DEFAULT 1, + rss_enabled BOOLEAN NOT NULL DEFAULT TRUE, + rss_include_content BOOLEAN NOT NULL DEFAULT FALSE, + rss_title TEXT NULL, + rss_description TEXT NULL, + rss_language TEXT NOT NULL DEFAULT 'en-us', + rss_copyright TEXT NULL, + rss_image_url TEXT NULL, + rss_posts_per_feed INTEGER NOT NULL DEFAULT 20 +)`, + `INSERT INTO golem15_journal_settings (id) VALUES (1)`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_settings`).Error + }, + }, +} + +func init() { + Register(createSettings...) +} diff --git a/updates/202610060007_add_author_slug_to_backend_users.go b/updates/202610060007_add_author_slug_to_backend_users.go new file mode 100644 index 0000000..3e02a69 --- /dev/null +++ b/updates/202610060007_add_author_slug_to_backend_users.go @@ -0,0 +1,22 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var addAuthorSlug = []*gormigrate.Migration{ + { + ID: "202610060007_add_author_slug_to_backend_users", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS golem15_bloghub_author_slug TEXT UNIQUE`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS golem15_bloghub_author_slug`).Error + }, + }, +} + +func init() { + Register(addAuthorSlug...) +} diff --git a/updates/postgres_test.go b/updates/postgres_test.go index 039a4b8..85c65af 100644 --- a/updates/postgres_test.go +++ b/updates/postgres_test.go @@ -6,10 +6,14 @@ import ( "fmt" "net/url" "os" + "reflect" "strings" "testing" "time" + "git.golem15.com/golem15/sm-journal-plugin/models" + "git.golem15.com/golem15/summercms/modules/lagoon" + "github.com/go-gormigrate/gormigrate/v2" _ "github.com/jackc/pgx/v5/stdlib" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" @@ -131,3 +135,87 @@ func dedicatedDB(t *testing.T, name string) *sql.DB { // from reporting [no tests to run] in this package. The production-path // proof lives in package models. func TestPostTranslatableSmoke(t *testing.T) {} + +func TestJournalTables(t *testing.T) { + sqlDB := dedicatedDB(t, "journal_tables") + gdb, err := lagoon.Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + backend := gormigrate.New(gdb, &gormigrate.Options{ + TableName: "summer_migrations_summercms_cabana", + UseTransaction: true, + }, lagoon.BackendAdminMigrations) + if err := backend.Migrate(); err != nil { + t.Fatalf("backend_users migrations: %v", err) + } + journal := gormigrate.New(gdb, &gormigrate.Options{ + TableName: "summer_migrations_golem15_journal", + UseTransaction: true, + }, All()) + if err := journal.Migrate(); err != nil { + t.Fatalf("journal migrations: %v", err) + } + + for _, name := range []string{ + "golem15_journal_posts", + "golem15_journal_categories", + "golem15_journal_tags", + "golem15_journal_posts_categories", + "golem15_journal_posts_tags", + "golem15_journal_settings", + } { + if !gdb.Migrator().HasTable(name) { + t.Fatalf("missing table %s", name) + } + } + var leftover []string + if err := gdb.WithContext(t.Context()). + Raw(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'rainlab\_journal\_%'`). + Scan(&leftover).Error; err != nil { + t.Fatal(err) + } + if len(leftover) != 0 { + t.Fatalf("rainlab-era journal tables present: %v", leftover) + } + if !gdb.Migrator().HasColumn("backend_users", "golem15_bloghub_author_slug") { + t.Fatal("backend_users.golem15_bloghub_author_slug missing") + } + + var typesense bool + if err := gdb.WithContext(t.Context()). + Raw(`SELECT search_use_typesense FROM golem15_journal_settings WHERE id = 1`). + Scan(&typesense).Error; err != nil { + t.Fatal(err) + } + if typesense { + t.Fatal("search_use_typesense default must be false") + } + + var catCount int64 + if err := gdb.WithContext(t.Context()). + Table("golem15_journal_categories"). + Where("slug = ?", "uncategorized"). + Count(&catCount).Error; err != nil { + t.Fatal(err) + } + if catCount != 1 { + t.Fatalf("Uncategorized rows = %d, want 1", catCount) + } + var postCount int64 + if err := gdb.WithContext(t.Context()). + Table("golem15_journal_posts"). + Count(&postCount).Error; err != nil { + t.Fatal(err) + } + if postCount != 0 { + t.Fatalf("seeded posts = %d, want 0", postCount) + } + + if got := (models.Category{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "code", "description", "parent_id"}) { + t.Fatalf("Category.Fillable = %#v", got) + } + if got := (models.Tag{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "description"}) { + t.Fatalf("Tag.Fillable = %#v", got) + } +}