Files
sm-bm-plugin/controllers/api/checklists.go
Jakub Zych 418f8240a4 feat: private materials with per-user shares
A material may be private: only its author and the users it is shared
with see it. Every student read (list, detail, course detail and
counters, the checklist linked material, the token twins) goes through
one visibility fragment. Existing materials stay public with no author.

GET/PUT /_bm/api/v1/materials/{id}/access let the author, a material
manager, toggle the flag and pick the users; the first change of an
author-less material makes the caller its author.
2026-11-08 11:52:47 +01:00

695 lines
20 KiB
Go

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. Percent weighs them by
// estimate_minutes, so a 3 h item counts more than a 5 min one; without any
// estimate it counts 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
}
if p.MinutesTotal > 0 {
p.Percent = p.MinutesDone * 100 / p.MinutesTotal
} else {
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 the caller may see, so a private
// material's title never shows.
var ms []models.Material
err := db.Select("id", "title").Where("id IN ? AND published_at IS NOT NULL", materialIDs).
Where(visibleMaterialSQL("jz_bm_materials"), userID, userID).Find(&ms).Error
if 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 {
list := items[c.ID]
if list == nil {
list = []checklistItemJSON{}
}
item.Items = &list
}
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)
}