feat: personal pins on materials

A per-user jz_bm_material_pins table and PUT/DELETE /materials/{id}/pin
on the student API and its token twins: 204 for a material the caller
may see, idempotent, 404 otherwise. List rows, course rows and the
detail carry pinned. The plain list lists the caller's pins first
(newest pin first); with q the relevance order stays. A pin writes only
the pins table and never changes the material's revision.
This commit is contained in:
Jakub Zych
2026-11-09 13:13:54 +01:00
parent b8b46be398
commit 9436fb62d6
13 changed files with 316 additions and 18 deletions

View File

@@ -51,6 +51,8 @@ func handlers(h *api.Handlers) map[string]http.HandlerFunc {
"unown": h.UnmarkOwned,
"materials": h.Materials,
"material": h.Material,
"pin": h.PinMaterial,
"unpin": h.UnpinMaterial,
"mat-access": h.MaterialAccess,
"mat-edit": h.EditMaterial,
"tags": h.Tags,

View File

@@ -213,6 +213,8 @@ type materialListJSON struct {
Tags []tagJSON `json:"tags"`
FilesCount int64 `json:"files_count"`
IsPrivate bool `json:"is_private"`
// Pinned is the caller's own pin; nobody else sees it.
Pinned bool `json:"pinned"`
// Snippet is the matched passage of a search result (*snippetJSON, or
// a plain string through MCP). It is nil, and the key absent, without
// q or without a highlight.

View File

@@ -77,6 +77,35 @@ func TestMaterialJSONCarriesPrivacy(t *testing.T) {
}
}
// TestMaterialJSONCarriesPinned checks that pinned is always present in a
// material row, false unless the caller set it, and that the detail carries
// the row's value.
func TestMaterialJSONCarriesPinned(t *testing.T) {
row := newMaterialListJSON(models.Material{ID: 1, Title: "Materiał", Type: "notatka"}, 0)
b, err := json.Marshal(row)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"pinned":false`) {
t.Errorf("material JSON %s does not contain pinned false", b)
}
row.Pinned = true
b, err = json.Marshal(row)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"pinned":true`) {
t.Errorf("pinned material JSON %s does not contain pinned true", b)
}
b, err = json.Marshal(materialDetailJSON{materialListJSON: row})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"pinned":true`) {
t.Errorf("pinned material detail JSON %s does not contain pinned true", b)
}
}
// TestVisibleMaterialSQL checks the visibility fragment: public, the author,
// or a share of the caller, with the caller id bound twice.
func TestVisibleMaterialSQL(t *testing.T) {

View File

@@ -0,0 +1,121 @@
package api
import (
"net/http"
"strconv"
"time"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// pinnedFirstOrder orders the plain material list for userID: the caller's
// own pins first, newest pin first, then the plain list's order (newest
// publication first). id breaks every tie, so pages are stable. userID is
// the authenticated caller's id, never request input, and is inlined as a
// parsed integer like rankOrder.
func pinnedFirstOrder(userID uint) string {
return "(SELECT p.pinned_at FROM jz_bm_material_pins p WHERE p.material_id = jz_bm_materials.id AND p.user_id = " +
strconv.FormatUint(uint64(userID), 10) +
") DESC NULLS LAST, jz_bm_materials.published_at DESC, jz_bm_materials.id DESC"
}
// pinnedMaterialIDs tells which of ids userID pinned. No ids is an empty
// answer without a query.
func pinnedMaterialIDs(db *gorm.DB, userID uint, ids []uint) (map[uint]bool, error) {
out := map[uint]bool{}
if len(ids) == 0 {
return out, nil
}
var pinned []uint
err := db.Model(&models.MaterialPin{}).
Where("user_id = ? AND material_id IN ?", userID, ids).
Pluck("material_id", &pinned).Error
if err != nil {
return nil, err
}
for _, id := range pinned {
out[id] = true
}
return out, nil
}
// pinTarget resolves {id} to a material userID may see and answers 404
// itself otherwise: the same 404 as the detail for an unknown id, a draft, a
// future publication or another user's private material, so nothing hints
// that a hidden material exists.
func pinTarget(w http.ResponseWriter, r *http.Request, db *gorm.DB, now time.Time, userID uint) (uint, bool) {
id, ok := surf.IntParam(r, "id")
if !ok || id <= 0 {
writeNotFound(w)
return 0, false
}
var m models.Material
res := visibleMaterials(db.Model(&models.Material{}), now, userID).
Where("jz_bm_materials.id = ?", id).
Select("jz_bm_materials.id").
Limit(1).
Find(&m)
if res.Error != nil {
writeOpaque500(w)
return 0, false
}
if res.RowsAffected == 0 {
writeNotFound(w)
return 0, false
}
return m.ID, true
}
// PinMaterial answers PUT /materials/{id}/pin: it pins a material the
// caller may see, for the caller only. Pinning again keeps the first
// pinned_at. A pin is personal and is not content: it writes only
// jz_bm_material_pins and leaves the material row as it is, so the
// material's version, what search finds and what an open editor sees stay
// the same. It answers 204.
func (h *Handlers) PinMaterial(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
materialID, ok := pinTarget(w, r, db, h.now(), userID)
if !ok {
return
}
err := db.Exec(`INSERT INTO jz_bm_material_pins (material_id, user_id, pinned_at) VALUES (?, ?, ?)
ON CONFLICT (material_id, user_id) DO NOTHING`, materialID, userID, h.now().UTC()).Error
if err != nil {
writeOpaque500(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UnpinMaterial answers DELETE /materials/{id}/pin: it removes the caller's
// own pin from a material the caller may see. Clearing a material that is
// not pinned is not an error. Like PinMaterial it writes only
// jz_bm_material_pins and never the material itself. It answers 204.
func (h *Handlers) UnpinMaterial(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
materialID, ok := pinTarget(w, r, db, h.now(), userID)
if !ok {
return
}
if err := db.Where("material_id = ? AND user_id = ?", materialID, userID).Delete(&models.MaterialPin{}).Error; err != nil {
writeOpaque500(w)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,64 @@
package api_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
"gorm.io/gorm"
)
// pinnedFirstQueries runs GET /materials?course=7&type=notatka as user on a
// dry-run database and returns its count and page queries.
func pinnedFirstQueries(t *testing.T, user uint) (string, string) {
t.Helper()
db, rec := dryDB(t)
h := &api.Handlers{DB: func() *gorm.DB { return db }}
req := httptest.NewRequest(http.MethodGet, "/materials?course=7&type=notatka", nil)
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user}))
w := httptest.NewRecorder()
h.Materials(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var count, page string
for _, q := range rec.queries() {
switch {
case strings.HasPrefix(q, "SELECT count(*)"):
count = q
case strings.HasPrefix(q, "SELECT") && strings.Contains(q, `FROM "jz_bm_materials"`):
page = q
}
}
if count == "" || page == "" {
t.Fatalf("queries = %q, want a count and a page", rec.queries())
}
return count, page
}
// TestMaterialsPinnedFirstTrace checks the plain list's order: the caller's
// own pins first, then newest first with id as the tie-breaker, inside the
// same filters, while the count ignores pins.
func TestMaterialsPinnedFirstTrace(t *testing.T) {
count, page := pinnedFirstQueries(t, 5)
for name, q := range map[string]string{"count": count, "page": page} {
for _, want := range []string{"jz_bm_materials.course_id = 7", "jz_bm_materials.type = 'notatka'", "jz_bm_materials.is_private = FALSE"} {
if !strings.Contains(q, want) {
t.Errorf("%s query lacks %q:\n%s", name, want, q)
}
}
}
if want := "p.user_id = 5) DESC NULLS LAST, jz_bm_materials.published_at DESC, jz_bm_materials.id DESC"; !strings.Contains(page, want) {
t.Errorf("page query is not pinned first for user 5, want %q:\n%s", want, page)
}
if strings.Contains(count, "jz_bm_material_pins") {
t.Errorf("count query reads pins:\n%s", count)
}
_, other := pinnedFirstQueries(t, 6)
if !strings.Contains(other, "p.user_id = 6)") || strings.Contains(other, "p.user_id = 5") {
t.Errorf("page query of user 6 does not use only their pins:\n%s", other)
}
}

View File

@@ -233,6 +233,9 @@ func TestMaterialsSearchTrace(t *testing.T) {
if !strings.Contains(page, "array_position(ARRAY[9,3]::bigint[], jz_bm_materials.id)") {
t.Errorf("page query is not in rank order:\n%s", page)
}
if strings.Contains(page, "jz_bm_material_pins") {
t.Errorf("page query with q orders by pins, want the rank order only:\n%s", page)
}
var out struct {
Data []map[string]any `json:"data"`
Meta struct {

View File

@@ -52,10 +52,12 @@ func preloadMaterial(q *gorm.DB) *gorm.DB {
}
// listMaterials returns one page of the published materials userID may
// see. With ids nil the page is newest first. With ids non-nil (search
// see. With ids nil the page lists the caller's pinned materials first
// (newest pin first), then newest first. With ids non-nil (search
// candidates in the engine's rank order) both the count and the page are
// also limited to ids and the page follows their order; the ids never widen
// what visibleMaterials and the filter allow. An empty ids is an empty page.
// also limited to ids and the page follows their order, where pins are only
// marked; the ids never widen what visibleMaterials and the filter allow.
// An empty ids is an empty page. The count never depends on pins.
func listMaterials(db *gorm.DB, now time.Time, userID uint, f materialFilter, ids []uint, page, perPage int) ([]materialListJSON, []models.Material, int64, error) {
if ids != nil && len(ids) == 0 {
return []materialListJSON{}, nil, 0, nil
@@ -72,19 +74,20 @@ func listMaterials(db *gorm.DB, now time.Time, userID uint, f materialFilter, id
if ids != nil {
rowsQ = rowsQ.Where("jz_bm_materials.id IN ?", ids).Order(rankOrder(ids))
} else {
rowsQ = rowsQ.Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC")
rowsQ = rowsQ.Order(pinnedFirstOrder(userID))
}
var rows []models.Material
err := rowsQ.Offset((page - 1) * perPage).Limit(perPage).Find(&rows).Error
if err != nil {
return nil, nil, 0, err
}
out, err := materialsJSON(db, rows)
out, err := materialsJSON(db, userID, rows)
return out, rows, total, err
}
// materialsJSON builds list rows with their file counts.
func materialsJSON(db *gorm.DB, rows []models.Material) ([]materialListJSON, error) {
// materialsJSON builds list rows with their file counts and userID's own
// pins.
func materialsJSON(db *gorm.DB, userID uint, rows []models.Material) ([]materialListJSON, error) {
ids := make([]string, 0, len(rows))
for _, m := range rows {
ids = append(ids, strconv.FormatUint(uint64(m.ID), 10))
@@ -107,9 +110,15 @@ func materialsJSON(db *gorm.DB, rows []models.Material) ([]materialListJSON, err
counts[row.AttachmentID] = row.Count
}
}
pinned, err := pinnedMaterialIDs(db, userID, materialIDs(rows))
if err != nil {
return nil, err
}
out := make([]materialListJSON, 0, len(rows))
for _, m := range rows {
out = append(out, newMaterialListJSON(m, counts[strconv.FormatUint(uint64(m.ID), 10)]))
row := newMaterialListJSON(m, counts[strconv.FormatUint(uint64(m.ID), 10)])
row.Pinned = pinned[m.ID]
out = append(out, row)
}
return out, nil
}
@@ -119,7 +128,9 @@ func materialsJSON(db *gorm.DB, rows []models.Material) ([]materialListJSON, err
// shape of lagoon.Paginate. With q (2 to 100 characters) the engine answers
// candidates, which SQL re-gates, counts and pages in rank order, each row
// with at most one snippet; when the engine cannot answer the request is a
// 503. Without q the list is newest first and never calls the engine.
// 503. Without q the caller's own pins come first (newest pin first), then
// newest first, and the engine is never called. With q the order is
// relevance and rows only carry pinned.
func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
@@ -227,7 +238,7 @@ func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
// 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
// files, can_manage, the revision and the caller's own pin. 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
@@ -259,6 +270,11 @@ func (h *Handlers) loadMaterialDetail(ctx context.Context, db *gorm.DB, userID,
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))
pinned, err := pinnedMaterialIDs(db, userID, []uint{m.ID})
if err != nil {
return materialDetailJSON{}, false, err
}
out.Pinned = pinned[m.ID]
if m.AuthorUserID == nil || *m.AuthorUserID == userID {
manager, err := h.managesMaterials(ctx, db, userID)
if err != nil {
@@ -270,7 +286,8 @@ func (h *Handlers) loadMaterialDetail(ctx context.Context, db *gorm.DB, userID,
}
// publishedMaterialsOfCourse lists a course's published materials that
// userID may see, newest first, for /courses/{id}.
// userID may see, newest first, for /courses/{id}. Rows carry the caller's
// pins but keep this order.
func publishedMaterialsOfCourse(db *gorm.DB, now time.Time, userID uint, courseID uint) ([]materialListJSON, error) {
var rows []models.Material
err := preloadMaterial(visibleMaterials(db, now, userID)).
@@ -280,5 +297,5 @@ func publishedMaterialsOfCourse(db *gorm.DB, now time.Time, userID uint, courseI
if err != nil {
return nil, err
}
return materialsJSON(db, rows)
return materialsJSON(db, userID, rows)
}