feat: management API and an MCP tool per SEO admin operation

- /_seo/api/v1 routes for the effective SEO of a URL with sources and
  owner, URL rules, redirects (loops refused with redirect_loop), the
  404 log actions, robots.txt and its tester, settings and history
- the CMS-data issue report: missing and duplicate titles and
  descriptions, rules over records, redirect chains, canonicals to
  noindex or redirected addresses, noindex addresses robots.txt blocks
- 24 MCP tools on those routes (backend guard, golem15.seo.manage)
- AdminOperations maps every admin operation to its tool;
  TestEveryAdminOperationHasTool and TestMCPReadToolsAreGET guard drift
- robots.txt writes go through classes.SaveRobots from the admin too
This commit is contained in:
2026-10-27 12:17:36 +01:00
parent cadf6ad805
commit aa0c3bcacf
22 changed files with 2723 additions and 46 deletions

259
classes/issues.go Normal file
View File

@@ -0,0 +1,259 @@
package classes
import (
"context"
"net/url"
"sort"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
)
// The kinds of the CMS-data issue report (D-13, D-14 CMS part). The
// crawler's checks of the rendered pages are Phase 33.
const (
IssueMissingTitle = "missing_title"
IssueMissingDescription = "missing_description"
IssueDuplicateTitle = "duplicate_title"
IssueDuplicateDesc = "duplicate_description"
IssueRuleOverridesRecord = "rule_overrides_record"
IssueRedirectChain = "redirect_chain"
IssueCanonicalNoindex = "canonical_noindex"
IssueCanonicalRedirected = "canonical_redirected"
IssueNoindexBlocked = "noindex_blocked"
)
// Issue is one finding of the issue report: the address, the kind, the
// details of the finding and the MCP tool that fixes it.
type Issue struct {
Kind string `json:"kind"`
URL string `json:"url"`
Locale string `json:"locale,omitempty"`
Details map[string]any `json:"details,omitempty"`
Tool string `json:"tool"`
}
// The tools the issues point to (pier prefixes them with seo_).
const (
toolSetURLSEO = "seo_set_url_seo"
toolUpdateRule = "seo_update_rule"
toolUpdateRedirect = "seo_update_redirect"
toolUpdateRobots = "seo_update_robots"
)
// Issues builds the CMS-data issue report (D-13, D-14): missing effective
// titles and descriptions per record and locale, effective titles and
// descriptions shared by more than one address (records and URL rules),
// URL rules overriding a record, redirect chains with their final
// targets, canonicals pointing to a noindex or redirected address, and
// noindex addresses robots.txt keeps crawlers from. Records are read
// through their SEO entries (the generation job creates one per
// published record and locale).
func (s *Service) Issues(ctx context.Context) ([]Issue, error) {
db, err := s.DB(ctx)
if err != nil {
return nil, err
}
var out []Issue
var entries []models.Entry
if err := db.Where("url <> ''").Order("url ASC, locale ASC").Find(&entries).Error; err != nil {
return nil, err
}
var rules []models.Rule
if err := db.Order("url ASC").Find(&rules).Error; err != nil {
return nil, err
}
// Missing and duplicate effective values.
type place struct{ url, locale string }
titles := map[string][]place{}
descs := map[string][]place{}
for _, e := range entries {
if strings.TrimSpace(e.EffectiveTitle) == "" {
out = append(out, Issue{Kind: IssueMissingTitle, URL: e.URL, Locale: e.Locale, Tool: toolSetURLSEO})
} else {
k := e.Locale + "|" + strings.ToLower(strings.TrimSpace(e.EffectiveTitle))
titles[k] = append(titles[k], place{e.URL, e.Locale})
}
if strings.TrimSpace(e.EffectiveDescription) == "" {
out = append(out, Issue{Kind: IssueMissingDescription, URL: e.URL, Locale: e.Locale, Tool: toolSetURLSEO})
} else {
k := e.Locale + "|" + strings.ToLower(strings.TrimSpace(e.EffectiveDescription))
descs[k] = append(descs[k], place{e.URL, e.Locale})
}
}
locale := s.DefaultLocale(ctx, db)
for _, r := range rules {
if r.Match != models.MatchExact {
continue
}
if t := strings.TrimSpace(r.Title); t != "" {
k := locale + "|" + strings.ToLower(t)
titles[k] = append(titles[k], place{r.URL, ""})
}
if d := strings.TrimSpace(r.Description); d != "" {
k := locale + "|" + strings.ToLower(d)
descs[k] = append(descs[k], place{r.URL, ""})
}
}
duplicates := func(groups map[string][]place, kind string) {
for key, list := range groups {
if len(list) < 2 {
continue
}
_, text, _ := strings.Cut(key, "|")
for i, p := range list {
others := make([]string, 0, len(list)-1)
for j, o := range list {
if j != i && o.url != p.url {
others = append(others, o.url)
}
}
if len(others) == 0 {
continue
}
out = append(out, Issue{Kind: kind, URL: p.url, Locale: p.locale, Tool: toolSetURLSEO, Details: map[string]any{"value": text, "also": others}})
}
}
}
duplicates(titles, IssueDuplicateTitle)
duplicates(descs, IssueDuplicateDesc)
// URL rules overriding a record.
if reg := s.Registry(); reg != nil {
for _, r := range rules {
if r.Match != models.MatchExact || strings.Contains(r.URL, "?") {
continue
}
var fields []string
for _, p := range models.RuleProperties {
if strings.TrimSpace(r.Value(p.Column)) != "" {
fields = append(fields, p.Column)
}
}
if len(fields) == 0 {
continue
}
key, match, err := reg.MatchPath(ctx, trimSlash(r.URL), s.UnprefixedLocale())
if err != nil {
return nil, err
}
if match == nil || !match.Exact {
continue
}
out = append(out, Issue{Kind: IssueRuleOverridesRecord, URL: r.URL, Locale: match.Locale, Tool: toolUpdateRule, Details: map[string]any{
"rule_id": r.ID, "type": key, "id": match.ID, "fields": fields,
}})
}
}
// Redirect chains.
redirects, err := LoadRedirects(db)
if err != nil {
return nil, err
}
set := NewRedirectSet(redirects)
site := s.SiteURL()
for _, r := range redirects {
if r.Status == models.Status410 {
continue
}
path, query, ok := redirectProbe(r)
if !ok {
continue
}
ch, err := set.Follow(site, path, query, MaxRedirectHops)
if err != nil {
return nil, err
}
if ch.Loop || len(ch.Hops) < 2 || ch.Hops[0].Rule.ID != r.ID {
continue
}
out = append(out, Issue{Kind: IssueRedirectChain, URL: r.Source, Tool: toolUpdateRedirect, Details: map[string]any{
"redirect_id": r.ID, "hops": len(ch.Hops), "final": displayURL(site, ch.Final), "path": displayPath(r, ch),
}})
}
// Canonicals pointing to a noindex or redirected address.
canonical := func(u, loc, value, tool string, details map[string]any) error {
kind, target, err := s.canonicalProblem(ctx, value)
if err != nil || kind == "" {
return err
}
if details == nil {
details = map[string]any{}
}
details["canonical"] = value
if target != "" {
details["target"] = target
}
out = append(out, Issue{Kind: kind, URL: u, Locale: loc, Tool: tool, Details: details})
return nil
}
for _, e := range entries {
if err := canonical(e.URL, e.Locale, e.Canonical, toolSetURLSEO, nil); err != nil {
return nil, err
}
}
for _, r := range rules {
if err := canonical(r.URL, "", r.Canonical, toolUpdateRule, map[string]any{"rule_id": r.ID}); err != nil {
return nil, err
}
}
// Noindex addresses robots.txt blocks.
robots, err := LoadRobots(db)
if err != nil {
return nil, err
}
blocked, err := s.BlockedNoindex(db, robots.Content, robots.Bots)
if err != nil {
return nil, err
}
for _, u := range blocked {
out = append(out, Issue{Kind: IssueNoindexBlocked, URL: u, Tool: toolUpdateRobots})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Kind != out[j].Kind {
return out[i].Kind < out[j].Kind
}
if out[i].URL != out[j].URL {
return out[i].URL < out[j].URL
}
return out[i].Locale < out[j].Locale
})
return out, nil
}
// canonicalProblem checks a canonical on this site (D-06): a redirect
// answers canonical_redirected with the final address, a page served
// noindex (in production) canonical_noindex. Other sites and empty values
// answer no kind.
func (s *Service) canonicalProblem(ctx context.Context, canonical string) (kind, target string, err error) {
canonical = strings.TrimSpace(canonical)
if canonical == "" {
return "", "", nil
}
ref := canonical
if u, err := url.Parse(canonical); err == nil && u.IsAbs() {
if !sameSite(s.SiteURL(), u.Host) {
return "", "", nil
}
ref = u.String()
}
res, err := s.Resolver().Resolve(ctx, ref)
if err != nil {
if err == ErrBadURL {
return "", "", nil
}
return "", "", err
}
if res.Action == ActionRedirect {
return IssueCanonicalRedirected, res.Location, nil
}
if s.Staging() || res.SEO == nil || !strings.Contains(res.SEO.Robots, "noindex") {
return "", "", nil
}
return IssueCanonicalNoindex, "", nil
}

64
classes/manage.go Normal file
View File

@@ -0,0 +1,64 @@
package classes
import (
"context"
"strconv"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// LoadRedirect returns redirect id; ErrNoRecord when there is none.
func LoadRedirect(db *gorm.DB, id uint) (models.Redirect, error) {
var rows []models.Redirect
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
return models.Redirect{}, err
}
if len(rows) == 0 {
return models.Redirect{}, ErrNoRecord
}
return rows[0], nil
}
// LoadNotFound returns 404 log entry id; ErrNoRecord when there is none.
func LoadNotFound(db *gorm.DB, id uint) (models.NotFound, error) {
var rows []models.NotFound
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
return models.NotFound{}, err
}
if len(rows) == 0 {
return models.NotFound{}, ErrNoRecord
}
return rows[0], nil
}
// NotFoundValues are a 404 log entry's values for history.
func NotFoundValues(n models.NotFound) map[string]any {
return map[string]any{"url": n.URL, "hits": n.Hits, "referer": n.Referer}
}
// DeleteNotFound removes a 404 log entry inside tx and records it in
// history; the address is logged again on its next 404.
func (s *Service) DeleteNotFound(ctx context.Context, tx *gorm.DB, n models.NotFound) error {
if err := tx.Delete(&models.NotFound{}, n.ID).Error; err != nil {
return err
}
return s.Record(ctx, tx, Change{
Kind: models.SubjectNotFound,
SubjectID: strconv.FormatUint(uint64(n.ID), 10),
What: s.Phrase("golem15.seo::lang.history.what_notfound", map[string]string{"url": n.URL}),
Before: NotFoundValues(n),
})
}
// RedirectOrigin is the origin of a redirect created through ctx: mcp
// through MCP, import through an import, else manual.
func RedirectOrigin(ctx context.Context) string {
switch ViaOf(ctx) {
case models.ViaMCP:
return models.OriginMCP
case models.ViaImport:
return models.OriginImport
}
return models.OriginManual
}

111
classes/robots_write.go Normal file
View File

@@ -0,0 +1,111 @@
package classes
import (
"context"
"errors"
"strconv"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RobotsRow returns robots row 1 inside db, creating it when the
// migration's row is gone (first or create, safe against a concurrent
// first request).
func RobotsRow(db *gorm.DB) (models.Robots, error) {
var rows []models.Robots
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Robots{}, err
}
if len(rows) > 0 {
return rows[0], nil
}
row := models.Robots{ID: 1, Bots: models.BotSwitches{}}
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return models.Robots{}, err
}
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Robots{}, err
}
if len(rows) == 0 {
return models.Robots{}, errors.New("golem15.seo: robots row is missing")
}
return rows[0], nil
}
// RobotsValues are the robots row's values for history: the editor's text
// and every listed bot's switch.
func RobotsValues(r models.Robots) map[string]any {
bots := map[string]any{}
for _, b := range Bots {
bots[b.Token] = r.Bots.Allowed(b.Token)
}
return map[string]any{"content": r.Content, "bots": bots}
}
// RobotsWrite is one change of robots.txt: the editor's text when Content
// is set, and the switches of the bots listed in Bots (by token).
type RobotsWrite struct {
Content *string
Bots map[string]bool
}
// SaveRobots writes robots.txt inside tx and records it in history
// (D-22, D-25). A text over MaxRobotsBytes and a token that is not a
// listed AI bot are refused with a *ProblemsError. It returns the stored
// row and the noindex addresses Googlebot may no longer fetch under it
// (the robots.blocks_noindex warning).
func (s *Service) SaveRobots(ctx context.Context, tx *gorm.DB, in RobotsWrite) (models.Robots, []string, error) {
row, err := RobotsRow(tx)
if err != nil {
return models.Robots{}, nil, err
}
problems := map[string][]models.Problem{}
if in.Content != nil && len(*in.Content) > MaxRobotsBytes {
problems["content"] = append(problems["content"], models.Problem{Key: "golem15.seo::lang.robots.too_large"})
}
for token := range in.Bots {
if _, ok := BotByToken(token); !ok {
problems["bots."+token] = append(problems["bots."+token], models.Problem{Key: "golem15.seo::lang.api.bot_unknown"})
}
}
if len(problems) > 0 {
return models.Robots{}, nil, &ProblemsError{Problems: problems}
}
before := RobotsValues(row)
if in.Content != nil {
row.Content = *in.Content
}
bots := models.BotSwitches{}
for token, on := range row.Bots {
bots[token] = on
}
for token, on := range in.Bots {
b, _ := BotByToken(token)
bots[b.Token] = on
}
row.Bots = bots
now := time.Now().UTC()
row.UpdatedAt = &now
if err := tx.Model(&models.Robots{}).Where("id = ?", row.ID).Updates(map[string]any{
"content": row.Content, "bots": row.Bots, "updated_at": now,
}).Error; err != nil {
return models.Robots{}, nil, err
}
if err := s.Record(ctx, tx, Change{
Kind: models.SubjectRobots,
SubjectID: strconv.FormatUint(uint64(row.ID), 10),
What: s.Phrase("golem15.seo::lang.history.what_robots", nil),
Before: before,
After: RobotsValues(row),
}); err != nil {
return models.Robots{}, nil, err
}
blocked, err := s.BlockedNoindex(tx, row.Content, row.Bots)
if err != nil {
return models.Robots{}, nil, err
}
return row, blocked, nil
}

308
classes/settings_write.go Normal file
View File

@@ -0,0 +1,308 @@
package classes
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SettingsRow returns settings row 1 inside db, creating it with the
// defaults when the migration's row is gone.
func SettingsRow(db *gorm.DB) (models.Settings, error) {
var rows []models.Settings
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Settings{}, err
}
if len(rows) > 0 {
return rows[0], nil
}
row := models.DefaultSettings()
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return models.Settings{}, err
}
return LoadSettings(db)
}
// SettingsColumns are the settings columns the API reads and writes, in
// form order: the admin form's columns and the CSV delimiter of the
// import and export screen.
var SettingsColumns = []string{
"site_name", "twitter_site", "title_template", "home_template",
"default_description", "default_og_image", "query_params",
"noindex_paths", "search_paths", "url_host", "url_slash",
"url_lowercase", "verify_google", "verify_bing", "verify_yandex",
"verify_other", "verify_files", "csv_delimiter",
}
// SettingsValues are the settings row's values keyed by column, for
// history and the API.
func SettingsValues(st models.Settings) map[string]any {
return map[string]any{
"site_name": st.SiteName,
"twitter_site": st.TwitterSite,
"title_template": st.TitleTemplate,
"home_template": st.HomeTemplate,
"default_description": st.DefaultDescription,
"default_og_image": st.DefaultOGImage,
"query_params": plainValue(st.Params()),
"noindex_paths": st.NoindexPaths,
"search_paths": st.SearchPaths,
"url_host": st.URLHost,
"url_slash": st.URLSlash,
"url_lowercase": st.URLLowercase,
"verify_google": st.VerifyGoogle,
"verify_bing": st.VerifyBing,
"verify_yandex": st.VerifyYandex,
"verify_other": plainValue(st.OtherMeta()),
"verify_files": plainValue(st.Files()),
"csv_delimiter": st.CSVDelimiter,
}
}
// SetSettingsValue writes one column of the settings from a JSON value
// (a string, a bool, or a list of objects); an unknown column or a value
// of the wrong shape is an error.
func SetSettingsValue(st *models.Settings, column string, v any) error {
text := func() (string, error) {
switch s := v.(type) {
case string:
return s, nil
case nil:
return "", nil
}
return "", fmt.Errorf("golem15.seo: settings %s is not text", column)
}
list := func(dst any) error {
raw, err := json.Marshal(v)
if err != nil {
return err
}
if string(raw) == "null" {
raw = []byte("[]")
}
return json.Unmarshal(raw, dst)
}
var err error
switch column {
case "site_name":
st.SiteName, err = text()
case "twitter_site":
st.TwitterSite, err = text()
case "title_template":
st.TitleTemplate, err = text()
case "home_template":
st.HomeTemplate, err = text()
case "default_description":
st.DefaultDescription, err = text()
case "default_og_image":
st.DefaultOGImage, err = text()
case "noindex_paths":
st.NoindexPaths, err = text()
case "search_paths":
st.SearchPaths, err = text()
case "url_host":
st.URLHost, err = text()
case "url_slash":
st.URLSlash, err = text()
case "verify_google":
st.VerifyGoogle, err = text()
case "verify_bing":
st.VerifyBing, err = text()
case "verify_yandex":
st.VerifyYandex, err = text()
case "csv_delimiter":
st.CSVDelimiter, err = text()
case "url_lowercase":
b, ok := v.(bool)
if !ok {
return fmt.Errorf("golem15.seo: settings %s is not a bool", column)
}
st.URLLowercase = b
case "query_params":
var out []models.QueryParam
if err = list(&out); err == nil {
st.QueryParams = lagoon.Jsonable[[]models.QueryParam]{Data: out, Valid: true}
}
case "verify_other":
var out []models.VerifyMeta
if err = list(&out); err == nil {
st.VerifyOther = lagoon.Jsonable[[]models.VerifyMeta]{Data: out, Valid: true}
}
case "verify_files":
var out []models.VerifyFile
if err = list(&out); err == nil {
st.VerifyFiles = lagoon.Jsonable[[]models.VerifyFile]{Data: out, Valid: true}
}
default:
return fmt.Errorf("golem15.seo: unknown settings column %q", column)
}
return err
}
// TypeSettingWrite changes the settings of one content type: a nil field
// is left as it is.
type TypeSettingWrite struct {
Inject *bool `json:"inject,omitempty"`
TitleTemplate *string `json:"title_template,omitempty"`
DescriptionTemplate *string `json:"description_template,omitempty"`
Robots *string `json:"robots,omitempty"`
}
// SettingsWrite is one change of the SEO settings: Values are columns of
// SettingsColumns with JSON values, Types per content type key.
type SettingsWrite struct {
Values map[string]any
Types map[string]TypeSettingWrite
}
// SaveSettings writes the settings inside tx and records them in history,
// like the settings screen (D-104): templates with an unknown placeholder,
// malformed or duplicate verification files, an unknown address option or
// delimiter, robots values outside the grammar and unknown content types
// are refused with a *ProblemsError. Once the write commits, the entry
// generation job is queued so the effective-value cache follows.
func (s *Service) SaveSettings(ctx context.Context, tx *gorm.DB, in SettingsWrite) (models.Settings, error) {
row, err := SettingsRow(tx)
if err != nil {
return models.Settings{}, err
}
before := SettingsValues(row)
problems := map[string][]models.Problem{}
known := map[string]bool{}
for _, c := range SettingsColumns {
known[c] = true
}
for column, v := range in.Values {
if !known[column] {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.field_unknown"})
continue
}
if s, ok := v.(string); ok {
v = strings.TrimSpace(s)
}
if err := SetSettingsValue(&row, column, v); err != nil {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
}
}
oneOf := func(column, value string, allowed ...string) {
for _, a := range allowed {
if value == a {
return
}
}
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
}
oneOf("url_host", row.URLHost, models.HostKeep, models.HostWWW, models.HostBare)
oneOf("url_slash", row.URLSlash, models.SlashKeep, models.SlashAdd, models.SlashDrop)
oneOf("csv_delimiter", row.CSVDelimiter, "", ",", ";")
for field, list := range row.Problems() {
problems[field] = append(problems[field], list...)
}
types, err := LoadTypeSettings(tx)
if err != nil {
return models.Settings{}, err
}
changed := map[string]models.TypeSetting{}
reg := s.Registry()
for key, w := range in.Types {
field := "types." + key
if reg == nil {
problems[field] = append(problems[field], models.Problem{Key: "golem15.seo::lang.api.type_unknown"})
continue
}
if _, ok := reg.ContentType(key); !ok {
problems[field] = append(problems[field], models.Problem{Key: "golem15.seo::lang.api.type_unknown"})
continue
}
ts, ok := types[key]
if !ok {
ts = models.DefaultTypeSetting(key)
}
if w.Inject != nil {
ts.Inject = *w.Inject
}
if w.TitleTemplate != nil {
ts.TitleTemplate = strings.TrimSpace(*w.TitleTemplate)
problems[field+".title_template"] = append(problems[field+".title_template"], models.TemplateProblems(ts.TitleTemplate)...)
}
if w.DescriptionTemplate != nil {
ts.DescriptionTemplate = strings.TrimSpace(*w.DescriptionTemplate)
problems[field+".description_template"] = append(problems[field+".description_template"], models.TemplateProblems(ts.DescriptionTemplate)...)
}
if w.Robots != nil {
normal, err := NormalizeRobots(*w.Robots)
if err != nil {
problems[field+".robots"] = append(problems[field+".robots"], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
}
ts.Robots = normal
}
changed[key] = ts
}
for field, list := range problems {
if len(list) == 0 {
delete(problems, field)
}
}
if len(problems) > 0 {
return models.Settings{}, &ProblemsError{Problems: problems}
}
now := time.Now().UTC()
row.UpdatedAt = &now
if err := tx.Save(&row).Error; err != nil {
return models.Settings{}, err
}
if err := s.Record(ctx, tx, Change{
Kind: models.SubjectSettings,
SubjectID: "1",
What: s.Phrase("golem15.seo::lang.history.what_settings", nil),
Before: before,
After: SettingsValues(row),
}); err != nil {
return models.Settings{}, err
}
for key, ts := range changed {
old, ok := types[key]
if !ok {
old = models.DefaultTypeSetting(key)
}
if err := s.SaveTypeSetting(ctx, tx, ts); err != nil {
return models.Settings{}, err
}
label := key
if typ, ok := reg.ContentType(key); ok && typ.Label != "" {
label = s.Phrase(typ.Label, nil)
}
if err := s.Record(ctx, tx, Change{
Kind: models.SubjectSettings,
SubjectID: "type:" + key,
What: s.Phrase("golem15.seo::lang.history.what_settings", nil) + " – " + label,
Before: TypeSettingValues(old),
After: TypeSettingValues(ts),
}); err != nil {
return models.Settings{}, err
}
}
s.queueGenerateAfterCommit(ctx, tx)
return row, nil
}
// queueGenerateAfterCommit queues the entry generation job once tx
// commits; a queue error is logged.
func (s *Service) queueGenerateAfterCommit(ctx context.Context, tx *gorm.DB) {
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
db, err := s.GormDB()
if err != nil {
return
}
if _, err := s.EnqueueGenerate(ctx, db.WithContext(ctx), ""); err != nil {
log.Warn("golem15.seo: queue entry generation", "error", err)
}
})
}