somtoday-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +220 -2
- package/dist/api/absence.d.ts +19 -0
- package/dist/api/absence.js +66 -0
- package/dist/api/account.d.ts +22 -0
- package/dist/api/account.js +58 -0
- package/dist/api/assignments.d.ts +49 -0
- package/dist/api/assignments.js +93 -0
- package/dist/api/client.d.ts +64 -0
- package/dist/api/client.js +175 -0
- package/dist/api/download.d.ts +21 -0
- package/dist/api/download.js +153 -0
- package/dist/api/grades.d.ts +29 -0
- package/dist/api/grades.js +49 -0
- package/dist/api/homework.d.ts +52 -0
- package/dist/api/homework.js +134 -0
- package/dist/api/lessons.d.ts +19 -0
- package/dist/api/lessons.js +33 -0
- package/dist/api/messages.d.ts +73 -0
- package/dist/api/messages.js +73 -0
- package/dist/api/misc.d.ts +8 -0
- package/dist/api/misc.js +42 -0
- package/dist/api/news.d.ts +19 -0
- package/dist/api/news.js +43 -0
- package/dist/api/profile.d.ts +92 -0
- package/dist/api/profile.js +71 -0
- package/dist/api/results.d.ts +319 -0
- package/dist/api/results.js +247 -0
- package/dist/api/schedule.d.ts +70 -0
- package/dist/api/schedule.js +79 -0
- package/dist/api/simplify.d.ts +17 -0
- package/dist/api/simplify.js +91 -0
- package/dist/api/studiewijzer.d.ts +165 -0
- package/dist/api/studiewijzer.js +108 -0
- package/dist/api/study-material.d.ts +35 -0
- package/dist/api/study-material.js +88 -0
- package/dist/api/types.d.ts +103 -0
- package/dist/api/types.js +1 -0
- package/dist/auth/authorize.d.ts +7 -0
- package/dist/auth/authorize.js +17 -0
- package/dist/auth/browser.d.ts +24 -0
- package/dist/auth/browser.js +56 -0
- package/dist/auth/default-session.d.ts +4 -0
- package/dist/auth/default-session.js +12 -0
- package/dist/auth/file-store.d.ts +12 -0
- package/dist/auth/file-store.js +67 -0
- package/dist/auth/keychain-store.d.ts +52 -0
- package/dist/auth/keychain-store.js +212 -0
- package/dist/auth/lock.d.ts +34 -0
- package/dist/auth/lock.js +96 -0
- package/dist/auth/login.d.ts +18 -0
- package/dist/auth/login.js +135 -0
- package/dist/auth/memory-store.d.ts +8 -0
- package/dist/auth/memory-store.js +16 -0
- package/dist/auth/redirect.d.ts +7 -0
- package/dist/auth/redirect.js +31 -0
- package/dist/auth/school.d.ts +10 -0
- package/dist/auth/school.js +70 -0
- package/dist/auth/session.d.ts +71 -0
- package/dist/auth/session.js +301 -0
- package/dist/auth/store.d.ts +42 -0
- package/dist/auth/store.js +47 -0
- package/dist/auth/tokens.d.ts +35 -0
- package/dist/auth/tokens.js +139 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +70 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +11 -0
- package/dist/constants.d.ts +30 -0
- package/dist/constants.js +55 -0
- package/dist/dates.d.ts +38 -0
- package/dist/dates.js +110 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +58 -0
- package/dist/html.d.ts +5 -0
- package/dist/html.js +51 -0
- package/dist/log.d.ts +4 -0
- package/dist/log.js +55 -0
- package/dist/packageVersion.d.ts +4 -0
- package/dist/packageVersion.js +16 -0
- package/dist/pkce.d.ts +5 -0
- package/dist/pkce.js +35 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +273 -0
- package/dist/tools/absence.d.ts +6 -0
- package/dist/tools/absence.js +18 -0
- package/dist/tools/assignments.d.ts +19 -0
- package/dist/tools/assignments.js +41 -0
- package/dist/tools/auth-status.d.ts +20 -0
- package/dist/tools/auth-status.js +41 -0
- package/dist/tools/child-tool.d.ts +10 -0
- package/dist/tools/child-tool.js +37 -0
- package/dist/tools/context.d.ts +12 -0
- package/dist/tools/context.js +20 -0
- package/dist/tools/download.d.ts +5 -0
- package/dist/tools/download.js +16 -0
- package/dist/tools/exam-grades.d.ts +6 -0
- package/dist/tools/exam-grades.js +26 -0
- package/dist/tools/grades-latest.d.ts +10 -0
- package/dist/tools/grades-latest.js +23 -0
- package/dist/tools/grades-overview.d.ts +6 -0
- package/dist/tools/grades-overview.js +15 -0
- package/dist/tools/grades.d.ts +5 -0
- package/dist/tools/grades.js +31 -0
- package/dist/tools/homework.d.ts +10 -0
- package/dist/tools/homework.js +11 -0
- package/dist/tools/lessons.d.ts +6 -0
- package/dist/tools/lessons.js +35 -0
- package/dist/tools/messages.d.ts +14 -0
- package/dist/tools/messages.js +67 -0
- package/dist/tools/misc.d.ts +7 -0
- package/dist/tools/misc.js +29 -0
- package/dist/tools/news.d.ts +6 -0
- package/dist/tools/news.js +18 -0
- package/dist/tools/profile.d.ts +7 -0
- package/dist/tools/profile.js +49 -0
- package/dist/tools/result.d.ts +19 -0
- package/dist/tools/result.js +58 -0
- package/dist/tools/schedule.d.ts +8 -0
- package/dist/tools/schedule.js +9 -0
- package/dist/tools/study-material.d.ts +7 -0
- package/dist/tools/study-material.js +24 -0
- package/dist/tools/subject-grades.d.ts +8 -0
- package/dist/tools/subject-grades.js +43 -0
- package/dist/tools/whoami.d.ts +3 -0
- package/dist/tools/whoami.js +19 -0
- package/ouder-portal.openapi.yaml +3072 -0
- package/package.json +67 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 somtoday-mcp contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,221 @@
|
|
|
1
|
-
#
|
|
1
|
+
# somtoday-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/barzoj/somtoday-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/barzoj/somtoday-mcp)
|
|
5
|
+
[](https://www.npmjs.com/package/somtoday-mcp)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://nodejs.org/)
|
|
8
|
+
|
|
9
|
+
Read-only [MCP](https://modelcontextprotocol.io/) server for the Somtoday parent portal. It lets an AI assistant such as Cursor or Claude Desktop look up your child's timetable, homework, and grades from your own parent account.
|
|
10
|
+
|
|
11
|
+
It runs locally on your computer over stdio. Nothing is sent anywhere except to Somtoday itself.
|
|
12
|
+
|
|
13
|
+
> **Disclaimer.** This is an unofficial community project. It is not affiliated with, endorsed by, or supported by Somtoday or Topicus. It works by interoperating with the undocumented internal web API that the official parent portal uses, so it may stop working or behave differently at any time without notice. It is intended for use with your own account, only reads data, and is provided as-is, without warranty of any kind (see [LICENSE](LICENSE)); use it at your own risk and in line with Somtoday's terms of use. "Somtoday" and "Topicus" are trademarks of their respective owners and are used here only to describe what the software works with.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
| Tool | What you get |
|
|
18
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
19
|
+
| `whoami` | Your school label, the linked children with their numeric ids, and which portal sections are visible to you. |
|
|
20
|
+
| `lessons` | The timetable for one day, and whether that day is a school holiday. |
|
|
21
|
+
| `homework` | What to do and learn on which day: every study-planner item for a date range (default today + 6 days, max 42), grouped by day, with description, learning goals, time estimate, test type, attachments (links), external links, hand-in window/status and the done tick. |
|
|
22
|
+
| `grades` | The most recent results (up to 30) and averages per subject. |
|
|
23
|
+
| `assignments` | All hand-in assignments with window, status (upcoming, to do, waiting for review, approved), late flag, description, attachments and links. `status=open` keeps the ones still to do. |
|
|
24
|
+
| `assignment_detail` | Submissions (date, status, file or link) and teacher reactions for one assignment. |
|
|
25
|
+
| `study_material` | Study material per subject: lesson material from the planner by date, year attachments by folder (file links), external links, and methods/publisher links. No arguments lists the subjects; `vak` picks one; `all=true` fetches every subject. |
|
|
26
|
+
| `schedule` | Timetable for a date range (default today + 6 days, max 42): time, lesson hour, subject, room, teachers, type (test, exam, parent evening …), lesson notes, changes and cancellations, attachments, holidays. |
|
|
27
|
+
| `grades_latest` | Newest results first with subject, column, description, grade, weight, test type, period, date, remarks and resits; `limit` (default 50, max 1000) and `dossier=examen` for SE results. |
|
|
28
|
+
| `grades_overview` | Full grade overview for a school year: every subject, every result per period, report grades, weights and remarks. `schooljaar` picks an earlier year. |
|
|
29
|
+
| `subject_grades` | Everything for one subject: every result column with weights and remarks, column definitions (also not yet graded), composite-test parts, for the progress and exam dossier. No `vak` lists the subjects. |
|
|
30
|
+
| `exam_grades` | School-exam dossier (PTA/SE): per subject the SE average, every SE result, tests still to come and test-type averages. |
|
|
31
|
+
| `absence` | Absence and registrations for the school year or grading period (absent, late, removed, homework/materials not in order) with counts and entries, plus active measures. |
|
|
32
|
+
| `messages` | Messages, read-only: newest conversations (subject, sender, date, unread, preview; `zoek`/`ongelezen` filters) or one full thread with `conversatieId`. Never marks anything as read. |
|
|
33
|
+
| `news` | School news items (newest first, as text) and the unread count. |
|
|
34
|
+
| `announcements` | Somtoday-wide announcements shown in the portal. |
|
|
35
|
+
| `children` | Linked children with profile, class, school data (mentors, year, programme), current placement and visible portal sections. |
|
|
36
|
+
| `school_info` | School contact data and the school's information pages; `paginaId` opens one page. |
|
|
37
|
+
| `teachers` | Staff you can message, with the subjects they teach your child. |
|
|
38
|
+
| `signing_requests` | Digital signing requests (read-only; nothing is signed). |
|
|
39
|
+
| `parent_evening` | Parent-evening details (slots, teachers); finds invitations in your messages (read-only; nothing is booked). |
|
|
40
|
+
| `consents` | Consents such as use of photos, as set in the portal (read-only). |
|
|
41
|
+
| `download_attachment` | Read an attachment by `fileUrl`: https Somtoday hosts only (extend with `SOMTODAY_MCP_ATTACHMENT_HOSTS`), max 10 MB, no token sent; text from PDF/text/HTML/CSV/JSON. |
|
|
42
|
+
| `auth_status` | Whether a session is stored, where, and how the last token renewal went. Never returns tokens. |
|
|
43
|
+
|
|
44
|
+
- `lessons` takes an optional `date` (`YYYY-MM-DD`, Europe/Amsterdam) and defaults to **tomorrow**. Range tools take `date`, or `from`/`to`.
|
|
45
|
+
- If more than one child is linked to your account, pass `leerlingId` (the numeric id from `whoami`).
|
|
46
|
+
- If a section is switched off for your account, the tool says so and does not call that part of the API.
|
|
47
|
+
|
|
48
|
+
Not included: news, messages, absence reporting, parent-evening scheduling, uploads, or anything that changes data.
|
|
49
|
+
|
|
50
|
+
## Read-only and privacy
|
|
51
|
+
|
|
52
|
+
- Tools only send `GET` requests. Nothing is written, submitted, or marked as read.
|
|
53
|
+
- The server talks only to Somtoday's login and API servers.
|
|
54
|
+
- No telemetry or analytics.
|
|
55
|
+
- Your password is typed only into the Somtoday login page in a browser window. No tool, CLI flag, environment variable, or prompt ever asks for it.
|
|
56
|
+
- Tools never return tokens.
|
|
57
|
+
|
|
58
|
+
Timetables, homework, and grades are personal data about children. Keep the output on your own devices, use only accounts you are entitled to use, and do not share or republish it. Under the GDPR you are responsible for what you do with data you export.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
Requires Node.js 22 or newer.
|
|
63
|
+
|
|
64
|
+
> The package is not published to npm yet. Until it is, use the "From a checkout" steps below; `npx somtoday-mcp` will not find it.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx -y somtoday-mcp # start the MCP server (used by your MCP client)
|
|
68
|
+
npx -y somtoday-mcp login # sign in, once, in a terminal
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Login opens a normal browser window. When **Google Chrome** (or else **Microsoft Edge**) is installed, that browser is used, with a fresh temporary profile that is deleted afterwards; it passes Somtoday's Cloudflare "Verify you are human" check more reliably. Only without Chrome/Edge does it fall back to Playwright's bundled Chromium; install that once in that case:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npx playwright install chromium
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The window is started without the "controlled by automated software" markers; nothing else about the browser is changed.
|
|
78
|
+
|
|
79
|
+
From a checkout:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/barzoj/somtoday-mcp.git
|
|
83
|
+
cd somtoday-mcp
|
|
84
|
+
npm install
|
|
85
|
+
npm run build
|
|
86
|
+
npx playwright install chromium # only needed without Chrome/Edge
|
|
87
|
+
node dist/cli.js login
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## MCP client config
|
|
91
|
+
|
|
92
|
+
### Cursor
|
|
93
|
+
|
|
94
|
+
`~/.cursor/mcp.json`:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"mcpServers": {
|
|
99
|
+
"somtoday": {
|
|
100
|
+
"command": "npx",
|
|
101
|
+
"args": ["-y", "somtoday-mcp"]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Claude Desktop
|
|
108
|
+
|
|
109
|
+
`claude_desktop_config.json`:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"mcpServers": {
|
|
114
|
+
"somtoday": {
|
|
115
|
+
"command": "npx",
|
|
116
|
+
"args": ["-y", "somtoday-mcp"]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### From a local checkout
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"mcpServers": {
|
|
127
|
+
"somtoday": {
|
|
128
|
+
"command": "node",
|
|
129
|
+
"args": ["/absolute/path/to/somtoday-mcp/dist/cli.js"]
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Signing in
|
|
136
|
+
|
|
137
|
+
Run login in a terminal, not from inside your MCP client:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npx -y somtoday-mcp login
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
1. A browser window (your installed Chrome or Edge, else Playwright Chromium) opens on the regular Somtoday login page. Search for your school there, the same way as in the official app.
|
|
144
|
+
2. Sign in as you normally would, including any two-step verification. You have 10 minutes (the Cloudflare check and school search count too).
|
|
145
|
+
3. When the window closes, the session is saved on your computer and the MCP server can use it.
|
|
146
|
+
|
|
147
|
+
If you know your school's tenant UUID you can skip the school search with `npx -y somtoday-mcp login --school-uuid <uuid>`.
|
|
148
|
+
|
|
149
|
+
If a tool is called before you have signed in, or after the saved session stops working, it returns:
|
|
150
|
+
|
|
151
|
+
`run `npx somtoday-mcp login` in a terminal`
|
|
152
|
+
|
|
153
|
+
Run login again in that case. If Somtoday cannot be reached, tools say so instead of asking you to log in.
|
|
154
|
+
|
|
155
|
+
### Commands
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npx -y somtoday-mcp status # where the session is stored, last renewal result (no tokens)
|
|
159
|
+
npx -y somtoday-mcp status --check # also renews the token and calls the API once
|
|
160
|
+
npx -y somtoday-mcp logout # delete the stored session from this computer
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The same diagnostics are available to your MCP client through the `auth_status` tool.
|
|
164
|
+
|
|
165
|
+
Several MCP clients (for example two Cursor windows) can share one session: token renewal is serialized with a lock file in the config directory, and each renewal re-reads the stored session first.
|
|
166
|
+
|
|
167
|
+
<details>
|
|
168
|
+
<summary>Technical details</summary>
|
|
169
|
+
|
|
170
|
+
- OAuth 2.0 authorization code flow with PKCE (S256), using the public (secret-less) client id and redirect URI of Somtoday's native app. There is no client secret and nothing uses the Somtoday Connect partner API.
|
|
171
|
+
- The login page redirects to a custom `somtoday://` URL; Playwright reads the authorization code from that request inside the browser window. No local web server is started.
|
|
172
|
+
- Short-lived access tokens are kept in memory only.
|
|
173
|
+
- The school is chosen on Somtoday's own login page. The school's tenant comes from the token response (`somtoday_tenant`), and the label shown by `whoami` and `status` from the `orgname` claim (or, failing that, the school data of your account).
|
|
174
|
+
|
|
175
|
+
</details>
|
|
176
|
+
|
|
177
|
+
### Where the session is stored
|
|
178
|
+
|
|
179
|
+
| Platform | Location |
|
|
180
|
+
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
181
|
+
| macOS | Login Keychain, service `somtoday-mcp`. Every write is read back and checked; if that fails, or `SOMTODAY_MCP_DISABLE_KEYCHAIN=1` is set, the file below is used |
|
|
182
|
+
| Linux / Windows | `session.json` with `0600` permissions in `$XDG_CONFIG_HOME/somtoday-mcp/` (default `~/.config/somtoday-mcp/`) |
|
|
183
|
+
|
|
184
|
+
To sign out, run `npx -y somtoday-mcp logout`.
|
|
185
|
+
|
|
186
|
+
### Environment variables
|
|
187
|
+
|
|
188
|
+
| Variable | Effect |
|
|
189
|
+
| ------------------------------- | -------------------------------------------------------------- |
|
|
190
|
+
| `XDG_CONFIG_HOME` | Base directory for the file fallback (default `~/.config`). |
|
|
191
|
+
| `SOMTODAY_MCP_CONFIG_DIR` | Use this directory instead of `$XDG_CONFIG_HOME/somtoday-mcp`. |
|
|
192
|
+
| `SOMTODAY_MCP_DISABLE_KEYCHAIN` | Set to `1` to skip the macOS Keychain and always use the file. |
|
|
193
|
+
|
|
194
|
+
Logs go to stderr (stdout is reserved for the MCP protocol) and redact token-like values.
|
|
195
|
+
|
|
196
|
+
## Development
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
npm install
|
|
200
|
+
npm run lint
|
|
201
|
+
npm run format:check
|
|
202
|
+
npm run typecheck
|
|
203
|
+
npm run build
|
|
204
|
+
npm test
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Tests never touch the network: `fetch` and the keychain are mocked, and requests to Somtoday hosts fail on purpose. See [CONTRIBUTING.md](CONTRIBUTING.md) for the rules on test data and privacy, and [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
|
|
208
|
+
|
|
209
|
+
`ouder-portal.openapi.yaml` describes the parts of the parent-portal API this project relies on, as observed from the portal; it is not an official specification.
|
|
210
|
+
|
|
211
|
+
### Manual check against a real account
|
|
212
|
+
|
|
213
|
+
Not part of CI. Write down HTTP status codes only; never paste tokens, codes, names, or API responses.
|
|
214
|
+
|
|
215
|
+
1. `node dist/cli.js login` and complete the sign-in.
|
|
216
|
+
2. Call `whoami`, `lessons`, `homework`, and `grades` from your MCP client.
|
|
217
|
+
3. Restart the MCP server and call a tool again, to check that the saved session is picked up.
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
[MIT](LICENSE). Not affiliated with Somtoday or Topicus.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ApiClient } from "./client.js";
|
|
2
|
+
export declare const REGISTRATION_CATEGORIES: readonly ["ongeoorloofdAfwezig", "geoorloofdAfwezig", "afwezigWaarnemingen", "teLaat", "verwijderd", "huiswerkNietGemaakt", "materiaalNietInOrde"];
|
|
3
|
+
export declare function fetchRegistrations(client: ApiClient, leerlingId: number, periode: "SCHOOLJAAR" | "CIJFERPERIODE"): Promise<{
|
|
4
|
+
periode: "SCHOOLJAAR" | "CIJFERPERIODE";
|
|
5
|
+
aantallen: Record<string, number>;
|
|
6
|
+
registraties: Record<string, unknown[]>;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function fetchActiveMeasures(client: ApiClient, leerlingId: number): Promise<{
|
|
9
|
+
id: number | undefined;
|
|
10
|
+
datum: string | undefined;
|
|
11
|
+
maatregel: string | undefined;
|
|
12
|
+
opmerkingen: string | undefined;
|
|
13
|
+
nagekomen: boolean | undefined;
|
|
14
|
+
automatisch: boolean | undefined;
|
|
15
|
+
aanleiding: {
|
|
16
|
+
omschrijving: string | undefined;
|
|
17
|
+
begin: string | undefined;
|
|
18
|
+
} | undefined;
|
|
19
|
+
}[]>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { htmlToPlain } from "../html.js";
|
|
2
|
+
import { linkId } from "./simplify.js";
|
|
3
|
+
import { compact } from "./studiewijzer.js";
|
|
4
|
+
export const REGISTRATION_CATEGORIES = [
|
|
5
|
+
"ongeoorloofdAfwezig",
|
|
6
|
+
"geoorloofdAfwezig",
|
|
7
|
+
"afwezigWaarnemingen",
|
|
8
|
+
"teLaat",
|
|
9
|
+
"verwijderd",
|
|
10
|
+
"huiswerkNietGemaakt",
|
|
11
|
+
"materiaalNietInOrde",
|
|
12
|
+
];
|
|
13
|
+
function mapLes(afspraak) {
|
|
14
|
+
return compact({
|
|
15
|
+
begin: afspraak.beginDatumTijd,
|
|
16
|
+
lesuur: afspraak.beginLesuur,
|
|
17
|
+
vak: afspraak.vak?.naam,
|
|
18
|
+
titel: afspraak.titel,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export async function fetchRegistrations(client, leerlingId, periode) {
|
|
22
|
+
const { body } = await client.get(`/leerlingen/${leerlingId}/registratieOverzicht`, new URLSearchParams({ periode }));
|
|
23
|
+
const categories = {};
|
|
24
|
+
const aantallen = {};
|
|
25
|
+
const keys = new Set([
|
|
26
|
+
...REGISTRATION_CATEGORIES,
|
|
27
|
+
...Object.keys(body).filter((key) => Array.isArray(body[key])),
|
|
28
|
+
]);
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
const entries = body[key] ?? [];
|
|
31
|
+
aantallen[key] = entries.length;
|
|
32
|
+
categories[key] = entries
|
|
33
|
+
.map((entry) => compact({
|
|
34
|
+
id: entry.registratieId,
|
|
35
|
+
soort: entry.registratieSoort,
|
|
36
|
+
begin: entry.begin,
|
|
37
|
+
eind: entry.eind,
|
|
38
|
+
omschrijving: htmlToPlain(entry.omschrijving) || undefined,
|
|
39
|
+
geoorloofd: entry.geoorloofd,
|
|
40
|
+
afgehandeld: entry.afgehandeld,
|
|
41
|
+
lessen: [
|
|
42
|
+
...(entry.afspraak ? [entry.afspraak] : []),
|
|
43
|
+
...(entry.afspraken ?? []),
|
|
44
|
+
].map(mapLes),
|
|
45
|
+
}))
|
|
46
|
+
.sort((a, b) => (b.begin ?? "").localeCompare(a.begin ?? ""));
|
|
47
|
+
}
|
|
48
|
+
return { periode, aantallen, registraties: categories };
|
|
49
|
+
}
|
|
50
|
+
export async function fetchActiveMeasures(client, leerlingId) {
|
|
51
|
+
const { items } = await client.getAll(`/maatregeltoekenningen/actief/${leerlingId}`);
|
|
52
|
+
return items.map((item) => compact({
|
|
53
|
+
id: linkId(item),
|
|
54
|
+
datum: item.maatregelDatum,
|
|
55
|
+
maatregel: item.maatregel?.omschrijving,
|
|
56
|
+
opmerkingen: item.opmerkingen,
|
|
57
|
+
nagekomen: item.nagekomen,
|
|
58
|
+
automatisch: item.automatischToegekend,
|
|
59
|
+
aanleiding: item.veroorzaaktDoor
|
|
60
|
+
? compact({
|
|
61
|
+
omschrijving: item.veroorzaaktDoor.omschrijving,
|
|
62
|
+
begin: item.veroorzaaktDoor.beginDatumTijd,
|
|
63
|
+
})
|
|
64
|
+
: undefined,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ApiClient } from "./client.js";
|
|
2
|
+
export type ChildAccess = {
|
|
3
|
+
leerlingId: number;
|
|
4
|
+
huiswerkBekijkenAan: boolean;
|
|
5
|
+
roosterBekijkenAan: boolean;
|
|
6
|
+
cijfersBekijkenAan: boolean;
|
|
7
|
+
vestigingsId?: number;
|
|
8
|
+
/** Every boolean in the school's restricties for this child (portal section switches). */
|
|
9
|
+
rights: Record<string, boolean>;
|
|
10
|
+
};
|
|
11
|
+
export declare function loadChildren(client: ApiClient): Promise<ChildAccess[]>;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the school shows a portal section for this child. Unknown flags
|
|
14
|
+
* count as allowed (the API itself still answers 403 when not).
|
|
15
|
+
*/
|
|
16
|
+
export declare function hasRight(child: ChildAccess, flag: string): boolean;
|
|
17
|
+
export declare function resolveChild(children: ChildAccess[], requested?: string): ChildAccess;
|
|
18
|
+
export declare class MultipleChildrenError extends Error {
|
|
19
|
+
readonly children: ChildAccess[];
|
|
20
|
+
constructor(children: ChildAccess[]);
|
|
21
|
+
}
|
|
22
|
+
export declare function describeChildren(children: ChildAccess[]): unknown;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export async function loadChildren(client) {
|
|
2
|
+
const query = new URLSearchParams();
|
|
3
|
+
query.append("additional", "restricties");
|
|
4
|
+
const { body } = await client.get("/account/me", query);
|
|
5
|
+
const items = body.additionalObjects?.restricties?.items ?? [];
|
|
6
|
+
return items
|
|
7
|
+
.filter((item) => {
|
|
8
|
+
return typeof item.leerlingId === "number";
|
|
9
|
+
})
|
|
10
|
+
.map((item) => ({
|
|
11
|
+
leerlingId: item.leerlingId,
|
|
12
|
+
huiswerkBekijkenAan: item.huiswerkBekijkenAan === true,
|
|
13
|
+
roosterBekijkenAan: item.roosterBekijkenAan === true,
|
|
14
|
+
cijfersBekijkenAan: item.cijfersBekijkenAan === true,
|
|
15
|
+
vestigingsId: typeof item.vestigingsId === "number" ? item.vestigingsId : undefined,
|
|
16
|
+
rights: Object.fromEntries(Object.entries(item).filter((entry) => typeof entry[1] === "boolean")),
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether the school shows a portal section for this child. Unknown flags
|
|
21
|
+
* count as allowed (the API itself still answers 403 when not).
|
|
22
|
+
*/
|
|
23
|
+
export function hasRight(child, flag) {
|
|
24
|
+
return child.rights[flag] !== false;
|
|
25
|
+
}
|
|
26
|
+
export function resolveChild(children, requested) {
|
|
27
|
+
if (children.length === 0) {
|
|
28
|
+
throw new Error("No linked leerling ids on this account");
|
|
29
|
+
}
|
|
30
|
+
if (requested) {
|
|
31
|
+
const id = Number(requested);
|
|
32
|
+
const match = children.find((child) => child.leerlingId === id);
|
|
33
|
+
if (!match) {
|
|
34
|
+
throw new Error(`Unknown leerlingId ${requested}`);
|
|
35
|
+
}
|
|
36
|
+
return match;
|
|
37
|
+
}
|
|
38
|
+
if (children.length === 1) {
|
|
39
|
+
return children[0];
|
|
40
|
+
}
|
|
41
|
+
throw new MultipleChildrenError(children);
|
|
42
|
+
}
|
|
43
|
+
export class MultipleChildrenError extends Error {
|
|
44
|
+
children;
|
|
45
|
+
constructor(children) {
|
|
46
|
+
super("Multiple linked students; pass leerlingId");
|
|
47
|
+
this.children = children;
|
|
48
|
+
this.name = "MultipleChildrenError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function describeChildren(children) {
|
|
52
|
+
return children.map((child) => ({
|
|
53
|
+
leerlingId: child.leerlingId,
|
|
54
|
+
huiswerkBekijkenAan: child.huiswerkBekijkenAan,
|
|
55
|
+
roosterBekijkenAan: child.roosterBekijkenAan,
|
|
56
|
+
cijfersBekijkenAan: child.cijfersBekijkenAan,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ApiClient } from "./client.js";
|
|
2
|
+
import { type AttachmentView, type StudiewijzerItemView } from "./studiewijzer.js";
|
|
3
|
+
export type AssignmentCategory = "AANKOMEND" | "IN_TE_LEVEREN" | "IN_AFWACHTING" | "AKKOORD";
|
|
4
|
+
export type AssignmentView = StudiewijzerItemView & {
|
|
5
|
+
categorie?: AssignmentCategory;
|
|
6
|
+
teLaat?: boolean;
|
|
7
|
+
};
|
|
8
|
+
/** Same buckets as the portal's assignment list. */
|
|
9
|
+
export declare function categorize(item: StudiewijzerItemView, now: Date): {
|
|
10
|
+
categorie?: AssignmentCategory;
|
|
11
|
+
teLaat?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function fetchAssignments(client: ApiClient, leerlingId: number, now: Date): Promise<AssignmentView[]>;
|
|
14
|
+
/**
|
|
15
|
+
* One assignment from exactly the list `assignments` returns (same request,
|
|
16
|
+
* all Range pages, same mapping and filter). Ids are compared as strings.
|
|
17
|
+
*/
|
|
18
|
+
export declare function findAssignment(client: ApiClient, leerlingId: number, toekenningId: string | number, now: Date): Promise<{
|
|
19
|
+
item?: AssignmentView;
|
|
20
|
+
total: number;
|
|
21
|
+
}>;
|
|
22
|
+
export type SubmissionView = {
|
|
23
|
+
id?: number;
|
|
24
|
+
verzendDatum?: string;
|
|
25
|
+
status: string;
|
|
26
|
+
statusGewijzigd?: string;
|
|
27
|
+
naam?: string;
|
|
28
|
+
fileUrl?: string;
|
|
29
|
+
fileExtension?: string;
|
|
30
|
+
url?: string;
|
|
31
|
+
ingeleverdDoor?: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Submissions and teacher reactions. Student sessions only: the portal calls
|
|
35
|
+
* this solely when the current context is a student, and a parent session
|
|
36
|
+
* gets HTTP 400. Not used by the parent tools; kept for a future student mode.
|
|
37
|
+
*/
|
|
38
|
+
export declare function fetchAssignmentDetail(client: ApiClient, toekenningId: number): Promise<{
|
|
39
|
+
aantalInleveringenInVerwerking: number;
|
|
40
|
+
inleveringen: SubmissionView[];
|
|
41
|
+
reacties: Array<{
|
|
42
|
+
verzendDatum?: string;
|
|
43
|
+
van?: string;
|
|
44
|
+
doorJou?: boolean;
|
|
45
|
+
onderwerp?: string;
|
|
46
|
+
inhoud?: string;
|
|
47
|
+
bijlagen?: AttachmentView[];
|
|
48
|
+
}>;
|
|
49
|
+
}>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { amsterdamYmd } from "../dates.js";
|
|
2
|
+
import { htmlToPlain } from "../html.js";
|
|
3
|
+
import { handInQuery } from "./homework.js";
|
|
4
|
+
import { linkId } from "./simplify.js";
|
|
5
|
+
import { compact, compactItem, mapAttachment, mapStudiewijzerItem, } from "./studiewijzer.js";
|
|
6
|
+
/** Same buckets as the portal's assignment list. */
|
|
7
|
+
export function categorize(item, now) {
|
|
8
|
+
const start = item.inlevermoment?.start
|
|
9
|
+
? new Date(item.inlevermoment.start)
|
|
10
|
+
: undefined;
|
|
11
|
+
const eind = item.inlevermoment?.eind
|
|
12
|
+
? new Date(item.inlevermoment.eind)
|
|
13
|
+
: undefined;
|
|
14
|
+
if (!start)
|
|
15
|
+
return {};
|
|
16
|
+
if (start > now)
|
|
17
|
+
return { categorie: "AANKOMEND" };
|
|
18
|
+
const status = item.inleverStatus;
|
|
19
|
+
if (!status || status === "HEROPEND") {
|
|
20
|
+
return {
|
|
21
|
+
categorie: "IN_TE_LEVEREN",
|
|
22
|
+
teLaat: eind ? eind < now : undefined,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (status === "TE_BEOORDELEN" || status === "IN_BEHANDELING") {
|
|
26
|
+
return { categorie: "IN_AFWACHTING" };
|
|
27
|
+
}
|
|
28
|
+
if (status === "AKKOORD")
|
|
29
|
+
return { categorie: "AKKOORD" };
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
export async function fetchAssignments(client, leerlingId, now) {
|
|
33
|
+
const { items } = await client.getAll("/studiewijzeritemdagtoekenningen", handInQuery(leerlingId));
|
|
34
|
+
return (items
|
|
35
|
+
.map((raw) => ({
|
|
36
|
+
raw,
|
|
37
|
+
view: mapStudiewijzerItem(raw, leerlingId, amsterdamYmd),
|
|
38
|
+
}))
|
|
39
|
+
// Like the portal: group assignments only count once the child is in a group.
|
|
40
|
+
.filter(({ raw, view }) => !(raw.studiewijzerItem?.projectgroepen && !view.projectgroep))
|
|
41
|
+
.map(({ view }) => compactItem({ ...view, ...categorize(view, now) }))
|
|
42
|
+
.sort((a, b) => (a.inlevermoment?.eind ?? "").localeCompare(b.inlevermoment?.eind ?? "")));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* One assignment from exactly the list `assignments` returns (same request,
|
|
46
|
+
* all Range pages, same mapping and filter). Ids are compared as strings.
|
|
47
|
+
*/
|
|
48
|
+
export async function findAssignment(client, leerlingId, toekenningId, now) {
|
|
49
|
+
const all = await fetchAssignments(client, leerlingId, now);
|
|
50
|
+
const wanted = String(toekenningId).trim();
|
|
51
|
+
return {
|
|
52
|
+
item: all.find((entry) => String(entry.toekenningId) === wanted),
|
|
53
|
+
total: all.length,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Submissions and teacher reactions. Student sessions only: the portal calls
|
|
58
|
+
* this solely when the current context is a student, and a parent session
|
|
59
|
+
* gets HTTP 400. Not used by the parent tools; kept for a future student mode.
|
|
60
|
+
*/
|
|
61
|
+
export async function fetchAssignmentDetail(client, toekenningId) {
|
|
62
|
+
const { body } = await client.get(`/studiewijzeritemdagtoekenningen/inleverdetails/${toekenningId}`);
|
|
63
|
+
return {
|
|
64
|
+
aantalInleveringenInVerwerking: body.aantalInleveringenInVerwerking ?? 0,
|
|
65
|
+
inleveringen: (body.inleveringen ?? []).map((inlevering) => {
|
|
66
|
+
const file = mapAttachment({
|
|
67
|
+
assemblyResults: inlevering.assemblyResults,
|
|
68
|
+
});
|
|
69
|
+
const isLink = !file && !inlevering.uploadContext && inlevering.inhoud;
|
|
70
|
+
return compact({
|
|
71
|
+
id: linkId(inlevering),
|
|
72
|
+
verzendDatum: inlevering.verzendDatum,
|
|
73
|
+
status: inlevering.status ?? "TE_BEOORDELEN",
|
|
74
|
+
statusGewijzigd: inlevering.wijzigingsDatum,
|
|
75
|
+
naam: file?.fileName ?? (isLink ? inlevering.inhoud : undefined),
|
|
76
|
+
fileUrl: file?.fileUrl,
|
|
77
|
+
fileExtension: file?.fileExtension,
|
|
78
|
+
url: isLink ? inlevering.inhoud : undefined,
|
|
79
|
+
ingeleverdDoor: inlevering.additionalObjects?.GROEP_INLEVERAAR,
|
|
80
|
+
});
|
|
81
|
+
}),
|
|
82
|
+
reacties: (body.conversatie?.boodschappen ?? []).map((boodschap) => compact({
|
|
83
|
+
verzendDatum: boodschap.verzendDatum,
|
|
84
|
+
van: boodschap.additionalObjects?.verzenderCorrespondent?.naam,
|
|
85
|
+
doorJou: boodschap.additionalObjects?.verzondenDoorGebruiker,
|
|
86
|
+
onderwerp: boodschap.onderwerp,
|
|
87
|
+
inhoud: htmlToPlain(boodschap.inhoud) || undefined,
|
|
88
|
+
bijlagen: (boodschap.bijlages ?? [])
|
|
89
|
+
.map(mapAttachment)
|
|
90
|
+
.filter((value) => Boolean(value)),
|
|
91
|
+
})),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { SessionManager } from "../auth/session.js";
|
|
2
|
+
import type { FetchFn } from "../auth/tokens.js";
|
|
3
|
+
export type ApiResponse<T> = {
|
|
4
|
+
status: number;
|
|
5
|
+
body: T;
|
|
6
|
+
contentRange?: string;
|
|
7
|
+
};
|
|
8
|
+
export type GetOptions = {
|
|
9
|
+
/** `json` for the endpoints that expect plain application/json (news, school info, signing). */
|
|
10
|
+
accept?: "platinum" | "json";
|
|
11
|
+
/** Inclusive item range, sent as `Range: items=start-end`. */
|
|
12
|
+
range?: {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export type AllOptions = {
|
|
18
|
+
accept?: "platinum" | "json";
|
|
19
|
+
/** Items per request (the server allows at most 100). */
|
|
20
|
+
pageSize?: number;
|
|
21
|
+
/** Stop after this many items. */
|
|
22
|
+
maxItems?: number;
|
|
23
|
+
/** Sees every page body (for top-level fields next to `items`). */
|
|
24
|
+
onPage?: (body: unknown) => void;
|
|
25
|
+
};
|
|
26
|
+
export declare const PAGE_SIZE = 100;
|
|
27
|
+
export declare const MAX_PAGES = 100;
|
|
28
|
+
/**
|
|
29
|
+
* The API answered with a real "no": a 403 with a body (for example no
|
|
30
|
+
* study planner yet, or a section the school switched off) or a 404.
|
|
31
|
+
* Tools report this as `available: false` instead of an error.
|
|
32
|
+
*/
|
|
33
|
+
export declare class ApiNotAvailableError extends Error {
|
|
34
|
+
readonly status: number;
|
|
35
|
+
readonly path: string;
|
|
36
|
+
constructor(status: number, path: string);
|
|
37
|
+
}
|
|
38
|
+
export declare class ApiClient {
|
|
39
|
+
private readonly session;
|
|
40
|
+
private readonly fetchFn;
|
|
41
|
+
constructor(session: SessionManager, fetchFn?: FetchFn);
|
|
42
|
+
get<T>(path: string, query?: URLSearchParams, options?: GetOptions): Promise<ApiResponse<T>>;
|
|
43
|
+
/**
|
|
44
|
+
* Fetch every item of a list endpoint the way the portal does: request
|
|
45
|
+
* `Range: items=a-b` in pages of at most 100 and continue while
|
|
46
|
+
* `Content-Range: items a-b/total` says there is more. A response without
|
|
47
|
+
* Content-Range is complete. Accepts `{items: [...]}` and top-level arrays.
|
|
48
|
+
*/
|
|
49
|
+
getAll<T>(path: string, query?: URLSearchParams, options?: AllOptions): Promise<{
|
|
50
|
+
items: T[];
|
|
51
|
+
total?: number;
|
|
52
|
+
}>;
|
|
53
|
+
private url;
|
|
54
|
+
private send;
|
|
55
|
+
private read;
|
|
56
|
+
}
|
|
57
|
+
export declare function listItems<T>(body: unknown): T[];
|
|
58
|
+
export declare function parseContentRange(header?: string): {
|
|
59
|
+
start: number;
|
|
60
|
+
end: number;
|
|
61
|
+
total?: number;
|
|
62
|
+
} | undefined;
|
|
63
|
+
export declare function parseContentRangeTotal(header?: string): number | undefined;
|
|
64
|
+
export declare function repeatedParams(key: string, values: readonly string[], into?: import("url").URLSearchParams): URLSearchParams;
|