feat: manage material visibility and shares

The management save/update body takes is_private, author_user_id and
share_user_ids (absent keeps each; a new material gets the caller as
author), and the managed material shows them. New routes PATCH
/materials/{id}/visibility, PUT /materials/{id}/shares and GET /users,
with the MCP tools bm_manage_set_material_visibility,
bm_manage_set_material_shares and bm_manage_list_users. The /bmadmin
form gets the private switch, the author picker and shared_with.
This commit is contained in:
Jakub Zych
2026-11-08 11:56:14 +01:00
parent 418f8240a4
commit 3346cb6ff6
13 changed files with 470 additions and 53 deletions

View File

@@ -136,7 +136,8 @@ func (h *Handlers) ApplySeed(w http.ResponseWriter, r *http.Request) {
}
// managedMaterialJSON is a material as the management API shows it: drafts
// too, so published_at may be null.
// too, so published_at may be null, and private ones with their author and
// the users they are shared with.
type managedMaterialJSON struct {
ID uint `json:"id"`
CourseID *uint `json:"course_id"`
@@ -149,12 +150,54 @@ type managedMaterialJSON struct {
URL *string `json:"url"`
PublishedAt *string `json:"published_at"`
Tags []tagJSON `json:"tags"`
IsPrivate bool `json:"is_private"`
AuthorUserID *uint `json:"author_user_id"`
Author *userRefJSON `json:"author"`
Shares []userRefJSON `json:"shares"`
}
func newManagedMaterialJSON(m models.Material) managedMaterialJSON {
// materialPeople are the authors and shares of a batch of materials, as
// loadMaterialPeople reads them.
type materialPeople struct {
authors map[uint]userRefJSON
shares map[uint][]userRefJSON
}
// loadMaterialPeople reads the authors and the shares of rows in two
// queries plus one for the shared users.
func loadMaterialPeople(db *gorm.DB, rows ...models.Material) (materialPeople, error) {
var authorIDs, materialIDs []uint
for _, m := range rows {
materialIDs = append(materialIDs, m.ID)
if m.AuthorUserID != nil {
authorIDs = append(authorIDs, *m.AuthorUserID)
}
}
authors, err := userRefsByID(db, authorIDs)
if err != nil {
return materialPeople{}, err
}
shares, err := materialShareRefs(db, materialIDs)
if err != nil {
return materialPeople{}, err
}
return materialPeople{authors: authors, shares: shares}, nil
}
func newManagedMaterialJSON(m models.Material, people materialPeople) managedMaterialJSON {
out := managedMaterialJSON{
ID: m.ID, CourseID: m.CourseID, Course: newCourseRef(m.Course), MeetingID: m.MeetingID, Title: m.Title, Type: m.Type,
Body: m.Body, Source: m.Source, URL: m.URL, Tags: newTagsJSON(m.Tags),
IsPrivate: m.IsPrivate, AuthorUserID: m.AuthorUserID, Shares: people.shares[m.ID],
}
if m.AuthorUserID != nil {
if a, ok := people.authors[*m.AuthorUserID]; ok {
out.Author = &a
}
}
if out.Shares == nil {
out.Shares = []userRefJSON{}
}
if m.PublishedAt != nil {
s := models.FormatDateTime(*m.PublishedAt)
@@ -176,9 +219,14 @@ func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {
writeOpaque500(w)
return
}
people, err := loadMaterialPeople(db, rows...)
if err != nil {
writeOpaque500(w)
return
}
out := make([]any, 0, len(rows))
for _, m := range rows {
out = append(out, managedMaterialView(newManagedMaterialJSON(m), viaMCP(r), includeBody(r)))
out = append(out, managedMaterialView(newManagedMaterialJSON(m, people), viaMCP(r), includeBody(r)))
}
writeData(w, out)
}
@@ -194,6 +242,10 @@ type materialInput struct {
URL *string `json:"url"`
PublishedAt *string `json:"published_at"`
Tags *[]string `json:"tags"`
IsPrivate *bool `json:"is_private"`
AuthorUserID *uint `json:"author_user_id"`
ShareUserIDs *[]uint `json:"share_user_ids"`
}
// materialDraft is a decoded and validated material body, ready to apply.
@@ -206,8 +258,13 @@ type materialDraft struct {
// one: absent keeps the stored meeting, so scripts that never send it
// keep working.
hasMeetingID bool
publishedAt *time.Time
tags []models.Tag
// hasAuthor tells a sent author_user_id (null included) from an absent
// one.
hasAuthor bool
publishedAt *time.Time
tags []models.Tag
// shares is the checked share_user_ids, when sent.
shares []uint
}
// readMaterial decodes and validates the body of POST /materials and
@@ -229,6 +286,7 @@ func (h *Handlers) readMaterial(w http.ResponseWriter, r *http.Request, db *gorm
_ = json.Unmarshal(raw, &keys)
_, d.hasPublishedAt = keys["published_at"]
_, d.hasMeetingID = keys["meeting_id"]
_, d.hasAuthor = keys["author_user_id"]
in := &d.in
in.Title = strings.TrimSpace(in.Title)
@@ -285,6 +343,27 @@ func (h *Handlers) readMaterial(w http.ResponseWriter, r *http.Request, db *gorm
errs["meeting_id"] = []string{"The selected meeting_id is invalid."}
}
}
if in.AuthorUserID != nil {
found, err := userRefsByID(db, []uint{*in.AuthorUserID})
if err != nil {
writeOpaque500(w)
return d, false
}
if _, ok := found[*in.AuthorUserID]; !ok {
errs["author_user_id"] = []string{"The selected author_user_id is invalid."}
}
}
if in.ShareUserIDs != nil {
clean, msg, err := cleanUserIDs(db, *in.ShareUserIDs)
if err != nil {
writeOpaque500(w)
return d, false
}
if msg != "" {
errs["share_user_ids"] = []string{msg}
}
d.shares = clean
}
if len(errs) > 0 {
writeValidation(w, errs)
return d, false
@@ -306,14 +385,27 @@ func (d materialDraft) identity(q *gorm.DB) *gorm.DB {
// a new material now and keeps an existing one as it is; null makes a
// draft; otherwise the sent time. meeting_id: absent keeps it; null clears
// it. tags: absent keeps them; otherwise the list replaces them, and unknown
// tags are created.
func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now time.Time) error {
// tags are created. is_private: absent keeps it (a new material is public).
// author_user_id: absent keeps it (a new material gets callerID); null
// clears it. share_user_ids: absent keeps the shares; otherwise the list
// replaces them.
func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now time.Time, callerID uint) error {
in := d.in
m.CourseID, m.Title, m.Type = in.CourseID, in.Title, in.Type
m.Body, m.Source, m.URL = in.Body, in.Source, in.URL
if d.hasMeetingID {
m.MeetingID = in.MeetingID
}
if in.IsPrivate != nil {
m.IsPrivate = *in.IsPrivate
}
switch {
case d.hasAuthor:
m.AuthorUserID = in.AuthorUserID
case created && callerID != 0:
id := callerID
m.AuthorUserID = &id
}
switch {
case d.hasPublishedAt:
m.PublishedAt = d.publishedAt
@@ -323,6 +415,11 @@ func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now
if err := tx.Omit("Course", "Meeting", "Tags").Save(m).Error; err != nil {
return err
}
if in.ShareUserIDs != nil {
if err := replaceMaterialShares(tx, m.ID, d.shares); err != nil {
return err
}
}
if in.Tags == nil {
return nil
}
@@ -357,7 +454,12 @@ func writeManaged(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint,
writeOpaque500(w)
return
}
payload := map[string]any{"data": managedMaterialView(newManagedMaterialJSON(out), viaMCP(r), false)}
people, err := loadMaterialPeople(db, out)
if err != nil {
writeOpaque500(w)
return
}
payload := map[string]any{"data": managedMaterialView(newManagedMaterialJSON(out, people), viaMCP(r), false)}
for k, v := range extra {
payload[k] = v
}
@@ -370,6 +472,10 @@ func writeManaged(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint,
// materialDraft.apply for published_at and tags. A renamed material is a
// new one here: PUT /materials/{id} renames.
func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
callerID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
@@ -386,7 +492,7 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
return res.Error
}
created = res.RowsAffected == 0
return d.apply(tx, &m, created, h.now())
return d.apply(tx, &m, created, h.now(), callerID)
})
if err != nil {
writeOpaque500(w)
@@ -404,6 +510,10 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
// another material already has is a 422, because POST would then be
// ambiguous. An unknown id is a 404.
func (h *Handlers) UpdateMaterial(w http.ResponseWriter, r *http.Request) {
callerID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
@@ -436,7 +546,7 @@ func (h *Handlers) UpdateMaterial(w http.ResponseWriter, r *http.Request) {
taken = true
return nil
}
return d.apply(tx, &m, false, h.now())
return d.apply(tx, &m, false, h.now(), callerID)
})
switch {
case err != nil:
@@ -497,7 +607,12 @@ func (h *Handlers) writeMaterialDetailWith(w http.ResponseWriter, r *http.Reques
writeOpaque500(w)
return
}
out := managedMaterialDetailJSON{managedMaterialJSON: newManagedMaterialJSON(m), Files: make([]fileJSON, 0, len(files))}
people, err := loadMaterialPeople(db, m)
if err != nil {
writeOpaque500(w)
return
}
out := managedMaterialDetailJSON{managedMaterialJSON: newManagedMaterialJSON(m, people), Files: make([]fileJSON, 0, len(files))}
for i := range files {
f := &files[i]
if !f.Public() {
@@ -574,3 +689,121 @@ func slugify(name string) string {
}
return b.String()
}
// managedAccessChange loads the material of r by id and, unless decode
// refuses the body, applies the access change it returns inside a
// transaction with the claim rule (a material with no author gets the
// caller). It answers 404, 422 or 500 itself, and on success the material as
// writeManaged shows it, so under MCP the body is never echoed.
func (h *Handlers) managedAccessChange(w http.ResponseWriter, r *http.Request, decode func(db *gorm.DB, raw []byte) (isPrivate *bool, setShares bool, shares []uint, ok bool)) {
callerID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return
}
var m models.Material
res := db.Where("id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
writeOpaque500(w)
return
}
if res.RowsAffected == 0 {
writeNotFound(w)
return
}
raw, ok := body(w, r)
if !ok {
return
}
isPrivate, setShares, shares, ok := decode(db, raw)
if !ok {
return
}
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
return applyMaterialAccess(tx, &m, callerID, isPrivate, setShares, shares)
})
if err != nil {
writeOpaque500(w)
return
}
writeManaged(w, r, db, m.ID, http.StatusOK, nil)
}
// SetMaterialVisibility answers PATCH /materials/{id}/visibility, body
// {"is_private": bool}: private means only the author and the users it is
// shared with see the material. A material with no author gets the caller
// as author. An unknown id is a 404.
func (h *Handlers) SetMaterialVisibility(w http.ResponseWriter, r *http.Request) {
h.managedAccessChange(w, r, func(_ *gorm.DB, raw []byte) (*bool, bool, []uint, bool) {
var in struct {
IsPrivate *bool `json:"is_private"`
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeInvalid(w, "invalid JSON: "+err.Error())
return nil, false, nil, false
}
if in.IsPrivate == nil {
writeValidation(w, map[string][]string{"is_private": {"The is_private field is required and must be true or false."}})
return nil, false, nil, false
}
return in.IsPrivate, false, nil, true
})
}
// SetMaterialShares answers PUT /materials/{id}/shares, body
// {"user_ids": [id]}: the list (null meaning none) replaces the users the
// material is shared with. The claim rule is as for SetMaterialVisibility.
func (h *Handlers) SetMaterialShares(w http.ResponseWriter, r *http.Request) {
h.managedAccessChange(w, r, func(db *gorm.DB, raw []byte) (*bool, bool, []uint, bool) {
var in struct {
UserIDs []uint `json:"user_ids"`
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeInvalid(w, "invalid JSON: "+err.Error())
return nil, false, nil, false
}
var keys map[string]json.RawMessage
_ = json.Unmarshal(raw, &keys)
if _, ok := keys["user_ids"]; !ok {
writeValidation(w, map[string][]string{"user_ids": {"The user_ids field is required: a list of user ids, empty for none."}})
return nil, false, nil, false
}
clean, msg, err := cleanUserIDs(db, in.UserIDs)
if err != nil {
writeOpaque500(w)
return nil, false, nil, false
}
if msg != "" {
writeValidation(w, map[string][]string{"user_ids": {msg}})
return nil, false, nil, false
}
return nil, true, clean, true
})
}
// ManagedUsers answers GET /users: the id, display name and email of every
// user, to pick a material's author or shares.
func (h *Handlers) ManagedUsers(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
users, err := listUserRefs(db, 0)
if err != nil {
writeOpaque500(w)
return
}
writeData(w, users)
}