From d7cd5baedf07e0870404a865b9b7131e6502aeb7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 30 Oct 2026 12:41:04 +0100 Subject: [PATCH] feat: private checklists for zjazd preparation Per-user checklists with items that may link a course, reading, deadline, material or own presentation deck. An item with a reading shares the reading's read mark. Student API routes with token twins, MCP tools, migrations, tests and README. --- README.md | 32 +- checklists_postgres_test.go | 126 ++++ controllers/api/checklists.go | 684 ++++++++++++++++++ controllers/api/manage_records.go | 49 +- mcp.go | 3 +- mcp_checklists.go | 125 ++++ mcp_test.go | 21 + models/checklist.go | 45 ++ models/checklist_item.go | 64 ++ plugin_test.go | 28 +- postgres_test.go | 3 + registry.gen.go | 4 + routes.go | 20 +- .../20261030120000_create_jz_bm_checklists.go | 33 + ...1030120100_create_jz_bm_checklist_items.go | 46 ++ 15 files changed, 1262 insertions(+), 21 deletions(-) create mode 100644 checklists_postgres_test.go create mode 100644 controllers/api/checklists.go create mode 100644 mcp_checklists.go create mode 100644 models/checklist.go create mode 100644 models/checklist_item.go create mode 100644 updates/20261030120000_create_jz_bm_checklists.go create mode 100644 updates/20261030120100_create_jz_bm_checklist_items.go diff --git a/README.md b/README.md index ba74398..b339253 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/W ## Features -- Models: `Course` (przedmiot, one row per course and form), `Meeting` (zjazd), `ClassSession` (zajęcia), `Deadline` (terminarz), `Reading` (lektura), `ReadingProgress`, `Material` with file attachments (`system_files`, field `files`) and `Tag`, many-to-many through `jz_bm_material_tag`, and `CalendarFeed` (one per user). +- Models: `Course` (przedmiot, one row per course and form), `Meeting` (zjazd), `ClassSession` (zajęcia), `Deadline` (terminarz), `Reading` (lektura), `ReadingProgress`, `Material` with file attachments (`system_files`, field `files`) and `Tag`, many-to-many through `jz_bm_material_tag`, `CalendarFeed` (one per user), and `Checklist` with `ChecklistItem` (each user's own checklists, e.g. the preparation for a zjazd). - A subscribable calendar feed (`/_bm/calendar/.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 and ownership 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 and checklists; deleting a checklist deletes its items; deleting a meeting, course, reading, deadline or material unlinks the checklists and items that point at it. - 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. @@ -47,6 +47,16 @@ Every route except the calendar feed itself needs `Authorization: Bearer `, to OAuth sessions and personal tokens of `golem15.user` (guard `user`). Every tool binds a token-guarded route, every description is static text naming the scope it needs (and, for management tools, the permission code), and no tool has a `dry_run`. `jz.bm` still requires only `golem15.user`. -**Token twins of the student API.** `/_bm/api/v1/token/*` (`TokenPrefix`) mounts the student routes a second time, from the same `mountStudent` helper as the `jwt.auth` group, behind `user.api_token`, the per-user bucket `throttle:jz.bm.token` (240 a minute) and `locale.from-principal`, with `user.scope:read` on the reads and `user.scope:write` on the four reading marks. The handlers read `bouncer.User`, so a personal token or an MCP session reaches only its own user's data. The calendar feed and the chat have no twin. `TestTokenMirrorRoutes` fails when a student route is added to one surface only. +**Token twins of the student API.** `/_bm/api/v1/token/*` (`TokenPrefix`) mounts the student routes a second time, from the same `mountStudent` helper as the `jwt.auth` group, behind `user.api_token`, the per-user bucket `throttle:jz.bm.token` (240 a minute) and `locale.from-principal`, with `user.scope:read` on the reads and `user.scope:write` on the reading marks and the checklist writes. The handlers read `bouncer.User`, so a personal token or an MCP session reaches only its own user's data. The calendar feed and the chat have no twin. `TestTokenMirrorRoutes` fails when a student route is added to one surface only. | Tool | Route | Scope | |------|-------|-------| | `bm_dashboard`, `bm_courses`, `bm_course`, `bm_schedule`, `bm_meetings`, `bm_deadlines`, `bm_readings`, `bm_materials`, `bm_material` | `GET /_bm/api/v1/token/…` | read | | `bm_mark_reading_read`, `bm_unmark_reading_read`, `bm_mark_reading_owned`, `bm_unmark_reading_owned` | `PUT` / `DELETE /_bm/api/v1/token/readings/{id}/progress` and `/owned` | write | +| `bm_checklists`, `bm_checklist` | `GET /_bm/api/v1/token/checklists[/{id}]` | read | +| `bm_create_checklist`, `bm_update_checklist`, `bm_delete_checklist`, `bm_add_checklist_item`, `bm_update_checklist_item`, `bm_delete_checklist_item`, `bm_mark_checklist_item_done`, `bm_unmark_checklist_item_done` | `POST` / `PATCH` / `PUT` / `DELETE /_bm/api/v1/token/checklists…` and `/checklist-items…` | write | **Management tools** bind the existing `/api/v1/bm` routes, whose `user.scope:*` and `user.permission:jz.bm.access_*` middleware filter the listing: a user whose groups lack the codes never sees them, and write tools need the write grant. Each input mirrors the model's `Fillable()` columns and `Rules()` (required columns are required on create, every update field is optional and nullable); enum values, lengths and date and time formats are in the field descriptions, and the routes still answer 422. diff --git a/checklists_postgres_test.go b/checklists_postgres_test.go new file mode 100644 index 0000000..a67145e --- /dev/null +++ b/checklists_postgres_test.go @@ -0,0 +1,126 @@ +package bm_test + +import ( + "encoding/json" + "net/http" + "testing" + + "git.golem15.com/golem15/summercms/modules/surf" + "git.golem15.com/jakub/sm-bm-plugin/models" +) + +// want is apiClient.send that fails unless the status is code. +func (c apiClient) want(code int, method, path, body string) map[string]any { + c.t.Helper() + got, out := c.send(method, path, body) + if got != code { + c.t.Fatalf("%s %s = %d %v, want %d", method, path, got, out, code) + } + return out +} + +func TestChecklistsAgainstDatabase(t *testing.T) { + app, plugins, gdb := migrated(t) + seedFixture(t, plugins, "testdata/seed.example.json") + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + aliceID := createUser(t, gdb, "alice@example.test") + alice := apiClient{t: t, h: h, token: tokenFor(t, aliceID)} + bob := apiClient{t: t, h: h, token: tokenFor(t, createUser(t, gdb, "bob@example.test"))} + const base = "/_bm/api/v1" + + var meeting models.Meeting + gdb.Order("number").Take(&meeting) + var reading models.Reading + gdb.Where("course_id IS NOT NULL").Order("id").Take(&reading) + + // Create, with validation of the zjazd and unknown fields. + alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists", `{"title":"X","meeting_id":99999}`) + alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists", `{"title":"X","user_id":1}`) + alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists", `{}`) + created := obj(alice.want(http.StatusCreated, "POST", base+"/checklists", + `{"title":"Zjazd 1","meeting_id":`+jsonID(meeting.ID)+`}`)["data"]) + cid := id(created["id"]) + if obj(created["meeting"])["number"] != float64(meeting.Number) { + t.Errorf("meeting = %v", created["meeting"]) + } + + // Items: a task with an estimate and an item with a reading. + alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists/"+cid+"/items", `{"title":"X","kind":"film"}`) + alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists/"+cid+"/items", `{"title":"X","presentation_slug":"nie-ma"}`) + alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists/"+cid+"/items", `{"title":"X","estimate_minutes":-5}`) + task := obj(alice.want(http.StatusCreated, "POST", base+"/checklists/"+cid+"/items", + `{"title":"Slajdy","estimate_minutes":60,"due_at":"2026-11-07 20:00"}`)["data"]) + if task["kind"] != "zadanie" || task["done"] != false { + t.Errorf("task = %v", task) + } + lektura := obj(alice.want(http.StatusCreated, "POST", base+"/checklists/"+cid+"/items", + `{"title":"Lektura","kind":"lektura","reading_id":`+jsonID(reading.ID)+`,"estimate_minutes":30}`)["data"]) + if lektura["sort_order"].(float64) <= task["sort_order"].(float64) { + t.Errorf("new item not last: %v after %v", lektura["sort_order"], task["sort_order"]) + } + if obj(lektura["reading"])["title"] != reading.Title { + t.Errorf("reading = %v", lektura["reading"]) + } + + // Bob sees none of it and cannot touch it. + if got := list(bob.ok("GET", base+"/checklists")); len(got) != 0 { + t.Errorf("bob sees %v", got) + } + bob.want(http.StatusNotFound, "GET", base+"/checklists/"+cid, "") + bob.want(http.StatusNotFound, "PATCH", base+"/checklists/"+cid, `{"title":"Mine"}`) + bob.want(http.StatusNotFound, "POST", base+"/checklists/"+cid+"/items", `{"title":"X"}`) + bob.want(http.StatusNotFound, "PUT", base+"/checklist-items/"+id(task["id"])+"/done", "") + bob.want(http.StatusNotFound, "DELETE", base+"/checklist-items/"+id(task["id"]), "") + bobList := obj(bob.want(http.StatusCreated, "POST", base+"/checklists", `{"title":"Bob"}`)["data"]) + alice.want(http.StatusUnprocessableEntity, "PATCH", base+"/checklist-items/"+id(task["id"]), `{"checklist_id":`+id(bobList["id"])+`}`) + + // Done marks: a task gets done_at, an item with a reading marks the + // reading read, and a reading read in the reading list shows done. + done := obj(alice.ok("PUT", base+"/checklist-items/"+id(task["id"])+"/done")["data"]) + if done["done"] != true || done["done_at"] == nil { + t.Errorf("task done = %v", done) + } + alice.ok("PUT", base+"/checklist-items/"+id(lektura["id"])+"/done") + var n int64 + gdb.Model(&models.ReadingProgress{}).Where("reading_id = ? AND user_id = ?", reading.ID, aliceID).Count(&n) + if n != 1 { + t.Errorf("reading progress rows = %d, want 1", n) + } + detail := obj(alice.ok("GET", base+"/checklists/"+cid)["data"]) + progress := obj(detail["progress"]) + if progress["total"] != 2.0 || progress["done"] != 2.0 || progress["percent"] != 100.0 || progress["minutes_left"] != 0.0 || progress["minutes_total"] != 90.0 { + t.Errorf("progress = %v", progress) + } + alice.ok("DELETE", "/_bm/api/v1/readings/"+jsonID(reading.ID)+"/progress") + summary := list(alice.ok("GET", base+"/checklists?meeting="+jsonID(meeting.ID))) + if len(summary) != 1 || obj(summary[0]["progress"])["done"] != 1.0 || obj(summary[0]["progress"])["minutes_left"] != 30.0 { + t.Errorf("after unread: %v", summary) + } + if summary[0]["items"] != nil { + t.Errorf("list carries items: %v", summary[0]["items"]) + } + undone := obj(alice.ok("DELETE", base+"/checklist-items/"+id(task["id"])+"/done")["data"]) + if undone["done"] != false { + t.Errorf("task undone = %v", undone) + } + + // Patch, the token twin, and delete with cascade. + patched := obj(alice.want(http.StatusOK, "PATCH", base+"/checklist-items/"+id(task["id"]), `{"title":"Slajdy v2","notes":""}`)["data"]) + if patched["title"] != "Slajdy v2" || patched["estimate_minutes"] != 60.0 { + t.Errorf("patched = %v", patched) + } + alice.want(http.StatusNoContent, "DELETE", base+"/checklist-items/"+id(task["id"]), "") + alice.want(http.StatusNoContent, "DELETE", base+"/checklists/"+cid, "") + if c := count(t, gdb, "jz_bm_checklist_items"); c != 0 { + t.Errorf("items left = %d", c) + } + alice.want(http.StatusNotFound, "GET", base+"/checklists/"+cid, "") +} + +func jsonID(id uint) string { + b, _ := json.Marshal(id) + return string(b) +} diff --git a/controllers/api/checklists.go b/controllers/api/checklists.go new file mode 100644 index 0000000..bf69057 --- /dev/null +++ b/controllers/api/checklists.go @@ -0,0 +1,684 @@ +package api + +import ( + "context" + "net/http" + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/surf" + "git.golem15.com/golem15/summercms/modules/wire" + "git.golem15.com/jakub/sm-bm-plugin/models" + "gorm.io/gorm" +) + +// The checklists of the student API: each user's own lists (usually the +// preparation for one zjazd), never anyone else's; a checklist or item of +// another user answers 404 like a missing one. Bodies are JSON objects of +// columns, checked as on the management API (Fillable, Rules, existing +// foreign keys). An item with a reading shares its done mark with the +// reading list: marking it done marks the reading read, and a reading read +// in the reading list shows its items done. + +// presentationsTable is the deck table of golem15.presentations, read by +// name without importing the plugin. +const presentationsTable = "golem15_presentations_presentations" + +var checklistsRules = []lagoon.RequestRule{ + {Field: "meeting", Rules: lagoon.ParseRules("nullable|integer|min:1")}, +} + +// checklistProgress counts a checklist's items. +type checklistProgress struct { + Total int `json:"total"` + Done int `json:"done"` + Percent int `json:"percent"` + MinutesTotal int `json:"minutes_total"` + MinutesDone int `json:"minutes_done"` + MinutesLeft int `json:"minutes_left"` +} + +func (p *checklistProgress) add(item checklistItemJSON) { + p.Total++ + minutes := 0 + if item.EstimateMinutes != nil { + minutes = *item.EstimateMinutes + } + p.MinutesTotal += minutes + if item.Done { + p.Done++ + p.MinutesDone += minutes + } else { + p.MinutesLeft += minutes + } + p.Percent = p.Done * 100 / p.Total +} + +// checklistMeetingRef is the short form of a checklist's zjazd. +type checklistMeetingRef struct { + ID uint `json:"id"` + Number int `json:"number"` + StartsOn string `json:"starts_on"` + EndsOn string `json:"ends_on"` +} + +// checklistJSON is a checklist with its progress, and its items on the +// detail route. +type checklistJSON struct { + ID uint `json:"id"` + MeetingID *uint `json:"meeting_id"` + Meeting *checklistMeetingRef `json:"meeting"` + Title string `json:"title"` + Description *string `json:"description"` + SortOrder int `json:"sort_order"` + Progress checklistProgress `json:"progress"` + Items []checklistItemJSON `json:"items,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type linkedReading struct { + ID uint `json:"id"` + Title string `json:"title"` + Author *string `json:"author"` +} + +type linkedDeadline struct { + ID uint `json:"id"` + Title string `json:"title"` + DueAt string `json:"due_at"` + Kind string `json:"kind"` +} + +type linkedMaterial struct { + ID uint `json:"id"` + Title string `json:"title"` +} + +type linkedPresentation struct { + Slug string `json:"slug"` + Title string `json:"title"` +} + +// checklistItemJSON is one item with its done state and the rows it points +// at (null when unset or gone; presentation is null when the caller owns no +// deck with that slug). +type checklistItemJSON struct { + ID uint `json:"id"` + ChecklistID uint `json:"checklist_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Notes *string `json:"notes"` + URL *string `json:"url"` + CourseID *uint `json:"course_id"` + Course *courseRef `json:"course"` + ReadingID *uint `json:"reading_id"` + Reading *linkedReading `json:"reading"` + DeadlineID *uint `json:"deadline_id"` + Deadline *linkedDeadline `json:"deadline"` + MaterialID *uint `json:"material_id"` + Material *linkedMaterial `json:"material"` + PresentationSlug *string `json:"presentation_slug"` + Presentation *linkedPresentation `json:"presentation"` + DueAt *string `json:"due_at"` + EstimateMinutes *int `json:"estimate_minutes"` + SortOrder int `json:"sort_order"` + Done bool `json:"done"` + DoneAt *string `json:"done_at"` +} + +// checklistItemRow is an item with its effective done mark: the reading's +// read_at for an item with a reading, else its own done_at. +type checklistItemRow struct { + models.ChecklistItem `gorm:"embedded"` + DoneMark *time.Time `gorm:"column:done_mark"` +} + +func formatTime(t *time.Time) *string { + if t == nil { + return nil + } + s := models.FormatDateTime(*t) + return &s +} + +// loadChecklistItems returns the caller's items of the given checklists, +// keyed by checklist, in display order. +func loadChecklistItems(db *gorm.DB, userID uint, checklistIDs []uint) (map[uint][]checklistItemJSON, error) { + out := map[uint][]checklistItemJSON{} + if len(checklistIDs) == 0 { + return out, nil + } + var rows []checklistItemRow + err := db.Table("jz_bm_checklist_items AS i"). + Select("i.*, CASE WHEN i.reading_id IS NULL THEN i.done_at ELSE p.read_at END AS done_mark"). + Joins("JOIN jz_bm_checklists c ON c.id = i.checklist_id"). + Joins("LEFT JOIN jz_bm_reading_progress p ON p.reading_id = i.reading_id AND p.user_id = c.user_id"). + Where("c.user_id = ? AND i.checklist_id IN ?", userID, checklistIDs). + Order("i.checklist_id, i.sort_order, i.id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + var courseIDs, readingIDs, deadlineIDs, materialIDs []uint + var slugs []string + for _, row := range rows { + if row.CourseID != nil { + courseIDs = append(courseIDs, *row.CourseID) + } + if row.ReadingID != nil { + readingIDs = append(readingIDs, *row.ReadingID) + } + if row.DeadlineID != nil { + deadlineIDs = append(deadlineIDs, *row.DeadlineID) + } + if row.MaterialID != nil { + materialIDs = append(materialIDs, *row.MaterialID) + } + if row.PresentationSlug != nil { + slugs = append(slugs, *row.PresentationSlug) + } + } + courses, err := coursesByID(db, courseIDs) + if err != nil { + return nil, err + } + readings := map[uint]*linkedReading{} + if len(readingIDs) > 0 { + var rs []models.Reading + if err := db.Where("id IN ?", readingIDs).Find(&rs).Error; err != nil { + return nil, err + } + for _, r := range rs { + readings[r.ID] = &linkedReading{ID: r.ID, Title: r.Title, Author: r.Author} + } + } + deadlines := map[uint]*linkedDeadline{} + if len(deadlineIDs) > 0 { + var ds []models.Deadline + if err := db.Where("id IN ?", deadlineIDs).Find(&ds).Error; err != nil { + return nil, err + } + for _, d := range ds { + deadlines[d.ID] = &linkedDeadline{ID: d.ID, Title: d.Title, DueAt: models.FormatDateTime(d.DueAt), Kind: d.Kind} + } + } + materials := map[uint]*linkedMaterial{} + if len(materialIDs) > 0 { + // Only published materials, as in the material list. + var ms []models.Material + if err := db.Select("id", "title").Where("id IN ? AND published_at IS NOT NULL", materialIDs).Find(&ms).Error; err != nil { + return nil, err + } + for _, m := range ms { + materials[m.ID] = &linkedMaterial{ID: m.ID, Title: m.Title} + } + } + decks, err := ownedDecks(db, userID, slugs) + if err != nil { + return nil, err + } + for _, row := range rows { + item := checklistItemJSON{ + ID: row.ID, + ChecklistID: row.ChecklistID, + Kind: row.Kind, + Title: row.Title, + Notes: row.Notes, + URL: row.URL, + CourseID: row.CourseID, + ReadingID: row.ReadingID, + DeadlineID: row.DeadlineID, + MaterialID: row.MaterialID, + PresentationSlug: row.PresentationSlug, + DueAt: formatTime(row.DueAt), + EstimateMinutes: row.EstimateMinutes, + SortOrder: row.SortOrder, + Done: row.DoneMark != nil, + DoneAt: formatTime(row.DoneMark), + } + if row.CourseID != nil { + item.Course = newCourseRef(courses[*row.CourseID]) + } + if row.ReadingID != nil { + item.Reading = readings[*row.ReadingID] + } + if row.DeadlineID != nil { + item.Deadline = deadlines[*row.DeadlineID] + } + if row.MaterialID != nil { + item.Material = materials[*row.MaterialID] + } + if row.PresentationSlug != nil { + item.Presentation = decks[*row.PresentationSlug] + } + out[row.ChecklistID] = append(out[row.ChecklistID], item) + } + return out, nil +} + +// ownedDecks returns the caller's decks among slugs, keyed by slug. Without +// golem15.presentations installed there are none. +func ownedDecks(db *gorm.DB, userID uint, slugs []string) (map[string]*linkedPresentation, error) { + out := map[string]*linkedPresentation{} + if len(slugs) == 0 || !db.Migrator().HasTable(presentationsTable) { + return out, nil + } + var rows []linkedPresentation + err := db.Table(presentationsTable).Select("slug, title"). + Where("owner_user_id = ? AND slug IN ?", userID, slugs).Scan(&rows).Error + if err != nil { + return nil, err + } + for i := range rows { + out[rows[i].Slug] = &rows[i] + } + return out, nil +} + +// checklistsJSON builds the answer for the given checklists, with items +// when withItems. +func checklistsJSON(db *gorm.DB, userID uint, lists []models.Checklist, withItems bool) ([]checklistJSON, error) { + ids := make([]uint, 0, len(lists)) + meetingIDs := []uint{} + for _, c := range lists { + ids = append(ids, c.ID) + if c.MeetingID != nil { + meetingIDs = append(meetingIDs, *c.MeetingID) + } + } + items, err := loadChecklistItems(db, userID, ids) + if err != nil { + return nil, err + } + meetings := map[uint]*checklistMeetingRef{} + if len(meetingIDs) > 0 { + var ms []models.Meeting + if err := db.Where("id IN ?", meetingIDs).Find(&ms).Error; err != nil { + return nil, err + } + for _, m := range ms { + meetings[m.ID] = &checklistMeetingRef{ID: m.ID, Number: m.Number, StartsOn: m.StartsOn.String(), EndsOn: m.EndsOn.String()} + } + } + out := make([]checklistJSON, 0, len(lists)) + for _, c := range lists { + item := checklistJSON{ + ID: c.ID, + MeetingID: c.MeetingID, + Title: c.Title, + Description: c.Description, + SortOrder: c.SortOrder, + CreatedAt: models.FormatDateTime(c.CreatedAt), + UpdatedAt: models.FormatDateTime(c.UpdatedAt), + } + if c.MeetingID != nil { + item.Meeting = meetings[*c.MeetingID] + } + for _, it := range items[c.ID] { + item.Progress.add(it) + } + if withItems { + item.Items = items[c.ID] + if item.Items == nil { + item.Items = []checklistItemJSON{} + } + } + out = append(out, item) + } + return out, nil +} + +// Checklists answers GET /checklists?meeting=ID: the caller's checklists +// with their progress, without items. +func (h *Handlers) Checklists(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + in := query(r, "meeting") + if !h.validate(w, r, in, checklistsRules) { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + q := db.Where("user_id = ?", userID) + if id := uintParam(in, "meeting"); id != 0 { + q = q.Where("meeting_id = ?", id) + } + var lists []models.Checklist + if err := q.Order("sort_order, id").Find(&lists).Error; err != nil { + writeOpaque500(w) + return + } + out, err := checklistsJSON(db, userID, lists, false) + if err != nil { + writeOpaque500(w) + return + } + writeData(w, out) +} + +// checklistFor resolves {id} to one of the caller's checklists and answers +// 404 itself when there is none. +func checklistFor(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID uint) (*models.Checklist, bool) { + id, ok := surf.IntParam(r, "id") + if !ok { + writeNotFound(w) + return nil, false + } + var c models.Checklist + res := db.Where("id = ? AND user_id = ?", id, userID).Limit(1).Find(&c) + if res.Error != nil { + writeOpaque500(w) + return nil, false + } + if res.RowsAffected == 0 { + writeNotFound(w) + return nil, false + } + return &c, true +} + +// itemFor resolves {id} to an item of one of the caller's checklists and +// answers 404 itself when there is none. +func itemFor(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID uint) (*models.ChecklistItem, bool) { + id, ok := surf.IntParam(r, "id") + if !ok { + writeNotFound(w) + return nil, false + } + var item models.ChecklistItem + res := db.Table("jz_bm_checklist_items AS i").Select("i.*"). + Joins("JOIN jz_bm_checklists c ON c.id = i.checklist_id"). + Where("i.id = ? AND c.user_id = ?", id, userID).Limit(1).Find(&item) + if res.Error != nil { + writeOpaque500(w) + return nil, false + } + if res.RowsAffected == 0 { + writeNotFound(w) + return nil, false + } + return &item, true +} + +// writeChecklist answers one checklist with its items. +func writeChecklist(w http.ResponseWriter, db *gorm.DB, userID uint, c models.Checklist, status int) { + out, err := checklistsJSON(db, userID, []models.Checklist{c}, true) + if err != nil || len(out) != 1 { + writeOpaque500(w) + return + } + wire.WriteJSON(w, status, map[string]any{"data": out[0]}) +} + +// writeItem answers one item as the detail route shows it. +func writeItem(w http.ResponseWriter, db *gorm.DB, userID uint, item models.ChecklistItem, status int) { + items, err := loadChecklistItems(db, userID, []uint{item.ChecklistID}) + if err != nil { + writeOpaque500(w) + return + } + for _, it := range items[item.ChecklistID] { + if it.ID == item.ID { + wire.WriteJSON(w, status, map[string]any{"data": it}) + return + } + } + writeOpaque500(w) +} + +// Checklist answers GET /checklists/{id}: one of the caller's checklists +// with its items. +func (h *Handlers) Checklist(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + c, ok := checklistFor(w, r, db, userID) + if !ok { + return + } + writeChecklist(w, db, userID, *c, http.StatusOK) +} + +var checklistReferences = map[string]string{"meeting_id": "jz_bm_meetings"} + +var itemReferences = map[string]string{ + "course_id": "jz_bm_courses", + "reading_id": "jz_bm_readings", + "deadline_id": "jz_bm_deadlines", + "material_id": "jz_bm_materials", +} + +// CreateChecklist answers POST /checklists: 201 with the new checklist. +func (h *Handlers) CreateChecklist(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + c := &models.Checklist{UserID: userID} + if _, ok := h.fillRecord(w, r, db, c, c, checklistReferences, nil); !ok { + return + } + if err := db.Create(c).Error; err != nil { + writeOpaque500(w) + return + } + writeChecklist(w, db, userID, *c, http.StatusCreated) +} + +// UpdateChecklist answers PATCH /checklists/{id}: the sent columns change. +func (h *Handlers) UpdateChecklist(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + c, ok := checklistFor(w, r, db, userID) + if !ok { + return + } + if _, ok := h.fillRecord(w, r, db, c, c, checklistReferences, nil); !ok { + return + } + if err := db.Save(c).Error; err != nil { + writeOpaque500(w) + return + } + writeChecklist(w, db, userID, *c, http.StatusOK) +} + +// DeleteChecklist answers DELETE /checklists/{id}: 204; its items go with +// it. Reading marks stay. +func (h *Handlers) DeleteChecklist(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + c, ok := checklistFor(w, r, db, userID) + if !ok { + return + } + if err := db.Delete(c).Error; err != nil { + writeOpaque500(w) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// checkItem adds the item checks the rules cannot make: the target +// checklist is the caller's, and a deck slug names one of the caller's +// decks. +func checkItem(db *gorm.DB, userID uint) func(map[string]any, map[string][]string) error { + return func(check map[string]any, errs map[string][]string) error { + if id, ok := check["checklist_id"].(uint); ok { + var n int64 + if err := db.Model(&models.Checklist{}).Where("id = ? AND user_id = ?", id, userID).Count(&n).Error; err != nil { + return err + } + if n == 0 { + errs["checklist_id"] = append(errs["checklist_id"], "The selected checklist_id is invalid.") + } + } + if slug, ok := check["presentation_slug"].(string); ok && slug != "" { + decks, err := ownedDecks(db, userID, []string{slug}) + if err != nil { + return err + } + if decks[slug] == nil { + errs["presentation_slug"] = append(errs["presentation_slug"], "The selected presentation_slug is not one of your decks.") + } + } + return nil + } +} + +// CreateChecklistItem answers POST /checklists/{id}/items: 201 with the new +// item, at the end of the list unless sort_order is sent. kind defaults to +// zadanie. +func (h *Handlers) CreateChecklistItem(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + c, ok := checklistFor(w, r, db, userID) + if !ok { + return + } + var last struct{ Max *int } + if err := db.Model(&models.ChecklistItem{}).Select("MAX(sort_order) AS max").Where("checklist_id = ?", c.ID).Scan(&last).Error; err != nil { + writeOpaque500(w) + return + } + item := &models.ChecklistItem{ChecklistID: c.ID, Kind: "zadanie"} + if last.Max != nil { + item.SortOrder = *last.Max + 1 + } + if _, ok := h.fillRecord(w, r, db, item, item, itemReferences, checkItem(db, userID)); !ok { + return + } + if err := db.Create(item).Error; err != nil { + writeOpaque500(w) + return + } + writeItem(w, db, userID, *item, http.StatusCreated) +} + +// UpdateChecklistItem answers PATCH /checklist-items/{id}: the sent columns +// change; checklist_id moves the item to another of the caller's lists. +func (h *Handlers) UpdateChecklistItem(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + item, ok := itemFor(w, r, db, userID) + if !ok { + return + } + if _, ok := h.fillRecord(w, r, db, item, item, itemReferences, checkItem(db, userID)); !ok { + return + } + if err := db.Save(item).Error; err != nil { + writeOpaque500(w) + return + } + writeItem(w, db, userID, *item, http.StatusOK) +} + +// DeleteChecklistItem answers DELETE /checklist-items/{id}: 204. A reading +// mark stays. +func (h *Handlers) DeleteChecklistItem(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + item, ok := itemFor(w, r, db, userID) + if !ok { + return + } + if err := db.Delete(item).Error; err != nil { + writeOpaque500(w) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// MarkChecklistItemDone answers PUT /checklist-items/{id}/done. An item +// with a reading marks the reading read by the caller (the reading list +// shows it read); any other item gets done_at. Marking it again keeps the +// first mark. +func (h *Handlers) MarkChecklistItemDone(w http.ResponseWriter, r *http.Request) { + h.setItemDone(w, r, true) +} + +// UnmarkChecklistItemDone answers DELETE /checklist-items/{id}/done: it +// clears the mark, and for an item with a reading the caller's read mark. +func (h *Handlers) UnmarkChecklistItemDone(w http.ResponseWriter, r *http.Request) { + h.setItemDone(w, r, false) +} + +func (h *Handlers) setItemDone(w http.ResponseWriter, r *http.Request, done bool) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + item, ok := itemFor(w, r, db, userID) + if !ok { + return + } + now := h.now().UTC() + err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error { + if item.ReadingID != nil { + if done { + return tx.Exec(`INSERT INTO jz_bm_reading_progress (reading_id, user_id, read_at) VALUES (?, ?, ?) +ON CONFLICT (reading_id, user_id) DO NOTHING`, *item.ReadingID, userID, now).Error + } + return tx.Where("reading_id = ? AND user_id = ?", *item.ReadingID, userID).Delete(&models.ReadingProgress{}).Error + } + q := tx.Model(&models.ChecklistItem{}).Where("id = ?", item.ID) + if done { + return q.Where("done_at IS NULL").Update("done_at", now).Error + } + return q.Update("done_at", nil).Error + }) + if err != nil { + writeOpaque500(w) + return + } + writeItem(w, db, userID, *item, http.StatusOK) +} diff --git a/controllers/api/manage_records.go b/controllers/api/manage_records.go index bb6d902..2f0365b 100644 --- a/controllers/api/manage_records.go +++ b/controllers/api/manage_records.go @@ -175,18 +175,31 @@ func (rec Record[T, PT]) find(w http.ResponseWriter, r *http.Request, db *gorm.D // save decodes the body onto row, validates the result and writes it. func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handlers, db *gorm.DB, row *T, created bool) { + if _, ok := h.fillRecord(w, r, db, row, PT(row), rec.References, nil); !ok { + return + } + writeSaved(w, r, db, row, created) +} + +// fillRecord decodes the JSON body onto row (only model.Fillable() keys), +// then checks model.Rules() and TemporalErrors() against the row as it +// would be saved, that every foreign key in references names an existing +// row, and extra (when given) for checks of its own. It answers 422 or 500 +// itself and returns false when the row must not be saved; else it returns +// the row as a column map. +func (h *Handlers) fillRecord(w http.ResponseWriter, r *http.Request, db *gorm.DB, row any, model recordModel, + references map[string]string, extra func(check map[string]any, errs map[string][]string) error) (map[string]any, bool) { raw, ok := body(w, r) if !ok { - return + return nil, false } var in map[string]any dec := json.NewDecoder(bytes.NewReader(raw)) dec.UseNumber() if err := dec.Decode(&in); err != nil || in == nil { writeInvalid(w, "The body must be a JSON object of columns.") - return + return nil, false } - model := PT(row) fillable := model.Fillable() errs := map[string][]string{} for key := range in { @@ -196,7 +209,7 @@ func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handler } if len(errs) > 0 { writeValidation(w, errs) - return + return nil, false } values, fieldErrs := normalizeValues(row, in) for k, v := range fieldErrs { @@ -208,12 +221,12 @@ func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handler errs[typeErr.Key] = []string{"The " + typeErr.Key + " has the wrong type."} } else if err != nil { writeOpaque500(w) - return + return nil, false } } if len(errs) > 0 { writeValidation(w, errs) - return + return nil, false } // The rules run on the row as it would be saved, so a PATCH of one // column still meets the required ones. @@ -221,17 +234,17 @@ func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handler verrs, err := lagoon.Validate(r.Context(), db, row, model.Rules(), check, h.translator()) if err != nil { writeOpaque500(w) - return + return nil, false } for k, v := range verrs { errs[k] = v } - if tc, ok := any(row).(temporalChecker); ok { + if tc, ok := row.(temporalChecker); ok { for k, v := range tc.TemporalErrors() { errs[k] = append(errs[k], v...) } } - for col, table := range rec.References { + for col, table := range references { id, ok := check[col].(uint) if !ok || id == 0 { continue @@ -239,17 +252,29 @@ func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handler var n int64 if err := db.Table(table).Where("id = ?", id).Count(&n).Error; err != nil { writeOpaque500(w) - return + return nil, false } if n == 0 { errs[col] = append(errs[col], "The selected "+col+" is invalid.") } } + if extra != nil { + if err := extra(check, errs); err != nil { + writeOpaque500(w) + return nil, false + } + } if len(errs) > 0 { writeValidation(w, errs) - return + return nil, false } - err = lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error { + return check, true +} + +// writeSaved saves row in a transaction and answers it as a column map, 201 +// when created. +func writeSaved(w http.ResponseWriter, r *http.Request, db *gorm.DB, row any, created bool) { + err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error { return tx.Save(row).Error }) if err != nil { diff --git a/mcp.go b/mcp.go index 4105e9b..c9addfa 100644 --- a/mcp.go +++ b/mcp.go @@ -12,6 +12,7 @@ var _ pact.HasMCP = (*Plugin)(nil) // signed-in user under golem15.user's "user" guard, and their titles and // descriptions are static text, never built from data. func (p *Plugin) MCP() pact.MCP { - tools := append(studentTools(), manageTools()...) + tools := append(studentTools(), checklistTools()...) + tools = append(tools, manageTools()...) return pact.MCP{Tools: append(tools, fileTools()...)} } diff --git a/mcp_checklists.go b/mcp_checklists.go new file mode 100644 index 0000000..1f9f0a4 --- /dev/null +++ b/mcp_checklists.go @@ -0,0 +1,125 @@ +package bm + +import ( + "net/http" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// checklistsInput is the input of bm_checklists. +type checklistsInput struct { + Meeting uint `json:"meeting,omitempty" query:"meeting" jsonschema:"only checklists of this zjazd (meeting) id"` +} + +// checklistIDInput names one of the student's checklists. +type checklistIDInput struct { + ID uint `json:"id" path:"id" jsonschema:"the checklist id, as listed by bm_checklists"` +} + +// checklistItemIDInput names one item of the student's checklists. +type checklistItemIDInput struct { + ID uint `json:"id" path:"id" jsonschema:"the checklist item id, as listed by bm_checklist"` +} + +// createChecklistInput is the body of bm_create_checklist. +type createChecklistInput struct { + MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing zjazd (meeting) id, as listed by bm_meetings"` + Title string `json:"title" jsonschema:"title, at most 255 characters, e.g. Zjazd 4"` + Description *string `json:"description,omitempty" jsonschema:"description, at most 10000 characters"` + SortOrder *int `json:"sort_order,omitempty" jsonschema:"position among the student's checklists, an integer"` +} + +// updateChecklistInput is the body of bm_update_checklist. +type updateChecklistInput struct { + ID uint `json:"id" path:"id" jsonschema:"the checklist id"` + MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing zjazd (meeting) id; null clears it"` + Title *string `json:"title,omitempty" jsonschema:"title, at most 255 characters; required, so not null"` + Description *string `json:"description,omitempty" jsonschema:"description, at most 10000 characters"` + SortOrder *int `json:"sort_order,omitempty" jsonschema:"position among the student's checklists, an integer"` +} + +// addChecklistItemInput is the body of bm_add_checklist_item. +type addChecklistItemInput struct { + ID uint `json:"id" path:"id" jsonschema:"the checklist id"` + Kind *string `json:"kind,omitempty" jsonschema:"one of: lektura, artykul, ksiazka, prezentacja, zadanie, inne (default zadanie)"` + Title string `json:"title" jsonschema:"title, at most 500 characters"` + Notes *string `json:"notes,omitempty" jsonschema:"notes, at most 10000 characters"` + URL *string `json:"url,omitempty" jsonschema:"link, at most 2048 characters"` + CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"` + ReadingID *uint `json:"reading_id,omitempty" jsonschema:"an existing reading id; the item is then done exactly when the reading is marked read"` + DeadlineID *uint `json:"deadline_id,omitempty" jsonschema:"an existing deadline id"` + MaterialID *uint `json:"material_id,omitempty" jsonschema:"an existing material id"` + PresentationSlug *string `json:"presentation_slug,omitempty" jsonschema:"slug of one of the student's own presentation decks"` + DueAt *string `json:"due_at,omitempty" jsonschema:"when it should be done: RFC 3339 or YYYY-MM-DD HH:MM in Europe/Warsaw"` + EstimateMinutes *int `json:"estimate_minutes,omitempty" jsonschema:"estimated work in minutes, 0 to 10000"` + SortOrder *int `json:"sort_order,omitempty" jsonschema:"position in the checklist, an integer (default: last)"` +} + +// updateChecklistItemInput is the body of bm_update_checklist_item. +type updateChecklistItemInput struct { + ID uint `json:"id" path:"id" jsonschema:"the checklist item id"` + ChecklistID *uint `json:"checklist_id,omitempty" jsonschema:"move the item to another of the student's checklists"` + Kind *string `json:"kind,omitempty" jsonschema:"one of: lektura, artykul, ksiazka, prezentacja, zadanie, inne"` + Title *string `json:"title,omitempty" jsonschema:"title, at most 500 characters; required, so not null"` + Notes *string `json:"notes,omitempty" jsonschema:"notes, at most 10000 characters"` + URL *string `json:"url,omitempty" jsonschema:"link, at most 2048 characters"` + CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"` + ReadingID *uint `json:"reading_id,omitempty" jsonschema:"an existing reading id; the item is then done exactly when the reading is marked read"` + DeadlineID *uint `json:"deadline_id,omitempty" jsonschema:"an existing deadline id"` + MaterialID *uint `json:"material_id,omitempty" jsonschema:"an existing material id"` + PresentationSlug *string `json:"presentation_slug,omitempty" jsonschema:"slug of one of the student's own presentation decks"` + DueAt *string `json:"due_at,omitempty" jsonschema:"when it should be done: RFC 3339 or YYYY-MM-DD HH:MM in Europe/Warsaw"` + EstimateMinutes *int `json:"estimate_minutes,omitempty" jsonschema:"estimated work in minutes, 0 to 10000"` + SortOrder *int `json:"sort_order,omitempty" jsonschema:"position in the checklist, an integer (default: last)"` +} + +// The sentences of the checklist tool descriptions. +const ( + checklistsPrivate = " Checklists are the signed-in student's own; another student's id answers 404." + checklistAnswer = " Returns the checklist with its progress (total, done, percent, minutes_total, minutes_done, minutes_left) and its items." + itemAnswer = " Returns the item with its done mark and the reading, deadline, material, course or deck it points at." + checklistInvalid = " Unknown fields and invalid values answer 422 with the errors per field." +) + +// checklistTools are the student's checklist tools over the token twins of +// the student API. +func checklistTools() []pact.MCPTool { + tool := func(name, title, desc, method, path string, input any) pact.MCPTool { + return pact.MCPTool{Name: name, Title: title, Description: desc + checklistsPrivate, Guard: "user", Method: method, Path: TokenPrefix + path, Input: input} + } + unmark := tool("unmark_checklist_item_done", "Unmark a checklist item as done", + "Clears the done mark of a checklist item; for an item with a reading it removes the student's read mark of that reading."+itemAnswer+needsScopeWrite, + http.MethodDelete, "/checklist-items/{id}/done", checklistItemIDInput{}) + unmark.Destructive = boolPtr(false) + unmark.Idempotent = boolPtr(true) + return []pact.MCPTool{ + tool("checklists", "BM checklists", + "Lists the student's checklists (e.g. the preparation for a zjazd) with zjazd and progress, without items."+needsScopeRead, + http.MethodGet, "/checklists", checklistsInput{}), + tool("checklist", "BM checklist", + "Returns one of the student's checklists."+checklistAnswer+needsScopeRead, + http.MethodGet, "/checklists/{id}", checklistIDInput{}), + tool("create_checklist", "Create a checklist", + "Creates a checklist for the student, optionally for one zjazd."+checklistAnswer+checklistInvalid+needsScopeWrite, + http.MethodPost, "/checklists", createChecklistInput{}), + tool("update_checklist", "Update a checklist", + "Changes the sent fields of one of the student's checklists; the rest stay."+checklistAnswer+checklistInvalid+needsScopeWrite, + http.MethodPatch, "/checklists/{id}", updateChecklistInput{}), + tool("delete_checklist", "Delete a checklist", + "Deletes one of the student's checklists with its items, for good; reading marks stay."+needsScopeWrite, + http.MethodDelete, "/checklists/{id}", checklistIDInput{}), + tool("add_checklist_item", "Add a checklist item", + "Adds an item (a reading, article, book, presentation, task) to one of the student's checklists, last unless sort_order is given."+itemAnswer+checklistInvalid+needsScopeWrite, + http.MethodPost, "/checklists/{id}/items", addChecklistItemInput{}), + tool("update_checklist_item", "Update a checklist item", + "Changes the sent fields of one checklist item; the rest stay."+itemAnswer+checklistInvalid+needsScopeWrite, + http.MethodPatch, "/checklist-items/{id}", updateChecklistItemInput{}), + tool("delete_checklist_item", "Delete a checklist item", + "Deletes one checklist item for good; a reading mark stays."+needsScopeWrite, + http.MethodDelete, "/checklist-items/{id}", checklistItemIDInput{}), + tool("mark_checklist_item_done", "Mark a checklist item as done", + "Marks a checklist item done; for an item with a reading it marks that reading read (the reading list shows it). Marking again keeps the first time."+itemAnswer+needsScopeWrite, + http.MethodPut, "/checklist-items/{id}/done", checklistItemIDInput{}), + unmark, + } +} diff --git a/mcp_test.go b/mcp_test.go index 96e7e16..34e9040 100644 --- a/mcp_test.go +++ b/mcp_test.go @@ -51,10 +51,16 @@ func (h mountHost) Owner(method, path string) (string, bool) { // pinnedTools is the sorted tool list of golem15.user plus jz.bm. var pinnedTools = []string{ + "bm_add_checklist_item", + "bm_checklist", + "bm_checklists", "bm_course", "bm_courses", + "bm_create_checklist", "bm_dashboard", "bm_deadlines", + "bm_delete_checklist", + "bm_delete_checklist_item", "bm_manage_attach_material_file", "bm_manage_create_class_session", "bm_manage_create_course", @@ -86,6 +92,7 @@ var pinnedTools = []string{ "bm_manage_update_material", "bm_manage_update_meeting", "bm_manage_update_reading", + "bm_mark_checklist_item_done", "bm_mark_reading_owned", "bm_mark_reading_read", "bm_material", @@ -93,8 +100,11 @@ var pinnedTools = []string{ "bm_meetings", "bm_readings", "bm_schedule", + "bm_unmark_checklist_item_done", "bm_unmark_reading_owned", "bm_unmark_reading_read", + "bm_update_checklist", + "bm_update_checklist_item", "user_me", } @@ -113,6 +123,7 @@ var queryKeys = map[string][]string{ "schedule": {"from", "to"}, "deadlines": {"from", "to"}, "readings": {"course"}, + "checklists": {"meeting"}, "materials": {"course", "meeting", "tag", "type", "page", "per_page"}, "material": {"include_body"}, } @@ -449,6 +460,16 @@ var studentRoutes = []string{ "DELETE /readings/{id}/owned", "GET /materials", "GET /materials/{id}", + "GET /checklists", + "POST /checklists", + "GET /checklists/{id}", + "PATCH /checklists/{id}", + "DELETE /checklists/{id}", + "POST /checklists/{id}/items", + "PATCH /checklist-items/{id}", + "DELETE /checklist-items/{id}", + "PUT /checklist-items/{id}/done", + "DELETE /checklist-items/{id}/done", } // TestTokenMirrorRoutes is the invariant of the two student surfaces: every diff --git a/models/checklist.go b/models/checklist.go new file mode 100644 index 0000000..6f374a3 --- /dev/null +++ b/models/checklist.go @@ -0,0 +1,45 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Checklist is a user's own list of things to do, usually the preparation +// for one zjazd. Only its owner (a golem15.user users row) sees it. +type Checklist struct { + ID uint `gorm:"column:id;primaryKey"` + UserID uint `gorm:"column:user_id"` + MeetingID *uint `gorm:"column:meeting_id"` + Meeting *Meeting `gorm:"foreignKey:MeetingID"` + Title string `gorm:"column:title"` + Description *string `gorm:"column:description"` + SortOrder int `gorm:"column:sort_order"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +// TableName is the plugin table. +func (Checklist) TableName() string { return "jz_bm_checklists" } + +// Fillable lists the columns the student API may set; user_id comes from +// the caller. +func (Checklist) Fillable() []string { + return []string{"meeting_id", "title", "description", "sort_order"} +} + +// Rules are checked by the student API on every save. +func (Checklist) Rules() map[string]string { + return map[string]string{ + "title": "required|max:255", + "description": "nullable|max:10000", + "sort_order": "nullable|integer", + } +} + +// BeforeSave stores NULL for empty optional text. +func (c *Checklist) BeforeSave(*gorm.DB) error { + blank(&c.Description) + return nil +} diff --git a/models/checklist_item.go b/models/checklist_item.go new file mode 100644 index 0000000..f41cab3 --- /dev/null +++ b/models/checklist_item.go @@ -0,0 +1,64 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// ChecklistItemKinds lists the allowed ChecklistItem.Kind values. +var ChecklistItemKinds = []string{"lektura", "artykul", "ksiazka", "prezentacja", "zadanie", "inne"} + +// ChecklistItem is one entry of a Checklist. It may point at a reading, a +// deadline, a material or a course, and name a deck of golem15.presentations +// by slug. An item with a reading is done when the checklist's owner has +// read it (ReadingProgress); any other item is done when DoneAt is set. +type ChecklistItem struct { + ID uint `gorm:"column:id;primaryKey"` + ChecklistID uint `gorm:"column:checklist_id"` + Kind string `gorm:"column:kind"` + Title string `gorm:"column:title"` + Notes *string `gorm:"column:notes"` + URL *string `gorm:"column:url"` + CourseID *uint `gorm:"column:course_id"` + ReadingID *uint `gorm:"column:reading_id"` + DeadlineID *uint `gorm:"column:deadline_id"` + MaterialID *uint `gorm:"column:material_id"` + PresentationSlug *string `gorm:"column:presentation_slug"` + DueAt *time.Time `gorm:"column:due_at"` + EstimateMinutes *int `gorm:"column:estimate_minutes"` + SortOrder int `gorm:"column:sort_order"` + DoneAt *time.Time `gorm:"column:done_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +// TableName is the plugin table. +func (ChecklistItem) TableName() string { return "jz_bm_checklist_items" } + +// Fillable lists the columns the student API may set. checklist_id moves an +// item to another of the caller's checklists; done_at is set by the done +// routes only. +func (ChecklistItem) Fillable() []string { + return []string{"checklist_id", "kind", "title", "notes", "url", "course_id", "reading_id", "deadline_id", + "material_id", "presentation_slug", "due_at", "estimate_minutes", "sort_order"} +} + +// Rules are checked by the student API on every save. +func (ChecklistItem) Rules() map[string]string { + return map[string]string{ + "kind": "required|in:lektura,artykul,ksiazka,prezentacja,zadanie,inne", + "title": "required|max:500", + "notes": "nullable|max:10000", + "url": "nullable|max:2048", + "presentation_slug": "nullable|max:255", + "estimate_minutes": "nullable|integer|min:0|max:10000", + "sort_order": "nullable|integer", + } +} + +// BeforeSave stores NULL for empty optional text. +func (i *ChecklistItem) BeforeSave(*gorm.DB) error { + blank(&i.Notes, &i.URL, &i.PresentationSlug) + return nil +} diff --git a/plugin_test.go b/plugin_test.go index 4c665d3..3d5098b 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -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 != 11 { - t.Errorf("Models = %d, want 11", n) + if n := len(p.Models()); n != 13 { + t.Errorf("Models = %d, want 13", n) } - if n := len(p.Migrations()); n != 16 { - t.Errorf("Migrations = %d, want 16", n) + if n := len(p.Migrations()); n != 18 { + t.Errorf("Migrations = %d, want 18", n) } if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) { t.Error("bm:seed is not registered") @@ -117,6 +117,16 @@ func TestRoutes(t *testing.T) { "DELETE /_bm/api/v1/readings/{id}/owned", "GET /_bm/api/v1/materials", "GET /_bm/api/v1/materials/{id}", + "GET /_bm/api/v1/checklists", + "POST /_bm/api/v1/checklists", + "GET /_bm/api/v1/checklists/{id}", + "PATCH /_bm/api/v1/checklists/{id}", + "DELETE /_bm/api/v1/checklists/{id}", + "POST /_bm/api/v1/checklists/{id}/items", + "PATCH /_bm/api/v1/checklist-items/{id}", + "DELETE /_bm/api/v1/checklist-items/{id}", + "PUT /_bm/api/v1/checklist-items/{id}/done", + "DELETE /_bm/api/v1/checklist-items/{id}/done", "GET /_bm/api/v1/calendar-feed", "PUT /_bm/api/v1/calendar-feed", "POST /_bm/api/v1/calendar-feed/rotate", @@ -134,6 +144,16 @@ 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}", + "GET /_bm/api/v1/token/checklists", + "POST /_bm/api/v1/token/checklists", + "GET /_bm/api/v1/token/checklists/{id}", + "PATCH /_bm/api/v1/token/checklists/{id}", + "DELETE /_bm/api/v1/token/checklists/{id}", + "POST /_bm/api/v1/token/checklists/{id}/items", + "PATCH /_bm/api/v1/token/checklist-items/{id}", + "DELETE /_bm/api/v1/token/checklist-items/{id}", + "PUT /_bm/api/v1/token/checklist-items/{id}/done", + "DELETE /_bm/api/v1/token/checklist-items/{id}/done", "GET /api/v1/bm/me", "GET /api/v1/bm/materials", "GET /api/v1/bm/materials/{id}", diff --git a/postgres_test.go b/postgres_test.go index 4e154d9..1d2e853 100644 --- a/postgres_test.go +++ b/postgres_test.go @@ -156,6 +156,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_reading_ownership", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag", "jz_bm_calendar_feeds", + "jz_bm_checklists", "jz_bm_checklist_items", } func TestMigrateUpAndRollback(t *testing.T) { @@ -201,6 +202,8 @@ func TestSchemaConstraints(t *testing.T) { `INSERT INTO jz_bm_class_sessions (course_id, starts_at, ends_at) VALUES (999, NOW(), NOW() + interval '1 hour')`, `INSERT INTO jz_bm_deadlines (title, due_at, kind) VALUES ('X', NOW(), 'party')`, `INSERT INTO jz_bm_materials (title, type) VALUES ('X', 'video')`, + `INSERT INTO jz_bm_checklists (user_id, title) VALUES (999, 'X')`, + `INSERT INTO jz_bm_checklist_items (checklist_id, title) VALUES (999, 'X')`, } { if err := exec(bad); err == nil { t.Errorf("accepted: %s", bad) diff --git a/registry.gen.go b/registry.gen.go index 094e41a..e2836d0 100644 --- a/registry.gen.go +++ b/registry.gen.go @@ -14,6 +14,8 @@ import ( func generatedModels() []any { return []any{ &models.CalendarFeed{}, + &models.Checklist{}, + &models.ChecklistItem{}, &models.ClassSession{}, &models.Course{}, &models.Deadline{}, @@ -45,6 +47,8 @@ func generatedMigrations() []*gormigrate.Migration { updates.CreateReadingOwnership(), updates.SeedChatGroups(), updates.AddMeetingToReadingsAndMaterials(), + updates.CreateChecklists(), + updates.CreateChecklistItems(), } } diff --git a/routes.go b/routes.go index 51d52af..7058eba 100644 --- a/routes.go +++ b/routes.go @@ -152,7 +152,7 @@ const TokenPrefix = APIPrefix + "/token" // 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 and owned marks. +// their own reading progress and owned marks and their own checklists. 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) { @@ -373,6 +373,24 @@ func mountStudent(g pact.Router, h *api.Handlers, read, write []string) { g.Get("/materials", h.Materials, read...) g.Get("/materials/{id}", h.Material, read...) g.Where("id", "[0-9]+") + g.Get("/checklists", h.Checklists, read...) + g.Post("/checklists", h.CreateChecklist, write...) + g.Get("/checklists/{id}", h.Checklist, read...) + g.Where("id", "[0-9]+") + g.Patch("/checklists/{id}", h.UpdateChecklist, write...) + g.Where("id", "[0-9]+") + g.Delete("/checklists/{id}", h.DeleteChecklist, write...) + g.Where("id", "[0-9]+") + g.Post("/checklists/{id}/items", h.CreateChecklistItem, write...) + g.Where("id", "[0-9]+") + g.Patch("/checklist-items/{id}", h.UpdateChecklistItem, write...) + g.Where("id", "[0-9]+") + g.Delete("/checklist-items/{id}", h.DeleteChecklistItem, write...) + g.Where("id", "[0-9]+") + g.Put("/checklist-items/{id}/done", h.MarkChecklistItemDone, write...) + g.Where("id", "[0-9]+") + g.Delete("/checklist-items/{id}/done", h.UnmarkChecklistItemDone, write...) + g.Where("id", "[0-9]+") } // recordRoutes is what Record provides to mountRecords. diff --git a/updates/20261030120000_create_jz_bm_checklists.go b/updates/20261030120000_create_jz_bm_checklists.go new file mode 100644 index 0000000..cc6710f --- /dev/null +++ b/updates/20261030120000_create_jz_bm_checklists.go @@ -0,0 +1,33 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// CreateChecklists returns the 20261030120000_create_jz_bm_checklists +// gormigrate entry: a user's own checklists, e.g. one per zjazd to plan the +// preparation. They go with the golem15.user user; a deleted zjazd leaves +// the checklist without one. +func CreateChecklists() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "20261030120000_create_jz_bm_checklists", + Migrate: func(tx *gorm.DB) error { + return execAll(tx, `CREATE TABLE jz_bm_checklists ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + meeting_id BIGINT NULL REFERENCES jz_bm_meetings (id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX jz_bm_checklists_user_id_index ON jz_bm_checklists (user_id); +CREATE INDEX jz_bm_checklists_meeting_id_index ON jz_bm_checklists (meeting_id)`) + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS jz_bm_checklists").Error + }, + } +} diff --git a/updates/20261030120100_create_jz_bm_checklist_items.go b/updates/20261030120100_create_jz_bm_checklist_items.go new file mode 100644 index 0000000..0e055fd --- /dev/null +++ b/updates/20261030120100_create_jz_bm_checklist_items.go @@ -0,0 +1,46 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// CreateChecklistItems returns the +// 20261030120100_create_jz_bm_checklist_items gormigrate entry: the items +// of a checklist. An item may point at a reading, a deadline, a material or +// a course (the link is cleared when that row goes) and name a deck of +// golem15.presentations by slug (no foreign key across plugins). done_at is +// the done mark of an item without a reading; an item with one is done when +// its owner has read the reading. +func CreateChecklistItems() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "20261030120100_create_jz_bm_checklist_items", + Migrate: func(tx *gorm.DB) error { + return execAll(tx, `CREATE TABLE jz_bm_checklist_items ( + id BIGSERIAL PRIMARY KEY, + checklist_id BIGINT NOT NULL REFERENCES jz_bm_checklists (id) ON DELETE CASCADE, + kind VARCHAR(20) NOT NULL DEFAULT 'zadanie' + CHECK (kind IN ('lektura', 'artykul', 'ksiazka', 'prezentacja', 'zadanie', 'inne')), + title VARCHAR(500) NOT NULL, + notes TEXT NULL, + url VARCHAR(2048) NULL, + course_id BIGINT NULL REFERENCES jz_bm_courses (id) ON DELETE SET NULL, + reading_id BIGINT NULL REFERENCES jz_bm_readings (id) ON DELETE SET NULL, + deadline_id BIGINT NULL REFERENCES jz_bm_deadlines (id) ON DELETE SET NULL, + material_id BIGINT NULL REFERENCES jz_bm_materials (id) ON DELETE SET NULL, + presentation_slug VARCHAR(255) NULL, + due_at TIMESTAMPTZ NULL, + estimate_minutes INTEGER NULL CHECK (estimate_minutes >= 0), + sort_order INTEGER NOT NULL DEFAULT 0, + done_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX jz_bm_checklist_items_checklist_id_index ON jz_bm_checklist_items (checklist_id, sort_order); +CREATE INDEX jz_bm_checklist_items_reading_id_index ON jz_bm_checklist_items (reading_id)`) + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS jz_bm_checklist_items").Error + }, + } +}