feat: delete-only chat moderation and embed search and resolve

- ChatContext implements golem15.chat's MessageModerator: moderator
  group members who are chat members delete in group conversations
  only, never in DMs
- GET /chat/embeds?type=deadline|class|reading&q= (members only,
  at most 20 rows, escaped ILIKE, q at most 100 characters)
- POST /chat/embeds/resolve {refs} (members only, at most 50 distinct
  refs, 8 KiB body): current data or null per ref
This commit is contained in:
2026-10-26 16:07:26 +01:00
parent ac4c2e6f73
commit 2681d304e2
3 changed files with 315 additions and 3 deletions

View File

@@ -0,0 +1,289 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/jakub/sm-bm-plugin/classes"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// The entity types a chat message can embed (D-14). Materials and quizzes
// are deferred and are not accepted.
const (
embedTypeDeadline = "deadline"
embedTypeClass = "class"
embedTypeReading = "reading"
)
// embedSearchLimit is the number of rows one picker search answers.
const embedSearchLimit = 20
// embedResolveLimit is the number of distinct refs one resolve call takes.
const embedResolveLimit = 50
var embedTypes = []string{embedTypeDeadline, embedTypeClass, embedTypeReading}
var chatEmbedsRules = []lagoon.RequestRule{
{Field: "type", Rules: append(lagoon.ParseRules("required"), lagoon.In(embedTypes...))},
{Field: "q", Rules: lagoon.ParseRules("nullable|string|max:100")},
}
// embedRef is one entity reference a message stores in its metadata.
type embedRef struct {
Type string `json:"type"`
ID uint `json:"id"`
}
// embedJSON is one embeddable item: its type and id, and the same JSON the
// rest of the API serves for it in the field named after the type.
type embedJSON struct {
Type string `json:"type"`
ID uint `json:"id"`
Deadline *deadlineJSON `json:"deadline,omitempty"`
Session *sessionJSON `json:"session,omitempty"`
Reading *readingCardJSON `json:"reading,omitempty"`
}
// readingCardJSON is a reading as an embed card shows it: no per-user read
// or owned state.
type readingCardJSON struct {
ID uint `json:"id"`
Title string `json:"title"`
Author *string `json:"author"`
IsRequired bool `json:"is_required"`
CourseID uint `json:"course_id"`
Course *courseRef `json:"course"`
}
// resolveEmbedsRequest is the body of POST /chat/embeds/resolve.
type resolveEmbedsRequest struct {
Refs []embedRef `json:"refs"`
}
// embedKey is the key of a ref in the resolve answer, "{type}:{id}".
func embedKey(typ string, id uint) string {
return typ + ":" + strconv.FormatUint(uint64(id), 10)
}
// escapeLike escapes the ILIKE wildcards % and _ and the escape character
// itself, so user input matches literally.
func escapeLike(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
// parseEmbedRefs checks refs: a supported type, a positive id, at most
// embedResolveLimit distinct refs. Duplicates collapse and the first-seen
// order is kept. Any problem returns validation errors and no refs.
func parseEmbedRefs(refs []embedRef) ([]embedRef, map[string][]string) {
errs := map[string][]string{}
seen := map[embedRef]bool{}
out := make([]embedRef, 0, len(refs))
for i, r := range refs {
if !slices.Contains(embedTypes, r.Type) {
errs[fmt.Sprintf("refs.%d.type", i)] = []string{"The selected type is invalid."}
continue
}
if r.ID == 0 {
errs[fmt.Sprintf("refs.%d.id", i)] = []string{"The id must be at least 1."}
continue
}
if seen[r] {
continue
}
seen[r] = true
out = append(out, r)
}
if len(out) > embedResolveLimit {
errs["refs"] = []string{fmt.Sprintf("The refs may not have more than %d items.", embedResolveLimit)}
}
if len(errs) > 0 {
return nil, errs
}
return out, nil
}
// chatMember answers 404 itself and returns false when userID is not in the
// chat group (500 when the check fails).
func (h *Handlers) chatMember(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID uint) bool {
member, err := classes.IsChatMember(r.Context(), db, userID, h.ChatGroup)
if err != nil {
writeOpaque500(w)
return false
}
if !member {
writeNotFound(w)
return false
}
return true
}
// ChatEmbeds answers GET /chat/embeds?type=deadline|class|reading&q=, the
// embed picker search, to chat-group members (404 otherwise). It lists at
// most embedSearchLimit items: upcoming deadlines soonest first, upcoming
// class sessions soonest first, readings required first then by title. q
// (at most 100 characters) narrows by deadline title, course name or
// reading title and author, case-insensitively.
func (h *Handlers) ChatEmbeds(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
in := query(r, "type", "q")
if !h.validate(w, r, in, chatEmbedsRules) {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
if !h.chatMember(w, r, db, userID) {
return
}
items, err := searchEmbeds(db, str(in, "type"), strings.TrimSpace(str(in, "q")), h.now().UTC())
if err != nil {
writeOpaque500(w)
return
}
writeData(w, items)
}
func searchEmbeds(db *gorm.DB, typ, q string, now time.Time) ([]embedJSON, error) {
pattern := "%" + escapeLike(q) + "%"
out := []embedJSON{}
switch typ {
case embedTypeDeadline:
var rows []models.Deadline
find := db.Preload("Course").Where("jz_bm_deadlines.due_at >= ?", now)
if q != "" {
find = find.Where("jz_bm_deadlines.title ILIKE ?", pattern)
}
if err := find.Order("jz_bm_deadlines.due_at, jz_bm_deadlines.id").Limit(embedSearchLimit).Find(&rows).Error; err != nil {
return nil, err
}
for _, d := range newDeadlinesJSON(rows) {
out = append(out, embedJSON{Type: embedTypeDeadline, ID: d.ID, Deadline: &d})
}
case embedTypeClass:
var rows []models.ClassSession
find := preloadSession(db).Where("jz_bm_class_sessions.ends_at > ?", now)
if q != "" {
find = find.Joins("JOIN jz_bm_courses ON jz_bm_courses.id = jz_bm_class_sessions.course_id").
Where("jz_bm_courses.name ILIKE ?", pattern)
}
if err := find.Order("jz_bm_class_sessions.starts_at, jz_bm_class_sessions.id").Limit(embedSearchLimit).Find(&rows).Error; err != nil {
return nil, err
}
for _, s := range newSessionsJSON(rows) {
out = append(out, embedJSON{Type: embedTypeClass, ID: s.ID, Session: &s})
}
case embedTypeReading:
var rows []models.Reading
find := db.Preload("Course").Where("jz_bm_readings.course_id IS NOT NULL")
if q != "" {
find = find.Where("(jz_bm_readings.title ILIKE ? OR jz_bm_readings.author ILIKE ?)", pattern, pattern)
}
if err := find.Order("jz_bm_readings.is_required DESC, jz_bm_readings.title, jz_bm_readings.id").Limit(embedSearchLimit).Find(&rows).Error; err != nil {
return nil, err
}
for _, rd := range rows {
card := newReadingCardJSON(rd)
out = append(out, embedJSON{Type: embedTypeReading, ID: rd.ID, Reading: &card})
}
}
return out, nil
}
func newReadingCardJSON(r models.Reading) readingCardJSON {
return readingCardJSON{
ID: r.ID,
Title: r.Title,
Author: r.Author,
IsRequired: r.IsRequired,
CourseID: idOf(r.CourseID),
Course: newCourseRef(r.Course),
}
}
// ResolveChatEmbeds answers POST /chat/embeds/resolve {"refs": [{type, id}]}
// for chat-group members (404 otherwise): {"data": {"{type}:{id}": item or
// null}} for every requested ref. Items are read on every call, so a
// renamed or moved deadline shows its current title and date. A missing
// row, or a class session without a course, is null: the neutral
// unavailable card (D-04).
func (h *Handlers) ResolveChatEmbeds(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
var body resolveEmbedsRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeValidation(w, map[string][]string{"refs": {"The request body must be a JSON object with a refs list."}})
return
}
refs, errs := parseEmbedRefs(body.Refs)
if len(errs) > 0 {
writeValidation(w, errs)
return
}
db, ok := h.db(w, r)
if !ok {
return
}
if !h.chatMember(w, r, db, userID) {
return
}
out, err := resolveEmbeds(db, refs)
if err != nil {
writeOpaque500(w)
return
}
writeData(w, out)
}
func resolveEmbeds(db *gorm.DB, refs []embedRef) (map[string]*embedJSON, error) {
out := make(map[string]*embedJSON, len(refs))
ids := map[string][]uint{}
for _, ref := range refs {
out[embedKey(ref.Type, ref.ID)] = nil
ids[ref.Type] = append(ids[ref.Type], ref.ID)
}
if list := ids[embedTypeDeadline]; len(list) > 0 {
var rows []models.Deadline
if err := db.Preload("Course").Where("jz_bm_deadlines.id IN ?", list).Find(&rows).Error; err != nil {
return nil, err
}
for _, d := range newDeadlinesJSON(rows) {
out[embedKey(embedTypeDeadline, d.ID)] = &embedJSON{Type: embedTypeDeadline, ID: d.ID, Deadline: &d}
}
}
if list := ids[embedTypeClass]; len(list) > 0 {
var rows []models.ClassSession
if err := preloadSession(db).Where("jz_bm_class_sessions.id IN ?", list).Find(&rows).Error; err != nil {
return nil, err
}
for _, s := range newSessionsJSON(rows) {
out[embedKey(embedTypeClass, s.ID)] = &embedJSON{Type: embedTypeClass, ID: s.ID, Session: &s}
}
}
if list := ids[embedTypeReading]; len(list) > 0 {
var rows []models.Reading
if err := db.Preload("Course").Where("jz_bm_readings.id IN ? AND jz_bm_readings.course_id IS NOT NULL", list).Find(&rows).Error; err != nil {
return nil, err
}
for _, rd := range rows {
card := newReadingCardJSON(rd)
out[embedKey(embedTypeReading, rd.ID)] = &embedJSON{Type: embedTypeReading, ID: rd.ID, Reading: &card}
}
}
return out, nil
}