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 }