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