feat: private materials with per-user shares

A material may be private: only its author and the users it is shared
with see it. Every student read (list, detail, course detail and
counters, the checklist linked material, the token twins) goes through
one visibility fragment. Existing materials stay public with no author.

GET/PUT /_bm/api/v1/materials/{id}/access let the author, a material
manager, toggle the flag and pick the users; the first change of an
author-less material makes the caller its author.
This commit is contained in:
Jakub Zych
2026-11-08 11:52:47 +01:00
parent 5ed02ed9f7
commit 418f8240a4
14 changed files with 688 additions and 65 deletions

View File

@@ -48,15 +48,16 @@ func preloadMaterial(q *gorm.DB) *gorm.DB {
return q.Preload("Course").Preload("Tags", func(db *gorm.DB) *gorm.DB { return db.Order("jz_bm_tags.name") })
}
// listMaterials returns one page of published materials, newest first.
func listMaterials(db *gorm.DB, now time.Time, f materialFilter, page, perPage int) ([]materialListJSON, int64, error) {
q := f.apply(published(db.Model(&models.Material{}), now))
// listMaterials returns one page of the published materials userID may
// see, newest first.
func listMaterials(db *gorm.DB, now time.Time, userID uint, f materialFilter, page, perPage int) ([]materialListJSON, int64, error) {
q := f.apply(visibleMaterials(db.Model(&models.Material{}), now, userID))
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []models.Material
err := preloadMaterial(f.apply(published(db, now))).
err := preloadMaterial(f.apply(visibleMaterials(db, now, userID))).
Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC").
Offset((page - 1) * perPage).Limit(perPage).
Find(&rows).Error
@@ -99,9 +100,11 @@ func materialsJSON(db *gorm.DB, rows []models.Material) ([]materialListJSON, err
}
// Materials answers GET /materials?course=&meeting=&tag=&type=&page=&per_page=:
// a page of published materials in the {data, meta} shape of lagoon.Paginate.
// a page of the published materials the caller may see, in the {data, meta}
// shape of lagoon.Paginate.
func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
if _, ok := h.caller(w, r); !ok {
userID, ok := h.caller(w, r)
if !ok {
return
}
in := query(r, "course", "meeting", "tag", "type", "page", "per_page")
@@ -115,7 +118,7 @@ func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
filter := materialFilter{CourseID: uintParam(in, "course"), MeetingID: uintParam(in, "meeting"), Tag: strings.TrimSpace(str(in, "tag")), Type: str(in, "type")}
page := min(intParam(in, "page", 1), 100000)
perPage := intParam(in, "per_page", h.perPage())
rows, total, err := listMaterials(db, h.now(), filter, page, perPage)
rows, total, err := listMaterials(db, h.now(), userID, filter, page, perPage)
if err != nil {
writeOpaque500(w)
return
@@ -123,11 +126,14 @@ func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
writeJSONPage(w, lagoon.Paginate(rows, page, perPage, total))
}
// Material answers GET /materials/{id}: one published material with its
// body, tags and files. A draft or a future publication is a 404. An MCP
// call gets the body only with include_body (see materialView).
// Material answers GET /materials/{id}: one published material the caller
// may see, with its body, tags and files. A draft, a future publication or
// another user's private material is a 404. can_manage tells the author (a
// material manager) that the access panel applies. An MCP call gets the body
// only with include_body (see materialView).
func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
if _, ok := h.caller(w, r); !ok {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
@@ -140,7 +146,7 @@ func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
return
}
var m models.Material
res := preloadMaterial(published(db, h.now())).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
res := preloadMaterial(visibleMaterials(db, h.now(), userID)).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
writeOpaque500(w)
return
@@ -170,14 +176,22 @@ func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType})
}
out.FilesCount = int64(len(out.Files))
if m.AuthorUserID == nil || *m.AuthorUserID == userID {
manager, err := h.managesMaterials(r.Context(), db, userID)
if err != nil {
writeOpaque500(w)
return
}
out.CanManage = canManageMaterial(m, userID, manager)
}
writeData(w, materialView(out, viaMCP(r), includeBody(r)))
}
// publishedMaterialsOfCourse lists a course's published materials, newest
// first, for /courses/{id}.
func publishedMaterialsOfCourse(db *gorm.DB, now time.Time, courseID uint) ([]materialListJSON, error) {
// publishedMaterialsOfCourse lists a course's published materials that
// userID may see, newest first, for /courses/{id}.
func publishedMaterialsOfCourse(db *gorm.DB, now time.Time, userID uint, courseID uint) ([]materialListJSON, error) {
var rows []models.Material
err := preloadMaterial(published(db, now)).
err := preloadMaterial(visibleMaterials(db, now, userID)).
Where("jz_bm_materials.course_id = ?", courseID).
Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC").
Find(&rows).Error