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.
685 lines
20 KiB
Go
685 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.
|
|
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)
|
|
}
|