- 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
115 lines
3.8 KiB
Go
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
|
|
}
|