feat: announce material revisions on materials:{id}

- 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
This commit is contained in:
Jakub Zych
2026-11-09 01:12:49 +01:00
parent 40e4a2438b
commit 2a47186a7a
13 changed files with 571 additions and 10 deletions

View File

@@ -78,6 +78,9 @@ type Handlers struct {
// means a search (q) answers 503 and the syncs after a write are
// skipped.
MaterialIndex *classes.MaterialIndexer
// Revisions publishes the live heads-up after a write that bumped a
// material's revision (D-20). Nil means no live event.
Revisions *classes.MaterialRevisionNotifier
}
func (h *Handlers) now() time.Time {

View File

@@ -170,6 +170,12 @@ func replaceMaterialShares(tx *gorm.DB, materialID uint, userIDs []uint) error {
// managesMaterials tells whether the user holds jz.bm.access_materials.
func (h *Handlers) managesMaterials(ctx context.Context, db *gorm.DB, userID uint) (bool, error) {
return ManagesMaterials(ctx, db, userID)
}
// ManagesMaterials tells whether the user holds jz.bm.access_materials. The
// realtime connection-token guard admits such users (D-20).
func ManagesMaterials(ctx context.Context, db *gorm.DB, userID uint) (bool, error) {
principal, err := userclasses.PrincipalByID(ctx, db, userID)
if err != nil {
return false, err
@@ -177,6 +183,26 @@ func (h *Handlers) managesMaterials(ctx context.Context, db *gorm.DB, userID uin
return principal != nil && cabana.Allows(principal, []string{controllers.PermMaterials}), nil
}
// CanWatchMaterial tells whether the user may follow material id's live
// revision channel (D-20): the material is visible to them at now and they
// may manage it (canManageMaterial). It reads current state on every call;
// a material they cannot see is false without an error.
func CanWatchMaterial(ctx context.Context, db *gorm.DB, now time.Time, userID, id uint) (bool, error) {
var m models.Material
res := visibleMaterials(db.WithContext(ctx), now, userID).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
return false, res.Error
}
if res.RowsAffected == 0 {
return false, nil
}
manager, err := ManagesMaterials(ctx, db, userID)
if err != nil {
return false, err
}
return canManageMaterial(m, userID, manager), nil
}
// canManageMaterial tells whether a material manager may change the
// material's access: it is theirs, or it has no author yet (the first change
// makes them its author).

View File

@@ -27,10 +27,18 @@ func staleRevision(sent *uint, stored uint) bool {
// revise bumps the revision of material id inside tx when its content
// differs from before (models.ReviseMaterial). Every content writer of the
// API calls it after its write.
// API calls it after its write. A bump also enqueues the live heads-up on
// tx (D-20), published only when the write commits; its failure never fails
// the write.
func (h *Handlers) revise(ctx context.Context, tx *gorm.DB, id uint, before string) error {
_, _, err := models.ReviseMaterial(tx.WithContext(ctx), id, before)
return err
rev, bumped, err := models.ReviseMaterial(tx.WithContext(ctx), id, before)
if err != nil {
return err
}
if bumped {
h.Revisions.Revised(ctx, tx, id, rev)
}
return nil
}
// writeConflict answers 409 to a write that sent a stale revision, with the

View File

@@ -50,6 +50,29 @@ func installedMaterialIndex() MaterialIndex {
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.
@@ -88,7 +111,8 @@ func (c materialsAdmin) FormBeforeUpdate(ctx context.Context, model any) error {
}
// FormAfterUpdate bumps the revision when the save changed the material's
// content, then queues the file text job in the save transaction and syncs
// 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
@@ -102,9 +126,13 @@ func (materialsAdmin) FormAfterUpdate(ctx context.Context, model any) error {
}
if pending, found := pendingRevisions.LoadAndDelete(m.ID); found {
if tx, ok := cabana.TxFromContext(ctx); ok {
if _, _, err := models.ReviseMaterial(tx, m.ID, pending.(string)); err != nil {
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)

View File

@@ -36,6 +36,14 @@ func (r *recordingIndex) QueueExtract(_ context.Context, tx *gorm.DB, id uint) {
func itoa(id uint) string { return strconv.FormatUint(uint64(id), 10) }
// recordingNotifier records the live revision events of the materials
// admin.
type recordingNotifier struct{ calls []string }
func (r *recordingNotifier) Revised(_ context.Context, _ *gorm.DB, id, revision uint) {
r.calls = append(r.calls, itoa(id)+"@"+itoa(revision))
}
func TestMaterialsAdminHooks(t *testing.T) {
t.Cleanup(func() { UseMaterialIndex(nil) })
var c materialsAdmin
@@ -95,8 +103,13 @@ func TestMaterialsAdminHooks(t *testing.T) {
// store no snapshot, a model that is not a material is an error, and the
// embedded date check still runs first.
func TestMaterialsAdminRevision(t *testing.T) {
t.Cleanup(func() { UseMaterialIndex(nil) })
t.Cleanup(func() {
UseMaterialIndex(nil)
UseMaterialRevisionNotifier(nil)
})
UseMaterialIndex(nil)
notifier := &recordingNotifier{}
UseMaterialRevisionNotifier(notifier)
var c materialsAdmin
ctx := context.Background()
m := &models.Material{ID: 41, Title: "Notatka", Type: "notatka"}
@@ -110,6 +123,18 @@ func TestMaterialsAdminRevision(t *testing.T) {
if err := c.FormAfterUpdate(ctx, m); err != nil {
t.Errorf("after update without a transaction: %v", err)
}
// Outside a write nothing is revised, so nothing is announced.
if len(notifier.calls) != 0 {
t.Errorf("notifier called outside a transaction: %v", notifier.calls)
}
// Turning the notifier off is safe.
UseMaterialRevisionNotifier(nil)
if n := installedMaterialRevisionNotifier(); n != nil {
t.Errorf("notifier after UseMaterialRevisionNotifier(nil) = %v, want nil", n)
}
if err := c.FormAfterUpdate(ctx, m); err != nil {
t.Errorf("after update without a notifier: %v", err)
}
if err := c.FormBeforeUpdate(ctx, &models.Course{ID: 41}); err == nil {
t.Error("before update with a course: no error")