Files
sm-bm-plugin/mcp_manage.go
Jakub Zych dd13795e6f feat: search the management list and the MCP material tools
- GET /api/v1/bm/materials takes q: candidates over every material, in
  rank order, with the snippet of the student list; 503 when the engine
  cannot answer
- bm_materials and bm_manage_list_materials take q; under MCP the snippet
  is plain text with ellipses where text was cut
- MCPMaterialPrivacy no longer claims no file text is ever returned
- README documents q, the snippet, the 503, the Search section and
  bm:materials-reindex
2026-11-08 19:53:26 +01:00

356 lines
25 KiB
Go

package bm
import (
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
)
// The sentences the management tool descriptions end with (D-15): the scope
// and the permission code the route checks.
const (
readCourses = " Needs scope read and the jz.bm.access_courses permission."
writeCourses = " Needs scope write and the jz.bm.access_courses permission."
readSchedule = " Needs scope read and the jz.bm.access_schedule permission."
writeSchedule = " Needs scope write and the jz.bm.access_schedule permission."
readMaterials = " Needs scope read and the jz.bm.access_materials permission."
writeMaterials = " Needs scope write and the jz.bm.access_materials permission."
)
// Shared description fragments (D-22).
const (
recordAnswer = " Returns the row as an object of its columns (id, created_at and updated_at included); times are RFC 3339 in Europe/Warsaw, dates YYYY-MM-DD."
invalidAnswer = " A value that breaks a rule is refused with 422 and the errors per field."
patchSemantics = " Only the fields sent change; null or an empty string clears an optional column."
deleteForever = " The delete cannot be undone."
replacedAsSent = " body, source and url are replaced as sent (null clears them): read the material with include_body true first to keep its body."
)
// --- courses -------------------------------------------------------------
// listCoursesInput filters bm_manage_list_courses.
type listCoursesInput struct {
Semester int `json:"semester,omitempty" query:"semester" jsonschema:"only courses of this semester, 1 to 4"`
Form string `json:"form,omitempty" query:"form" jsonschema:"only courses of this form, one of: wyklad, cwiczenia, konwersatorium, seminarium, inne"`
Code string `json:"code,omitempty" query:"code" jsonschema:"only the course with this code; null for courses without one"`
}
// createCourseInput is the body of bm_manage_create_course.
type createCourseInput struct {
Code *string `json:"code,omitempty" jsonschema:"course code, at most 50 characters"`
Name string `json:"name" jsonschema:"course name, at most 255 characters"`
Form string `json:"form" jsonschema:"one of: wyklad, cwiczenia, konwersatorium, seminarium, inne"`
Lecturer *string `json:"lecturer,omitempty" jsonschema:"lecturer, at most 255 characters, with the title as the university writes it"`
Semester int `json:"semester" jsonschema:"semester, 1 to 4"`
ECTS *float64 `json:"ects,omitempty" jsonschema:"ECTS points, 0 to 60"`
Assessment *string `json:"assessment,omitempty" jsonschema:"form of assessment, at most 100 characters"`
ElectiveGroup *string `json:"elective_group,omitempty" jsonschema:"elective group, at most 100 characters"`
Description *string `json:"description,omitempty" jsonschema:"description"`
SyllabusURL *string `json:"syllabus_url,omitempty" jsonschema:"syllabus link, at most 2048 characters"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position within the semester, an integer"`
}
// updateCourseInput is the body of bm_manage_update_course.
type updateCourseInput struct {
ID uint `json:"id" path:"id" jsonschema:"the course id"`
Code *string `json:"code,omitempty" jsonschema:"course code, at most 50 characters"`
Name *string `json:"name,omitempty" jsonschema:"course name, at most 255 characters; required, so not null"`
Form *string `json:"form,omitempty" jsonschema:"one of: wyklad, cwiczenia, konwersatorium, seminarium, inne"`
Lecturer *string `json:"lecturer,omitempty" jsonschema:"lecturer, at most 255 characters"`
Semester *int `json:"semester,omitempty" jsonschema:"semester, 1 to 4"`
ECTS *float64 `json:"ects,omitempty" jsonschema:"ECTS points, 0 to 60"`
Assessment *string `json:"assessment,omitempty" jsonschema:"form of assessment, at most 100 characters"`
ElectiveGroup *string `json:"elective_group,omitempty" jsonschema:"elective group, at most 100 characters"`
Description *string `json:"description,omitempty" jsonschema:"description"`
SyllabusURL *string `json:"syllabus_url,omitempty" jsonschema:"syllabus link, at most 2048 characters"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position within the semester, an integer"`
}
// --- meetings ------------------------------------------------------------
// createMeetingInput is the body of bm_manage_create_meeting.
type createMeetingInput struct {
Number int `json:"number" jsonschema:"the zjazd number, 1 or more, unique"`
StartsOn string `json:"starts_on" jsonschema:"first day, YYYY-MM-DD"`
EndsOn string `json:"ends_on" jsonschema:"last day, YYYY-MM-DD, not before starts_on"`
Notes *string `json:"notes,omitempty" jsonschema:"notes"`
}
// updateMeetingInput is the body of bm_manage_update_meeting.
type updateMeetingInput struct {
ID uint `json:"id" path:"id" jsonschema:"the meeting id"`
Number *int `json:"number,omitempty" jsonschema:"the zjazd number, 1 or more, unique"`
StartsOn *string `json:"starts_on,omitempty" jsonschema:"first day, YYYY-MM-DD"`
EndsOn *string `json:"ends_on,omitempty" jsonschema:"last day, YYYY-MM-DD, not before starts_on"`
Notes *string `json:"notes,omitempty" jsonschema:"notes"`
}
// --- class sessions ------------------------------------------------------
// listClassSessionsInput filters bm_manage_list_class_sessions.
type listClassSessionsInput struct {
CourseID string `json:"course_id,omitempty" query:"course_id" jsonschema:"only sessions of this course id; null for sessions without a course"`
MeetingID string `json:"meeting_id,omitempty" query:"meeting_id" jsonschema:"only sessions of this meeting (zjazd) id; null for sessions without one"`
}
// createClassSessionInput is the body of bm_manage_create_class_session.
type createClassSessionInput struct {
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id"`
StartsAt string `json:"starts_at" jsonschema:"start time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 08:00"`
EndsAt string `json:"ends_at" jsonschema:"end time, after starts_at: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 09:30"`
Room *string `json:"room,omitempty" jsonschema:"room, at most 100 characters"`
Kind *string `json:"kind,omitempty" jsonschema:"kind of class, at most 100 characters"`
Lecturer *string `json:"lecturer,omitempty" jsonschema:"lecturer, at most 255 characters"`
Notes *string `json:"notes,omitempty" jsonschema:"notes"`
IsOnline *bool `json:"is_online,omitempty" jsonschema:"true when the class is online"`
}
// updateClassSessionInput is the body of bm_manage_update_class_session.
type updateClassSessionInput struct {
ID uint `json:"id" path:"id" jsonschema:"the class session id"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; null clears it"`
StartsAt *string `json:"starts_at,omitempty" jsonschema:"start time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 08:00"`
EndsAt *string `json:"ends_at,omitempty" jsonschema:"end time, after starts_at: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 09:30"`
Room *string `json:"room,omitempty" jsonschema:"room, at most 100 characters"`
Kind *string `json:"kind,omitempty" jsonschema:"kind of class, at most 100 characters"`
Lecturer *string `json:"lecturer,omitempty" jsonschema:"lecturer, at most 255 characters"`
Notes *string `json:"notes,omitempty" jsonschema:"notes"`
IsOnline *bool `json:"is_online,omitempty" jsonschema:"true when the class is online"`
}
// --- readings ------------------------------------------------------------
// listReadingsInput filters bm_manage_list_readings.
type listReadingsInput struct {
CourseID string `json:"course_id,omitempty" query:"course_id" jsonschema:"only readings of this course id; null for readings without a course"`
MeetingID string `json:"meeting_id,omitempty" query:"meeting_id" jsonschema:"only readings of this meeting (zjazd) id; null for readings without one"`
IsRequired string `json:"is_required,omitempty" query:"is_required" jsonschema:"true for required readings only, false for optional ones only"`
}
// createReadingInput is the body of bm_manage_create_reading.
type createReadingInput struct {
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id"`
Title string `json:"title" jsonschema:"title, at most 500 characters"`
Author *string `json:"author,omitempty" jsonschema:"author, at most 255 characters"`
Source *string `json:"source,omitempty" jsonschema:"source (book, journal, chapter), at most 500 characters"`
URL *string `json:"url,omitempty" jsonschema:"link, at most 2048 characters"`
IsRequired *bool `json:"is_required,omitempty" jsonschema:"true when the reading is required"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position within the course, an integer"`
}
// updateReadingInput is the body of bm_manage_update_reading.
type updateReadingInput struct {
ID uint `json:"id" path:"id" jsonschema:"the reading id"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; null clears it"`
Title *string `json:"title,omitempty" jsonschema:"title, at most 500 characters; required, so not null"`
Author *string `json:"author,omitempty" jsonschema:"author, at most 255 characters"`
Source *string `json:"source,omitempty" jsonschema:"source (book, journal, chapter), at most 500 characters"`
URL *string `json:"url,omitempty" jsonschema:"link, at most 2048 characters"`
IsRequired *bool `json:"is_required,omitempty" jsonschema:"true when the reading is required"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position within the course, an integer"`
}
// --- deadlines -----------------------------------------------------------
// listDeadlinesInput filters bm_manage_list_deadlines.
type listDeadlinesInput struct {
CourseID string `json:"course_id,omitempty" query:"course_id" jsonschema:"only deadlines of this course id; null for deadlines without a course"`
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"only deadlines of this kind, one of: zaliczenie, egzamin, praca, organizacyjne, inne"`
}
// createDeadlineInput is the body of bm_manage_create_deadline.
type createDeadlineInput struct {
Title string `json:"title" jsonschema:"title, at most 255 characters"`
DueAt string `json:"due_at" jsonschema:"due time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 23:59"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
Kind string `json:"kind" jsonschema:"one of: zaliczenie, egzamin, praca, organizacyjne, inne"`
Description *string `json:"description,omitempty" jsonschema:"description"`
}
// updateDeadlineInput is the body of bm_manage_update_deadline.
type updateDeadlineInput struct {
ID uint `json:"id" path:"id" jsonschema:"the deadline id"`
Title *string `json:"title,omitempty" jsonschema:"title, at most 255 characters; required, so not null"`
DueAt *string `json:"due_at,omitempty" jsonschema:"due time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-03 23:59"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id; null clears it"`
Kind *string `json:"kind,omitempty" jsonschema:"one of: zaliczenie, egzamin, praca, organizacyjne, inne"`
Description *string `json:"description,omitempty" jsonschema:"description"`
}
// --- materials -----------------------------------------------------------
// listManagedMaterialsInput is the input of bm_manage_list_materials.
type listManagedMaterialsInput struct {
Q string `json:"q,omitempty" query:"q" jsonschema:"full-text search over the title, tags, source, course, link, body and the text of attached PDFs, 2 to 100 characters; results then come by relevance, each with a short plain-text snippet when the match is in the body or a file"`
IncludeBody bool `json:"include_body,omitempty" query:"include_body" jsonschema:"true to include each material's body (it may hold transcript text)"`
}
// showManagedMaterialInput is the input of bm_manage_show_material.
type showManagedMaterialInput struct {
ID uint `json:"id" path:"id" jsonschema:"the material id"`
IncludeBody bool `json:"include_body,omitempty" query:"include_body" jsonschema:"true to include the material's body (it may hold transcript text)"`
}
// saveMaterialInput is the body of bm_manage_save_material. body, source
// and url have no omitempty: the route replaces them as sent, so the agent
// must state each (null clears it).
type saveMaterialInput struct {
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id; together with title it identifies the material (absent or null: no course)"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; absent keeps the stored one, null clears it"`
Title string `json:"title" jsonschema:"title, 1 to 255 characters"`
Type string `json:"type" jsonschema:"one of: pdf, notatka, link, inne"`
Body *string `json:"body" jsonschema:"Markdown body, or null for none; replaced as sent"`
Source *string `json:"source" jsonschema:"source, at most 500 characters, or null; replaced as sent"`
URL *string `json:"url" jsonschema:"link, at most 2048 characters, or null; replaced as sent"`
PublishedAt *string `json:"published_at,omitempty" jsonschema:"publication time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-16 20:00; absent publishes a new material now and keeps an existing one as it is; null makes a draft"`
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names (1 to 100 characters each); absent keeps the tags, a list replaces them"`
IsPrivate *bool `json:"is_private,omitempty" jsonschema:"true makes the material private: only its author and the users it is shared with see it; absent keeps it, a new material is public"`
AuthorUserID *uint `json:"author_user_id,omitempty" jsonschema:"the author's user id from bm_manage_list_users; absent keeps it, a new material gets the caller as author; null clears it"`
ShareUserIDs *[]uint `json:"share_user_ids,omitempty" jsonschema:"user ids from bm_manage_list_users the material is shared with (at most 200); absent keeps the shares, a list replaces them"`
}
// updateMaterialInput is the body of bm_manage_update_material.
type updateMaterialInput struct {
ID uint `json:"id" path:"id" jsonschema:"the material id"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id (absent or null: no course)"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; absent keeps the stored one, null clears it"`
Title string `json:"title" jsonschema:"title, 1 to 255 characters"`
Type string `json:"type" jsonschema:"one of: pdf, notatka, link, inne"`
Body *string `json:"body" jsonschema:"Markdown body, or null for none; replaced as sent"`
Source *string `json:"source" jsonschema:"source, at most 500 characters, or null; replaced as sent"`
URL *string `json:"url" jsonschema:"link, at most 2048 characters, or null; replaced as sent"`
PublishedAt *string `json:"published_at,omitempty" jsonschema:"publication time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-16 20:00; absent keeps it; null makes a draft"`
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names (1 to 100 characters each); absent keeps the tags, a list replaces them"`
IsPrivate *bool `json:"is_private,omitempty" jsonschema:"true makes the material private: only its author and the users it is shared with see it; absent keeps it, a new material is public"`
AuthorUserID *uint `json:"author_user_id,omitempty" jsonschema:"the author's user id from bm_manage_list_users; absent keeps it, a new material gets the caller as author; null clears it"`
ShareUserIDs *[]uint `json:"share_user_ids,omitempty" jsonschema:"user ids from bm_manage_list_users the material is shared with (at most 200); absent keeps the shares, a list replaces them"`
}
// materialVisibilityInput is the body of bm_manage_set_material_visibility.
type materialVisibilityInput struct {
ID uint `json:"id" path:"id" jsonschema:"the material id"`
IsPrivate bool `json:"is_private" jsonschema:"true: only the author and the users it is shared with see it; false: every student sees it"`
}
// materialSharesInput is the body of bm_manage_set_material_shares.
type materialSharesInput struct {
ID uint `json:"id" path:"id" jsonschema:"the material id"`
UserIDs []uint `json:"user_ids" jsonschema:"user ids from bm_manage_list_users, at most 200; the list replaces the shares, an empty list removes them"`
}
// manageTools are the tools over the management API (/api/v1/bm): typed
// CRUD of the BM tables (D-13). The routes' user.scope and user.permission
// middleware filter the listing, so only a user whose groups hold the
// jz.bm.access_* codes sees them, and write tools only with the write
// grant. There is no tool for POST /seed or GET /me, and no dry_run (D-14).
func manageTools() []pact.MCPTool {
tool := func(name, title, desc, method, path string, input any) pact.MCPTool {
return pact.MCPTool{Name: name, Title: title, Description: desc, Guard: "user", Method: method, Path: ManagePrefix + path, Input: input}
}
g, po, pa, pu, d := http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete
return []pact.MCPTool{
tool("manage_list_courses", "List courses (manage)",
"Lists every course as rows of columns, ordered by semester and position, optionally filtered."+readCourses,
g, "/courses", listCoursesInput{}),
tool("manage_show_course", "Show a course (manage)",
"Returns one course; 404 when unknown."+recordAnswer+readCourses,
g, "/courses/{id}", idInput{}),
tool("manage_create_course", "Create a course",
"Creates a course."+recordAnswer+invalidAnswer+writeCourses,
po, "/courses", createCourseInput{}),
tool("manage_update_course", "Update a course",
"Updates one course."+patchSemantics+recordAnswer+invalidAnswer+writeCourses,
pa, "/courses/{id}", updateCourseInput{}),
tool("manage_list_meetings", "List meetings (manage)",
"Lists every zjazd (meeting) as rows of columns, ordered by number."+readSchedule,
g, "/meetings", noInput{}),
tool("manage_show_meeting", "Show a meeting (manage)",
"Returns one zjazd (meeting); 404 when unknown."+recordAnswer+readSchedule,
g, "/meetings/{id}", idInput{}),
tool("manage_create_meeting", "Create a meeting",
"Creates a zjazd (meeting)."+recordAnswer+invalidAnswer+writeSchedule,
po, "/meetings", createMeetingInput{}),
tool("manage_update_meeting", "Update a meeting",
"Updates one zjazd (meeting)."+patchSemantics+recordAnswer+invalidAnswer+writeSchedule,
pa, "/meetings/{id}", updateMeetingInput{}),
tool("manage_list_class_sessions", "List class sessions (manage)",
"Lists every class session as rows of columns, ordered by start time, optionally filtered by course or meeting."+readSchedule,
g, "/class-sessions", listClassSessionsInput{}),
tool("manage_show_class_session", "Show a class session (manage)",
"Returns one class session; 404 when unknown."+recordAnswer+readSchedule,
g, "/class-sessions/{id}", idInput{}),
tool("manage_create_class_session", "Create a class session",
"Creates a class session; course_id and meeting_id must exist."+recordAnswer+invalidAnswer+writeSchedule,
po, "/class-sessions", createClassSessionInput{}),
tool("manage_update_class_session", "Update a class session",
"Updates one class session."+patchSemantics+recordAnswer+invalidAnswer+writeSchedule,
pa, "/class-sessions/{id}", updateClassSessionInput{}),
tool("manage_delete_class_session", "Delete a class session",
"Deletes one class session; 404 when unknown."+deleteForever+writeSchedule,
d, "/class-sessions/{id}", idInput{}),
tool("manage_list_readings", "List readings (manage)",
"Lists every reading as rows of columns, ordered by course and position, optionally filtered."+readCourses,
g, "/readings", listReadingsInput{}),
tool("manage_show_reading", "Show a reading (manage)",
"Returns one reading; 404 when unknown."+recordAnswer+readCourses,
g, "/readings/{id}", idInput{}),
tool("manage_create_reading", "Create a reading",
"Creates a reading; course_id and meeting_id must exist."+recordAnswer+invalidAnswer+writeCourses,
po, "/readings", createReadingInput{}),
tool("manage_update_reading", "Update a reading",
"Updates one reading."+patchSemantics+recordAnswer+invalidAnswer+writeCourses,
pa, "/readings/{id}", updateReadingInput{}),
tool("manage_delete_reading", "Delete a reading",
"Deletes one reading with every student's read and owned marks of it; 404 when unknown."+deleteForever+writeCourses,
d, "/readings/{id}", idInput{}),
tool("manage_list_deadlines", "List deadlines (manage)",
"Lists every deadline as rows of columns, ordered by due time, optionally filtered by course or kind."+readSchedule,
g, "/deadlines", listDeadlinesInput{}),
tool("manage_show_deadline", "Show a deadline (manage)",
"Returns one deadline; 404 when unknown."+recordAnswer+readSchedule,
g, "/deadlines/{id}", idInput{}),
tool("manage_create_deadline", "Create a deadline",
"Creates a deadline; course_id must exist."+recordAnswer+invalidAnswer+writeSchedule,
po, "/deadlines", createDeadlineInput{}),
tool("manage_update_deadline", "Update a deadline",
"Updates one deadline."+patchSemantics+recordAnswer+invalidAnswer+writeSchedule,
pa, "/deadlines/{id}", updateDeadlineInput{}),
tool("manage_delete_deadline", "Delete a deadline",
"Deletes one deadline; 404 when unknown."+deleteForever+writeSchedule,
d, "/deadlines/{id}", idInput{}),
tool("manage_list_materials", "List materials (manage)",
"Lists every material, drafts and private ones included, newest first, or by relevance with q, with course, zjazd, type, source, link, publication time and tags (and the snippet with q); 503 when search is unavailable. "+MCPMaterialPrivacy+readMaterials,
g, "/materials", listManagedMaterialsInput{}),
tool("manage_show_material", "Show a material (manage)",
"Returns one material, draft or not, with its public files (id, absolute link, name, size and type); 404 when unknown. "+MCPMaterialPrivacy+readMaterials,
g, "/materials/{id}", showManagedMaterialInput{}),
tool("manage_save_material", "Save a material",
"Creates a material, or updates the one with the same title and course (answer created true or false); a renamed material is a new one here, use bm_manage_update_material to rename. is_private, author_user_id and share_user_ids set its visibility, author and shares (a new material is public, with the caller as author)."+replacedAsSent+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
po, "/materials", saveMaterialInput{}),
tool("manage_update_material", "Update a material",
"Updates one material, so its title or course can change; 404 when unknown, 422 when another material has the same title and course. is_private, author_user_id and share_user_ids change its visibility, author and shares when sent."+replacedAsSent+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
pu, "/materials/{id}", updateMaterialInput{}),
tool("manage_delete_material", "Delete a material",
"Deletes one material with all its files; 404 when unknown."+deleteForever+" "+MCPMaterialPrivacy+writeMaterials,
d, "/materials/{id}", idInput{}),
tool("manage_set_material_visibility", "Set a material's visibility",
"Makes a material private or public: private means only its author and the users it is shared with see it in the student app and the student tools, public means every student; a material without an author gets the caller as author; 404 when unknown. "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
pa, "/materials/{id}/visibility", materialVisibilityInput{}),
tool("manage_set_material_shares", "Share a material with users",
"Sets the users a material is shared with: the list replaces them (an empty list removes them); shares matter only while the material is private; ids come from bm_manage_list_users; a material without an author gets the caller as author; 404 when unknown. "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
pu, "/materials/{id}/shares", materialSharesInput{}),
tool("manage_list_users", "List users (manage)",
"Lists the id, display name and email of every user, to pick a material's author or the users it is shared with."+readMaterials,
g, "/users", noInput{}),
}
}