package api import ( "net/http" "time" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/jakub/sm-bm-plugin/models" "gorm.io/gorm" ) // readingRow is a reading joined with the caller's progress row. type readingRow struct { models.Reading `gorm:"embedded"` ReadAt *time.Time `gorm:"column:read_at"` } // listReadings returns the readings, optionally of one course, in course and // reading order, each with the caller's read_at. func listReadings(db *gorm.DB, userID, courseID uint) ([]readingJSON, error) { q := db.Table("jz_bm_readings AS r"). Select("r.*, p.read_at"). Joins("JOIN jz_bm_courses c ON c.id = r.course_id"). Joins("LEFT JOIN jz_bm_reading_progress p ON p.reading_id = r.id AND p.user_id = ?", userID) if courseID != 0 { q = q.Where("r.course_id = ?", courseID) } var rows []readingRow if err := q.Order("c.semester, c.sort_order, c.name, r.course_id, r.sort_order, r.id").Scan(&rows).Error; err != nil { return nil, err } courses, err := coursesByID(db, readingCourseIDs(rows)) if err != nil { return nil, err } out := make([]readingJSON, 0, len(rows)) for _, row := range rows { item := readingJSON{ ID: row.ID, CourseID: idOf(row.CourseID), Course: newCourseRef(courses[idOf(row.CourseID)]), Title: row.Title, Author: row.Author, Source: row.Source, URL: row.URL, IsRequired: row.IsRequired, SortOrder: row.SortOrder, } if row.ReadAt != nil { s := models.FormatDateTime(*row.ReadAt) item.ReadAt = &s } out = append(out, item) } return out, nil } func readingCourseIDs(rows []readingRow) []uint { seen := map[uint]bool{} var ids []uint for _, row := range rows { if id := idOf(row.CourseID); !seen[id] { seen[id] = true ids = append(ids, id) } } return ids } // coursesByID loads the given courses keyed by ID. func coursesByID(db *gorm.DB, ids []uint) (map[uint]*models.Course, error) { out := map[uint]*models.Course{} if len(ids) == 0 { return out, nil } var rows []models.Course if err := db.Where("id IN ?", ids).Find(&rows).Error; err != nil { return nil, err } for i := range rows { out[rows[i].ID] = &rows[i] } return out, nil } // Readings answers GET /readings?course=ID: the reading lists with the // caller's read state. An unknown course gives an empty list. func (h *Handlers) Readings(w http.ResponseWriter, r *http.Request) { userID, ok := h.caller(w, r) if !ok { return } in := query(r, "course") if !h.validate(w, r, in, courseFilterRules) { return } db, ok := h.db(w, r) if !ok { return } out, err := listReadings(db, userID, uintParam(in, "course")) if err != nil { writeOpaque500(w) return } writeData(w, out) } // progressJSON is the answer of the progress routes. type progressJSON struct { ReadingID uint `json:"reading_id"` ReadAt *string `json:"read_at"` } // readingFor resolves {id} to an existing reading and answers 404 itself // when there is none. func readingFor(w http.ResponseWriter, r *http.Request, db *gorm.DB) (uint, bool) { id, ok := surf.IntParam(r, "id") if !ok { writeNotFound(w) return 0, false } var reading models.Reading res := db.Select("id").Where("id = ? AND course_id IS NOT NULL", id).Limit(1).Find(&reading) if res.Error != nil { writeOpaque500(w) return 0, false } if res.RowsAffected == 0 { writeNotFound(w) return 0, false } return reading.ID, true } // MarkRead answers PUT /readings/{id}/progress: it marks the reading read // by the caller. Marking it again keeps the first read_at. func (h *Handlers) MarkRead(w http.ResponseWriter, r *http.Request) { userID, ok := h.caller(w, r) if !ok { return } db, ok := h.db(w, r) if !ok { return } readingID, ok := readingFor(w, r, db) if !ok { return } now := h.now().UTC() err := db.Exec(`INSERT INTO jz_bm_reading_progress (reading_id, user_id, read_at) VALUES (?, ?, ?) ON CONFLICT (reading_id, user_id) DO NOTHING`, readingID, userID, now).Error if err != nil { writeOpaque500(w) return } var progress models.ReadingProgress if err := db.Where("reading_id = ? AND user_id = ?", readingID, userID).Take(&progress).Error; err != nil { writeOpaque500(w) return } readAt := models.FormatDateTime(progress.ReadAt) writeData(w, progressJSON{ReadingID: readingID, ReadAt: &readAt}) } // UnmarkRead answers DELETE /readings/{id}/progress: it clears the caller's // read mark. Clearing an unread reading is not an error. func (h *Handlers) UnmarkRead(w http.ResponseWriter, r *http.Request) { userID, ok := h.caller(w, r) if !ok { return } db, ok := h.db(w, r) if !ok { return } readingID, ok := readingFor(w, r, db) if !ok { return } if err := db.Where("reading_id = ? AND user_id = ?", readingID, userID).Delete(&models.ReadingProgress{}).Error; err != nil { writeOpaque500(w) return } writeData(w, progressJSON{ReadingID: readingID}) }