From ab8905c429539be12029c3cf5e9a64f09bebaeae Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 27 Oct 2026 10:48:02 +0100 Subject: [PATCH] feat: entry generation job and cleanup on delete --- classes/generate.go | 250 ++++++++++++++++++++++++++++++++++++++++ commands.go | 86 ++++++++++++++ controllers/settings.go | 32 +++++ jobs.go | 32 +++++ lang/en/lang.yaml | 2 + lang/pl/lang.yaml | 2 + listeners.go | 32 +++++ plugin.go | 3 +- seo_tab_test.go | 90 ++++++++++++++- settings_test.go | 4 +- 10 files changed, 529 insertions(+), 4 deletions(-) create mode 100644 classes/generate.go create mode 100644 commands.go create mode 100644 jobs.go create mode 100644 listeners.go diff --git a/classes/generate.go b/classes/generate.go new file mode 100644 index 0000000..378e095 --- /dev/null +++ b/classes/generate.go @@ -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 + }) +} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..fa9c487 --- /dev/null +++ b/commands.go @@ -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) diff --git a/controllers/settings.go b/controllers/settings.go index 7d568f6..cce14b4 100644 --- a/controllers/settings.go +++ b/controllers/settings.go @@ -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{} ) diff --git a/jobs.go b/jobs.go new file mode 100644 index 0000000..0d07c8c --- /dev/null +++ b/jobs.go @@ -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) diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index a91eccb..7467184 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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' diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 72dbd6f..65b663a 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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' diff --git a/listeners.go b/listeners.go new file mode 100644 index 0000000..0e4e362 --- /dev/null +++ b/listeners.go @@ -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) + }) +} diff --git a/plugin.go b/plugin.go index 1470e20..2ed506a 100644 --- a/plugin.go +++ b/plugin.go @@ -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 } diff --git a/seo_tab_test.go b/seo_tab_test.go index fbaa795..5ca60be 100644 --- a/seo_tab_test.go +++ b/seo_tab_test.go @@ -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") + } +} diff --git a/settings_test.go b/settings_test.go index 01ebc57..412c148 100644 --- a/settings_test.go +++ b/settings_test.go @@ -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{