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.
This commit is contained in:
Jakub Zych
2026-10-30 12:41:04 +01:00
parent 643f8cbf7c
commit d7cd5baedf
15 changed files with 1262 additions and 21 deletions

View File

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