feat: private materials with per-user shares

A material may be private: only its author and the users it is shared
with see it. Every student read (list, detail, course detail and
counters, the checklist linked material, the token twins) goes through
one visibility fragment. Existing materials stay public with no author.

GET/PUT /_bm/api/v1/materials/{id}/access let the author, a material
manager, toggle the flag and pick the users; the first change of an
author-less material makes the caller its author.
This commit is contained in:
Jakub Zych
2026-11-08 11:52:47 +01:00
parent 5ed02ed9f7
commit 418f8240a4
14 changed files with 688 additions and 65 deletions

View File

@@ -211,9 +211,12 @@ func loadChecklistItems(db *gorm.DB, userID uint, checklistIDs []uint) (map[uint
}
materials := map[uint]*linkedMaterial{}
if len(materialIDs) > 0 {
// Only published materials, as in the material list.
// Only published materials the caller may see, so a private
// material's title never shows.
var ms []models.Material
if err := db.Select("id", "title").Where("id IN ? AND published_at IS NOT NULL", materialIDs).Find(&ms).Error; err != nil {
err := db.Select("id", "title").Where("id IN ? AND published_at IS NOT NULL", materialIDs).
Where(visibleMaterialSQL("jz_bm_materials"), userID, userID).Find(&ms).Error
if err != nil {
return nil, err
}
for _, m := range ms {

View File

@@ -23,7 +23,7 @@ func (c courseRow) counts() courseCounts {
}
// coursesWithCounts selects courses with their session, reading and
// published material counts and the caller's read readings.
// visible published material counts and the caller's read readings.
func coursesWithCounts(db *gorm.DB, userID uint, now time.Time) *gorm.DB {
return db.Table("jz_bm_courses AS c").Select(`c.*,
(SELECT COUNT(*) FROM jz_bm_class_sessions s WHERE s.course_id = c.id) AS sessions_count,
@@ -31,7 +31,7 @@ func coursesWithCounts(db *gorm.DB, userID uint, now time.Time) *gorm.DB {
(SELECT COUNT(*) FROM jz_bm_reading_progress p JOIN jz_bm_readings r ON r.id = p.reading_id
WHERE r.course_id = c.id AND p.user_id = ?) AS readings_read,
(SELECT COUNT(*) FROM jz_bm_materials m WHERE m.course_id = c.id
AND m.published_at IS NOT NULL AND m.published_at <= ?) AS materials_count`, userID, now.UTC())
AND m.published_at IS NOT NULL AND m.published_at <= ? AND `+visibleMaterialSQL("m")+`) AS materials_count`, userID, now.UTC(), userID, userID)
}
// Courses answers GET /courses?semester=N: every course in programme order
@@ -103,7 +103,7 @@ func (h *Handlers) Course(w http.ResponseWriter, r *http.Request) {
writeOpaque500(w)
return
}
materials, err := publishedMaterialsOfCourse(db, now, row.ID)
materials, err := publishedMaterialsOfCourse(db, now, userID, row.ID)
if err != nil {
writeOpaque500(w)
return

View File

@@ -38,25 +38,26 @@ func serve(t *testing.T, h http.HandlerFunc, method, target string, user *bounce
func handlers(h *api.Handlers) map[string]http.HandlerFunc {
return map[string]http.HandlerFunc{
"dashboard": h.Dashboard,
"courses": h.Courses,
"course": h.Course,
"schedule": h.Schedule,
"meetings": h.Meetings,
"deadlines": h.Deadlines,
"readings": h.Readings,
"mark": h.MarkRead,
"unmark": h.UnmarkRead,
"own": h.MarkOwned,
"unown": h.UnmarkOwned,
"materials": h.Materials,
"material": h.Material,
"feed": h.CalendarFeed,
"feed-put": h.UpdateCalendarFeed,
"rotate": h.RotateCalendarFeed,
"feed-del": h.DeleteCalendarFeed,
"chat": h.Chat,
"chat-mem": h.ChatMembers,
"dashboard": h.Dashboard,
"courses": h.Courses,
"course": h.Course,
"schedule": h.Schedule,
"meetings": h.Meetings,
"deadlines": h.Deadlines,
"readings": h.Readings,
"mark": h.MarkRead,
"unmark": h.UnmarkRead,
"own": h.MarkOwned,
"unown": h.UnmarkOwned,
"materials": h.Materials,
"material": h.Material,
"mat-access": h.MaterialAccess,
"feed": h.CalendarFeed,
"feed-put": h.UpdateCalendarFeed,
"rotate": h.RotateCalendarFeed,
"feed-del": h.DeleteCalendarFeed,
"chat": h.Chat,
"chat-mem": h.ChatMembers,
}
}
@@ -71,8 +72,9 @@ type bodyRequest struct {
func bodyHandlers(h *api.Handlers) map[string]bodyRequest {
return map[string]bodyRequest{
"chat-embeds": {h.ChatEmbeds, http.MethodGet, "/?type=deadline&q=esej", ""},
"chat-resolve": {h.ResolveChatEmbeds, http.MethodPost, "/", `{"refs":[{"type":"reading","id":1}]}`},
"chat-embeds": {h.ChatEmbeds, http.MethodGet, "/?type=deadline&q=esej", ""},
"chat-resolve": {h.ResolveChatEmbeds, http.MethodPost, "/", `{"refs":[{"type":"reading","id":1}]}`},
"mat-access-put": {h.UpdateMaterialAccess, http.MethodPut, "/", `{"is_private":true,"user_ids":[]}`},
}
}

View File

@@ -199,7 +199,7 @@ func newTagsJSON(tags []models.Tag) []tagJSON {
return out
}
// materialListJSON is a published material in a list.
// materialListJSON is a published material the caller may see, in a list.
type materialListJSON struct {
ID uint `json:"id"`
CourseID *uint `json:"course_id"`
@@ -212,6 +212,7 @@ type materialListJSON struct {
PublishedAt string `json:"published_at"`
Tags []tagJSON `json:"tags"`
FilesCount int64 `json:"files_count"`
IsPrivate bool `json:"is_private"`
}
func newMaterialListJSON(m models.Material, files int64) materialListJSON {
@@ -226,6 +227,7 @@ func newMaterialListJSON(m models.Material, files int64) materialListJSON {
URL: m.URL,
Tags: newTagsJSON(m.Tags),
FilesCount: files,
IsPrivate: m.IsPrivate,
}
if m.PublishedAt != nil {
out.PublishedAt = models.FormatDateTime(*m.PublishedAt)
@@ -233,11 +235,13 @@ func newMaterialListJSON(m models.Material, files int64) materialListJSON {
return out
}
// materialDetailJSON is a published material with its body and files.
// materialDetailJSON is a published material the caller may see, with its
// body and files. CanManage is true for the caller who may change its access.
type materialDetailJSON struct {
materialListJSON
Body *string `json:"body"`
Files []fileJSON `json:"files"`
Body *string `json:"body"`
Files []fileJSON `json:"files"`
CanManage bool `json:"can_manage"`
}
// fileJSON is one attached file.

View File

@@ -49,3 +49,44 @@ func TestMaterialJSONCarriesMeetingID(t *testing.T) {
}
}
}
// TestMaterialJSONCarriesPrivacy checks that is_private is always present in
// a material and that the detail carries can_manage, false by default.
func TestMaterialJSONCarriesPrivacy(t *testing.T) {
for _, tc := range []struct {
private bool
want string
}{
{true, `"is_private":true`},
{false, `"is_private":false`},
} {
b, err := json.Marshal(newMaterialListJSON(models.Material{ID: 1, Title: "Materiał", Type: "pdf", IsPrivate: tc.private}, 0))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), tc.want) {
t.Errorf("material JSON %s does not contain %s", b, tc.want)
}
}
b, err := json.Marshal(materialDetailJSON{materialListJSON: newMaterialListJSON(models.Material{ID: 1, Title: "Materiał", Type: "pdf"}, 0)})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"can_manage":false`) {
t.Errorf("material detail JSON %s does not contain can_manage false", 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) {
got := visibleMaterialSQL("m")
for _, want := range []string{"m.is_private = FALSE", "m.author_user_id = ?", "jz_bm_material_shares", ".material_id = m.id"} {
if !strings.Contains(got, want) {
t.Errorf("visibleMaterialSQL(m) = %q, missing %q", got, want)
}
}
if n := strings.Count(got, "?"); n != 2 {
t.Errorf("visibleMaterialSQL(m) has %d placeholders, want 2", n)
}
}

View File

@@ -0,0 +1,343 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"slices"
"strconv"
"strings"
"time"
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/controllers"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// Private materials: a material with is_private is visible only to its
// author and to the users it is shared with (jz_bm_material_shares). Every
// student read goes through visibleMaterials or, in raw SQL, through
// visibleMaterialSQL, so a private material never shows up elsewhere.
// visibleMaterialSQL is the visibility condition of the materials table
// under alias: public, written by the caller, or shared with the caller. It
// takes the args (userID, userID).
func visibleMaterialSQL(alias string) string {
return "(" + alias + ".is_private = FALSE OR " + alias + ".author_user_id = ? OR EXISTS (SELECT 1 FROM jz_bm_material_shares ms WHERE ms.material_id = " + alias + ".id AND ms.user_id = ?))"
}
// visibleMaterials scopes a material query to what the caller may see: a
// publication time that has passed and the visibility rule. It is the only
// scope student material reads may use.
func visibleMaterials(q *gorm.DB, now time.Time, userID uint) *gorm.DB {
return published(q, now).Where(visibleMaterialSQL("jz_bm_materials"), userID, userID)
}
// userRefJSON is a user as the material access lists show it.
type userRefJSON struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// newUserRef labels a user by "name surname", or by the email when both are
// empty.
func newUserRef(u usermodels.User) userRefJSON {
name := strings.TrimSpace(deref(u.Name) + " " + deref(u.Surname))
if name == "" {
name = u.Email
}
return userRefJSON{ID: u.ID, Name: name, Email: u.Email}
}
func selectUserRefs(db *gorm.DB) *gorm.DB {
return db.Model(&usermodels.User{}).Select("id", "name", "surname", "email")
}
// listUserRefs lists every user but excludeID (none when 0), by name.
func listUserRefs(db *gorm.DB, excludeID uint) ([]userRefJSON, error) {
q := selectUserRefs(db)
if excludeID != 0 {
q = q.Where("id <> ?", excludeID)
}
var users []usermodels.User
if err := q.Order("name, surname, email, id").Find(&users).Error; err != nil {
return nil, err
}
out := make([]userRefJSON, 0, len(users))
for _, u := range users {
out = append(out, newUserRef(u))
}
return out, nil
}
// userRefsByID returns the existing users among ids, keyed by id.
func userRefsByID(db *gorm.DB, ids []uint) (map[uint]userRefJSON, error) {
out := map[uint]userRefJSON{}
if len(ids) == 0 {
return out, nil
}
var users []usermodels.User
if err := selectUserRefs(db).Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, err
}
for _, u := range users {
out[u.ID] = newUserRef(u)
}
return out, nil
}
// materialShareRefs returns the users each material is shared with, by user
// id. Deleted users drop out.
func materialShareRefs(db *gorm.DB, materialIDs []uint) (map[uint][]userRefJSON, error) {
out := map[uint][]userRefJSON{}
if len(materialIDs) == 0 {
return out, nil
}
var rows []models.MaterialShare
if err := db.Where("material_id IN ?", materialIDs).Order("material_id, user_id").Find(&rows).Error; err != nil {
return nil, err
}
userIDs := make([]uint, 0, len(rows))
for _, row := range rows {
userIDs = append(userIDs, row.UserID)
}
users, err := userRefsByID(db, userIDs)
if err != nil {
return nil, err
}
for _, row := range rows {
if u, ok := users[row.UserID]; ok {
out[row.MaterialID] = append(out[row.MaterialID], u)
}
}
return out, nil
}
// maxShareUsers caps the users one material may be shared with.
const maxShareUsers = 200
// cleanUserIDs dedupes a share list and checks it: no zero ids, at most
// maxShareUsers, and every id an existing user. msg is the validation
// message when the list is refused.
func cleanUserIDs(db *gorm.DB, ids []uint) ([]uint, string, error) {
clean := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
return nil, "Each user id must be a positive number.", nil
}
if !slices.Contains(clean, id) {
clean = append(clean, id)
}
}
if len(clean) > maxShareUsers {
return nil, "A material may be shared with at most " + strconv.Itoa(maxShareUsers) + " users.", nil
}
slices.Sort(clean)
if len(clean) == 0 {
return clean, "", nil
}
var n int64
if err := db.Model(&usermodels.User{}).Where("id IN ?", clean).Count(&n).Error; err != nil {
return nil, "", err
}
if int(n) != len(clean) {
return nil, "The selected user_ids are invalid.", nil
}
return clean, "", nil
}
// replaceMaterialShares makes userIDs the users the material is shared with.
func replaceMaterialShares(tx *gorm.DB, materialID uint, userIDs []uint) error {
if err := tx.Where("material_id = ?", materialID).Delete(&models.MaterialShare{}).Error; err != nil {
return err
}
for _, id := range userIDs {
if err := tx.Create(&models.MaterialShare{MaterialID: materialID, UserID: id}).Error; err != nil {
return err
}
}
return nil
}
// managesMaterials tells whether the user holds jz.bm.access_materials.
func (h *Handlers) managesMaterials(ctx context.Context, db *gorm.DB, userID uint) (bool, error) {
principal, err := userclasses.PrincipalByID(ctx, db, userID)
if err != nil {
return false, err
}
return principal != nil && cabana.Allows(principal, []string{controllers.PermMaterials}), nil
}
// canManageMaterial tells whether a material manager may change the
// material's access: it is theirs, or it has no author yet (the first change
// makes them its author).
func canManageMaterial(m models.Material, userID uint, manager bool) bool {
return manager && (m.AuthorUserID == nil || *m.AuthorUserID == userID)
}
// materialAccessJSON is GET /materials/{id}/access: the material's
// visibility, the users it is shared with and the users to pick from.
type materialAccessJSON struct {
MaterialID uint `json:"material_id"`
IsPrivate bool `json:"is_private"`
AuthorUserID *uint `json:"author_user_id"`
Shares []userRefJSON `json:"shares"`
Users []userRefJSON `json:"users"`
}
// materialAccessInput is the body of PUT /materials/{id}/access.
type materialAccessInput struct {
IsPrivate *bool `json:"is_private"`
UserIDs *[]uint `json:"user_ids"`
}
// managedMaterialForCaller loads a material the caller sees and may manage.
// It answers 404 or 500 itself and returns false then: a material the caller
// may not manage is a 404, so nothing hints that a private one exists.
func (h *Handlers) managedMaterialForCaller(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID uint) (models.Material, bool) {
var m models.Material
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return m, false
}
res := visibleMaterials(db, h.now(), userID).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
writeOpaque500(w)
return m, false
}
if res.RowsAffected == 0 {
writeNotFound(w)
return m, false
}
manager, err := h.managesMaterials(r.Context(), db, userID)
if err != nil {
writeOpaque500(w)
return m, false
}
if !canManageMaterial(m, userID, manager) {
writeNotFound(w)
return m, false
}
return m, true
}
// writeMaterialAccess answers the access of m as the caller sees it.
func writeMaterialAccess(w http.ResponseWriter, db *gorm.DB, m models.Material, userID uint) {
shares, err := materialShareRefs(db, []uint{m.ID})
if err != nil {
writeOpaque500(w)
return
}
users, err := listUserRefs(db, userID)
if err != nil {
writeOpaque500(w)
return
}
out := materialAccessJSON{MaterialID: m.ID, IsPrivate: m.IsPrivate, AuthorUserID: m.AuthorUserID, Shares: shares[m.ID], Users: users}
if out.Shares == nil {
out.Shares = []userRefJSON{}
}
writeData(w, out)
}
// MaterialAccess answers GET /materials/{id}/access for the material's
// author (or any material manager while it has no author): its visibility,
// its shares and the users it can be shared with. Anyone else gets a 404.
func (h *Handlers) MaterialAccess(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
m, ok := h.managedMaterialForCaller(w, r, db, userID)
if !ok {
return
}
writeMaterialAccess(w, db, m, userID)
}
// UpdateMaterialAccess answers PUT /materials/{id}/access, body
// {"is_private": bool?, "user_ids": [id]?}: absent keys keep the stored
// value, and user_ids (null meaning none) replaces the shares. The first
// change of a material with no author makes the caller its author. Access is
// as for MaterialAccess.
func (h *Handlers) UpdateMaterialAccess(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
m, ok := h.managedMaterialForCaller(w, r, db, userID)
if !ok {
return
}
raw, ok := body(w, r)
if !ok {
return
}
var in materialAccessInput
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeInvalid(w, "invalid JSON: "+err.Error())
return
}
var shares []uint
if in.UserIDs != nil {
clean, msg, err := cleanUserIDs(db, *in.UserIDs)
if err != nil {
writeOpaque500(w)
return
}
if msg != "" {
writeValidation(w, map[string][]string{"user_ids": {msg}})
return
}
shares = clean
}
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
return applyMaterialAccess(tx, &m, userID, in.IsPrivate, in.UserIDs != nil, shares)
})
if err != nil {
writeOpaque500(w)
return
}
writeMaterialAccess(w, db, m, userID)
}
// applyMaterialAccess writes a visibility and share change onto m inside
// tx. A material with no author gets userID as author (the claim rule).
// Only is_private and author_user_id are updated; the shares are replaced
// when setShares.
func applyMaterialAccess(tx *gorm.DB, m *models.Material, userID uint, isPrivate *bool, setShares bool, shares []uint) error {
if m.AuthorUserID == nil {
id := userID
m.AuthorUserID = &id
}
if isPrivate != nil {
m.IsPrivate = *isPrivate
}
err := tx.Model(&models.Material{}).Where("id = ?", m.ID).
Updates(map[string]any{"is_private": m.IsPrivate, "author_user_id": m.AuthorUserID}).Error
if err != nil {
return err
}
if !setShares {
return nil
}
return replaceMaterialShares(tx, m.ID, shares)
}

View File

@@ -48,15 +48,16 @@ func preloadMaterial(q *gorm.DB) *gorm.DB {
return q.Preload("Course").Preload("Tags", func(db *gorm.DB) *gorm.DB { return db.Order("jz_bm_tags.name") })
}
// listMaterials returns one page of published materials, newest first.
func listMaterials(db *gorm.DB, now time.Time, f materialFilter, page, perPage int) ([]materialListJSON, int64, error) {
q := f.apply(published(db.Model(&models.Material{}), now))
// listMaterials returns one page of the published materials userID may
// see, newest first.
func listMaterials(db *gorm.DB, now time.Time, userID uint, f materialFilter, page, perPage int) ([]materialListJSON, int64, error) {
q := f.apply(visibleMaterials(db.Model(&models.Material{}), now, userID))
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []models.Material
err := preloadMaterial(f.apply(published(db, now))).
err := preloadMaterial(f.apply(visibleMaterials(db, now, userID))).
Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC").
Offset((page - 1) * perPage).Limit(perPage).
Find(&rows).Error
@@ -99,9 +100,11 @@ func materialsJSON(db *gorm.DB, rows []models.Material) ([]materialListJSON, err
}
// Materials answers GET /materials?course=&meeting=&tag=&type=&page=&per_page=:
// a page of published materials in the {data, meta} shape of lagoon.Paginate.
// a page of the published materials the caller may see, in the {data, meta}
// shape of lagoon.Paginate.
func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
if _, ok := h.caller(w, r); !ok {
userID, ok := h.caller(w, r)
if !ok {
return
}
in := query(r, "course", "meeting", "tag", "type", "page", "per_page")
@@ -115,7 +118,7 @@ func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
filter := materialFilter{CourseID: uintParam(in, "course"), MeetingID: uintParam(in, "meeting"), Tag: strings.TrimSpace(str(in, "tag")), Type: str(in, "type")}
page := min(intParam(in, "page", 1), 100000)
perPage := intParam(in, "per_page", h.perPage())
rows, total, err := listMaterials(db, h.now(), filter, page, perPage)
rows, total, err := listMaterials(db, h.now(), userID, filter, page, perPage)
if err != nil {
writeOpaque500(w)
return
@@ -123,11 +126,14 @@ func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
writeJSONPage(w, lagoon.Paginate(rows, page, perPage, total))
}
// Material answers GET /materials/{id}: one published material with its
// body, tags and files. A draft or a future publication is a 404. An MCP
// call gets the body only with include_body (see materialView).
// Material answers GET /materials/{id}: one published material the caller
// may see, with its body, tags and files. A draft, a future publication or
// another user's private material is a 404. can_manage tells the author (a
// material manager) that the access panel applies. An MCP call gets the body
// only with include_body (see materialView).
func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
if _, ok := h.caller(w, r); !ok {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
@@ -140,7 +146,7 @@ func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
return
}
var m models.Material
res := preloadMaterial(published(db, h.now())).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
res := preloadMaterial(visibleMaterials(db, h.now(), userID)).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
writeOpaque500(w)
return
@@ -170,14 +176,22 @@ func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) {
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))
if m.AuthorUserID == nil || *m.AuthorUserID == userID {
manager, err := h.managesMaterials(r.Context(), db, userID)
if err != nil {
writeOpaque500(w)
return
}
out.CanManage = canManageMaterial(m, userID, manager)
}
writeData(w, materialView(out, viaMCP(r), includeBody(r)))
}
// publishedMaterialsOfCourse lists a course's published materials, newest
// first, for /courses/{id}.
func publishedMaterialsOfCourse(db *gorm.DB, now time.Time, courseID uint) ([]materialListJSON, error) {
// publishedMaterialsOfCourse lists a course's published materials that
// userID may see, newest first, for /courses/{id}.
func publishedMaterialsOfCourse(db *gorm.DB, now time.Time, userID uint, courseID uint) ([]materialListJSON, error) {
var rows []models.Material
err := preloadMaterial(published(db, now)).
err := preloadMaterial(visibleMaterials(db, now, userID)).
Where("jz_bm_materials.course_id = ?", courseID).
Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC").
Find(&rows).Error