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