From 6138ffe99d016cc311f250bc1555b5403b8b1216 Mon Sep 17 00:00:00 2001
From: alex <alex@alexloehr.net>
Date: Thu, 08 Oct 2026 17:18:04 +0000
Subject: [PATCH] GS-2555
---
REST.md | 659 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 659 insertions(+), 0 deletions(-)
diff --git a/REST.md b/REST.md
new file mode 100644
index 0000000..e92a286
--- /dev/null
+++ b/REST.md
@@ -0,0 +1,659 @@
+# REST API
+
+HTTP API of **globus-ilias-rest** — a REST service for POPCORN → ILIAS synchronization.
+
+The service is implemented with [Fastify](https://fastify.dev/) in `app.js` and starts on
+`settings.port` (default `4101`). The active config is `settings.$NODE_ENV.json`
+(see `settings.js`).
+
+## Base URL
+
+```
+http://<host>:4101
+```
+
+In production the service is usually served behind a reverse proxy, e.g.
+`https://globusfm-dev2.minervis.com/popcorn`.
+
+## Authentication
+
+Every request must pass the auth token as a query parameter:
+
+```
+?token=<authtoken>
+```
+
+`authtoken` is defined in `settings.$NODE_ENV.json`.
+
+Requests without a valid token are rejected with `403` after a 500 ms delay
+(anti brute-force / DoS measure):
+
+```json
+{ "status": "error", "error": "access denied" }
+```
+
+**Exceptions that do not require a token:**
+
+- `/api/version`
+- everything under `/ui/` (static frontend)
+
+## Common error format
+
+Most error responses use a consistent shape:
+
+```json
+{ "status": "error", "msg": "not found" }
+```
+
+Some routes use `message` or `error` instead of `msg`; this is noted per route.
+
+## Route overview
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
+| GET | `/api/version` | no | Service version |
+| GET | `/api/search/user` | yes | Search users (returns usr_ids) |
+| POST | `/api/search/reindex` | yes | Rebuild the user search index |
+| GET | `/api/user` | yes | List/search users (paged) |
+| GET | `/api/user/count` | yes | Total number of (numeric-login) users |
+| GET | `/api/user/login/:login` | yes | Get one user by login |
+| GET | `/api/user/userid/:userid` | yes | Get one user by usr_id |
+| GET | `/api/user/teilnahmen/:userId` | yes | Memberships of a user |
+| POST | `/api/user` | yes | Import/create a user in ILIAS |
+| DELETE | `/api/user/:usr_id` | yes | Delete a user in ILIAS |
+| GET | `/api/ref_id/:ref_id` | yes | Resolve ref_id → obj_id |
+| GET | `/api/obj_id/:obj_id` | yes | Resolve obj_id → ref_id |
+| GET | `/api/kurs` | yes | List all courses |
+| GET | `/api/kurs/:refId` | yes | Get one course |
+| GET | `/api/kurs/items/:refId` | yes | Course item tree (recursive) |
+| GET | `/api/kurs/:refId/teilnehmer` | yes | Course members |
+| GET | `/api/kurs/:refId/teilnehmer/:userId` | yes | Single course member |
+| GET | `/api/kurs/:refId/lp` | yes | Learning progress |
+| GET | `/api/kurs/:refId/teilnehmerByRole` | yes | Course members via role |
+| GET | `/api/kurs/:refId/roles` | yes | Roles assigned in a course |
+| POST | `/api/kurs/:refId/status/:usrId` | yes | Set status + passed for a member |
+| GET | `/api/kurs/:refId/offline` | yes | Get offline flag of a course |
+| POST | `/api/kurs/:refId/offline` | yes | Set offline flag of a course |
+| DELETE | `/api/kurs/:refId/teilnehmer/:usrId` | yes | Unenroll a member (abmelden) |
+| GET | `/api/kurs/rolle/admin` | yes | Courses with admin role |
+| GET | `/api/kurs/rolle/noadmin` | yes | Courses without assigned admin role |
+| GET | `/api/ping` | yes | Ping the ILIAS PHP component |
+| GET | `/ui/*` | no | Built Vue frontend (SPA) |
+
+---
+
+## System
+
+### GET /api/version
+
+Returns the service version from `package.json`. No token required.
+
+**Response 200**
+
+```json
+{ "version": "0.1" }
+```
+
+### GET /api/ping
+
+Pings the custom ILIAS PHP component (`lib/libIlias.js` → `ping`).
+
+**Response 200** — forwarded from the PHP component, e.g.
+
+```json
+{ "method": "GET", "command": "ping", "status": "ok" }
+```
+
+**Response 500**
+
+```json
+{ "status": "error", "error": "<error>" }
+```
+
+---
+
+## Search
+
+### GET /api/search/user
+
+Full-text search over the user index (`lib/search.js`, FlexSearch). The index is
+built from `login firstname lastname institution department`.
+
+**Query parameters**
+
+| Name | Required | Description |
+|------|----------|-------------|
+| `search` | yes | Search term |
+
+**Response 200** — array of matching `usr_id`s (numbers):
+
+```json
+[ 23300, 23301 ]
+```
+
+**Response 422** — when `search` is missing:
+
+```json
+{ "status": "error", "msg": "no search" }
+```
+
+### POST /api/search/reindex
+
+Rebuilds the user search index from the database.
+
+**Response 200**
+
+```json
+{ "status": "ok", "msg": "reindexed in 231 ms" }
+```
+
+---
+
+## Users
+
+### GET /api/user
+
+Paged list / search of users.
+
+**Query parameters**
+
+| Name | Default | Description |
+|------|---------|-------------|
+| `offset` | `0` | Offset |
+| `limit` | `10` | Max results |
+| `search` | — | Optional search term (uses the search index) |
+
+**Response 200**
+
+```json
+{
+ "total": 11066,
+ "offset": 0,
+ "limit": 10,
+ "data": [
+ {
+ "usr_id": 23300,
+ "login": "134942",
+ "firstname": "Aksana",
+ "lastname": "Donhauser",
+ "gender": "f",
+ "email": "alex@minervis.com",
+ "institution": "Globus Baumarkt St. Wendel",
+ "street": "",
+ "city": "",
+ "zipcode": "",
+ "country": "",
+ "department": "MITARBEITER | FARBEN/TAPETEN/BODENBEL.",
+ "active": 1
+ }
+ ]
+}
+```
+
+> Only users whose `login` is numeric (`login REGEXP '^[0-9]+$'`) are returned.
+> If the query fails, an empty result is returned:
+> `{ "total": 0, "offset": 0, "limit": 0, "data": [] }`.
+
+### GET /api/user/count
+
+Total number of users (numeric login). Uses `db.getUserCount()` without filters.
+
+**Response 200**
+
+```json
+11066
+```
+
+### GET /api/user/login/:login
+
+Get a single user by login.
+
+**Path parameters**
+
+| Name | Description |
+|------|-------------|
+| `login` | ILIAS login |
+
+**Response 200** — user object (same fields as `/api/user`) plus all user-defined
+fields merged in as top-level keys.
+
+**Response 404**
+
+```json
+{ "status": "error", "msg": "not found" }
+```
+
+### GET /api/user/userid/:userid
+
+Get a single user by `usr_id`.
+
+**Path parameters**
+
+| Name | Description |
+|------|-------------|
+| `userid` | numeric `usr_id` |
+
+**Response 200** — user object incl. user-defined fields.
+**Response 404** — `{ "status": "error", "msg": "not found" }`
+**Response 500** — if `userid` is missing or not numeric:
+
+```json
+{ "status": "error", "msg": "userid error" }
+```
+
+### GET /api/user/teilnahmen/:userId
+
+Memberships of a user (rows from `obj_members` with `member = 1`), enriched with
+object title and learning-progress status.
+
+**Path parameters**
+
+| Name | Description |
+|------|-------------|
+| `userId` | numeric `usr_id` |
+
+**Response 200** — array of
+
+```json
+[
+ {
+ "obj_id": 32212,
+ "ref_id": 213,
+ "usr_id": 6,
+ "title": "112 Feuerwehr ist da",
+ "status": 2,
+ "passed": 1,
+ "status_changed": "2025-07-03T11:26:20.000Z"
+ }
+]
+```
+
+**Response 500** — invalid `userId`: `{ "status": "error", "msg": "userId error" }`
+
+### POST /api/user
+
+Import (create/update) a user in ILIAS via the custom PHP component.
+
+**Body** (JSON) — POPCORN/SOAP-style user object:
+
+```json
+{
+ "login": "123456789",
+ "passwd": "123456789",
+ "passwd_type": "plain",
+ "firstname": "Adolfo",
+ "lastname": "de la Cruz",
+ "email": "alex@example.com",
+ "gender": "m",
+ "department": "Bananenpflücker",
+ "institution": "Globus Budapest",
+ "role": 4,
+ "udf": {
+ "Markt": "Markt UDF 2",
+ "Marktnummer": "Marktnummer UDF 2",
+ "Personalnummer": "Personal UDF 2"
+ }
+}
+```
+
+The keys of `udf` are user-defined-field **names**; they are mapped to field ids
+via `udf_definition` before the request is forwarded to ILIAS.
+
+**Response 200** — result forwarded from the PHP component.
+
+> If a `udf` key does not match a defined field name, the request fails
+> (`udfMap[key]` is `undefined`).
+
+### DELETE /api/user/:usr_id
+
+Delete a user in ILIAS.
+
+**Path parameters**
+
+| Name | Description |
+|------|-------------|
+| `usr_id` | numeric `usr_id` |
+
+**Response 200** — result forwarded from the PHP component.
+**Response 500** — invalid `usr_id`: `{ "status": "error", "msg": "userId error" }`
+
+---
+
+## ref_id / obj_id
+
+### GET /api/ref_id/:ref_id
+
+Resolve a `ref_id` (tree/reference id) to an `obj_id`.
+
+**Response 200**
+
+```json
+{ "ref_id": 213, "obj_id": 32212 }
+```
+
+**Response 404** — `{ "status": "error", "msg": "not found" }`
+
+### GET /api/obj_id/:obj_id
+
+Resolve an `obj_id` to a `ref_id`.
+
+**Response 200**
+
+```json
+{ "ref_id": 213, "obj_id": 32212 }
+```
+
+**Response 404** — `{ "status": "error", "msg": "not found" }`
+
+---
+
+## Courses (Kurs)
+
+### GET /api/kurs
+
+List all courses (`type = 'crs'`, not deleted).
+
+**Response 200** — array of
+
+```json
+[
+ {
+ "ref_id": 213,
+ "obj_id": 32212,
+ "title": "112 Feuerwehr ist da",
+ "description": "...",
+ "type": "crs",
+ "offline": 0
+ }
+]
+```
+
+### GET /api/kurs/:refId
+
+Get a single course by `ref_id`.
+
+**Response 200** — single object
+
+```json
+{
+ "ref_id": 213,
+ "obj_id": 32212,
+ "title": "112 Feuerwehr ist da",
+ "description": "...",
+ "type": "crs",
+ "create_date": "2024-01-01 00:00:00",
+ "offline": 0
+}
+```
+
+**Response 404** — `{ "status": "error", "msg": "not found" }`
+
+### GET /api/kurs/items/:refId
+
+Recursive tree of items contained in a course (`crs_items`, recursive CTE).
+
+**Response 200** — array of
+
+```json
+[
+ { "parent_id": 213, "obj_id": 36450, "ref_id": 597, "title": "asdf123", "type": "tst" }
+]
+```
+
+**Response 404** — `{ "status": "error", "msg": "not found" }`
+
+### GET /api/kurs/:refId/teilnehmer
+
+Course members.
+
+**Response 200** — array of
+
+```json
+[
+ {
+ "parent_id": 213,
+ "ref_id": 597,
+ "obj_id": 36450,
+ "title": "asdf123",
+ "type": "tst",
+ "usr_id": 6,
+ "login": "root",
+ "firstname": "root",
+ "lastname": "user",
+ "active": 1,
+ "passed": 1,
+ "status": 2,
+ "status_changed": "2025-10-22T07:42:36.000Z"
+ }
+]
+```
+
+**Response 404** — `{ "status": "error", "msg": "not found" }`
+
+### GET /api/kurs/:refId/teilnehmer/:userId
+
+Single course member.
+
+**Response 200** — single member object (same fields as above).
+**Response 404** — `{ "status": "error", "msg": "not found" }`
+
+### GET /api/kurs/:refId/lp
+
+Learning progress of a course.
+
+**Query parameters**
+
+| Name | Description |
+|------|-------------|
+| `raw` | If present (any non-empty value, e.g. `?raw=1`), returns raw sub-object rows instead of the aggregated view |
+
+**Response 200 (raw)** — one row per tracked sub-object and user:
+
+```json
+[
+ {
+ "usr_id": 6,
+ "login": "root",
+ "firstname": "root",
+ "lastname": "user",
+ "obj_id": 32212,
+ "item_id": 597,
+ "lpmode": 5,
+ "item_obj_id": 36450,
+ "type": "tst",
+ "status": 2,
+ "status_changed": "2025-10-22T07:42:36.000Z",
+ "percentage": 100,
+ "completed": 0
+ }
+]
+```
+
+**Response 200 (aggregated)** — one row per user, sub-object statuses combined
+via `lib/libLp.js`:
+
+```json
+[
+ {
+ "usr_id": 6,
+ "login": "root",
+ "firstname": "root",
+ "lastname": "user",
+ "status": 1,
+ "status_changed": "2025-10-22T09:45:56.000Z"
+ }
+]
+```
+
+`status` values:
+
+| Value | Meaning |
+|-------|---------|
+| `0` | not attempted (noch nicht bearbeitet) |
+| `1` | in progress (in Bearbeitung) |
+| `2` | passed (bestanden) |
+| `3` | failed (nicht bestanden) |
+
+**Response 404** — `{ "status": "error", "msg": "not found" }`
+
+### GET /api/kurs/:refId/teilnehmerByRole
+
+Course members resolved through the course's member role
+(`rbac_ua` joined via the role whose description matches `Member%<obj_id>`).
+
+**Response 200** — array of
+
+```json
+[ { "role_id": 12345, "usr_id": 6, "firstname": "root", "lastname": "user" } ]
+```
+
+### GET /api/kurs/:refId/roles
+
+Roles assigned in a course (`rbac_pa`).
+
+**Response 200** — array of
+
+```json
+[
+ {
+ "rol_id": 12345,
+ "ref_id": 213,
+ "obj_id": 999,
+ "type": "role",
+ "title": "il_crs_member_32212",
+ "description": "Member of course 32212"
+ }
+]
+```
+
+### POST /api/kurs/:refId/status/:usrId
+
+Set the learning-progress `status` and `passed` flag for a course member. Writes
+both `ut_lp_marks` (status + status_changed) and `obj_members` (passed).
+
+**Path parameters**
+
+| Name | Description |
+|------|-------------|
+| `refId` | course `ref_id` |
+| `usrId` | user `usr_id` |
+
+**Body** (JSON) — both fields required
+
+```json
+{ "passed": 1, "status": 2 }
+```
+
+**Response 200**
+
+```json
+{ "status": "ok" }
+```
+
+**Response 400** — missing arguments:
+
+```json
+{ "status": "error", "msg": "argument error", "statusCode": 400 }
+```
+
+**Response 500** — e.g. when the affected rows don't match:
+
+```json
+{ "status": "error", "msg": "<message>" }
+```
+
+### GET /api/kurs/:refId/offline
+
+Get the `offline` flag of a course.
+
+**Response 200**
+
+```json
+{ "offline": 0 }
+```
+
+**Response 500**
+
+```json
+{ "status": "error", "message": "<message>" }
+```
+
+### POST /api/kurs/:refId/offline
+
+Set the `offline` flag of a course.
+
+**Body** (JSON)
+
+```json
+{ "offline": 1 }
+```
+
+**Response 200**
+
+```json
+{ "offline": 1 }
+```
+
+**Response 500**
+
+```json
+{ "status": "error", "message": "<message>" }
+```
+
+### DELETE /api/kurs/:refId/teilnehmer/:usrId
+
+Unenroll a member ("abmelden") via the custom PHP component.
+
+**Path parameters**
+
+| Name | Description |
+|------|-------------|
+| `refId` | course `ref_id` |
+| `usrId` | user `usr_id` |
+
+**Response 200** — result forwarded from the PHP component.
+**Response 404** — `{ "status": "error", "msg": "Teilnahme not found" }`
+**Response 500** — `{ "status": "error", "msg": "<message>" }`
+
+---
+
+## Course roles / admins
+
+### GET /api/kurs/rolle/admin
+
+Courses that have an admin role assigned (role title contains `admin`).
+
+**Response 200** — array of
+
+```json
+[
+ {
+ "crs_obj_id": 32212,
+ "crs_ref_id": 213,
+ "crs_title": "112 Feuerwehr ist da",
+ "rol_id": 12345,
+ "role": "il_crs_admin_32212"
+ }
+]
+```
+
+**Response 500** — `{ "status": "error", "error": "<error>" }`
+
+### GET /api/kurs/rolle/noadmin
+
+Courses whose admin role exists but has no user assigned (no `rbac_ua` entry).
+
+**Response 200** — same shape as `/api/kurs/rolle/admin`.
+**Response 500** — `{ "status": "error", "error": "<error>" }`
+
+---
+
+## Static frontend
+
+### GET /ui/*
+
+Serves the built Vue single-page app from `vue/dist` (registered with
+`@fastify/static`, prefix `/ui/`). No token required.
+
+Any route that does not match an API route or a static file returns the SPA
+fallback `vue/dist/index.html`.
--
Gitblit v1.8.0