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

@@ -20,7 +20,7 @@ Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/W
- A subscribable calendar feed (`/_bm/calendar/<token>.ics`) of the class sessions, meetings and deadlines, see [Calendar feed](#calendar-feed).
- Courses that share an `elective_group` are alternatives ("do wyboru"), such as the specialisation courses.
- A material without `published_at` is a draft; a material whose `published_at` lies in the future is not shown yet.
- Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress rows; deleting a user deletes the user's calendar feed.
- Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress and ownership rows; deleting a user deletes the user's calendar feed.
- Admin screens for every model in a "Studia BM" navigation group, guarded by three permissions.
- `bm:seed`: an idempotent upsert of meetings, courses, class sessions, deadlines and readings from a JSON file, all or nothing, with `--prune` to delete what the file no longer lists and `--dry-run` to preview.
@@ -43,6 +43,8 @@ Every route except the calendar feed itself needs `Authorization: Bearer <golem1
| GET | `/_bm/api/v1/readings` | `course` (ID) | Readings with the caller's `read_at` |
| PUT | `/_bm/api/v1/readings/{id}/progress` | | Marks the reading read (idempotent) |
| DELETE | `/_bm/api/v1/readings/{id}/progress` | | Clears the mark (idempotent) |
| PUT | `/_bm/api/v1/readings/{id}/owned` | | Marks the reading owned (idempotent) |
| DELETE | `/_bm/api/v1/readings/{id}/owned` | | Clears the owned mark (idempotent) |
| GET | `/_bm/api/v1/materials` | `course`, `tag` (slug), `type`, `page`, `per_page` (1–100) | One page of published materials |
| GET | `/_bm/api/v1/materials/{id}` | | One published material with body, tags and files |
| GET | `/_bm/api/v1/calendar-feed` | | The caller's calendar feed, or `null` |
@@ -132,9 +134,9 @@ A course with counters (`readings_read` is the caller's; `materials` counts publ
`/schedule` answers `{"data": [<session>, …]}` by start time: the sessions that start from midnight of `from` (default today) until the end of `to` (default no limit), in Warsaw. `/meetings` answers `{"data": [<meeting>, …]}` by number. `/deadlines` answers `{"data": [<deadline>, …]}` by due time; without `from` it starts now, so it lists the deadlines not yet due.
### GET /readings, PUT and DELETE /readings/{id}/progress
### GET /readings, PUT and DELETE /readings/{id}/progress and /owned
`/readings` answers `{"data": [<reading>, …]}` in course and `sort_order` order; an unknown `course` gives an empty list. The progress routes answer:
`/readings` answers `{"data": [<reading>, …]}` in course and `sort_order` order; an unknown `course` gives an empty list. Each reading carries the caller's `read_at` and `owned_at`, `null` when unset; a course's readings in `/courses/{id}` carry the same pair. The progress routes answer:
```json
{"data": {"reading_id": 7, "read_at": "2026-10-02T21:15:04+02:00"}}
@@ -142,6 +144,8 @@ A course with counters (`readings_read` is the caller's; `materials` counts publ
Marking a read reading again keeps the first `read_at`. Clearing answers `read_at: null`, also when the reading was not marked.
The owned routes record that the caller owns a copy of the reading, apart from whether it is read, and answer `{"data": {"reading_id": 7, "owned_at": "2026-10-26T12:00:00+01:00"}}` with the same rules: marking an owned reading again keeps the first `owned_at`, and clearing answers `owned_at: null`, also when the reading was not owned.
### GET /materials, GET /materials/{id}
```json

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})
}

View File

@@ -78,6 +78,7 @@ readings:
form: Reading
author: Author
is_required: Required
owned: Owned
materials:
title: Materials
form: Material

View File

@@ -78,6 +78,7 @@ readings:
form: Lektura
author: Autor
is_required: Obowiązkowa
owned: Mam
materials:
title: Materiały
form: Materiał

View File

@@ -0,0 +1,16 @@
package models
import "time"
// ReadingOwnership records that a user (a golem15.user users row) owns a
// copy of a reading. One row per reading and user, kept apart from the read
// mark in ReadingProgress.
type ReadingOwnership struct {
ID uint `gorm:"column:id;primaryKey"`
ReadingID uint `gorm:"column:reading_id"`
UserID uint `gorm:"column:user_id"`
OwnedAt time.Time `gorm:"column:owned_at"`
}
// TableName is the plugin table.
func (ReadingOwnership) TableName() string { return "jz_bm_reading_ownership" }

View File

@@ -74,11 +74,11 @@ func TestPluginActivates(t *testing.T) {
if p.ID() != "jz.bm" || !slices.Equal(p.Requires(), []string{"golem15.user"}) {
t.Errorf("ID %q Requires %v", p.ID(), p.Requires())
}
if n := len(p.Models()); n != 10 {
t.Errorf("Models = %d, want 10", n)
if n := len(p.Models()); n != 11 {
t.Errorf("Models = %d, want 11", n)
}
if n := len(p.Migrations()); n != 13 {
t.Errorf("Migrations = %d, want 13", n)
if n := len(p.Migrations()); n != 14 {
t.Errorf("Migrations = %d, want 14", n)
}
if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) {
t.Error("bm:seed is not registered")
@@ -111,6 +111,8 @@ func TestRoutes(t *testing.T) {
"GET /_bm/api/v1/readings",
"PUT /_bm/api/v1/readings/{id}/progress",
"DELETE /_bm/api/v1/readings/{id}/progress",
"PUT /_bm/api/v1/readings/{id}/owned",
"DELETE /_bm/api/v1/readings/{id}/owned",
"GET /_bm/api/v1/materials",
"GET /_bm/api/v1/materials/{id}",
"GET /_bm/api/v1/calendar-feed",

View File

@@ -155,7 +155,7 @@ func migrated(t *testing.T) (*backpack.App, []party.Plugin, *gorm.DB) {
var bmTables = []string{
"jz_bm_meetings", "jz_bm_courses", "jz_bm_class_sessions", "jz_bm_deadlines", "jz_bm_readings",
"jz_bm_reading_progress", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag", "jz_bm_calendar_feeds",
"jz_bm_reading_progress", "jz_bm_reading_ownership", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag", "jz_bm_calendar_feeds",
}
func TestMigrateUpAndRollback(t *testing.T) {

View File

@@ -22,6 +22,7 @@ func generatedModels() []any {
&models.Meeting{},
&models.Reading{},
&models.ReadingProgress{},
&models.ReadingOwnership{},
&models.Tag{},
}
}
@@ -41,6 +42,7 @@ func generatedMigrations() []*gormigrate.Migration {
updates.CreateCalendarFeeds(),
updates.MakeCourseIDNullable(),
updates.AddLecturerToClassSessions(),
updates.CreateReadingOwnership(),
}
}

View File

@@ -87,7 +87,7 @@ const ManagePrefix = "/api/v1/bm"
// Routes declares the student API and the management API. Every student
// route needs a golem15.user JWT (jwt.auth); students only read, except for
// their own reading progress.
// their own reading progress and owned marks.
func (p *Plugin) Routes(r pact.Router) error {
h := p.handlers()
r.Group(APIPrefix, surf.Use("jwt.auth", "locale.from-principal"), func(g pact.Router) {
@@ -103,6 +103,10 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Where("id", "[0-9]+")
g.Delete("/readings/{id}/progress", h.UnmarkRead)
g.Where("id", "[0-9]+")
g.Put("/readings/{id}/owned", h.MarkOwned)
g.Where("id", "[0-9]+")
g.Delete("/readings/{id}/owned", h.UnmarkOwned)
g.Where("id", "[0-9]+")
g.Get("/materials", h.Materials)
g.Get("/materials/{id}", h.Material)
g.Where("id", "[0-9]+")

View File

@@ -0,0 +1,29 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// CreateReadingOwnership returns the
// 20261026120000_create_jz_bm_reading_ownership gormigrate entry: who owns a
// copy of which reading, apart from who has read it. The rows go with the
// reading or the golem15.user user.
func CreateReadingOwnership() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20261026120000_create_jz_bm_reading_ownership",
Migrate: func(tx *gorm.DB) error {
return execAll(tx, `CREATE TABLE jz_bm_reading_ownership (
id BIGSERIAL PRIMARY KEY,
reading_id BIGINT NOT NULL REFERENCES jz_bm_readings (id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
owned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (reading_id, user_id)
);
CREATE INDEX jz_bm_reading_ownership_user_id_index ON jz_bm_reading_ownership (user_id)`)
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("DROP TABLE IF EXISTS jz_bm_reading_ownership").Error
},
}
}