feat: entry generation job and cleanup on delete

This commit is contained in:
2026-10-27 10:48:02 +01:00
parent e8a6b9fc77
commit ab8905c429
10 changed files with 529 additions and 4 deletions

250
classes/generate.go Normal file
View File

@@ -0,0 +1,250 @@
package classes
import (
"context"
"fmt"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// GenerateKind is the River kind of the entry generation job.
const GenerateKind = "golem15.seo.generate_entries"
// generatePage is the page size of the generation job.
const generatePage = 200
// GenerateEntriesArgs are the arguments of the entry generation job (D-94,
// the mpseo GenerateSeoEntriesJob): Type limits it to one content type
// key; empty runs every content type.
type GenerateEntriesArgs struct {
Type string `json:"type"`
}
// Kind is GenerateKind.
func (GenerateEntriesArgs) Kind() string { return GenerateKind }
// GenerateResult counts what one generation run did.
type GenerateResult struct {
Created int
Updated int
}
// GenerateEntries walks the published records of every content type (or
// of typeKey) through their descriptors and makes sure each locale with an
// address has an entry: a missing one is created with the URL, an existing
// one gets its URL, type and effective title and description cache
// refreshed. Editor values are never written. Each page of records is one
// transaction.
func (s *Service) GenerateEntries(ctx context.Context, typeKey string) (GenerateResult, error) {
var res GenerateResult
reg := s.Registry()
if reg == nil {
return res, nil
}
db, err := s.DB(ctx)
if err != nil {
return res, err
}
var types []pact.MenuItemType
for _, typ := range reg.ContentTypes() {
if typeKey == "" || typ.Key == typeKey {
types = append(types, typ)
}
}
if typeKey != "" && len(types) == 0 {
return res, fmt.Errorf("golem15.seo: content type %q is not registered", typeKey)
}
settings, err := LoadSettings(db)
if err != nil {
return res, err
}
for _, typ := range types {
ts, err := LoadTypeSetting(db, typ.Key)
if err != nil {
return res, err
}
after := ""
for {
page, err := reg.Records(ctx, typ.Key, after, generatePage, s.UnprefixedLocale())
if err != nil {
return res, err
}
if err := db.Transaction(func(tx *gorm.DB) error {
for _, rec := range page.Records {
if !rec.Published {
continue
}
for _, l := range rec.Locales {
if strings.TrimSpace(l.URL) == "" {
continue
}
created, err := s.refreshEntry(ctx, tx, typ, rec.ID, l, settings, ts)
if err != nil {
return err
}
if created {
res.Created++
} else {
res.Updated++
}
}
}
return nil
}); err != nil {
return res, err
}
if page.Next == "" || page.Next == after || len(page.Records) == 0 {
break
}
after = page.Next
}
}
return res, nil
}
// refreshEntry creates or refreshes the entry of one record in one
// locale; created is true for a new row.
func (s *Service) refreshEntry(ctx context.Context, tx *gorm.DB, typ pact.MenuItemType, id string, l pact.ContentLocale, settings models.Settings, ts models.TypeSetting) (bool, error) {
entry, found, err := LoadEntry(tx, typ.Content.Model, id, l.Locale)
if err != nil {
return false, err
}
site := s.translatedSetting(ctx, tx, &settings, "site_name", settings.SiteName, l.Locale)
fallback := s.translatedSetting(ctx, tx, &settings, "default_description", settings.DefaultDescription, l.Locale)
title, description := EffectiveValues(entry, l, settings, ts, site, fallback)
if !found {
row := models.Entry{
ModelType: typ.Content.Model, RecordID: id, Locale: l.Locale, TypeKey: typ.Key, URL: l.URL,
EffectiveTitle: title, EffectiveDescription: description,
}
return true, tx.Create(&row).Error
}
return false, tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(map[string]any{
"url": l.URL,
"type_key": typ.Key,
"effective_title": title,
"effective_description": description,
}).Error
}
// EffectiveValues are a record's effective title and description without
// URL rules (the cache duplicate checks read): the record's values, else
// the type or general template on the content, else the content's own,
// else the settings.
func EffectiveValues(entry models.Entry, l pact.ContentLocale, settings models.Settings, ts models.TypeSetting, site, fallback string) (string, string) {
vars := TemplateVars{Title: l.Title, Description: l.Description, Site: site}
tpl := ts.TitleTemplate
if strings.TrimSpace(tpl) == "" {
tpl = settings.TitleTemplate
}
title, _ := pick(candidate{entry.Title, SourceRecord}, candidate{RenderTemplate(tpl, vars), SourceTemplate}, candidate{l.Title, SourceContent}, candidate{site, SourceGlobal})
description, _ := pick(candidate{entry.Description, SourceRecord}, candidate{RenderTemplate(ts.DescriptionTemplate, vars), SourceTemplate}, candidate{l.Description, SourceContent}, candidate{fallback, SourceGlobal})
return title, description
}
// EnqueueGenerate queues the generation job through conga for typeKey
// (empty: every type) and returns the summer_jobs id.
func (s *Service) EnqueueGenerate(ctx context.Context, db *gorm.DB, typeKey string) (uint, error) {
if s == nil || s.app == nil {
return 0, ErrNotBooted
}
m, err := conga.From(s.app)
if err != nil {
return 0, err
}
return m.Dispatch(ctx, db, GenerateEntriesArgs{Type: typeKey}, conga.DispatchOpts{Label: s.Phrase("golem15.seo::lang.jobs.generate_entries", nil)})
}
// RecordDeleted removes the entries of a deleted record in one
// transaction and records each in history (via auto), so its values stay
// recoverable (D-92, D-94). A model without a content type is ignored.
func (s *Service) RecordDeleted(ctx context.Context, ev *pact.RecordDeleted) error {
reg := s.Registry()
if reg == nil || ev == nil {
return nil
}
typ, ok := reg.ContentTypeForModel(ev.Model)
if !ok || typ.Content == nil {
return nil
}
db, err := s.DB(ctx)
if err != nil {
return err
}
ctx = WithVia(ctx, models.ViaAuto)
return db.Transaction(func(tx *gorm.DB) error {
entries, err := LoadEntries(tx, typ.Content.Model, ev.ID)
if err != nil || len(entries) == 0 {
return err
}
if err := tx.Where("model_type = ? AND record_id = ?", typ.Content.Model, ev.ID).Delete(&models.Entry{}).Error; err != nil {
return err
}
for locale, e := range entries {
if entryEmpty(e) {
continue
}
title := e.EffectiveTitle
if title == "" {
title = e.URL
}
if err := s.Record(ctx, tx, Change{
Kind: models.SubjectEntry,
SubjectID: EntrySubject(e.ModelType, e.RecordID, locale),
What: s.recordWhat(typ, ev.ID, pact.ContentLocale{Title: title}, locale),
Before: entryValues(e),
}); err != nil {
return err
}
}
return nil
})
}
// RecordCreated stores the address snapshot of a newly created record:
// each locale with an address gets an entry with its URL (existing entries,
// written by the SEO tab in the same save, only take the URL).
func (s *Service) RecordCreated(ctx context.Context, ev *pact.RecordSaved) error {
reg := s.Registry()
if reg == nil || ev == nil || !ev.Created {
return nil
}
typ, ok := reg.ContentTypeForModel(ev.Model)
if !ok || typ.Content == nil {
return nil
}
rec, err := reg.Describe(ctx, typ.Key, ev.ID, s.UnprefixedLocale())
if err != nil || rec == nil {
return err
}
db, err := s.DB(ctx)
if err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
for _, l := range rec.Locales {
if strings.TrimSpace(l.URL) == "" {
continue
}
entry, found, err := LoadEntry(tx, typ.Content.Model, ev.ID, l.Locale)
if err != nil {
return err
}
if found {
if err := tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(map[string]any{"url": l.URL, "type_key": typ.Key}).Error; err != nil {
return err
}
continue
}
row := models.Entry{ModelType: typ.Content.Model, RecordID: ev.ID, Locale: l.Locale, TypeKey: typ.Key, URL: l.URL}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return nil
})
}

86
commands.go Normal file
View File

@@ -0,0 +1,86 @@
package seo
import (
"context"
"fmt"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// Commands registers seo:generate-entries.
func (p *Plugin) Commands() []bonfire.Command {
return []bonfire.Command{{
Name: "seo:generate-entries",
Description: "Queue the job that creates missing SEO entries",
Long: `Queues the entry generation job on the background queue. The job walks the
published records of every content type registered in signpost (or of the
one named by --type), creates the missing SEO entry of each locale with its
current address and refreshes the address and the effective title and
description of existing entries. Values set in the SEO tab are never
changed. A worker (summer serve or queue:work) runs the job.`,
Flags: []bonfire.Flag{
{Name: "type", Description: "Content type key (signpost menu item type) to limit the run to"},
},
Examples: []bonfire.Example{
{Command: "seo:generate-entries", Description: "Queue generation for every content type"},
{Command: "seo:generate-entries --type journal-post", Description: "Queue generation for one content type"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
typeKey, _ := in.Flag("type")
typeKey = strings.TrimSpace(typeKey)
svc := p.Service()
if svc == nil {
return classes.ErrNotBooted
}
if typeKey != "" {
if reg := svc.Registry(); reg != nil {
if _, ok := reg.ContentType(typeKey); !ok {
return fmt.Errorf("Content type %q is not registered", typeKey)
}
}
}
db, done, err := p.commandDB(ctx)
if err != nil {
return err
}
defer done()
id, err := svc.EnqueueGenerate(ctx, db, typeKey)
if err != nil {
return err
}
out.Success(fmt.Sprintf("Queued SEO entry generation (job #%d).", id))
return nil
},
}}
}
// commandDB returns the app database, opening database.dsn when the binary
// published none for console commands. done closes a pool it opened.
func (p *Plugin) commandDB(ctx context.Context) (*gorm.DB, func(), error) {
app := p.app
if app == nil {
return nil, nil, classes.ErrNotBooted
}
if db, ok := app.Lookup[*gorm.DB](); ok && db != nil {
return db, func() {}, nil
}
if lagoon.DSN(app.Config) == "" {
return nil, nil, fmt.Errorf("golem15.seo: database is not configured (set SUMMER_DATABASE__DSN)")
}
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
if err != nil {
return nil, nil, err
}
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
_ = sqlDB.Close()
return nil, nil, err
}
return gdb, func() { _ = sqlDB.Close() }, nil
}
var _ pact.HasCommands = (*Plugin)(nil)

View File

@@ -4,13 +4,19 @@ import (
"context"
"errors"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/diary"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// log is the controllers' logger (component=seo).
var log = diary.For("seo")
// settingsAdminController edits the SEO settings singleton (D-104), linked
// from the SEO settings tile. Its per-content-type tabs are added by the
// plugin's own form extension on this controller.
@@ -65,6 +71,31 @@ func (c settingsAdminController) FormBeforeCreate(ctx context.Context, model any
return c.FormBeforeUpdate(ctx, model)
}
// FormAfterUpdate queues the entry generation job once the save commits,
// so the effective title and description cache follows new templates. A
// queue error is logged and never fails the save.
func (c settingsAdminController) FormAfterUpdate(ctx context.Context, _ any) error {
app := appOf(c.app)
svc, err := classes.NewService(app)
if err != nil {
return nil
}
tx, err := database(ctx, c.app)
if err != nil {
return nil
}
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
return
}
if _, err := svc.EnqueueGenerate(ctx, db.WithContext(ctx), ""); err != nil {
log.Warn("golem15.seo: queue entry generation", "error", err)
}
})
return nil
}
// stagingView is the view model of the staging partial.
type stagingView struct {
Staging bool
@@ -96,4 +127,5 @@ var (
_ pact.AdminPartialData = settingsAdminController{}
_ pact.FormBeforeCreate = settingsAdminController{}
_ pact.FormBeforeUpdate = settingsAdminController{}
_ pact.FormAfterUpdate = settingsAdminController{}
)

32
jobs.go Normal file
View File

@@ -0,0 +1,32 @@
package seo
import (
"context"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Jobs registers the entry generation job (D-94) on conga.
func (p *Plugin) Jobs() []pact.Job {
return []pact.Job{
conga.Job(p.generateEntries, conga.MaxAttempts(3)),
}
}
// generateEntries runs one generation job.
func (p *Plugin) generateEntries(ctx context.Context, args classes.GenerateEntriesArgs) error {
svc := p.Service()
if svc == nil {
return classes.ErrNotBooted
}
res, err := svc.GenerateEntries(ctx, args.Type)
if err != nil {
return err
}
pluginLog.Info("golem15.seo: entries generated", "type", args.Type, "created", res.Created, "updated", res.Updated)
return nil
}
var _ pact.HasJobs = (*Plugin)(nil)

View File

@@ -147,3 +147,5 @@ history:
diff_before: Before
diff_after: After
list_empty: No SEO changes recorded yet. Every change from the admin, MCP or an import appears here and can be undone.
jobs:
generate_entries: 'SEO: generate entries'

View File

@@ -151,3 +151,5 @@ history:
diff_before: Przed
diff_after: Po
list_empty: Nie zarejestrowano jeszcze zmian SEO. Każda zmiana z panelu, MCP lub importu pojawi się tutaj i będzie można ją cofnąć.
jobs:
generate_entries: 'SEO: generowanie wpisów'

32
listeners.go Normal file
View File

@@ -0,0 +1,32 @@
package seo
import (
"context"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// listen subscribes to the framework's record events (D-92): a deleted
// record of a content type loses its SEO entries (kept in history), and a
// created one gets its address snapshot. Listener errors are logged by the
// bus and never fail the write that fired them.
func (p *Plugin) listen(app *backpack.App) {
if app == nil || app.Events == nil {
return
}
app.Events.Listen("golem15.seo", func(ctx context.Context, ev *pact.RecordDeleted) error {
svc := p.Service()
if svc == nil {
return nil
}
return svc.RecordDeleted(ctx, ev)
})
app.Events.Listen("golem15.seo", func(ctx context.Context, ev *pact.RecordSaved) error {
svc := p.Service()
if svc == nil || ev == nil || !ev.Created {
return nil
}
return svc.RecordCreated(ctx, ev)
})
}

View File

@@ -56,7 +56,7 @@ func (p *Plugin) Register(app *backpack.App) error {
}
// Boot builds the service on the application's database, signpost registry
// and config.
// and config, and subscribes to the record events.
func (p *Plugin) Boot(app *backpack.App) error {
p.app = app
svc, err := classes.NewService(app)
@@ -64,6 +64,7 @@ func (p *Plugin) Boot(app *backpack.App) error {
return fmt.Errorf("golem15.seo: %w", err)
}
p.service = svc
p.listen(app)
return nil
}

View File

@@ -110,8 +110,8 @@ func TestSEOTabPermission(t *testing.T) {
}
id := env.createPage(t, editor, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Sneaky"}})
var count int64
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries`).Scan(&count).Error; err != nil || count != 0 {
t.Fatalf("entries after an editor create = %d err=%v, want 0", count, err)
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE title <> ''`).Scan(&count).Error; err != nil || count != 0 {
t.Fatalf("entries with a title after an editor create = %d err=%v, want 0", count, err)
}
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), super, map[string]any{
"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Custom"},
@@ -221,3 +221,89 @@ func TestHistoryRecordsAdminWrite(t *testing.T) {
t.Fatalf("ViaOf(WithVia import) = %q", got)
}
}
// TestEntryDeletedWithRecord checks D-92/D-94: a created record gets its
// address snapshot per locale, and deleting it removes its entries after
// commit while history keeps the values (via auto).
func TestEntryDeletedWithRecord(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Kept in history"}})
var urls []string
if err := env.db.Raw(`SELECT url FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? ORDER BY locale`, pagesModel, id).Scan(&urls).Error; err != nil {
t.Fatal(err)
}
if strings.Join(urls, " ") != "/pages/about /pl/pages/about" {
t.Fatalf("entry URLs after create = %v, want both locales", urls)
}
env.expect(t, http.StatusOK, http.MethodDelete, adminAPI("/acme/pages/pages/"+id), token, nil)
var count int64
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE record_id = ?`, id).Scan(&count).Error; err != nil || count != 0 {
t.Fatalf("entries after delete = %d err=%v, want 0", count, err)
}
var deleted []revisionRow
for _, r := range env.revisions(t, "entry") {
if r.Via == "auto" {
deleted = append(deleted, r)
}
}
if len(deleted) != 1 || deleted[0].After != "{}" || !jsonContains(deleted[0].Before, `"title": "Kept in history"`) || deleted[0].SubjectID != pagesModel+"|"+id+"|en" {
t.Fatalf("deletion revisions = %+v, want one with the title before", deleted)
}
if got := env.resolve(t, "/pages/about"); got["action"] != "not_found" {
t.Fatalf("resolve after delete = %v", got)
}
}
// TestGenerateEntries checks the D-94 generation job's work: every
// published record gets an entry per locale with its URL, drafts none, and
// a second run refreshes addresses without touching editor values.
func TestGenerateEntries(t *testing.T) {
env := bootEnv(t)
if err := env.db.Exec(`INSERT INTO acme_pages_pages (title, slug, excerpt, published) VALUES ('One', 'one', 'First', TRUE), ('Two', 'two', '', TRUE), ('Draft', 'draft', '', FALSE)`).Error; err != nil {
t.Fatal(err)
}
svc := env.seoPlugin(t).Service()
res, err := svc.GenerateEntries(t.Context(), pagesTypeKey)
if err != nil {
t.Fatal(err)
}
if res.Created != 4 || res.Updated != 0 {
t.Fatalf("first run = %+v, want 4 created (2 records x 2 locales)", res)
}
type row struct {
RecordID string
Locale string
URL string
Title string
EffectiveTitle string
}
var rows []row
if err := env.db.Raw(`SELECT record_id, locale, url, title, effective_title FROM golem15_seo_entries ORDER BY record_id, locale`).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 4 || rows[0].URL != "/pages/one" || rows[1].URL != "/pl/pages/one" || rows[0].EffectiveTitle != "One" || rows[2].URL != "/pages/two" {
t.Fatalf("entries = %+v", rows)
}
if err := env.db.Exec(`UPDATE golem15_seo_entries SET title = 'Editor title' WHERE record_id = ? AND locale = 'en'`, rows[0].RecordID).Error; err != nil {
t.Fatal(err)
}
if err := env.db.Exec(`UPDATE acme_pages_pages SET slug = 'one-renamed' WHERE slug = 'one'`).Error; err != nil {
t.Fatal(err)
}
if res, err = svc.GenerateEntries(t.Context(), ""); err != nil || res.Created != 0 || res.Updated != 4 {
t.Fatalf("second run = %+v err=%v, want 4 updated", res, err)
}
var again row
if err := env.db.Raw(`SELECT record_id, locale, url, title, effective_title FROM golem15_seo_entries WHERE record_id = ? AND locale = 'en'`, rows[0].RecordID).Scan(&again).Error; err != nil {
t.Fatal(err)
}
if again.Title != "Editor title" || again.URL != "/pages/one-renamed" || again.EffectiveTitle != "Editor title" {
t.Fatalf("after the second run = %+v, want the editor title kept and the URL refreshed", again)
}
if _, err := svc.GenerateEntries(t.Context(), "nope"); err == nil {
t.Fatal("unknown type: want an error")
}
}

View File

@@ -115,7 +115,9 @@ func TestResolverPrecedence(t *testing.T) {
env.createPage(t, token, map[string]any{"title": "Bare", "slug": "bare", "published": true})
env.createPage(t, token, map[string]any{"title": "Draft", "slug": "draft", "excerpt": "Secret", "published": false})
if err := env.db.Exec(`INSERT INTO golem15_seo_entries (model_type, record_id, locale, type_key, title, description, canonical, robots, og_image, created_at, updated_at)
VALUES (?, ?, 'en', ?, 'Custom SEO title', 'Custom SEO description', '/canonical-custom', 'noindex', '/media/custom.png', NOW(), NOW())`, pagesModel, custom, pagesTypeKey).Error; err != nil {
VALUES (?, ?, 'en', ?, 'Custom SEO title', 'Custom SEO description', '/canonical-custom', 'noindex', '/media/custom.png', NOW(), NOW())
ON CONFLICT (model_type, record_id, locale) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description,
canonical = EXCLUDED.canonical, robots = EXCLUDED.robots, og_image = EXCLUDED.og_image`, pagesModel, custom, pagesTypeKey).Error; err != nil {
t.Fatal(err)
}
for _, rule := range []map[string]any{