feat(19.3-04): purge the host site's page cache after menu and menu-entry saves, deletes and reorders

- menus controller gains the app accessor and FormAfterCreate/Update/Delete
  purging kind menu with the menu code
- menu entries purge kind menu_entry with the entry id after the placement
  checks pass, on delete, and through pact.ReorderAfterSave with
  partition:<menu id>
- purgeSiteCache helper on cabana.TxFromContext and cooler.SiteCacheFromApp
- TestMenuAdminPurgesSiteCache on Postgres
This commit is contained in:
Jakub Zych
2026-10-15 18:30:45 +02:00
parent 9c0f833878
commit 559d96b0a4
6 changed files with 414 additions and 10 deletions

View File

@@ -15,7 +15,7 @@ const PermissionManageMenu = "golem15.menu.manage_menu"
// a database handle or registry of its own.
func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
menusAdminController{},
menusAdminController{app: app},
menuEntriesAdminController{app: app},
}
}

View File

@@ -3,6 +3,7 @@ package controllers
import (
"context"
"errors"
"strconv"
"strings"
"git.golem15.com/golem15/sm-menu-plugin/classes"
@@ -186,16 +187,22 @@ func (c menuEntriesAdminController) checkEntry(model any) error {
// FormAfterCreate refuses an entry that ends up in no menu (no menu chosen
// and no parent to take it from). cabana writes the menu and parent from the
// relation fields after the model rules ran, so the check runs once the row
// is written, inside the save's transaction.
func (menuEntriesAdminController) FormAfterCreate(ctx context.Context, model any) error {
return checkPlacement(ctx, model)
// is written, inside the save's transaction. An entry that passes purges the
// host site's page caches once the save commits.
func (c menuEntriesAdminController) FormAfterCreate(ctx context.Context, model any) error {
if err := checkPlacement(ctx, model); err != nil {
return err
}
c.purge(ctx, model)
return nil
}
// FormAfterUpdate also refuses a parent that is the entry itself or one of
// its descendants, then moves the entry's descendants into its menu, so a
// subtree follows its root to another menu instead of staying behind with
// parents in a menu it is not in.
func (menuEntriesAdminController) FormAfterUpdate(ctx context.Context, model any) error {
// parents in a menu it is not in. An update that passes purges the host
// site's page caches once the save commits.
func (c menuEntriesAdminController) FormAfterUpdate(ctx context.Context, model any) error {
if err := checkPlacement(ctx, model); err != nil {
return err
}
@@ -207,13 +214,43 @@ func (menuEntriesAdminController) FormAfterUpdate(ctx context.Context, model any
if !ok {
return errors.New("golem15.menu: the entry check needs the save's transaction")
}
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Exec(`WITH RECURSIVE sub(id) AS (
err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Exec(`WITH RECURSIVE sub(id) AS (
SELECT id FROM golem15_menu_menu_entries WHERE parent_id = ? AND id <> ?
UNION
SELECT e.id FROM golem15_menu_menu_entries e JOIN sub ON e.parent_id = sub.id
)
UPDATE golem15_menu_menu_entries SET menu_id = ? WHERE id IN (SELECT id FROM sub) AND id <> ? AND menu_id <> ?`,
entry.ID, entry.ID, entry.MenuID, entry.ID, entry.MenuID).Error
if err != nil {
return err
}
c.purge(ctx, model)
return nil
}
// FormAfterDelete purges the host site's page caches once the delete
// commits.
func (c menuEntriesAdminController) FormAfterDelete(ctx context.Context, model any) error {
c.purge(ctx, model)
return nil
}
// ReorderAfterSave purges the host site's page caches once the reorder of
// one menu's entries commits; the slug names the partition (the menu id).
func (c menuEntriesAdminController) ReorderAfterSave(ctx context.Context, partition *uint64) error {
slug := "reorder"
if partition != nil {
slug = "partition:" + strconv.FormatUint(*partition, 10)
}
purgeSiteCache(ctx, c.app, siteCacheKindEntry, slug)
return nil
}
// purge registers a purge of kind menu_entry with the entry's id.
func (c menuEntriesAdminController) purge(ctx context.Context, model any) {
if entry, ok := model.(*models.MenuEntry); ok && entry != nil {
purgeSiteCache(ctx, c.app, siteCacheKindEntry, strconv.FormatUint(uint64(entry.ID), 10))
}
}
// checkPlacement refuses an entry in no menu, a parent from another menu
@@ -267,6 +304,8 @@ var (
_ pact.FormBeforeUpdate = menuEntriesAdminController{}
_ pact.FormAfterCreate = menuEntriesAdminController{}
_ pact.FormAfterUpdate = menuEntriesAdminController{}
_ pact.FormAfterDelete = menuEntriesAdminController{}
_ pact.ReorderAfterSave = menuEntriesAdminController{}
_ pact.RelationExtendOptionsQuery = menuEntriesAdminController{}
_ cabana.FieldRelationProvider = menuEntriesAdminController{}
)

View File

@@ -1,7 +1,10 @@
package controllers
import (
"context"
"git.golem15.com/golem15/sm-menu-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -10,7 +13,9 @@ const MenusControllerID = "golem15.menu.menus"
// menusAdminController lists and edits menus: name, code, CSS class and the
// active switch.
type menusAdminController struct{}
type menusAdminController struct {
app func() *backpack.App
}
func (menusAdminController) ID() string { return MenusControllerID }
@@ -24,8 +29,43 @@ func (menusAdminController) RequiredPermissions() []string {
func (menusAdminController) NewRecord() any { return &models.Menu{} }
// Site-cache purge kinds of the menu plugin's saves.
const (
siteCacheKindMenu = "menu"
siteCacheKindEntry = "menu_entry"
)
// FormAfterCreate purges the host site's page caches once the save commits.
func (c menusAdminController) FormAfterCreate(ctx context.Context, model any) error {
c.purge(ctx, model)
return nil
}
// FormAfterUpdate purges the host site's page caches once the save commits.
func (c menusAdminController) FormAfterUpdate(ctx context.Context, model any) error {
c.purge(ctx, model)
return nil
}
// FormAfterDelete purges the host site's page caches once the delete
// commits.
func (c menusAdminController) FormAfterDelete(ctx context.Context, model any) error {
c.purge(ctx, model)
return nil
}
// purge registers a purge of kind menu with the menu's code.
func (c menusAdminController) purge(ctx context.Context, model any) {
if menu, ok := model.(*models.Menu); ok && menu != nil {
purgeSiteCache(ctx, c.app, siteCacheKindMenu, menu.Code)
}
}
var (
_ pact.AdminController = menusAdminController{}
_ pact.AdminPermissioned = menusAdminController{}
_ pact.AdminRecordSource = menusAdminController{}
_ pact.FormAfterCreate = menusAdminController{}
_ pact.FormAfterUpdate = menusAdminController{}
_ pact.FormAfterDelete = menusAdminController{}
)

32
controllers/site_cache.go Normal file
View File

@@ -0,0 +1,32 @@
package controllers
import (
"context"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/cooler"
)
// purgeSiteCache registers one site-cache purge of kind and slug, sent by
// the framework's purger (site_cache.purge_url and purge_token) after the
// save transaction of ctx commits. Without a purge configured, or outside a
// save transaction, it does nothing. A purge never fails the save.
func purgeSiteCache(ctx context.Context, app func() *backpack.App, kind, slug string) {
if app == nil {
return
}
a := app()
if a == nil {
return
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return
}
purger := cooler.SiteCacheFromApp(a)
if purger == nil {
return
}
cooler.PurgeSiteCacheAfterCommit(ctx, tx, purger, kind, slug)
}