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.
122 lines
3.7 KiB
Go
122 lines
3.7 KiB
Go
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)
|
|
}
|