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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user