Files
sm-bm-plugin/models/material_revision.go
Jakub Zych 237069c827 feat: revision-check material writes
- add jz_bm_materials.revision (default 1), read-only for GORM
- bump it only on content changes (models.ReviseMaterial)
- PUT /materials/{id} locks the row and answers 409 to a stale revision
- management answers carry revision and updated_at, the student detail
  carries revision
2026-11-08 23:33:34 +01:00

115 lines
3.8 KiB
Go

package models
import (
"encoding/json"
"slices"
"strings"
"gorm.io/gorm"
)
// A material's revision counts changes to its content only (D-03): the
// title, type, body, source, url, course, zjazd (meeting) and tag set.
// Visibility, shares, publication, the author and attached files never bump
// it, so a writer holding an older revision is refused only when the note
// itself changed since.
// materialFingerprint is the content of a material that its revision
// counts, in a fixed key order.
type materialFingerprint struct {
CourseID *uint `json:"course_id"`
MeetingID *uint `json:"meeting_id"`
Title string `json:"title"`
Type string `json:"type"`
Body *string `json:"body"`
Source *string `json:"source"`
URL *string `json:"url"`
Tags []string `json:"tags"`
}
// MaterialFingerprint is the comparable form of m's content with the given
// tag slugs: two materials with the same fingerprint have the same content
// under D-03. Blank optional text counts as null, as BeforeSave stores it,
// and the tag order does not matter. published_at, is_private, the author
// and the shares are not inputs.
func MaterialFingerprint(m Material, tagSlugs []string) string {
body, source, url := m.Body, m.Source, m.URL
blank(&body, &source, &url)
tags := slices.Clone(tagSlugs)
if tags == nil {
tags = []string{}
}
slices.Sort(tags)
raw, _ := json.Marshal(materialFingerprint{
CourseID: m.CourseID,
MeetingID: m.MeetingID,
Title: m.Title,
Type: m.Type,
Body: body,
Source: source,
URL: url,
Tags: tags,
})
return string(raw)
}
// materialState reads the content fingerprint and the revision of material
// id through tx; a missing row is gorm.ErrRecordNotFound.
func materialState(tx *gorm.DB, id uint) (string, uint, error) {
db := tx.Session(&gorm.Session{NewDB: true})
var m Material
res := db.Select("id", "course_id", "meeting_id", "title", "type", "body", "source", "url", "revision").
Where("id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
return "", 0, res.Error
}
if res.RowsAffected == 0 {
return "", 0, gorm.ErrRecordNotFound
}
var slugs []string
err := db.Table(Tag{}.TableName()).
Joins("JOIN "+MaterialTag{}.TableName()+" ON "+MaterialTag{}.TableName()+".tag_id = "+Tag{}.TableName()+".id").
Where(MaterialTag{}.TableName()+".material_id = ?", id).
Pluck(Tag{}.TableName()+".slug", &slugs).Error
if err != nil {
return "", 0, err
}
for i := range slugs {
slugs[i] = strings.TrimSpace(slugs[i])
}
return MaterialFingerprint(m, slugs), m.Revision, nil
}
// MaterialContent reads the content fingerprint (MaterialFingerprint) of
// material id and its tags through tx. A writer takes it before a write and
// hands it to ReviseMaterial after; a missing row is gorm.ErrRecordNotFound.
func MaterialContent(tx *gorm.DB, id uint) (string, error) {
fp, _, err := materialState(tx, id)
return fp, err
}
// ReviseMaterial bumps the revision of material id by one when its content
// read through tx differs from before (a MaterialContent taken before the
// write), and answers the revision the row has then and whether it went
// up. Only content counts (D-03): a write that changed only visibility,
// shares, publication or files, or that sent identical content, keeps the
// revision. It is the only writer of the revision column, which GORM treats
// as read-only.
func ReviseMaterial(tx *gorm.DB, id uint, before string) (uint, bool, error) {
after, current, err := materialState(tx, id)
if err != nil {
return 0, false, err
}
if after == before {
return current, false, nil
}
var revision uint
err = tx.Session(&gorm.Session{NewDB: true}).
Raw("UPDATE jz_bm_materials SET revision = revision + 1 WHERE id = ? RETURNING revision", id).
Scan(&revision).Error
if err != nil {
return 0, false, err
}
return revision, true, nil
}