feat: personal pins on materials

A per-user jz_bm_material_pins table and PUT/DELETE /materials/{id}/pin
on the student API and its token twins: 204 for a material the caller
may see, idempotent, 404 otherwise. List rows, course rows and the
detail carry pinned. The plain list lists the caller's pins first
(newest pin first); with q the relevance order stays. A pin writes only
the pins table and never changes the material's revision.
This commit is contained in:
Jakub Zych
2026-11-09 13:13:54 +01:00
parent b8b46be398
commit 9436fb62d6
13 changed files with 316 additions and 18 deletions

View File

@@ -76,11 +76,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 != 14 {
t.Errorf("Models = %d, want 14", n)
if n := len(p.Models()); n != 15 {
t.Errorf("Models = %d, want 15", n)
}
if n := len(p.Migrations()); n != 22 {
t.Errorf("Migrations = %d, want 22", n)
if n := len(p.Migrations()); n != 23 {
t.Errorf("Migrations = %d, want 23", n)
}
if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) {
t.Error("bm:seed is not registered")
@@ -131,6 +131,8 @@ func TestRoutes(t *testing.T) {
"DELETE /_bm/api/v1/readings/{id}/owned",
"GET /_bm/api/v1/materials",
"GET /_bm/api/v1/materials/{id}",
"PUT /_bm/api/v1/materials/{id}/pin",
"DELETE /_bm/api/v1/materials/{id}/pin",
"GET /_bm/api/v1/checklists",
"POST /_bm/api/v1/checklists",
"GET /_bm/api/v1/checklists/{id}",
@@ -164,6 +166,8 @@ func TestRoutes(t *testing.T) {
"DELETE /_bm/api/v1/token/readings/{id}/owned",
"GET /_bm/api/v1/token/materials",
"GET /_bm/api/v1/token/materials/{id}",
"PUT /_bm/api/v1/token/materials/{id}/pin",
"DELETE /_bm/api/v1/token/materials/{id}/pin",
"GET /_bm/api/v1/token/checklists",
"POST /_bm/api/v1/token/checklists",
"GET /_bm/api/v1/token/checklists/{id}",