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:
684
controllers/api/checklists.go
Normal file
684
controllers/api/checklists.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user