Many teachers never use topics or boards; their material sits in the course's file area, and the tools answered "0 files" for courses holding dozens of worksheets — 21 of 26 courses on the live account. Persönliche, Kurs-, Team- and Geteilte Dateien live in the legacy file store, not in files-storage, and its service is not in the public ingress. The only way in is the legacy client: HTML listings, and GET /files/signedurl for a pre-signed download. core/legacy-files.ts turns that into one path tree — /my, /courses/<course>, /teams/<team>, /shared — resolving names that contain "/", ids anywhere in a path, and wrong or ambiguous names with a message saying what is there. A listing that does not parse throws; it never reads as an empty folder. Some of the legacy client's GET routes write (GET /files/share/ mints a share token), so getFileManagerPage allows only the listing routes, by pattern. Signed URLs are fetched with no credentials and must be https. - MCP: fs_list, fs_tree, fs_find and fs_read; get_course lists course files. - CLI: schulcloud fs ls, tree, find and get, recursive and resumable. - API: /api/fs/list, tree, find and file. - Index: the crawl walks the file manager (INDEX_FILE_MANAGER, on by default), so search covers the text inside those files and sync mirrors them under <course>/Kurs-Dateien. The local instance gains a fixture for all four areas. It needed a loopback, so signed URLs open from the host, and a pre-created bucket, since MinIO does not implement PutBucketCors. 135 tests. Smoke 55/55 live; 57/57 and 55/55 on the local instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
602 lines
24 KiB
JavaScript
Executable File
602 lines
24 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Simulate a teacher's work against the LOCAL instance: create, change and
|
|
* delete the things teachers actually create, so the MCP server can be exercised
|
|
* against content the real account has never contained.
|
|
*
|
|
* This is the only thing in this repository that writes to a Schulcloud, and the
|
|
* guard below is what keeps it that way. The MCP server itself stays read-only;
|
|
* nothing here runs through it.
|
|
*
|
|
* node scripts/simulate-teacher.mjs create # build the fixture, print ids
|
|
* node scripts/simulate-teacher.mjs update # rename/edit everything it made
|
|
* node scripts/simulate-teacher.mjs delete # remove it again
|
|
* node scripts/simulate-teacher.mjs files # (re)build only the file-manager part
|
|
*
|
|
* State lives in .simulate-teacher.json so the phases can be run one at a time
|
|
* with MCP checks in between.
|
|
*/
|
|
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const STATE = join(ROOT, '.simulate-teacher.json');
|
|
|
|
// The server's own port, not the nginx origin: `/api/v1` is deliberately not
|
|
// routed through the ingress (the live deployment does the same), and the
|
|
// legacy services are the only way to create tasks and topics.
|
|
const API = process.env.LOCAL_SC_API ?? 'http://127.0.0.1:3030';
|
|
const FILES = process.env.LOCAL_SC_FILES ?? 'http://127.0.0.1:4444';
|
|
|
|
for (const [name, url] of [['LOCAL_SC_API', API], ['LOCAL_SC_FILES', FILES]]) {
|
|
const { hostname } = new URL(url);
|
|
if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1') {
|
|
console.error(`refusing to run: ${name}=${url} is not a localhost address.`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
const TEACHER = process.env.SIM_TEACHER ?? 'klara.fall@schul-cloud.org';
|
|
const PASSWORD = process.env.SIM_PASSWORD ?? 'Schulcloud1!';
|
|
/** Everything is created here, because the demo student is a member. */
|
|
const COURSE = process.env.SIM_COURSE ?? '59a3c657a2049554a93fec3a'; // Biologie 9b
|
|
const STUDENT_EMAIL = 'demo-schueler@schul-cloud.org';
|
|
|
|
let jwt = '';
|
|
let me;
|
|
|
|
async function req(base, method, path, body, { raw = false } = {}) {
|
|
const headers = { Authorization: `Bearer ${jwt}` };
|
|
let payload;
|
|
if (body instanceof FormData) {
|
|
payload = body;
|
|
} else if (body !== undefined) {
|
|
headers['Content-Type'] = 'application/json';
|
|
payload = JSON.stringify(body);
|
|
}
|
|
const res = await fetch(`${base}${path}`, { method, headers, body: payload });
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 400)}`);
|
|
}
|
|
if (raw) return text;
|
|
return text ? JSON.parse(text) : undefined;
|
|
}
|
|
|
|
const v3 = (method, path, body, opts) => req(API, method, `/api/v3${path}`, body, opts);
|
|
const v1 = (method, path, body, opts) => req(API, method, `/api/v1${path}`, body, opts);
|
|
const files = (method, path, body, opts) => req(FILES, method, `/api/v3/file${path}`, body, opts);
|
|
|
|
const log = (...a) => console.log(' ', ...a);
|
|
const step = (s) => console.log(`\n== ${s} ==`);
|
|
|
|
function loadState() {
|
|
return existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {};
|
|
}
|
|
function saveState(s) {
|
|
writeFileSync(STATE, JSON.stringify(s, null, '\t') + '\n');
|
|
}
|
|
|
|
async function login() {
|
|
const res = await fetch(`${API}/api/v3/authentication/local`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: TEACHER, password: PASSWORD }),
|
|
});
|
|
if (!res.ok) throw new Error(`login failed: ${res.status} ${await res.text()}`);
|
|
jwt = (await res.json()).accessToken;
|
|
me = await v3('GET', '/me');
|
|
log(`signed in as ${me.user.firstName} ${me.user.lastName} (${me.roles.map((r) => r.name).join(', ')})`);
|
|
}
|
|
|
|
/** The demo student, so created content is visible to the account under test. */
|
|
async function studentId() {
|
|
const found = await v1('GET', `/users?email=${encodeURIComponent(STUDENT_EMAIL)}`);
|
|
const user = found.data?.[0] ?? found[0];
|
|
if (!user) throw new Error(`could not find ${STUDENT_EMAIL}`);
|
|
return user._id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- create ---
|
|
|
|
async function create() {
|
|
const s = loadState();
|
|
// Saved after every step, not at the end: a failure halfway through used to
|
|
// leave created objects with no record of their ids, and so no way to delete
|
|
// them again.
|
|
const keep = (k, v) => { s[k] = v; saveState(s); return v; };
|
|
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
|
const student = await studentId();
|
|
|
|
step('course');
|
|
const course = await v3('POST', '/courses', { name: `MCP-Test Kurs (${stamp})` });
|
|
keep('courseId', course.courseId ?? course.id ?? course._id);
|
|
// v3 creates the course but takes no members; the legacy service does.
|
|
await v1('PATCH', `/courses/${s.courseId}`, { userIds: [student], teacherIds: [me.user.id] });
|
|
log(`course ${s.courseId} with the demo student enrolled`);
|
|
|
|
step('room');
|
|
const room = await v3('POST', '/rooms', {
|
|
name: `MCP-Test Raum (${stamp})`,
|
|
color: 'blue',
|
|
features: [],
|
|
});
|
|
keep('roomId', room.id);
|
|
log(`room ${s.roomId}`);
|
|
|
|
step('room contents');
|
|
// Rooms are the newer collaboration space, separate from courses: a room has
|
|
// its own members and its own boards, and appears under "Räume" in the UI
|
|
// while courses appear under "Kurse" at a /rooms/... url.
|
|
await v3('PATCH', `/rooms/${s.roomId}/members/add`, { userIds: [student] });
|
|
log('demo student added as a room member');
|
|
|
|
const roomBoard = await v3('POST', '/boards', {
|
|
title: `MCP-Test Raum-Board (${stamp})`,
|
|
parentId: s.roomId,
|
|
parentType: 'room',
|
|
layout: 'columns',
|
|
});
|
|
keep('roomBoardId', roomBoard.id);
|
|
const roomColumn = await v3('POST', `/boards/${s.roomBoardId}/columns`);
|
|
await v3('PATCH', `/columns/${roomColumn.id}/title`, { title: 'Projektarbeit' });
|
|
const roomCard = await v3('POST', `/columns/${roomColumn.id}/cards`);
|
|
await v3('PATCH', `/cards/${roomCard.id}/title`, { title: 'Aufgabenverteilung' });
|
|
const roomText = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'richText' });
|
|
await v3('PATCH', `/elements/${roomText.id}/content`, {
|
|
data: {
|
|
content: {
|
|
text: '<p>Raum-Inhalt, nicht Kurs-Inhalt. Suchbegriff: Projektsteuerung.</p>',
|
|
inputFormat: 'richTextCk5',
|
|
},
|
|
type: 'richText',
|
|
},
|
|
});
|
|
// A file in a room board, so the mirror path (room name, not course name) and
|
|
// the CLI manifest are exercised for rooms too.
|
|
const roomFileEl = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'file' });
|
|
keep('roomFileId', await upload(roomFileEl.id, 'projektplan.txt', 'text/plain',
|
|
'Projektplan\n\nMeilenstein 1: Anforderungen. Stichwort: Projektsteuerung.\n'));
|
|
|
|
await v3('PATCH', `/boards/${s.roomBoardId}/visibility`, { isVisible: true });
|
|
log(`room board ${s.roomBoardId} published, with one card`);
|
|
|
|
// A second room the student is NOT in, so "only rooms I belong to" is testable.
|
|
const other = await v3('POST', '/rooms', {
|
|
name: `MCP-Test Raum ohne Zugriff (${stamp})`,
|
|
color: 'red',
|
|
features: [],
|
|
});
|
|
keep('roomWithoutStudentId', other.id);
|
|
log(`second room ${other.id} left without the student on purpose`);
|
|
|
|
step('team');
|
|
// Teams cannot be created through the API: the legacy service registers
|
|
// ['find','get','update','patch','remove'] and no 'create'
|
|
// (schulcloud-server src/services/teams/index.js), and v3 has no team route
|
|
// beyond news and create-room. POST /teams answers 405. So the simulation
|
|
// adopts a seeded team and edits that instead — which is all the MCP server
|
|
// needs, since it reads teams only through api_get.
|
|
const teams = await v1('GET', '/teams');
|
|
const existing = (teams.data ?? [])[0];
|
|
if (existing) {
|
|
keep('teamId', existing._id);
|
|
log(`adopted seeded team ${existing._id} (${existing.name}) — creation is not exposed`);
|
|
} else {
|
|
log('no seeded team to adopt; skipping');
|
|
}
|
|
|
|
step('topic (legacy lesson) in Biologie 9b');
|
|
const lesson = await v1('POST', '/lessons', {
|
|
name: `MCP-Test Thema (${stamp})`,
|
|
courseId: COURSE,
|
|
hidden: false,
|
|
contents: [
|
|
{
|
|
title: 'Einführung',
|
|
hidden: false,
|
|
component: 'text',
|
|
content: { text: '<p>Dieses Thema prüft, was der MCP-Server aus einem Thema liest.</p>' },
|
|
},
|
|
],
|
|
});
|
|
keep('lessonId', lesson._id);
|
|
log(`lesson ${s.lessonId}`);
|
|
|
|
step('task (homework) in Biologie 9b');
|
|
const task = await v1('POST', '/homework', {
|
|
name: `MCP-Test Aufgabe (${stamp})`,
|
|
description: '<p>Beschreibe in drei Sätzen, was ein Neuron tut.</p>',
|
|
courseId: COURSE,
|
|
availableDate: new Date(Date.now() - 86400_000).toISOString(),
|
|
dueDate: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
|
private: false,
|
|
teacherId: me.user.id,
|
|
schoolId: me.school.id,
|
|
});
|
|
keep('taskId', task._id);
|
|
log(`task ${s.taskId}`);
|
|
|
|
step('column board in Biologie 9b');
|
|
const board = await v3('POST', '/boards', {
|
|
title: `MCP-Test Board (${stamp})`,
|
|
parentId: COURSE,
|
|
parentType: 'course',
|
|
layout: 'columns',
|
|
});
|
|
keep('boardId', board.id);
|
|
const column = await v3('POST', `/boards/${s.boardId}/columns`);
|
|
keep('columnId', column.id);
|
|
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material' });
|
|
const card = await v3('POST', `/columns/${s.columnId}/cards`);
|
|
keep('cardId', card.id);
|
|
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem' });
|
|
log(`board ${s.boardId} / column ${s.columnId} / card ${s.cardId}`);
|
|
|
|
step('card elements');
|
|
const rich = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'richText' });
|
|
keep('richTextId', rich.id);
|
|
await v3('PATCH', `/elements/${s.richTextId}/content`, {
|
|
data: {
|
|
content: {
|
|
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Suchbegriff: Synapsenspalt.</p>',
|
|
inputFormat: 'richTextCk5',
|
|
},
|
|
type: 'richText',
|
|
},
|
|
});
|
|
log(`richText ${s.richTextId}`);
|
|
|
|
const link = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'link' });
|
|
keep('linkId', link.id);
|
|
await v3('PATCH', `/elements/${s.linkId}/content`, {
|
|
data: {
|
|
content: { url: 'https://www.dbildungscloud.de/', title: 'dBildungscloud', description: '', imageUrl: '', originalImageUrl: '' },
|
|
type: 'link',
|
|
},
|
|
});
|
|
log(`link ${s.linkId}`);
|
|
|
|
const pad = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'collaborativeTextEditor' });
|
|
keep('padElementId', pad.id);
|
|
log(`collaborativeTextEditor (Etherpad) ${s.padElementId}`);
|
|
|
|
const folder = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'fileFolder' });
|
|
keep('folderId', folder.id);
|
|
await v3('PATCH', `/elements/${s.folderId}/content`, {
|
|
data: { content: { title: 'Arbeitsblätter' }, type: 'fileFolder' },
|
|
});
|
|
log(`fileFolder (directory) ${s.folderId}`);
|
|
|
|
step('files');
|
|
const fileEl = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'file' });
|
|
keep('fileElementId', fileEl.id);
|
|
keep('fileId', await upload(s.fileElementId, 'nervensystem-notiz.txt', 'text/plain',
|
|
'Das Nervensystem\n\nReizleitung erfolgt ueber Synapsen. Stichwort: Synapsenspalt.\n'));
|
|
log(`file element ${s.fileElementId} holding file ${s.fileId}`);
|
|
|
|
keep('folderFileId', await upload(s.folderId, 'arbeitsblatt-1.txt', 'text/plain',
|
|
'Arbeitsblatt 1\n\nAufgabe: Beschrifte die Teile eines Neurons.\n'));
|
|
log(`file ${s.folderFileId} inside the directory`);
|
|
|
|
step('publishing');
|
|
// A board is created as a draft. Students get 403 on it while the course page
|
|
// still lists its title, so the fixture needs both states to be useful.
|
|
await v3('PATCH', `/boards/${s.boardId}/visibility`, { isVisible: true });
|
|
log('main board published');
|
|
|
|
const draft = await v3('POST', '/boards', {
|
|
title: `MCP-Test Entwurf, unveröffentlicht (${stamp})`,
|
|
parentId: COURSE,
|
|
parentType: 'course',
|
|
layout: 'columns',
|
|
});
|
|
keep('draftBoardId', draft.id);
|
|
log(`second board left unpublished on purpose: ${draft.id}`);
|
|
|
|
saveState(s);
|
|
await fileManager();
|
|
console.log(`\nstate written to ${STATE}`);
|
|
summary(loadState());
|
|
}
|
|
|
|
/** files-storage attaches bytes to a board node (element) id, not to the card. */
|
|
async function upload(parentId, name, type, body) {
|
|
const form = new FormData();
|
|
form.append('file', new Blob([body], { type }), name);
|
|
const record = await files('POST', `/upload/school/${me.school.id}/boardnodes/${parentId}`, form);
|
|
return record.id;
|
|
}
|
|
|
|
// ---------------------------------------------------------- file manager ---
|
|
//
|
|
// The file manager ("Dateien": Persönliche, Kurs-, Team- and Geteilte Dateien)
|
|
// is the legacy file system, a different store from files-storage above, with a
|
|
// real folder tree. Many teachers use nothing else, so the MCP server's fs_*
|
|
// tools need content there. Its services are only reachable on the server's own
|
|
// port, like the other /api/v1 writes in this script.
|
|
|
|
const STUDENT_PASSWORD = process.env.SIM_STUDENT_PASSWORD ?? 'schulcloud';
|
|
const TEAM_MEMBER_ROLE = '5bb5c190fb457b1c3c0c7e0f'; // "teammember" in the seed
|
|
|
|
/** Runs `fn` signed in as another account, then restores the teacher. */
|
|
async function asUser(email, password, fn) {
|
|
const saved = { jwt, me };
|
|
const res = await fetch(`${API}/api/v3/authentication/local`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: email, password }),
|
|
});
|
|
if (!res.ok) throw new Error(`login as ${email} failed: ${res.status}`);
|
|
jwt = (await res.json()).accessToken;
|
|
me = await v3('GET', '/me');
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
({ jwt, me } = saved);
|
|
}
|
|
}
|
|
|
|
async function legacyDir(name, owner, parent) {
|
|
return (await v1('POST', '/fileStorage/directories', { name, owner, parent }))._id;
|
|
}
|
|
|
|
/**
|
|
* The browser's own upload sequence: a signed PUT url, the bytes, then the
|
|
* record. The url comes from the server, so it is held to the same localhost
|
|
* rule as everything else this script writes to.
|
|
*/
|
|
async function legacyUpload({ owner, parent, name, type, body }) {
|
|
const bytes = Buffer.from(body);
|
|
const signed = await v1('POST', '/fileStorage/signedUrl', { parent, filename: name, fileType: type });
|
|
const { hostname } = new URL(signed.url);
|
|
if (hostname !== '127.0.0.1' && hostname !== 'localhost') {
|
|
throw new Error(`refusing to upload to ${hostname}: not a localhost address (see minio-loopback in docker-compose.yml)`);
|
|
}
|
|
const put = await fetch(signed.url, { method: 'PUT', headers: signed.header, body: bytes });
|
|
if (!put.ok) throw new Error(`PUT ${name} to storage -> ${put.status} ${(await put.text()).slice(0, 200)}`);
|
|
const record = await v1('POST', '/fileStorage', {
|
|
name,
|
|
owner,
|
|
parent,
|
|
type,
|
|
size: bytes.length,
|
|
storageFileName: signed.header['x-amz-meta-flat-name'],
|
|
});
|
|
return record._id;
|
|
}
|
|
|
|
async function fileManager() {
|
|
const s = loadState();
|
|
if (!s.courseId) throw new Error('run `create` first: the file-manager fixture lives in its course');
|
|
const keep = (key, value) => {
|
|
s[key] = value;
|
|
saveState(s);
|
|
};
|
|
const student = await studentId();
|
|
|
|
step('file manager: Kurs-Dateien');
|
|
keep('fmCourseRootFileId', await legacyUpload({
|
|
owner: s.courseId, name: 'Kursplan.txt', type: 'text/plain',
|
|
body: 'Kursplan Biologie\n\nThemen: Zelle, Gewebe, Organe. Stichwort: Photosynthese-Lichtreaktion.\n',
|
|
}));
|
|
keep('fmCourseDirId', await legacyDir('Arbeitsblätter', s.courseId));
|
|
keep('fmCourseFileId', await legacyUpload({
|
|
owner: s.courseId, parent: s.fmCourseDirId, name: 'Blatt 1 - Zellorganellen.txt', type: 'text/plain',
|
|
body: 'Blatt 1: Zellorganellen\n\nBeschrifte die Mitochondrienmembran und das endoplasmatische Retikulum.\n',
|
|
}));
|
|
keep('fmCourseSubDirId', await legacyDir('Woche 1', s.courseId, s.fmCourseDirId));
|
|
keep('fmCourseDeepFileId', await legacyUpload({
|
|
owner: s.courseId, parent: s.fmCourseSubDirId, name: 'Blatt 2 - Gewebe.txt', type: 'text/plain',
|
|
body: 'Blatt 2: Gewebe\n\nVergleiche Epithelgewebe und Bindegewebe.\n',
|
|
}));
|
|
log(`course root file, folder "Arbeitsblätter" with a file, and "Woche 1" nested inside it`);
|
|
|
|
if (s.teamId) {
|
|
step('file manager: Team-Dateien');
|
|
// Teams cannot be created (see the README), and the adopted one does not
|
|
// include the demo student, whose view is the one under test.
|
|
const team = await v1('GET', `/teams/${s.teamId}`);
|
|
if (!team.userIds.some((entry) => String(entry.userId?._id ?? entry.userId) === student)) {
|
|
const userIds = team.userIds.map((entry) => ({
|
|
userId: String(entry.userId?._id ?? entry.userId),
|
|
role: String(entry.role?._id ?? entry.role),
|
|
schoolId: String(entry.schoolId?._id ?? entry.schoolId),
|
|
}));
|
|
userIds.push({ userId: student, role: TEAM_MEMBER_ROLE, schoolId: me.school.id });
|
|
await v1('PATCH', `/teams/${s.teamId}`, { userIds });
|
|
keep('fmStudentAddedToTeam', true);
|
|
log('demo student added to the team');
|
|
}
|
|
keep('fmTeamDirId', await legacyDir('Projekt', s.teamId));
|
|
keep('fmTeamFileId', await legacyUpload({
|
|
owner: s.teamId, parent: s.fmTeamDirId, name: 'Projektplan.txt', type: 'text/plain',
|
|
body: 'Projektplan\n\nMeilenstein Chlorophyll bis Freitag.\n',
|
|
}));
|
|
log('team folder "Projekt" with a file');
|
|
}
|
|
|
|
step('file manager: Persönliche Dateien (the student\'s own)');
|
|
// No `owner` for personal files, exactly as the upload page sends none: the
|
|
// server decides the owner model as "a course, or else a team", so passing a
|
|
// user id records the folder as a team's, and every later permission check on
|
|
// it then dereferences a team that does not exist.
|
|
await asUser(STUDENT_EMAIL, STUDENT_PASSWORD, async () => {
|
|
keep('fmStudentDirId', await legacyDir('Notizen'));
|
|
keep('fmStudentFileId', await legacyUpload({
|
|
parent: s.fmStudentDirId, name: 'Lernzettel.txt', type: 'text/plain',
|
|
body: 'Lernzettel\n\nRibosomenfabrik: Proteinbiosynthese am rauen ER.\n',
|
|
}));
|
|
});
|
|
log('student folder "Notizen" with a file');
|
|
|
|
step('file manager: Geteilte Dateien');
|
|
keep('fmSharedFileId', await legacyUpload({
|
|
name: 'Geteilt vom Lehrer.txt', type: 'text/plain',
|
|
body: 'Zusatzmaterial\n\nDie Zellkernhuelle trennt Kernplasma und Zytoplasma.\n',
|
|
}));
|
|
// What accepting a share link does in the legacy client: a read-only user
|
|
// permission. Not the permission service, which writes `refOwnerModel` where
|
|
// the "shared with me" query reads `refPermModel`, so its shares never show.
|
|
const shared = await v1('GET', `/files/${s.fmSharedFileId}`);
|
|
await v1('PATCH', `/files/${s.fmSharedFileId}`, {
|
|
permissions: [
|
|
...shared.permissions,
|
|
{ refId: student, refPermModel: 'user', read: true, write: false, delete: false, create: false },
|
|
],
|
|
});
|
|
log('teacher file shared read-only with the student');
|
|
}
|
|
|
|
// ---------------------------------------------------------------- update ---
|
|
|
|
async function update() {
|
|
const s = loadState();
|
|
if (!s.boardId) throw new Error('nothing to update — run `create` first');
|
|
const stamp = new Date().toISOString().slice(11, 16);
|
|
|
|
step('renames');
|
|
await v1('PATCH', `/courses/${s.courseId}`, { name: `MCP-Test Kurs [umbenannt ${stamp}]` });
|
|
log('course renamed');
|
|
await v3('PATCH', `/boards/${s.boardId}/title`, { title: `MCP-Test Board [umbenannt ${stamp}]` });
|
|
log('board renamed');
|
|
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material (überarbeitet)' });
|
|
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem — überarbeitet' });
|
|
log('column and card renamed');
|
|
await v1('PATCH', `/homework/${s.taskId}`, { name: `MCP-Test Aufgabe [umbenannt ${stamp}]` });
|
|
log('task renamed');
|
|
await v1('PATCH', `/lessons/${s.lessonId}`, { name: `MCP-Test Thema [umbenannt ${stamp}]` });
|
|
log('topic renamed');
|
|
if (s.teamId) {
|
|
const team = await v1('GET', `/teams/${s.teamId}`);
|
|
await v1('PATCH', `/teams/${s.teamId}`, { name: `${team.name.replace(/ \[MCP .*$/, '')} [MCP ${stamp}]` });
|
|
log('team renamed (the one write teams do allow)');
|
|
}
|
|
await v3('PUT', `/rooms/${s.roomId}`, { name: `MCP-Test Raum [umbenannt ${stamp}]`, color: 'green', features: [] });
|
|
log('room renamed');
|
|
|
|
step('content edits');
|
|
await v3('PATCH', `/elements/${s.richTextId}/content`, {
|
|
data: {
|
|
content: {
|
|
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Neuer Suchbegriff: Ranvierscher Schnürring.</p>',
|
|
inputFormat: 'richTextCk5',
|
|
},
|
|
type: 'richText',
|
|
},
|
|
});
|
|
log('rich text rewritten (new search term: Ranvierscher Schnürring)');
|
|
await files('PATCH', `/rename/${s.fileId}`, { fileName: 'nervensystem-notiz-v2.txt' });
|
|
log('file renamed');
|
|
|
|
if (s.fmCourseFileId) {
|
|
step('file manager edits');
|
|
await v1('POST', '/fileStorage/rename', { id: s.fmCourseFileId, newName: 'Blatt 1 - Zellorganellen (korrigiert).txt' });
|
|
log('course file renamed');
|
|
s.fmCourseAddedFileId = await legacyUpload({
|
|
owner: s.courseId, parent: s.fmCourseSubDirId, name: 'Blatt 3 - Organe.txt', type: 'text/plain',
|
|
body: 'Blatt 3: Organe\n\nNeuer Suchbegriff: Nephronschleife.\n',
|
|
});
|
|
log('course file added in "Woche 1" (new search term: Nephronschleife)');
|
|
}
|
|
|
|
saveState({ ...s, updated: true });
|
|
}
|
|
|
|
// ---------------------------------------------------------------- delete ---
|
|
|
|
async function remove() {
|
|
const s = loadState();
|
|
if (!s.boardId) throw new Error('nothing to delete — run `create` first');
|
|
|
|
step('deleting what was created');
|
|
const tries = [
|
|
['file', () => files('DELETE', `/delete/${s.folderFileId}`)],
|
|
['room file', () => files('DELETE', `/delete/${s.roomFileId}`)],
|
|
['file element', () => v3('DELETE', `/elements/${s.fileElementId}`)],
|
|
['fileFolder element', () => v3('DELETE', `/elements/${s.folderId}`)],
|
|
['etherpad element', () => v3('DELETE', `/elements/${s.padElementId}`)],
|
|
['link element', () => v3('DELETE', `/elements/${s.linkId}`)],
|
|
['card', () => v3('DELETE', `/cards/${s.cardId}`)],
|
|
['column', () => v3('DELETE', `/columns/${s.columnId}`)],
|
|
['board', () => v3('DELETE', `/boards/${s.boardId}`)],
|
|
['draft board', () => v3('DELETE', `/boards/${s.draftBoardId}`)],
|
|
['task', () => v3('DELETE', `/tasks/${s.taskId}`)],
|
|
['topic', () => v3('DELETE', `/lessons/${s.lessonId}`)],
|
|
['room board', () => v3('DELETE', `/boards/${s.roomBoardId}`)],
|
|
['room', () => v3('DELETE', `/rooms/${s.roomId}`)],
|
|
['second room', () => v3('DELETE', `/rooms/${s.roomWithoutStudentId}`)],
|
|
['course', () => v1('DELETE', `/courses/${s.courseId}`)],
|
|
];
|
|
// File-manager content goes first, while its course and team still exist.
|
|
const fileManagerTries = [
|
|
['file-manager course files', async () => {
|
|
for (const id of [s.fmCourseAddedFileId, s.fmCourseDeepFileId, s.fmCourseFileId, s.fmCourseRootFileId]) {
|
|
if (id) await v1('DELETE', `/fileStorage?_id=${id}`);
|
|
}
|
|
}],
|
|
['file-manager course folders', async () => {
|
|
for (const id of [s.fmCourseSubDirId, s.fmCourseDirId]) if (id) await v1('DELETE', `/fileStorage/directories?_id=${id}`);
|
|
}],
|
|
['file-manager team content', async () => {
|
|
if (s.fmTeamFileId) await v1('DELETE', `/fileStorage?_id=${s.fmTeamFileId}`);
|
|
if (s.fmTeamDirId) await v1('DELETE', `/fileStorage/directories?_id=${s.fmTeamDirId}`);
|
|
}],
|
|
['shared file', async () => {
|
|
if (s.fmSharedFileId) await v1('DELETE', `/fileStorage?_id=${s.fmSharedFileId}`);
|
|
}],
|
|
['student personal files', async () => {
|
|
if (!s.fmStudentFileId && !s.fmStudentDirId) return;
|
|
await asUser(STUDENT_EMAIL, STUDENT_PASSWORD, async () => {
|
|
if (s.fmStudentFileId) await v1('DELETE', `/fileStorage?_id=${s.fmStudentFileId}`);
|
|
if (s.fmStudentDirId) await v1('DELETE', `/fileStorage/directories?_id=${s.fmStudentDirId}`);
|
|
});
|
|
}],
|
|
['student team membership', async () => {
|
|
if (!s.fmStudentAddedToTeam || !s.teamId) return;
|
|
const student = await studentId();
|
|
const team = await v1('GET', `/teams/${s.teamId}`);
|
|
const userIds = team.userIds
|
|
.map((entry) => ({
|
|
userId: String(entry.userId?._id ?? entry.userId),
|
|
role: String(entry.role?._id ?? entry.role),
|
|
schoolId: String(entry.schoolId?._id ?? entry.schoolId),
|
|
}))
|
|
.filter((entry) => entry.userId !== student);
|
|
await v1('PATCH', `/teams/${s.teamId}`, { userIds });
|
|
}],
|
|
];
|
|
tries.unshift(...fileManagerTries);
|
|
|
|
for (const [what, fn] of tries) {
|
|
try {
|
|
await fn();
|
|
log(`deleted ${what}`);
|
|
} catch (err) {
|
|
log(`could NOT delete ${what}: ${String(err.message).slice(0, 160)}`);
|
|
}
|
|
}
|
|
unlinkSync(STATE);
|
|
console.log(`\nstate file removed`);
|
|
}
|
|
|
|
function summary(s) {
|
|
console.log('\nids for the MCP side:');
|
|
for (const [k, v] of Object.entries(s)) console.log(` ${k.padEnd(16)} ${v}`);
|
|
}
|
|
|
|
const phase = process.argv[2] ?? 'create';
|
|
await login();
|
|
if (phase === 'create') await create();
|
|
else if (phase === 'update') await update();
|
|
else if (phase === 'delete') await remove();
|
|
else if (phase === 'files') {
|
|
await fileManager();
|
|
summary(loadState());
|
|
} else if (phase === 'show') summary(loadState());
|
|
else {
|
|
console.error(`unknown phase ${phase}; expected create | update | delete | files | show`);
|
|
process.exit(2);
|
|
}
|