feat: the author edits a material in the app

- PUT /_bm/api/v1/materials/{id} (jwt.auth only, no token twin): every
  key of the edit body required, revision checked on the row locked FOR
  UPDATE, 409 with the student detail of the server version
- editableMaterialForCaller: 404 for what the caller cannot see, 403 for
  what they see but may not manage
- tags from the dictionary only (at most 4), http(s) urls, the body
  normalised as bm-notatka push does; course, author and publication kept
- loadMaterialDetail shared by GET /materials/{id} and the edit answers
This commit is contained in:
Jakub Zych
2026-11-09 01:01:25 +01:00
parent 263c9c3926
commit 7bf18de987
9 changed files with 725 additions and 11 deletions

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"net/http"
"strconv"
"strings"
@@ -208,27 +209,41 @@ func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
return
}
id, ok := surf.IntParam(r, "id")
if !ok {
if !ok || id <= 0 {
writeNotFound(w)
return
}
var m models.Material
res := preloadMaterial(visibleMaterials(db, h.now(), userID)).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
out, found, err := h.loadMaterialDetail(r.Context(), db, userID, uint(id))
if err != nil {
writeOpaque500(w)
return
}
if res.RowsAffected == 0 {
if !found {
writeNotFound(w)
return
}
writeData(w, materialView(out, viaMCP(r), includeBody(r)))
}
// loadMaterialDetail builds the student detail of material id as userID
// sees it: the published, visible row with its course and tags, its public
// files, can_manage and the revision. found is false when the caller cannot
// see it. GET /materials/{id} and the author's routes answer with it.
func (h *Handlers) loadMaterialDetail(ctx context.Context, db *gorm.DB, userID, id uint) (materialDetailJSON, bool, error) {
var m models.Material
res := preloadMaterial(visibleMaterials(db, h.now(), userID)).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
return materialDetailJSON{}, false, res.Error
}
if res.RowsAffected == 0 {
return materialDetailJSON{}, false, nil
}
var files []attach.File
err := db.Where("attachment_type = ? AND attachment_id = ? AND field = ?", m.MorphName(), strconv.FormatUint(uint64(m.ID), 10), models.MaterialFilesField).
Order("sort_order, id").
Find(&files).Error
if err != nil {
writeOpaque500(w)
return
return materialDetailJSON{}, false, err
}
out := materialDetailJSON{
materialListJSON: newMaterialListJSON(m, int64(len(files))),
@@ -245,14 +260,13 @@ func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
}
out.FilesCount = int64(len(out.Files))
if m.AuthorUserID == nil || *m.AuthorUserID == userID {
manager, err := h.managesMaterials(r.Context(), db, userID)
manager, err := h.managesMaterials(ctx, db, userID)
if err != nil {
writeOpaque500(w)
return
return materialDetailJSON{}, false, err
}
out.CanManage = canManageMaterial(m, userID, manager)
}
writeData(w, materialView(out, viaMCP(r), includeBody(r)))
return out, true, nil
}
// publishedMaterialsOfCourse lists a course's published materials that