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

@@ -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)
}