- every revision bump (API writes and /bmadmin saves) enqueues
updated.jz.bm.material with {material_id, revision} in a savepoint
- the materials namespace authorizer allows a subscribe only to a
visible material the user may manage
- api.ManagesMaterials and api.CanWatchMaterial
167 lines
5.9 KiB
Go
167 lines
5.9 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var (
|
|
_ pact.FormBeforeUpdate = materialsAdmin{}
|
|
_ pact.FormAfterCreate = materialsAdmin{}
|
|
_ pact.FormAfterUpdate = materialsAdmin{}
|
|
_ pact.FormAfterDelete = materialsAdmin{}
|
|
)
|
|
|
|
// pendingRevisions holds, per material id, the content fingerprint
|
|
// (models.MaterialContent) FormBeforeUpdate took before an admin save, for
|
|
// FormAfterUpdate to compare. The row lock FormBeforeUpdate takes keeps one
|
|
// pending save per id at a time.
|
|
var pendingRevisions sync.Map
|
|
|
|
// MaterialIndex keeps the materials search index current once a write
|
|
// commits (classes.MaterialIndexer). QueueExtract enqueues the file text job
|
|
// inside tx. Its calls never fail the write.
|
|
type MaterialIndex interface {
|
|
SyncAfterCommit(ctx context.Context, tx *gorm.DB, id uint)
|
|
RemoveAfterCommit(ctx context.Context, tx *gorm.DB, id uint)
|
|
QueueExtract(ctx context.Context, tx *gorm.DB, id uint)
|
|
}
|
|
|
|
type materialIndexHolder struct{ ix MaterialIndex }
|
|
|
|
var materialIndex atomic.Value
|
|
|
|
// UseMaterialIndex installs the index the materials admin syncs after a
|
|
// save or delete; nil turns the syncs off. Boot installs it, because the
|
|
// generated controller value has no field to carry it.
|
|
func UseMaterialIndex(ix MaterialIndex) {
|
|
materialIndex.Store(materialIndexHolder{ix: ix})
|
|
}
|
|
|
|
func installedMaterialIndex() MaterialIndex {
|
|
h, _ := materialIndex.Load().(materialIndexHolder)
|
|
return h.ix
|
|
}
|
|
|
|
// MaterialRevisionNotifier publishes the live heads-up after an admin save
|
|
// bumped a material's revision (classes.MaterialRevisionNotifier). It
|
|
// enqueues on tx and never fails the save.
|
|
type MaterialRevisionNotifier interface {
|
|
Revised(ctx context.Context, tx *gorm.DB, id, revision uint)
|
|
}
|
|
|
|
type materialRevisionNotifierHolder struct{ n MaterialRevisionNotifier }
|
|
|
|
var materialRevisionNotifier atomic.Value
|
|
|
|
// UseMaterialRevisionNotifier installs the notifier the materials admin
|
|
// calls after a save bumped the revision; nil turns it off. Boot installs
|
|
// it, as UseMaterialIndex.
|
|
func UseMaterialRevisionNotifier(n MaterialRevisionNotifier) {
|
|
materialRevisionNotifier.Store(materialRevisionNotifierHolder{n: n})
|
|
}
|
|
|
|
func installedMaterialRevisionNotifier() MaterialRevisionNotifier {
|
|
h, _ := materialRevisionNotifier.Load().(materialRevisionNotifierHolder)
|
|
return h.n
|
|
}
|
|
|
|
// FormAfterCreate queues the file text job in the save transaction (the
|
|
// form's deferred files commit in the same one) and syncs the new material
|
|
// once it commits.
|
|
func (materialsAdmin) FormAfterCreate(ctx context.Context, model any) error {
|
|
return materialAfterCommit(ctx, model, false)
|
|
}
|
|
|
|
// FormBeforeUpdate runs the embedded date check, then, inside the save
|
|
// transaction, locks the material's row and snapshots its content for
|
|
// FormAfterUpdate. An admin save is not revision-checked (the form sends no
|
|
// revision); it only bumps the revision when the content changed, so a later
|
|
// push holding an older revision is refused instead of overwriting the
|
|
// admin edit. Outside a write (no transaction) it does nothing more.
|
|
func (c materialsAdmin) FormBeforeUpdate(ctx context.Context, model any) error {
|
|
if err := c.temporalHooks.FormBeforeUpdate(ctx, model); err != nil {
|
|
return err
|
|
}
|
|
m, ok := model.(*models.Material)
|
|
if !ok || m == nil {
|
|
return errors.New("materials admin: form model is not a material")
|
|
}
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok || m.ID == 0 {
|
|
return nil
|
|
}
|
|
var locked int
|
|
if err := tx.Session(&gorm.Session{NewDB: true}).Raw("SELECT 1 FROM jz_bm_materials WHERE id = ? FOR UPDATE", m.ID).Scan(&locked).Error; err != nil {
|
|
return err
|
|
}
|
|
before, err := models.MaterialContent(tx, m.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pendingRevisions.Store(m.ID, before)
|
|
return nil
|
|
}
|
|
|
|
// FormAfterUpdate bumps the revision when the save changed the material's
|
|
// content, and then enqueues the live heads-up on the save transaction
|
|
// (D-20), then queues the file text job in the save transaction and syncs
|
|
// the material once it commits. The compare runs here, not in
|
|
// FormBeforeUpdate, because the course, the zjazd and the tags are final
|
|
// only after the row write and the pivot sync. A revise error fails the save
|
|
// like any database error. A save that added no file queues a job that finds
|
|
// no new PDF and only resyncs; a file removed in the form leaves the
|
|
// document through the cascade and this sync.
|
|
func (materialsAdmin) FormAfterUpdate(ctx context.Context, model any) error {
|
|
m, ok := model.(*models.Material)
|
|
if !ok || m == nil {
|
|
return errors.New("materials admin: form model is not a material")
|
|
}
|
|
if pending, found := pendingRevisions.LoadAndDelete(m.ID); found {
|
|
if tx, ok := cabana.TxFromContext(ctx); ok {
|
|
rev, bumped, err := models.ReviseMaterial(tx, m.ID, pending.(string))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n := installedMaterialRevisionNotifier(); bumped && n != nil {
|
|
n.Revised(ctx, tx, m.ID, rev)
|
|
}
|
|
}
|
|
}
|
|
return materialAfterCommit(ctx, model, false)
|
|
}
|
|
|
|
// FormAfterDelete removes the material's document once the delete commits.
|
|
func (materialsAdmin) FormAfterDelete(ctx context.Context, model any) error {
|
|
return materialAfterCommit(ctx, model, true)
|
|
}
|
|
|
|
// materialAfterCommit schedules the index work of an admin save on the save
|
|
// transaction. Only a model that is not a material is an error: the index
|
|
// never fails a save.
|
|
func materialAfterCommit(ctx context.Context, model any, remove bool) error {
|
|
m, ok := model.(*models.Material)
|
|
if !ok || m == nil {
|
|
return errors.New("materials admin: form model is not a material")
|
|
}
|
|
ix := installedMaterialIndex()
|
|
if ix == nil || m.ID == 0 {
|
|
return nil
|
|
}
|
|
tx, _ := cabana.TxFromContext(ctx)
|
|
if remove {
|
|
ix.RemoveAfterCommit(ctx, tx, m.ID)
|
|
return nil
|
|
}
|
|
ix.QueueExtract(ctx, tx, m.ID)
|
|
ix.SyncAfterCommit(ctx, tx, m.ID)
|
|
return nil
|
|
}
|