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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user