feat: per-student owned flag on readings

- new table jz_bm_reading_ownership and its migration
  20261026120000_create_jz_bm_reading_ownership
- PUT and DELETE /_bm/api/v1/readings/{id}/owned, idempotent
- owned_at on every reading in /readings and /courses/{id}
- read semantics, progress routes and counts unchanged
This commit is contained in:
2026-10-26 11:11:22 +01:00
parent 85cae45cb9
commit cf84f1d0e8
12 changed files with 142 additions and 14 deletions

View File

@@ -47,6 +47,8 @@ func handlers(h *api.Handlers) map[string]http.HandlerFunc {
"readings": h.Readings,
"mark": h.MarkRead,
"unmark": h.UnmarkRead,
"own": h.MarkOwned,
"unown": h.UnmarkOwned,
"materials": h.Materials,
"material": h.Material,
"feed": h.CalendarFeed,

View File

@@ -168,7 +168,7 @@ func newDeadlinesJSON(rows []models.Deadline) []deadlineJSON {
return out
}
// readingJSON is one reading with the caller's read state.
// readingJSON is one reading with the caller's read and owned state.
type readingJSON struct {
ID uint `json:"id"`
CourseID uint `json:"course_id"`
@@ -180,6 +180,7 @@ type readingJSON struct {
IsRequired bool `json:"is_required"`
SortOrder int `json:"sort_order"`
ReadAt *string `json:"read_at"`
OwnedAt *string `json:"owned_at"`
}
// tagJSON is one material tag.

View File

@@ -9,19 +9,22 @@ import (
"gorm.io/gorm"
)
// readingRow is a reading joined with the caller's progress row.
// readingRow is a reading joined with the caller's progress and ownership
// rows.
type readingRow struct {
models.Reading `gorm:"embedded"`
ReadAt *time.Time `gorm:"column:read_at"`
OwnedAt *time.Time `gorm:"column:owned_at"`
}
// listReadings returns the readings, optionally of one course, in course and
// reading order, each with the caller's read_at.
// reading order, each with the caller's read_at and owned_at.
func listReadings(db *gorm.DB, userID, courseID uint) ([]readingJSON, error) {
q := db.Table("jz_bm_readings AS r").
Select("r.*, p.read_at").
Select("r.*, p.read_at, o.owned_at").
Joins("JOIN jz_bm_courses c ON c.id = r.course_id").
Joins("LEFT JOIN jz_bm_reading_progress p ON p.reading_id = r.id AND p.user_id = ?", userID)
Joins("LEFT JOIN jz_bm_reading_progress p ON p.reading_id = r.id AND p.user_id = ?", userID).
Joins("LEFT JOIN jz_bm_reading_ownership o ON o.reading_id = r.id AND o.user_id = ?", userID)
if courseID != 0 {
q = q.Where("r.course_id = ?", courseID)
}
@@ -50,6 +53,10 @@ func listReadings(db *gorm.DB, userID, courseID uint) ([]readingJSON, error) {
s := models.FormatDateTime(*row.ReadAt)
item.ReadAt = &s
}
if row.OwnedAt != nil {
s := models.FormatDateTime(*row.OwnedAt)
item.OwnedAt = &s
}
out = append(out, item)
}
return out, nil
@@ -185,3 +192,62 @@ func (h *Handlers) UnmarkRead(w http.ResponseWriter, r *http.Request) {
}
writeData(w, progressJSON{ReadingID: readingID})
}
// ownershipJSON is the answer of the owned routes.
type ownershipJSON struct {
ReadingID uint `json:"reading_id"`
OwnedAt *string `json:"owned_at"`
}
// MarkOwned answers PUT /readings/{id}/owned: it marks the reading owned
// by the caller. Marking it again keeps the first owned_at.
func (h *Handlers) MarkOwned(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
readingID, ok := readingFor(w, r, db)
if !ok {
return
}
now := h.now().UTC()
err := db.Exec(`INSERT INTO jz_bm_reading_ownership (reading_id, user_id, owned_at) VALUES (?, ?, ?)
ON CONFLICT (reading_id, user_id) DO NOTHING`, readingID, userID, now).Error
if err != nil {
writeOpaque500(w)
return
}
var ownership models.ReadingOwnership
if err := db.Where("reading_id = ? AND user_id = ?", readingID, userID).Take(&ownership).Error; err != nil {
writeOpaque500(w)
return
}
ownedAt := models.FormatDateTime(ownership.OwnedAt)
writeData(w, ownershipJSON{ReadingID: readingID, OwnedAt: &ownedAt})
}
// UnmarkOwned answers DELETE /readings/{id}/owned: it clears the caller's
// owned mark. Clearing a reading that is not owned is not an error.
func (h *Handlers) UnmarkOwned(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
readingID, ok := readingFor(w, r, db)
if !ok {
return
}
if err := db.Where("reading_id = ? AND user_id = ?", readingID, userID).Delete(&models.ReadingOwnership{}).Error; err != nil {
writeOpaque500(w)
return
}
writeData(w, ownershipJSON{ReadingID: readingID})
}