Files
sm-bm-plugin/controllers/api/readings.go
Jakub Zych cf84f1d0e8 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
2026-10-26 11:11:22 +01:00

254 lines
6.7 KiB
Go

package api
import (
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// 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 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, 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_ownership o ON o.reading_id = r.id AND o.user_id = ?", userID)
if courseID != 0 {
q = q.Where("r.course_id = ?", courseID)
}
var rows []readingRow
if err := q.Order("c.semester, c.sort_order, c.name, r.course_id, r.sort_order, r.id").Scan(&rows).Error; err != nil {
return nil, err
}
courses, err := coursesByID(db, readingCourseIDs(rows))
if err != nil {
return nil, err
}
out := make([]readingJSON, 0, len(rows))
for _, row := range rows {
item := readingJSON{
ID: row.ID,
CourseID: idOf(row.CourseID),
Course: newCourseRef(courses[idOf(row.CourseID)]),
Title: row.Title,
Author: row.Author,
Source: row.Source,
URL: row.URL,
IsRequired: row.IsRequired,
SortOrder: row.SortOrder,
}
if row.ReadAt != nil {
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
}
func readingCourseIDs(rows []readingRow) []uint {
seen := map[uint]bool{}
var ids []uint
for _, row := range rows {
if id := idOf(row.CourseID); !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
return ids
}
// coursesByID loads the given courses keyed by ID.
func coursesByID(db *gorm.DB, ids []uint) (map[uint]*models.Course, error) {
out := map[uint]*models.Course{}
if len(ids) == 0 {
return out, nil
}
var rows []models.Course
if err := db.Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, err
}
for i := range rows {
out[rows[i].ID] = &rows[i]
}
return out, nil
}
// Readings answers GET /readings?course=ID: the reading lists with the
// caller's read state. An unknown course gives an empty list.
func (h *Handlers) Readings(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
in := query(r, "course")
if !h.validate(w, r, in, courseFilterRules) {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
out, err := listReadings(db, userID, uintParam(in, "course"))
if err != nil {
writeOpaque500(w)
return
}
writeData(w, out)
}
// progressJSON is the answer of the progress routes.
type progressJSON struct {
ReadingID uint `json:"reading_id"`
ReadAt *string `json:"read_at"`
}
// readingFor resolves {id} to an existing reading and answers 404 itself
// when there is none.
func readingFor(w http.ResponseWriter, r *http.Request, db *gorm.DB) (uint, bool) {
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return 0, false
}
var reading models.Reading
res := db.Select("id").Where("id = ? AND course_id IS NOT NULL", id).Limit(1).Find(&reading)
if res.Error != nil {
writeOpaque500(w)
return 0, false
}
if res.RowsAffected == 0 {
writeNotFound(w)
return 0, false
}
return reading.ID, true
}
// MarkRead answers PUT /readings/{id}/progress: it marks the reading read
// by the caller. Marking it again keeps the first read_at.
func (h *Handlers) MarkRead(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_progress (reading_id, user_id, read_at) VALUES (?, ?, ?)
ON CONFLICT (reading_id, user_id) DO NOTHING`, readingID, userID, now).Error
if err != nil {
writeOpaque500(w)
return
}
var progress models.ReadingProgress
if err := db.Where("reading_id = ? AND user_id = ?", readingID, userID).Take(&progress).Error; err != nil {
writeOpaque500(w)
return
}
readAt := models.FormatDateTime(progress.ReadAt)
writeData(w, progressJSON{ReadingID: readingID, ReadAt: &readAt})
}
// UnmarkRead answers DELETE /readings/{id}/progress: it clears the caller's
// read mark. Clearing an unread reading is not an error.
func (h *Handlers) UnmarkRead(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.ReadingProgress{}).Error; err != nil {
writeOpaque500(w)
return
}
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})
}