feat(manage): GET and PUT /api/v1/bm/materials/{id}

The management API could only upsert a material on its title and course,
so a renamed note became a second material. PUT /materials/{id} applies the
same body to one material (422 when the new title and course belong to
another one), GET /materials/{id} shows a draft or published material with
its public files, and the upsert, update and validation share one path.
The README documents the management API.
This commit is contained in:
Jakub Zych
2026-10-16 20:40:47 +02:00
parent 2a8cdac282
commit d2096e2128
5 changed files with 387 additions and 72 deletions

View File

@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"slices"
"strconv"
"strings"
"time"
"unicode"
@@ -15,6 +16,7 @@ import (
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire"
"git.golem15.com/jakub/sm-bm-plugin/classes"
@@ -198,7 +200,7 @@ func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {
writeData(w, out)
}
// materialInput is the body of POST /materials.
// materialInput is the body of POST /materials and PUT /materials/{id}.
type materialInput struct {
CourseID *uint `json:"course_id"`
Title string `json:"title"`
@@ -210,29 +212,36 @@ type materialInput struct {
Tags *[]string `json:"tags"`
}
// UpsertMaterial answers POST /materials: it creates the material, or
// updates the one with the same title and course, so sending a document
// twice changes nothing. body, source and url are replaced as sent.
// published_at: absent publishes a new material now and keeps an existing
// one as it is; null makes a draft; otherwise a timestamp as in the seed
// file. tags: absent keeps them; otherwise the list of tag names replaces
// them, and unknown tags are created.
func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
// materialDraft is a decoded and validated material body, ready to apply.
type materialDraft struct {
in materialInput
// hasPublishedAt tells a sent published_at (null included) from an
// absent one.
hasPublishedAt bool
publishedAt *time.Time
tags []models.Tag
}
// readMaterial decodes and validates the body of POST /materials and
// PUT /materials/{id}. It answers 400, 413, 422 or 500 itself and returns
// false then.
func (h *Handlers) readMaterial(w http.ResponseWriter, r *http.Request, db *gorm.DB) (materialDraft, bool) {
var d materialDraft
raw, ok := body(w, r)
if !ok {
return
return d, false
}
var in materialInput
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
if err := dec.Decode(&d.in); err != nil {
writeInvalid(w, "invalid JSON: "+err.Error())
return
return d, false
}
var keys map[string]json.RawMessage
_ = json.Unmarshal(raw, &keys)
_, hasPublishedAt := keys["published_at"]
_, d.hasPublishedAt = keys["published_at"]
in := &d.in
in.Title = strings.TrimSpace(in.Title)
errs := map[string][]string{}
if in.Title == "" || len(in.Title) > 255 {
@@ -247,15 +256,13 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
if in.URL != nil && len(*in.URL) > 2048 {
errs["url"] = []string{"The url may not be longer than 2048 characters."}
}
var publishedAt *time.Time
if in.PublishedAt != nil {
t, err := models.ParseDateTime(*in.PublishedAt)
if err != nil {
errs["published_at"] = []string{"The published_at is not a valid date and time."}
}
publishedAt = &t
d.publishedAt = &t
}
var tags []models.Tag
if in.Tags != nil {
for _, name := range *in.Tags {
name = strings.TrimSpace(name)
@@ -264,20 +271,16 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
errs["tags"] = []string{"Each tag must be a name of 1 to 100 characters."}
break
}
if !slices.ContainsFunc(tags, func(t models.Tag) bool { return t.Slug == slug }) {
tags = append(tags, models.Tag{Name: name, Slug: slug})
if !slices.ContainsFunc(d.tags, func(t models.Tag) bool { return t.Slug == slug }) {
d.tags = append(d.tags, models.Tag{Name: name, Slug: slug})
}
}
}
db, ok := h.db(w, r)
if !ok {
return
}
if in.CourseID != nil {
var n int64
if err := db.Model(&models.Course{}).Where("id = ?", *in.CourseID).Count(&n).Error; err != nil {
writeOpaque500(w)
return
return d, false
}
if n == 0 {
errs["course_id"] = []string{"The selected course_id is invalid."}
@@ -285,73 +288,210 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
}
if len(errs) > 0 {
writeValidation(w, errs)
return d, false
}
return d, true
}
// identity scopes a query to the materials with the draft's title and
// course: the identity POST /materials upserts on.
func (d materialDraft) identity(q *gorm.DB) *gorm.DB {
q = q.Where("title = ?", d.in.Title)
if d.in.CourseID != nil {
return q.Where("course_id = ?", *d.in.CourseID)
}
return q.Where("course_id IS NULL")
}
// apply writes the draft onto m inside tx. published_at: absent publishes
// a new material now and keeps an existing one as it is; null makes a
// draft; otherwise the sent time. 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 {
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
switch {
case d.hasPublishedAt:
m.PublishedAt = d.publishedAt
case created:
m.PublishedAt = &now
}
if err := tx.Omit("Course", "Tags").Save(m).Error; err != nil {
return err
}
if in.Tags == nil {
return nil
}
if err := tx.Where("material_id = ?", m.ID).Delete(&models.MaterialTag{}).Error; err != nil {
return err
}
for _, t := range d.tags {
var tag models.Tag
res := tx.Where("slug = ?", t.Slug).Limit(1).Find(&tag)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
tag = t
if err := tx.Create(&tag).Error; err != nil {
return err
}
}
if err := tx.Create(&models.MaterialTag{MaterialID: m.ID, TagID: tag.ID}).Error; err != nil {
return err
}
}
return nil
}
// writeManaged answers with the material id, reloaded with its course and
// tags, plus any extra top-level keys.
func writeManaged(w http.ResponseWriter, db *gorm.DB, id uint, status int, extra map[string]any) {
var out models.Material
if err := preloadMaterial(db).First(&out, id).Error; err != nil {
writeOpaque500(w)
return
}
payload := map[string]any{"data": newManagedMaterialJSON(out)}
for k, v := range extra {
payload[k] = v
}
wire.WriteJSON(w, status, payload)
}
// UpsertMaterial answers POST /materials: it creates the material, or
// updates the one with the same title and course, so sending a document
// twice changes nothing. body, source and url are replaced as sent; see
// 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) {
db, ok := h.db(w, r)
if !ok {
return
}
d, ok := h.readMaterial(w, r, db)
if !ok {
return
}
var m models.Material
created := false
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
q := tx.Where("title = ?", in.Title)
if in.CourseID != nil {
q = q.Where("course_id = ?", *in.CourseID)
} else {
q = q.Where("course_id IS NULL")
}
res := q.Order("id").Limit(1).Find(&m)
res := d.identity(tx).Order("id").Limit(1).Find(&m)
if res.Error != nil {
return res.Error
}
created = res.RowsAffected == 0
m.CourseID, m.Title, m.Type = in.CourseID, in.Title, in.Type
m.Body, m.Source, m.URL = in.Body, in.Source, in.URL
switch {
case hasPublishedAt:
m.PublishedAt = publishedAt
case created:
now := h.now()
m.PublishedAt = &now
}
if err := tx.Omit("Course", "Tags").Save(&m).Error; err != nil {
return err
}
if in.Tags == nil {
return nil
}
if err := tx.Where("material_id = ?", m.ID).Delete(&models.MaterialTag{}).Error; err != nil {
return err
}
for _, t := range tags {
var tag models.Tag
res := tx.Where("slug = ?", t.Slug).Limit(1).Find(&tag)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
tag = t
if err := tx.Create(&tag).Error; err != nil {
return err
}
}
if err := tx.Create(&models.MaterialTag{MaterialID: m.ID, TagID: tag.ID}).Error; err != nil {
return err
}
}
return nil
return d.apply(tx, &m, created, h.now())
})
if err != nil {
writeOpaque500(w)
return
}
var out models.Material
if err := preloadMaterial(db).First(&out, m.ID).Error; err != nil {
writeOpaque500(w)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
wire.WriteJSON(w, status, map[string]any{"data": newManagedMaterialJSON(out), "created": created})
writeManaged(w, db, m.ID, status, map[string]any{"created": created})
}
// UpdateMaterial answers PUT /materials/{id}: the same body as POST, applied
// to that material, so a title or course can change. A title and course
// 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) {
db, ok := h.db(w, r)
if !ok {
return
}
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return
}
d, ok := h.readMaterial(w, r, db)
if !ok {
return
}
var m models.Material
found, taken := false, false
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
res := tx.Where("id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil
}
found = true
var n int64
if err := d.identity(tx.Model(&models.Material{})).Where("id <> ?", m.ID).Count(&n).Error; err != nil {
return err
}
if n > 0 {
taken = true
return nil
}
return d.apply(tx, &m, false, h.now())
})
switch {
case err != nil:
writeOpaque500(w)
case !found:
writeNotFound(w)
case taken:
writeValidation(w, map[string][]string{"title": {"Another material already has this title and course."}})
default:
writeManaged(w, db, m.ID, http.StatusOK, nil)
}
}
// managedMaterialDetailJSON is GET /materials/{id}: the material with its
// public files.
type managedMaterialDetailJSON struct {
managedMaterialJSON
Files []fileJSON `json:"files"`
}
// ManagedMaterial answers GET /materials/{id}: one material, draft or not,
// with its public files.
func (h *Handlers) ManagedMaterial(w http.ResponseWriter, r *http.Request) {
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 := preloadMaterial(db).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
writeOpaque500(w)
return
}
if res.RowsAffected == 0 {
writeNotFound(w)
return
}
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
}
out := managedMaterialDetailJSON{managedMaterialJSON: newManagedMaterialJSON(m), Files: make([]fileJSON, 0, len(files))}
for i := range files {
f := &files[i]
if !f.Public() {
continue
}
out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType})
}
writeData(w, out)
}
// DeleteMaterial answers DELETE /materials/{id}.