Compare commits
23 Commits
fix/protec
...
17cb30003d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17cb30003d | ||
|
|
85452f66a7 | ||
| e73700763d | |||
|
|
182fb2357f | ||
| b1d3686d35 | |||
|
|
b1330ebe3e | ||
|
|
5214c9db3b | ||
| 48715df147 | |||
| 85204938df | |||
|
|
cbce4555ff | ||
|
|
6bf8bad5fb | ||
|
|
54137122a7 | ||
| 8decdd454f | |||
|
|
897b46d4a1 | ||
|
|
80f738ddcb | ||
| 1a1351ddcb | |||
|
|
d79e69aad2 | ||
|
|
89d3395a62 | ||
|
|
d5191073f0 | ||
| fab7a12f7b | |||
|
|
fe99381cb7 | ||
|
|
3af105bccd | ||
| 5f34a6f825 |
4
.github/workflows/deploy-dev.yml
vendored
4
.github/workflows/deploy-dev.yml
vendored
@@ -28,4 +28,8 @@ jobs:
|
|||||||
-i infra/development/hosts.ini \
|
-i infra/development/hosts.ini \
|
||||||
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
|
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
|
||||||
-e "anthropic_api_key=${{ secrets.ANTHROPIC_API_KEY }}" \
|
-e "anthropic_api_key=${{ secrets.ANTHROPIC_API_KEY }}" \
|
||||||
|
-e "entra_tenant_id=${{ secrets.ENTRA_TENANT_ID }}" \
|
||||||
|
-e "entra_client_id=${{ secrets.ENTRA_CLIENT_ID }}" \
|
||||||
|
-e "entra_client_secret=${{ secrets.ENTRA_CLIENT_SECRET }}" \
|
||||||
|
-e "entra_admin_emails=${{ secrets.ENTRA_ADMIN_EMAILS }}" \
|
||||||
infra/development/site/deploy-playbook.yml
|
infra/development/site/deploy-playbook.yml
|
||||||
|
|||||||
4
.github/workflows/deploy-prod.yml
vendored
4
.github/workflows/deploy-prod.yml
vendored
@@ -30,4 +30,8 @@ jobs:
|
|||||||
-i infra/production/hosts.ini \
|
-i infra/production/hosts.ini \
|
||||||
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
|
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
|
||||||
-e "anthropic_api_key=${{ secrets.ANTHROPIC_API_KEY }}" \
|
-e "anthropic_api_key=${{ secrets.ANTHROPIC_API_KEY }}" \
|
||||||
|
-e "entra_tenant_id=${{ secrets.ENTRA_TENANT_ID }}" \
|
||||||
|
-e "entra_client_id=${{ secrets.ENTRA_CLIENT_ID }}" \
|
||||||
|
-e "entra_client_secret=${{ secrets.ENTRA_CLIENT_SECRET }}" \
|
||||||
|
-e "entra_admin_emails=${{ secrets.ENTRA_ADMIN_EMAILS }}" \
|
||||||
infra/production/site/deploy-playbook.yml
|
infra/production/site/deploy-playbook.yml
|
||||||
|
|||||||
16
.github/workflows/test.yml
vendored
16
.github/workflows/test.yml
vendored
@@ -14,6 +14,22 @@ jobs:
|
|||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: docker build -t learning-platform .
|
run: docker build -t learning-platform .
|
||||||
|
|
||||||
|
# The production Caddyfile is otherwise never exercised by CI — the test
|
||||||
|
# image below swaps in Caddyfile.test — so a parse error only surfaced at
|
||||||
|
# deploy time, after the broken container had already replaced the healthy
|
||||||
|
# one (issue #20/#26). Validate it here, inside the freshly built image
|
||||||
|
# where it lives at /etc/caddy/Caddyfile. This runs caddy against the
|
||||||
|
# exact file that ships and needs no bind mount — a `-v "$PWD":...` mount
|
||||||
|
# does not propagate to the sibling Docker daemon in the Gitea runner,
|
||||||
|
# which is why the earlier mount-based step failed with
|
||||||
|
# "open Caddyfile: no such file or directory" (#26).
|
||||||
|
# Caddyfile.test needs no separate check: the "Test homepage" step below
|
||||||
|
# runs the test image and fails if that config can't serve.
|
||||||
|
- name: Validate production Caddyfile
|
||||||
|
run: |
|
||||||
|
docker run --rm --entrypoint caddy learning-platform \
|
||||||
|
validate --adapter caddyfile --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
- name: Build test image
|
- name: Build test image
|
||||||
run: |
|
run: |
|
||||||
cat > Dockerfile.test <<'EOF'
|
cat > Dockerfile.test <<'EOF'
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ The platform ships a global chatbot avatar called **R42**, rendered as the Respe
|
|||||||
* **PocketBase auto-cancellation is OFF.** Set in `src/lib/pb.js`; never re-enable.
|
* **PocketBase auto-cancellation is OFF.** Set in `src/lib/pb.js`; never re-enable.
|
||||||
* **Go through `callLLM`.** Never call the Anthropic proxy directly; you lose retry, schema validation, and telemetry.
|
* **Go through `callLLM`.** Never call the Anthropic proxy directly; you lose retry, schema validation, and telemetry.
|
||||||
* **AI token budget.** Truncation surfaces as `LLMTruncatedError` (`stop_reason: max_tokens`). For extraction, tighten the prompt's topic cap before raising `max_tokens`.
|
* **AI token budget.** Truncation surfaces as `LLMTruncatedError` (`stop_reason: max_tokens`). For extraction, tighten the prompt's topic cap before raising `max_tokens`.
|
||||||
|
* **PocketBase migrations & the `_migrations` ledger (issue #18).** The deployed Labs/prod databases were originally provisioned WITHOUT `--migrationsDir` (schema came from `scripts/setup-pb-collections.mjs`), so their ledger started empty. `pb_migrations/1000000000_baseline_ledger_sync.js` marks the historical files as applied on such databases — never remove it, never rename an applied migration file (the ledger is filename-based), and give new migrations a timestamp that sorts after the existing ones. A failed migration aborts `pocketbase serve` → container crash-loop → 502 on every `/api/*` call; the deploy playbooks gate on `/api/health` to catch this.
|
||||||
|
* **OIDC provider config lives in a hook, not a migration.** `pb_hooks/entra_oidc.pb.js` reconciles the Entra provider from `ENTRA_*` env on every bootstrap + cron tick. Don't move provider credentials back into a one-shot migration — an apply while the env is empty would permanently disable OAuth2.
|
||||||
|
* **JSVM hook scope.** PocketBase runs each hook callback as an isolated program: top-level functions in a `*.pb.js` file are NOT in scope inside callbacks. Shared helpers live in `pb_hooks/utils.js` and are pulled in per-callback via `require(`${__hooks}/utils.js`)`.
|
||||||
|
|
||||||
## 13. 26-Week Per-User Curriculum System
|
## 13. 26-Week Per-User Curriculum System
|
||||||
The platform uses a **26-week perpetual curriculum cycle**. Every employee covers the knowledge base in focused, thematic weekly blocks, **starting whenever they enroll**.
|
The platform uses a **26-week perpetual curriculum cycle**. Every employee covers the knowledge base in focused, thematic weekly blocks, **starting whenever they enroll**.
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ when the PB binary starts.
|
|||||||
- `docs/generation-spec.md` — learning content + micro-learning generation
|
- `docs/generation-spec.md` — learning content + micro-learning generation
|
||||||
- `docs/curriculum-spec.md` — 26-week per-user curriculum engine
|
- `docs/curriculum-spec.md` — 26-week per-user curriculum engine
|
||||||
- `docs/r42-spec.md` — the R42 chatbot
|
- `docs/r42-spec.md` — the R42 chatbot
|
||||||
|
- `docs/auth-spec.md` — Azure (Entra ID) login: OIDC via PocketBase, provisioning, roles
|
||||||
- `docs/frontend-spec.md` — screens, routing, onboarding
|
- `docs/frontend-spec.md` — screens, routing, onboarding
|
||||||
- `docs/gamification-spec.md` — points, badges, leaderboard
|
- `docs/gamification-spec.md` — points, badges, leaderboard
|
||||||
- `micro-learning-spec.md` — micro-learning learner experience
|
- `micro-learning-spec.md` — micro-learning learner experience
|
||||||
|
|||||||
10
Caddyfile
10
Caddyfile
@@ -46,6 +46,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle_errors {
|
handle_errors {
|
||||||
|
# Don't mask API errors with the SPA shell — return a proper
|
||||||
|
# JSON error so the frontend can display a meaningful message.
|
||||||
|
# NOTE: `respond` only accepts `body`/`close` subdirectives; the
|
||||||
|
# Content-Type must be set via a separate `header` directive. An
|
||||||
|
# invalid subdirective is a Caddyfile parse error and crash-loops
|
||||||
|
# the container at startup (issue #20).
|
||||||
|
@api path /api/*
|
||||||
|
header @api Content-Type application/json
|
||||||
|
respond @api `{"code":{err.status_code},"message":"Backend unavailable"}` {err.status_code}
|
||||||
|
|
||||||
rewrite * /index.html
|
rewrite * /index.html
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ services:
|
|||||||
container_name: pocketbase-learning
|
container_name: pocketbase-learning
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /pb
|
working_dir: /pb
|
||||||
|
env_file:
|
||||||
|
# Optional: absent .env.local must not block `docker compose up` (issue #18).
|
||||||
|
- path: .env.local
|
||||||
|
required: false
|
||||||
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]
|
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
volumes:
|
volumes:
|
||||||
- pb_data:/pb/pb_data
|
- pb_data:/pb/pb_data
|
||||||
- ./pb_migrations:/pb/pb_migrations
|
- ./pb_migrations:/pb/pb_migrations
|
||||||
|
|||||||
122
docs/auth-spec.md
Normal file
122
docs/auth-spec.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Authentication — Azure (Entra ID) login
|
||||||
|
|
||||||
|
> Implemented for issue #16. Replaces the previous client-side PIN login.
|
||||||
|
> Hardened for issue #18 (migratie-ledger-sync + env-onafhankelijke provider-reconciliatie).
|
||||||
|
|
||||||
|
## Doel
|
||||||
|
|
||||||
|
Elke gebruiker logt in met zijn Respellion **Microsoft (Azure Entra ID)**-account.
|
||||||
|
Bij de eerste login wordt automatisch een `team_members`-record aangemaakt. Er is
|
||||||
|
geen PIN, geen handmatige user-aanmaak en geen client-side wachtwoordcontrole meer.
|
||||||
|
|
||||||
|
## Architectuur in één oogopslag
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (SPA) PocketBase (auth) Entra ID
|
||||||
|
───────────── ───────────────── ────────
|
||||||
|
"Sign in with Microsoft"
|
||||||
|
└─ pb.collection('team_members')
|
||||||
|
.authWithOAuth2({provider:'oidc'})
|
||||||
|
──────────────────► opens OIDC popup ───► login.microsoftonline.com
|
||||||
|
token exchange ◄─── (server-side, client secret)
|
||||||
|
◄────────────────── auth record + token
|
||||||
|
pb.authStore (token in localStorage)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **PocketBase is de OIDC-client.** De token-exchange en het client-secret blijven
|
||||||
|
server-side in PocketBase — er is geen aparte backend en geen client-side key.
|
||||||
|
- **`team_members` is een PocketBase `auth`-collection.** De record-`id` blijft de
|
||||||
|
user-identifier die alle voortgangscollecties (`test_results`,
|
||||||
|
`on_demand_attempts`, `micro_learning_completions`, `leaderboard`,
|
||||||
|
`theme_session_completions`) als `user_id` referencen.
|
||||||
|
- **Sessie** = `pb.authStore` (token in `localStorage`), niet langer een
|
||||||
|
`team_members.id` in `sessionStorage`.
|
||||||
|
|
||||||
|
## Belangrijke beslissingen (ADR)
|
||||||
|
|
||||||
|
| ADR | Beslissing | Reden |
|
||||||
|
|---|---|---|
|
||||||
|
| 001 | OIDC-provider tegen Entra v2-endpoints i.p.v. eigen MSAL-flow of header-trust | Sluit aan op "PocketBase = enige backend"; secret server-side; auto-provisioning gratis; werkt los van de perimeter-implementatie |
|
||||||
|
| 002 | `team_members` hergebruikt als auth-collection (zelfde naam) | Alle `user_id`-referenties blijven werken; minimale blast-radius |
|
||||||
|
| 003 | Provisioning + admin-rol via `pb_hooks/team_members.pb.js` | Rol-logica server-side, niet in de client |
|
||||||
|
| 004 | Admin-rol via e-mail-allowlist (`ENTRA_ADMIN_EMAILS`), **escalate-only** bij elke login (aangepast in #27) | Allow-list garandeert admin (promotie werkt bij volgende login) maar demoteert niet meer — anders zou elke TeamManager-promotie bij de volgende login worden teruggedraaid. Demotie via TeamManager; allow-list-leden demoteer je door ze van de lijst te halen |
|
||||||
|
| 005 | API-rules → `@request.auth.id != ""` op alle app-collecties | Geen anonieme toegang meer; admins/users zijn beide geauthenticeerd |
|
||||||
|
| 006 | Baseline-ledger-sync migratie voor out-of-band geprovisionede DB's | Labs/prod draaide historisch zonder `--migrationsDir`; replay van de historie crashte PB (issue #18). De vroegst-sorterende migratie markeert de historie als applied wanneer schema bestaat maar de ledger leeg is |
|
||||||
|
| 007 | Migratie = structuur, hook = configuratie | De OIDC-provider wordt bij elke start (en per cron-minuut) gereconcilieerd vanuit `ENTRA_*` env door `pb_hooks/entra_oidc.pb.js`; een one-shot migratie die van deploy-time env afhangt kan OAuth2 anders permanent uitschakelen |
|
||||||
|
| 008 | Hook-helpers via `require(`${__hooks}/utils.js`)` | De JSVM draait elke hook-callback als geïsoleerd programma; top-level functies zijn níet in scope op call-time |
|
||||||
|
| 009 | `createRule: '@request.context = "oauth2"'` op `team_members` | PocketBase past de createRule toe op de OAuth2-sign-up (eerste login maakt het record aan); `null` blokkeerde élke eerste login met 403 (issue #22). De context-rule staat alléén de OAuth2-flow toe — anonieme REST-creates blijven geweigerd |
|
||||||
|
| 010 | Claims uit het Entra **id_token** (`userInfoURL: ""`) + UPN-fallback voor e-mail | Graph `/oidc/userinfo` geeft géén `email`-claim voor accounts zonder mail-attribuut → 400 bij eerste login (issue #24). Het id_token bevat altijd `preferred_username` (UPN = primair e-mailadres bij Respellion); `entra_oidc.pb.js` gebruikt die als fallback (regex-guard tegen guest-UPN's) en logt gefaalde OAuth2-pogingen naar de containerlog |
|
||||||
|
| 011 | `updateRule: '(@request.auth.id = id && @request.body.role:isset = false) \|\| @request.auth.role = "admin"'`, `deleteRule: '@request.auth.role = "admin"'` | TeamManager-rosterbeheer werkt nu voor app-admins (issue #27). De `role:isset`-guard sluit tegelijk de privilege-escalatie waarbij een gebruiker zijn eigen `role` naar admin kon patchen; onboarding raakt `role` niet en blijft self-service |
|
||||||
|
|
||||||
|
## Bestanden
|
||||||
|
|
||||||
|
| Bestand | Rol |
|
||||||
|
|---|---|
|
||||||
|
| `pb_migrations/1000000000_baseline_ledger_sync.js` | Detecteert een out-of-band geprovisionede DB (schema bestaat, `_migrations`-ledger leeg) en markeert de historische migraties als applied, zodat de replay niet crasht (issue #18). |
|
||||||
|
| `pb_migrations/1781000000_team_members_to_auth.js` | Converteert relation-velden naar text (data blijft behouden), dropt de oude PIN-collection en maakt `team_members` als auth-collection. Idempotent; provider wordt alleen als fast-path meegenomen als de env al aanwezig is. |
|
||||||
|
| `pb_migrations/1781000001_tighten_api_rules.js` | Zet alle niet-systeem-collecties op `@request.auth.id != ""`. |
|
||||||
|
| `pb_migrations/1781000002_allow_oauth2_signup.js` | Zet `createRule = '@request.context = "oauth2"'` op reeds-gemigreerde omgevingen (issue #22). |
|
||||||
|
| `pb_migrations/1781000003_team_members_admin_rules.js` | Admin-rosterbeheer + role-escalatie-guard op reeds-gemigreerde omgevingen (issue #27). |
|
||||||
|
| `pb_hooks/entra_oidc.pb.js` | Reconcilieert de OIDC-provider vanuit `ENTRA_*` env bij elke start + cron-tick (compare-before-save). |
|
||||||
|
| `pb_hooks/utils.js` | Gedeelde helpers (`resolveRole`, `reconcileEntraOidc`) — per callback binnenhalen via `require()`. |
|
||||||
|
| `pb_hooks/team_members.pb.js` | Auto-provisioning (`role`, `enrollment_status`, naam-fallback) + admin-rol re-sync. |
|
||||||
|
| `src/lib/azureAuth.js` | Canonieke, unit-geteste helpers (`OIDC_PROVIDER`, `resolveRole`, `parseAdminEmails`, `deriveName`). |
|
||||||
|
| `src/store/AppContext.jsx` | `loginWithAzure()` / `logout()` op basis van `pb.authStore` + `authRefresh()`. |
|
||||||
|
| `src/pages/Login.jsx` | "Sign in with Microsoft"-scherm (geen dropdown/PIN). |
|
||||||
|
| `src/components/admin/TeamManager.jsx` | Roster-beheer: rol wijzigen + verwijderen (geen aanmaken/PIN). |
|
||||||
|
|
||||||
|
> De rol-/allowlist-logica bestaat tweemaal: canoniek in `src/lib/azureAuth.js`
|
||||||
|
> (met tests) en herhaald in `pb_hooks/team_members.pb.js` (PocketBase JSVM kan de
|
||||||
|
> module niet importeren). **Houd ze in sync.**
|
||||||
|
|
||||||
|
## Configuratie (environment)
|
||||||
|
|
||||||
|
Gerenderd in `.env` door de Ansible deploy-playbooks en doorgegeven aan de
|
||||||
|
`pocketbase-learning`-container:
|
||||||
|
|
||||||
|
| Variabele | Betekenis |
|
||||||
|
|---|---|
|
||||||
|
| `ENTRA_TENANT_ID` | Entra tenant-id (of `common`). |
|
||||||
|
| `ENTRA_CLIENT_ID` | App-registration client-id. |
|
||||||
|
| `ENTRA_CLIENT_SECRET` | App-registration client-secret. |
|
||||||
|
| `ENTRA_ADMIN_EMAILS` | Komma-gescheiden e-mail-allowlist die `role=admin` krijgt, bv. `rve@respellion.nl,admin@respellion.nl`. |
|
||||||
|
|
||||||
|
Ansible-variabelen (vault): `entra_tenant_id`, `entra_client_id`,
|
||||||
|
`entra_client_secret`, `entra_admin_emails`.
|
||||||
|
|
||||||
|
## Entra App Registration (eenmalig, buiten de codebase)
|
||||||
|
|
||||||
|
1. **Redirect-URI (Web):** `https://<host>/api/oauth2-redirect`
|
||||||
|
- Labs: `https://learning-platform.labs.respellion.tech/api/oauth2-redirect`
|
||||||
|
- Lokaal dev: `http://localhost:8090/api/oauth2-redirect` (PB-origin)
|
||||||
|
2. **API permissions / scopes:** `openid`, `email`, `profile`.
|
||||||
|
3. Genereer een client-secret en zet de waarden in de Ansible-vault.
|
||||||
|
|
||||||
|
## Uitbreidingspunten
|
||||||
|
|
||||||
|
- **Silent SSO (geen tweede klik):** als bevestigd is dat de perimeter veilige,
|
||||||
|
niet-spoofbare identity-headers doorstuurt, kan een PocketBase-route die headers
|
||||||
|
vertrouwen en direct een token minten (ADR-004-alternatief).
|
||||||
|
- **Least-privilege rules:** schrijf/verwijder op de knowledge-authoring-collecties
|
||||||
|
(`topics`, `relations`, `content`, `sources`, `question_bank`,
|
||||||
|
`curriculum_versions`) verder beperken tot `@request.auth.role = "admin"`. Let op:
|
||||||
|
`micro_learnings` en `theme_sessions` worden door reguliere users geschreven
|
||||||
|
(generate-then-cache). Vereist runtime-verificatie.
|
||||||
|
- **Rol op Entra group-claims** i.p.v. e-mail-allowlist (aanpassing in
|
||||||
|
`pb_hooks/team_members.pb.js` + `src/lib/azureAuth.js`).
|
||||||
|
|
||||||
|
## Bekende aandachtspunten / go-live
|
||||||
|
|
||||||
|
- De OIDC-popup is een top-level navigatie naar `login.microsoftonline.com`; de
|
||||||
|
bestaande Caddy-CSP (`connect-src`) raakt dit niet. Verifieer alsnog bij de
|
||||||
|
eerste deploy.
|
||||||
|
- Verweesde voortgangsrecords van verwijderde (pre-Azure) users zijn acceptabel:
|
||||||
|
zonder geldig Entra-account kan niemand inloggen, dus die records zijn onbereikbaar.
|
||||||
|
- Credential-rotatie: update de `ENTRA_*` secrets (Gitea → Ansible `.env`) en
|
||||||
|
herstart/redeploy — `pb_hooks/entra_oidc.pb.js` reconcilieert de provider
|
||||||
|
automatisch bij elke start en elke cron-minuut. Geen handmatige re-apply meer.
|
||||||
|
- **Migratiebestanden nooit hernoemen nadat ze ergens applied zijn** — de
|
||||||
|
`_migrations`-ledger is filename-based; hernoemen triggert een re-apply.
|
||||||
|
- De deploy-playbooks bevatten een health-gate: als PocketBase na de deploy niet
|
||||||
|
healthy wordt, faalt de pipeline zichtbaar en print hij de containerlogs
|
||||||
|
(issue #18 bleef 2 weken onzichtbaar doordat de deploy "groen" was).
|
||||||
@@ -212,6 +212,34 @@ Best-effort telemetry for every Anthropic call (written by `callLLM`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `onboarding_overviews`
|
||||||
|
Cached, breadth-first per-theme overview for the onboarding track (issue #30).
|
||||||
|
One row per theme, shared across all users. Generated lazily on first open.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| theme | text | canonical theme name (from `topics.theme`); **unique** |
|
||||||
|
| content | json | validated `onboardingOverviewSchema` payload (title, what_it_is, why_it_matters, key_points, topics_covered) |
|
||||||
|
| topics_fingerprint | text | stable hash of the theme's sorted topic ids; a mismatch triggers regeneration |
|
||||||
|
|
||||||
|
Unique index on `(theme)`. Rules: authenticated-only (`@request.auth.id != ""`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `onboarding_completions`
|
||||||
|
Per-user, per-theme completion marker for the onboarding track (issue #30).
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| team_member_id | text | the employee — **plain text**, not a relation (admins can delete members; orphan rows are harmless) |
|
||||||
|
| theme | text | the theme marked complete |
|
||||||
|
|
||||||
|
Unique index on `(team_member_id, theme)` → idempotent. Rules: authenticated-only.
|
||||||
|
Completion is tracked per **theme**, not per day; the 5 "days" are a read-time
|
||||||
|
presentation grouping, so re-chunking never loses progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Dropped / legacy collections
|
## Dropped / legacy collections
|
||||||
|
|
||||||
These existed in earlier iterations and have been removed. Their `db.js` helpers
|
These existed in earlier iterations and have been removed. Their `db.js` helpers
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ A React 19 SPA built with Vite, routed by React Router 7. Entry: `src/main.jsx`
|
|||||||
| Route | Screen | Access |
|
| Route | Screen | Access |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/login` | `Login.jsx` | public |
|
| `/login` | `Login.jsx` | public |
|
||||||
| `/onboarding` | `Onboarding.jsx` | logged-in, not yet enrolled |
|
| `/onboarding` | `Onboarding.jsx` | logged-in, not yet enrolled (enrollment page) |
|
||||||
| `/` | `Dashboard.jsx` | enrolled user |
|
| `/` | `Dashboard.jsx` | enrolled user |
|
||||||
|
| `/onboarding-track` | `OnboardingTrack.jsx` | any logged-in user, **any enrollment** (`skipEnrollmentGate`) |
|
||||||
| `/learn` | `Leren.jsx` | enrolled user |
|
| `/learn` | `Leren.jsx` | enrolled user |
|
||||||
| `/test` | `Testen.jsx` | enrolled user |
|
| `/test` | `Testen.jsx` | enrolled user |
|
||||||
| `/leaderboard` | `Leaderboard.jsx` | enrolled user |
|
| `/leaderboard` | `Leaderboard.jsx` | enrolled user |
|
||||||
@@ -20,9 +21,14 @@ A React 19 SPA built with Vite, routed by React Router 7. Entry: `src/main.jsx`
|
|||||||
`ProtectedRoute`:
|
`ProtectedRoute`:
|
||||||
- redirects to `/login` if not authenticated;
|
- redirects to `/login` if not authenticated;
|
||||||
- redirects to `/onboarding` if `enrollment_status !== 'active'` — **except** admins
|
- redirects to `/onboarding` if `enrollment_status !== 'active'` — **except** admins
|
||||||
heading to the admin panel, who are exempt;
|
heading to the admin panel, and routes passing `skipEnrollmentGate` (the onboarding
|
||||||
|
track), which are exempt;
|
||||||
- enforces `requireAdmin` for `/admin`.
|
- enforces `requireAdmin` for `/admin`.
|
||||||
|
|
||||||
|
> `/onboarding` (enrollment) and `/onboarding-track` (the 5-day theme intro) are
|
||||||
|
> distinct. The track is decoupled from enrollment on purpose so it also works as a
|
||||||
|
> refresher for existing staff.
|
||||||
|
|
||||||
Navigation chrome (top bar + mobile bottom nav) is rendered by `ProtectedRoute`.
|
Navigation chrome (top bar + mobile bottom nav) is rendered by `ProtectedRoute`.
|
||||||
`ChatLauncher` (R42) is mounted globally.
|
`ChatLauncher` (R42) is mounted globally.
|
||||||
|
|
||||||
@@ -51,7 +57,17 @@ enrolled are redirected to `/`. See `docs/curriculum-spec.md`.
|
|||||||
## Employee screens
|
## Employee screens
|
||||||
|
|
||||||
- **Dashboard** — current cycle/week, assigned topic, cycle progress ring, quick
|
- **Dashboard** — current cycle/week, assigned topic, cycle progress ring, quick
|
||||||
links to Learn and Test, mini leaderboard, recent activity.
|
links to Learn and Test, mini leaderboard, recent activity. The dismissible
|
||||||
|
"New here?" explainer card also carries the **onboarding-track CTA** (a progress
|
||||||
|
chip `X/5 days` + a Start/Continue/Review button linking to `/onboarding-track`),
|
||||||
|
shown only when the KB has themes to introduce.
|
||||||
|
- **Onboarding Track (`/onboarding-track`)** — a self-paced, breadth-first tour of
|
||||||
|
every theme, grouped into ≤5 day cards (`OnboardingTrack.jsx`). Opening a theme
|
||||||
|
lazily generates a short overview (what it is / why it matters for daily & weekly
|
||||||
|
work / key points / topics) and offers "Mark as done"; a progress ring + day bar
|
||||||
|
track completion, and a completion banner shows when every theme is done. Available
|
||||||
|
to any logged-in user regardless of enrollment; completion is stored per theme in
|
||||||
|
`onboarding_completions`. See `docs/generation-spec.md §D`.
|
||||||
- **Learning Station (`/learn`)** — the week's required topic + the rest of the
|
- **Learning Station (`/learn`)** — the week's required topic + the rest of the
|
||||||
knowledge library; opening a topic shows the micro-learning selector
|
knowledge library; opening a topic shows the micro-learning selector
|
||||||
(`src/components/micro_learning/`). Completing ≥1 micro-learning marks the week done.
|
(`src/components/micro_learning/`). Completing ≥1 micro-learning marks the week done.
|
||||||
|
|||||||
@@ -74,6 +74,29 @@ explanation, difficulty }`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## D. Onboarding overviews — `src/lib/onboardingService.js`
|
||||||
|
|
||||||
|
Powers the 5-day onboarding track (issue #30): a short, breadth-first overview of
|
||||||
|
**one theme** for a brand-new employee — deliberately light, not a deep lesson.
|
||||||
|
|
||||||
|
- **Tool:** `emit_onboarding_overview` · **tier:** `fast` (Haiku) · `maxTokens: 1500`,
|
||||||
|
60s timeout · schema `onboardingOverviewSchema`.
|
||||||
|
- **Shape:** `{ title, what_it_is, why_it_matters, key_points[3–5], topics_covered[{topic_id,label}] }`.
|
||||||
|
`why_it_matters` is framed around day-to-day / week-to-week work at Respellion.
|
||||||
|
- **Cache:** `onboarding_overviews`, one row per theme, keyed by theme name plus a
|
||||||
|
`topics_fingerprint` (stable hash of the theme's sorted topic ids).
|
||||||
|
`getOrGenerateOnboardingOverview(theme, topics, {force})` returns the cached row when
|
||||||
|
the fingerprint matches; a mismatch (topic added/removed) or `force` regenerates.
|
||||||
|
- **Simulation:** `emit_onboarding_overview` has a stub in `SIMULATION_TOOL_STUBS`.
|
||||||
|
|
||||||
|
Theme ordering + day grouping are pure helpers in the same module
|
||||||
|
(`orderThemes`, `distributeThemesIntoDays`, `computeTopicsFingerprint`,
|
||||||
|
`computeOnboardingProgress`, `buildOnboardingPlan`), unit-tested in
|
||||||
|
`src/lib/__tests__/onboardingService.test.js`. Completion is recorded per **theme**
|
||||||
|
in `onboarding_completions` (`{ team_member_id, theme }`), not per day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Shared infrastructure (`src/lib/llm.js`)
|
## Shared infrastructure (`src/lib/llm.js`)
|
||||||
|
|
||||||
- **Tiers:** `fast` (Haiku 4.5), `standard` (Sonnet 4.6), `reasoning` (Opus 4.7);
|
- **Tiers:** `fast` (Haiku 4.5), `standard` (Sonnet 4.6), `reasoning` (Opus 4.7);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ client-side and is grounded by local TF-IDF retrieval — **no vector database**
|
|||||||
context is truncated with a notice.
|
context is truncated with a notice.
|
||||||
- A greeting message seeds an empty thread.
|
- A greeting message seeds an empty thread.
|
||||||
- Each turn calls `callLLM` (fast/standard Claude tier — low latency matters for chat).
|
- Each turn calls `callLLM` (fast/standard Claude tier — low latency matters for chat).
|
||||||
|
- The chat header has a **clear** button (trash icon). It confirms, then wipes
|
||||||
|
`chat:thread:{userId}` and reseeds the greeting via `clearThread` in `useChat.js`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,13 +27,21 @@ client-side and is grounded by local TF-IDF retrieval — **no vector database**
|
|||||||
|
|
||||||
`buildKbContext` in `rag.js`:
|
`buildKbContext` in `rag.js`:
|
||||||
1. Build / reuse the TF-IDF index over `topics` (`src/lib/retrieval.js`).
|
1. Build / reuse the TF-IDF index over `topics` (`src/lib/retrieval.js`).
|
||||||
2. Retrieve the top **10** topics for the user's message.
|
2. Retrieve the top **10** topics for the user's message. Scoring is exact-token
|
||||||
|
TF-IDF **plus a compound-word fallback**: an unmatched query token (≥6 chars)
|
||||||
|
also matches a document term when they share a ≥6-char stem or one contains
|
||||||
|
the other, at a reduced weight. This recovers Dutch compounds — e.g. a
|
||||||
|
`pensioen` query matches `pensioenregeling` and `partnerpensioen`.
|
||||||
3. Always include topics whose `id` or `label` appears verbatim in the message.
|
3. Always include topics whose `id` or `label` appears verbatim in the message.
|
||||||
4. Include relations only when **both** endpoints are in the retrieved set.
|
4. Include relations only when **both** endpoints are in the retrieved set.
|
||||||
5. For explicitly mentioned topics, inject up to ~1200 chars of their generated
|
5. Inject up to ~1000 chars of generated content for up to **5** topics —
|
||||||
content.
|
verbatim-mentioned first, then the highest-ranked retrieved ones — so a query
|
||||||
|
that never names a topic exactly still gets rich content for what it matched.
|
||||||
6. Append a short KB hash so the cached context busts when topics change.
|
6. Append a short KB hash so the cached context busts when topics change.
|
||||||
|
|
||||||
|
If the summarised context is still too thin, R42 can call the `lookup_topic`
|
||||||
|
tool to pull a topic's full description and learning content on demand.
|
||||||
|
|
||||||
The system prompt (`prompts.js`) is assembled as cacheable blocks: a stable
|
The system prompt (`prompts.js`) is assembled as cacheable blocks: a stable
|
||||||
preamble (role, tasks, style, "answer only from the KB"), the KB context block, and
|
preamble (role, tasks, style, "answer only from the KB"), the KB context block, and
|
||||||
a per-turn tail with the user's name and admin/non-admin flag.
|
a per-turn tail with the user's name and admin/non-admin flag.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist', 'pb_migrations']),
|
globalIgnores(['dist', 'pb_migrations', 'pb_hooks']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx}'],
|
files: ['**/*.{js,jsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ networks:
|
|||||||
learning-platform:
|
learning-platform:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
learning-platform:
|
learning-platform:
|
||||||
image: git.labs.respellion.tech/respellion/learning-platform/learning-platform:latest
|
image: git.labs.respellion.tech/respellion/learning-platform/learning-platform:latest
|
||||||
@@ -18,11 +21,18 @@ services:
|
|||||||
image: ghcr.io/muchobien/pocketbase:latest
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
container_name: pocketbase-learning
|
container_name: pocketbase-learning
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data"]
|
working_dir: /pb
|
||||||
|
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
|
||||||
|
environment:
|
||||||
|
# Azure Entra ID (OIDC) — consumed by pb_migrations/1781000000 and
|
||||||
|
# pb_hooks/team_members.pb.js. Rendered into .env by the deploy playbook.
|
||||||
|
- ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
|
||||||
|
- ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
|
||||||
|
- ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET}
|
||||||
|
- ENTRA_ADMIN_EMAILS=${ENTRA_ADMIN_EMAILS}
|
||||||
volumes:
|
volumes:
|
||||||
- pb_data:/pb/pb_data
|
- pb_data:/pb/pb_data
|
||||||
|
- ./pb_migrations:/pb/pb_migrations
|
||||||
|
- ./pb_hooks:/pb/pb_hooks
|
||||||
networks:
|
networks:
|
||||||
- learning-platform
|
- learning-platform
|
||||||
|
|
||||||
volumes:
|
|
||||||
pb_data:
|
|
||||||
|
|||||||
@@ -36,11 +36,27 @@
|
|||||||
src: compose.yml
|
src: compose.yml
|
||||||
dest: "{{ dest_dir }}/compose.yml"
|
dest: "{{ dest_dir }}/compose.yml"
|
||||||
|
|
||||||
|
- name: Copy pb_migrations to destination
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: ../../../pb_migrations
|
||||||
|
dest: "{{ dest_dir }}/"
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Copy pb_hooks to destination
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: ../../../pb_hooks
|
||||||
|
dest: "{{ dest_dir }}/"
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
- name: Create .env file for secrets
|
- name: Create .env file for secrets
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: "{{ dest_dir }}/.env"
|
dest: "{{ dest_dir }}/.env"
|
||||||
content: |
|
content: |
|
||||||
ANTHROPIC_API_KEY={{ anthropic_api_key | default('') }}
|
ANTHROPIC_API_KEY={{ anthropic_api_key | default('') }}
|
||||||
|
ENTRA_TENANT_ID={{ entra_tenant_id | default('') }}
|
||||||
|
ENTRA_CLIENT_ID={{ entra_client_id | default('') }}
|
||||||
|
ENTRA_CLIENT_SECRET={{ entra_client_secret | default('') }}
|
||||||
|
ENTRA_ADMIN_EMAILS={{ entra_admin_emails | default('') }}
|
||||||
mode: '0600'
|
mode: '0600'
|
||||||
|
|
||||||
- name: Pull latest image
|
- name: Pull latest image
|
||||||
@@ -56,3 +72,81 @@
|
|||||||
state: present
|
state: present
|
||||||
files: compose.yml
|
files: compose.yml
|
||||||
recreate: always
|
recreate: always
|
||||||
|
|
||||||
|
# A failed migration aborts `pocketbase serve` and the container crash-loops
|
||||||
|
# while `docker compose up` still reports success (issue #18). Gate the
|
||||||
|
# deploy on PocketBase actually serving its health endpoint.
|
||||||
|
- name: Wait for PocketBase to become healthy
|
||||||
|
ansible.builtin.command: docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/health
|
||||||
|
register: pb_health
|
||||||
|
until: pb_health.rc == 0
|
||||||
|
retries: 18
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Collect PocketBase logs for diagnosis
|
||||||
|
ansible.builtin.command: docker logs --tail 80 pocketbase-learning
|
||||||
|
register: pb_logs
|
||||||
|
when: pb_health is failed
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Abort deploy — PocketBase is not healthy
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: |
|
||||||
|
PocketBase did not become healthy after the deploy (health endpoint unreachable).
|
||||||
|
Recent container logs:
|
||||||
|
{{ pb_logs.stdout | default('') }}
|
||||||
|
{{ pb_logs.stderr | default('') }}
|
||||||
|
when: pb_health is failed
|
||||||
|
|
||||||
|
# The frontend container carries the SPA + Caddy. An invalid Caddyfile
|
||||||
|
# crash-loops it at startup while the PocketBase gate stays green — that
|
||||||
|
# outage (issue #20) was invisible to CI because the test job swaps in
|
||||||
|
# Caddyfile.test. Gate on the real container actually serving.
|
||||||
|
- name: Wait for the frontend (Caddy) to become healthy
|
||||||
|
ansible.builtin.command: docker exec learning-platform wget -q --spider http://127.0.0.1:80/
|
||||||
|
register: fe_health
|
||||||
|
until: fe_health.rc == 0
|
||||||
|
retries: 18
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Collect frontend logs for diagnosis
|
||||||
|
ansible.builtin.command: docker logs --tail 80 learning-platform
|
||||||
|
register: fe_logs
|
||||||
|
when: fe_health is failed
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Abort deploy — frontend is not healthy
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: |
|
||||||
|
The frontend (Caddy) container did not become healthy after the deploy.
|
||||||
|
Recent container logs:
|
||||||
|
{{ fe_logs.stdout | default('') }}
|
||||||
|
{{ fe_logs.stderr | default('') }}
|
||||||
|
when: fe_health is failed
|
||||||
|
|
||||||
|
# Observability behind the auth perimeter: surface the end-to-end state
|
||||||
|
# in the CI log on every deploy.
|
||||||
|
- name: Post-deploy smoke report
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
cd /opt/learning-platform
|
||||||
|
echo '--- docker compose ps ---'
|
||||||
|
docker compose ps
|
||||||
|
echo '--- frontend -> pocketbase proxy health ---'
|
||||||
|
docker exec learning-platform wget -q -O - http://127.0.0.1:80/api/health || echo 'PROXY HEALTH FAILED'
|
||||||
|
echo '--- team_members auth methods (OIDC provider present?) ---'
|
||||||
|
docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/collections/team_members/auth-methods || echo 'AUTH-METHODS FAILED'
|
||||||
|
echo '--- pocketbase logs (tail 30) ---'
|
||||||
|
docker compose logs --tail=30 pocketbase-learning
|
||||||
|
register: smoke_report
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Show smoke report
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ smoke_report.stdout_lines }}"
|
||||||
|
|||||||
@@ -22,9 +22,17 @@ services:
|
|||||||
container_name: pocketbase-learning
|
container_name: pocketbase-learning
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /pb
|
working_dir: /pb
|
||||||
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]
|
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
|
||||||
|
environment:
|
||||||
|
# Azure Entra ID (OIDC) — consumed by pb_migrations/1781000000 and
|
||||||
|
# pb_hooks/team_members.pb.js. Rendered into .env by the deploy playbook.
|
||||||
|
- ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
|
||||||
|
- ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
|
||||||
|
- ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET}
|
||||||
|
- ENTRA_ADMIN_EMAILS=${ENTRA_ADMIN_EMAILS}
|
||||||
volumes:
|
volumes:
|
||||||
- pb_data:/pb/pb_data
|
- pb_data:/pb/pb_data
|
||||||
- ./pb_migrations:/pb/pb_migrations
|
- ./pb_migrations:/pb/pb_migrations
|
||||||
|
- ./pb_hooks:/pb/pb_hooks
|
||||||
networks:
|
networks:
|
||||||
- learning-platform
|
- learning-platform
|
||||||
|
|||||||
@@ -42,11 +42,21 @@
|
|||||||
dest: "{{ dest_dir }}/"
|
dest: "{{ dest_dir }}/"
|
||||||
mode: "0755"
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Copy pb_hooks to destination
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: ../../../pb_hooks
|
||||||
|
dest: "{{ dest_dir }}/"
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
- name: Create .env file for secrets
|
- name: Create .env file for secrets
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: "{{ dest_dir }}/.env"
|
dest: "{{ dest_dir }}/.env"
|
||||||
content: |
|
content: |
|
||||||
ANTHROPIC_API_KEY={{ anthropic_api_key | default('') }}
|
ANTHROPIC_API_KEY={{ anthropic_api_key | default('') }}
|
||||||
|
ENTRA_TENANT_ID={{ entra_tenant_id | default('') }}
|
||||||
|
ENTRA_CLIENT_ID={{ entra_client_id | default('') }}
|
||||||
|
ENTRA_CLIENT_SECRET={{ entra_client_secret | default('') }}
|
||||||
|
ENTRA_ADMIN_EMAILS={{ entra_admin_emails | default('') }}
|
||||||
mode: '0600'
|
mode: '0600'
|
||||||
|
|
||||||
- name: Pull latest image
|
- name: Pull latest image
|
||||||
@@ -62,3 +72,81 @@
|
|||||||
state: present
|
state: present
|
||||||
files: compose.yml
|
files: compose.yml
|
||||||
recreate: always
|
recreate: always
|
||||||
|
|
||||||
|
# A failed migration aborts `pocketbase serve` and the container crash-loops
|
||||||
|
# while `docker compose up` still reports success (issue #18). Gate the
|
||||||
|
# deploy on PocketBase actually serving its health endpoint.
|
||||||
|
- name: Wait for PocketBase to become healthy
|
||||||
|
ansible.builtin.command: docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/health
|
||||||
|
register: pb_health
|
||||||
|
until: pb_health.rc == 0
|
||||||
|
retries: 18
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Collect PocketBase logs for diagnosis
|
||||||
|
ansible.builtin.command: docker logs --tail 80 pocketbase-learning
|
||||||
|
register: pb_logs
|
||||||
|
when: pb_health is failed
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Abort deploy — PocketBase is not healthy
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: |
|
||||||
|
PocketBase did not become healthy after the deploy (health endpoint unreachable).
|
||||||
|
Recent container logs:
|
||||||
|
{{ pb_logs.stdout | default('') }}
|
||||||
|
{{ pb_logs.stderr | default('') }}
|
||||||
|
when: pb_health is failed
|
||||||
|
|
||||||
|
# The frontend container carries the SPA + Caddy. An invalid Caddyfile
|
||||||
|
# crash-loops it at startup while the PocketBase gate stays green — that
|
||||||
|
# outage (issue #20) was invisible to CI because the test job swaps in
|
||||||
|
# Caddyfile.test. Gate on the real container actually serving.
|
||||||
|
- name: Wait for the frontend (Caddy) to become healthy
|
||||||
|
ansible.builtin.command: docker exec learning-platform wget -q --spider http://127.0.0.1:80/
|
||||||
|
register: fe_health
|
||||||
|
until: fe_health.rc == 0
|
||||||
|
retries: 18
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Collect frontend logs for diagnosis
|
||||||
|
ansible.builtin.command: docker logs --tail 80 learning-platform
|
||||||
|
register: fe_logs
|
||||||
|
when: fe_health is failed
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Abort deploy — frontend is not healthy
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: |
|
||||||
|
The frontend (Caddy) container did not become healthy after the deploy.
|
||||||
|
Recent container logs:
|
||||||
|
{{ fe_logs.stdout | default('') }}
|
||||||
|
{{ fe_logs.stderr | default('') }}
|
||||||
|
when: fe_health is failed
|
||||||
|
|
||||||
|
# Observability behind the auth perimeter: surface the end-to-end state
|
||||||
|
# in the CI log on every deploy.
|
||||||
|
- name: Post-deploy smoke report
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
cd /opt/learning-platform
|
||||||
|
echo '--- docker compose ps ---'
|
||||||
|
docker compose ps
|
||||||
|
echo '--- frontend -> pocketbase proxy health ---'
|
||||||
|
docker exec learning-platform wget -q -O - http://127.0.0.1:80/api/health || echo 'PROXY HEALTH FAILED'
|
||||||
|
echo '--- team_members auth methods (OIDC provider present?) ---'
|
||||||
|
docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/collections/team_members/auth-methods || echo 'AUTH-METHODS FAILED'
|
||||||
|
echo '--- pocketbase logs (tail 30) ---'
|
||||||
|
docker compose logs --tail=30 pocketbase-learning
|
||||||
|
register: smoke_report
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Show smoke report
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ smoke_report.stdout_lines }}"
|
||||||
|
|||||||
118
knowledge-base/pensioenpremie-proforma-berekening.md
Normal file
118
knowledge-base/pensioenpremie-proforma-berekening.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# pensioenpremie berekenen en verwerken in een proforma-loonstrook
|
||||||
|
|
||||||
|
## doel
|
||||||
|
|
||||||
|
Handleiding voor het handmatig berekenen en verwerken van de pensioenpremie bij het
|
||||||
|
opstellen van een proforma-salarisspecificatie in NMBRS, wanneer de pensioengrondslag
|
||||||
|
niet automatisch op de proforma-berekening verschijnt.
|
||||||
|
|
||||||
|
## wanneer van toepassing
|
||||||
|
|
||||||
|
Bij het opstellen van een proforma-loonstrook (bijvoorbeeld ter voorbereiding van een
|
||||||
|
aanbod aan een kandidaat) toont NMBRS geen automatische pensioenpremie-regel. Deze moet
|
||||||
|
dan handmatig worden toegevoegd volgens onderstaande methode.
|
||||||
|
|
||||||
|
## benodigde gegevens vooraf
|
||||||
|
|
||||||
|
Voordat je begint moet je de volgende vier parameters hebben. Geen van deze staat op de
|
||||||
|
proforma zelf.
|
||||||
|
|
||||||
|
| parameter | waar te vinden |
|
||||||
|
| --- | --- |
|
||||||
|
| pensioenfranchise (Respellion / a.s.r. Doen Pensioen) | `pension-scheme-and-benefits.md` of het a.s.r.-polisblad; kan afwijken van het wettelijk minimum |
|
||||||
|
| verdeelsleutel werkgever/werknemer | `pension-scheme-and-benefits.md` of het a.s.r.-contract |
|
||||||
|
| definitie pensioengevend salaris | a.s.r.-polisvoorwaarden — bevestig welke looncomponenten meetellen (onkostenvergoedingen in elk geval niet) |
|
||||||
|
| fiscaal maximum pensioengevend salaris (aftoppingsgrens) | jaarlijkse publicatie belastingdienst/SZW, zie tabel hieronder |
|
||||||
|
|
||||||
|
Deze vier punten waren bij het opstellen van dit document nog niet allemaal bevestigd
|
||||||
|
voor Respellion specifiek — zie "openstaande vragen" onderaan.
|
||||||
|
|
||||||
|
## rekenstappen
|
||||||
|
|
||||||
|
1. **pensioengevend salaris (jaarbasis) bepalen**
|
||||||
|
Standaard: 12 × maandsalaris + vakantietoeslag (8%).
|
||||||
|
`pensioengevend salaris = 12 × bruto maandsalaris × 1,08`
|
||||||
|
Onkostenvergoedingen (reiskosten, telefoonvergoeding) tellen niet mee.
|
||||||
|
|
||||||
|
2. **aftoppen op het fiscaal maximum**
|
||||||
|
`gemaximeerd pensioengevend salaris = min(pensioengevend salaris, aftoppingsgrens)`
|
||||||
|
|
||||||
|
3. **franchise aftrekken**
|
||||||
|
`pensioengrondslag = gemaximeerd pensioengevend salaris − franchise`
|
||||||
|
|
||||||
|
4. **parttimefactor toepassen**
|
||||||
|
`pensioengrondslag (parttime) = pensioengrondslag × parttimefactor`
|
||||||
|
Parttimefactor = contracturen / 40 (of de fulltime-norm die Respellion hanteert).
|
||||||
|
|
||||||
|
5. **premie berekenen**
|
||||||
|
`jaarpremie = pensioengrondslag (parttime) × 5%`
|
||||||
|
`maandpremie = jaarpremie / 12`
|
||||||
|
|
||||||
|
6. **verdelen werkgever/werknemer**
|
||||||
|
`werknemersdeel = maandpremie × werknemerspercentage`
|
||||||
|
`werkgeversdeel = maandpremie − werknemersdeel`
|
||||||
|
|
||||||
|
## fiscale kernbedragen 2026
|
||||||
|
|
||||||
|
| bedrag | waarde 2026 | bron |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| aftoppingsgrens pensioengevend salaris | € 137.800 | belastingdienst/SZW, voorlopige bedragen 2026 |
|
||||||
|
| minimale AOW-franchise (Wtp-regelingen / beschikbare premieregelingen) | € 19.172 | belastingdienst/SZW, voorlopige bedragen 2026 |
|
||||||
|
|
||||||
|
Deze twee bedragen worden jaarlijks (eind december) opnieuw gepubliceerd. Controleer bij
|
||||||
|
elke proforma-berekening of de bedragen nog actueel zijn.
|
||||||
|
|
||||||
|
## verwerking in NMBRS — fiscale nuance
|
||||||
|
|
||||||
|
Het werknemersdeel van de pensioenpremie is een pre-tax inhouding:
|
||||||
|
|
||||||
|
- het verlaagt de grondslag voor de loonheffing (de kolom "Tabel" op de proforma-uitdraai)
|
||||||
|
- het verlaagt **niet** het SV-loon (de kolom "SVW" blijft ongewijzigd)
|
||||||
|
|
||||||
|
Trek het werknemersdeel dus niet rechtstreeks af van het bestaande netto-bedrag. Voer de
|
||||||
|
premie in met dezelfde paycode die in de reguliere (niet-proforma) loonrun voor
|
||||||
|
pensioenpremie werknemer/werkgever is ingericht — controleer de codeconfiguratie in
|
||||||
|
NMBRS (niet de bedragen) op de vlag "aftrekbaar voor loonheffing, niet voor SVW". Laat
|
||||||
|
NMBRS de loonheffing zelf herberekenen over de verlaagde grondslag; reken dit niet
|
||||||
|
handmatig na.
|
||||||
|
|
||||||
|
Het werkgeversdeel is een werkgeverslast (vergelijkbaar met de bestaande WGA/Aof-regels
|
||||||
|
op de proforma) en raakt het netto van de werknemer niet, wel de totale loonkosten.
|
||||||
|
|
||||||
|
## rekenvoorbeeld
|
||||||
|
|
||||||
|
Uitgangspunt: proforma fulltime (40 uur/week), bruto maandsalaris € 6.700, 100%
|
||||||
|
dienstverband.
|
||||||
|
|
||||||
|
1. Pensioengevend salaris (jaar): 12 × 6.700 × 1,08 = € 86.832
|
||||||
|
2. Aftopping: 86.832 < 137.800 → geen aftopping
|
||||||
|
3. Franchise (voorbeeld met het wettelijk minimum € 19.172 — niet bevestigd als
|
||||||
|
Respellion-waarde): 86.832 − 19.172 = € 67.660
|
||||||
|
4. Parttimefactor: 100% → geen aanpassing
|
||||||
|
5. Premie: 67.660 × 5% = € 3.383 per jaar = € 281,92 per maand
|
||||||
|
6. Verdeling (voorbeeld 50/50 — verdeelsleutel niet bevestigd): € 140,96 werknemer,
|
||||||
|
€ 140,96 werkgever
|
||||||
|
|
||||||
|
Dit rekenvoorbeeld gebruikt aannames voor franchise en verdeelsleutel die nog niet
|
||||||
|
bevestigd zijn voor Respellion — zie "openstaande vragen".
|
||||||
|
|
||||||
|
## openstaande vragen
|
||||||
|
|
||||||
|
- Exacte pensioenfranchise van de Respellion a.s.r. Doen Pensioen-regeling (mogelijk
|
||||||
|
hoger dan het wettelijk minimum van € 19.172).
|
||||||
|
- Verdeelsleutel werkgever/werknemer voor de 5% premie.
|
||||||
|
- Of vakantietoeslag standaard meetelt in het pensioengevend salaris volgens de
|
||||||
|
a.s.r.-polis, of dat er een afwijkende definitie geldt.
|
||||||
|
- Exacte NMBRS-paycode voor pensioenpremie werknemer/werkgever (te vinden in een
|
||||||
|
bestaande productierun of de paycode-lijst).
|
||||||
|
|
||||||
|
Zodra deze vier punten bevestigd zijn, kan dit document worden aangevuld met de
|
||||||
|
definitieve waarden in plaats van de wettelijke minimum-aannames.
|
||||||
|
|
||||||
|
## bronnen
|
||||||
|
|
||||||
|
- Proforma-salarisspecificatie Respellion B.V. (voorbeeldcasus, dit document)
|
||||||
|
- Belastingdienst/SZW: voorlopige bedragen AOW-franchise en maximum pensioengevend
|
||||||
|
salaris 2026
|
||||||
|
- `pension-scheme-and-benefits.md` (Respellion-handboek) — te raadplegen voor de
|
||||||
|
resterende openstaande vragen
|
||||||
62
pb_hooks/entra_oidc.pb.js
Normal file
62
pb_hooks/entra_oidc.pb.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #18 — Reconcile the Entra (Azure) OIDC provider from the environment.
|
||||||
|
//
|
||||||
|
// The team_members→auth migration (pb_migrations/1781000000) is structure-only
|
||||||
|
// and runs exactly once. Provider CONFIGURATION lives here instead, so that:
|
||||||
|
//
|
||||||
|
// * an environment whose migration applied while the ENTRA_* secrets were
|
||||||
|
// absent gets its provider enabled on the next startup (no re-apply),
|
||||||
|
// * rotating ENTRA_CLIENT_SECRET / changing the tenant only requires new env
|
||||||
|
// values and a container restart,
|
||||||
|
// * a fresh database gets its provider on the first cron tick right after
|
||||||
|
// the migrations created the collection (onBootstrap fires BEFORE the
|
||||||
|
// migrations run — there is no post-migration lifecycle hook in the JSVM,
|
||||||
|
// hence the cron fallback).
|
||||||
|
//
|
||||||
|
// The reconciler compares before saving, so both hooks are cheap no-ops when
|
||||||
|
// everything is already in sync.
|
||||||
|
|
||||||
|
onBootstrap((e) => {
|
||||||
|
e.next();
|
||||||
|
const { reconcileEntraOidc } = require(`${__hooks}/utils.js`);
|
||||||
|
// reconcileEntraOidc logs a warn-once itself when the ENTRA_* env is absent.
|
||||||
|
console.log("entra_oidc reconcile (bootstrap): " + reconcileEntraOidc(e.app));
|
||||||
|
});
|
||||||
|
|
||||||
|
cronAdd("entraOidcReconcile", "* * * * *", () => {
|
||||||
|
const { reconcileEntraOidc } = require(`${__hooks}/utils.js`);
|
||||||
|
const result = reconcileEntraOidc($app);
|
||||||
|
// Only log state changes — this tick runs every minute.
|
||||||
|
if (result === "updated") {
|
||||||
|
console.log("entra_oidc reconcile (cron): OIDC provider (re)configured from ENTRA_* env");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Entra does not always supply an `email` claim: the id_token only carries it
|
||||||
|
// when the account has a mail attribute (issue #24). The UPN in
|
||||||
|
// `preferred_username` is always present and equals the primary e-mail for
|
||||||
|
// Respellion organisation accounts, so fall back to it when it looks like a
|
||||||
|
// real address. Guest UPNs ("user_ext#EXT#@tenant...") fail the regex on
|
||||||
|
// purpose — those still get a clear validation error instead of a bogus email.
|
||||||
|
// The catch also surfaces the underlying OAuth2 failure in the container log,
|
||||||
|
// which PocketBase otherwise only writes to its internal logs database.
|
||||||
|
onRecordAuthWithOAuth2Request((e) => {
|
||||||
|
const u = e.oAuth2User;
|
||||||
|
if (u && !u.email) {
|
||||||
|
const upn = String((u.rawUser && u.rawUser.preferred_username) || u.username || "").trim().toLowerCase();
|
||||||
|
// `#` is RFC-valid in an e-mail local part, so guest UPNs
|
||||||
|
// ("ext_user#EXT#@tenant.onmicrosoft.com") pass a naive e-mail check AND
|
||||||
|
// PocketBase's own validation — exclude them explicitly.
|
||||||
|
if (/^[^@\s#]+@[^@\s#]+\.[^@\s#]+$/.test(upn)) {
|
||||||
|
u.email = upn;
|
||||||
|
console.log("entra_oidc: email claim absent — falling back to UPN " + upn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
e.next();
|
||||||
|
} catch (err) {
|
||||||
|
console.log("entra_oidc auth-with-oauth2 FAILED:", String(err));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, "team_members");
|
||||||
58
pb_hooks/team_members.pb.js
Normal file
58
pb_hooks/team_members.pb.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #16 — Auto-provisioning & role assignment for Azure (Entra ID) users.
|
||||||
|
//
|
||||||
|
// PocketBase auto-creates a `team_members` auth record on the first OIDC login
|
||||||
|
// (name is filled from the OIDC `name` claim via the collection's mappedFields).
|
||||||
|
// These hooks add the application-specific bits:
|
||||||
|
//
|
||||||
|
// * default `role` from an admin allow-list (env ENTRA_ADMIN_EMAILS),
|
||||||
|
// * default `enrollment_status = "not_started"` so new users go through the
|
||||||
|
// existing onboarding screen,
|
||||||
|
// * re-sync the admin role on every login so allow-list changes take effect
|
||||||
|
// without manual edits.
|
||||||
|
//
|
||||||
|
// The allow-list is a comma-separated list of e-mail addresses, e.g.:
|
||||||
|
// ENTRA_ADMIN_EMAILS=rve@respellion.nl,admin@respellion.nl
|
||||||
|
//
|
||||||
|
// resolveRole itself lives in pb_hooks/utils.js and is pulled in per-callback
|
||||||
|
// via require() — PocketBase's JSVM runs each hook callback below as its own
|
||||||
|
// isolated program, so a shared top-level function here would not be in scope
|
||||||
|
// at call time. See pb_hooks/utils.js for details. Keep the logic in sync with
|
||||||
|
// src/lib/azureAuth.js (resolveRole / parseAdminEmails), which carries the
|
||||||
|
// canonical, unit-tested version for the frontend/tests.
|
||||||
|
|
||||||
|
// On creation (first login): set defaults before the record is persisted.
|
||||||
|
onRecordCreate(function (e) {
|
||||||
|
const { resolveRole } = require(`${__hooks}/utils.js`);
|
||||||
|
const email = e.record.get("email") || "";
|
||||||
|
|
||||||
|
e.record.set("role", resolveRole(email));
|
||||||
|
|
||||||
|
if (!e.record.get("enrollment_status")) {
|
||||||
|
e.record.set("enrollment_status", "not_started");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback name when the IdP supplied no `name` claim.
|
||||||
|
if (!e.record.get("name")) {
|
||||||
|
e.record.set("name", email ? email.split("@")[0] : "Onbekend");
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next();
|
||||||
|
}, "team_members");
|
||||||
|
|
||||||
|
// On every successful auth (incl. OIDC): guarantee the admin role for
|
||||||
|
// allow-list members. ESCALATE-ONLY (#27): the allow-list grants admin, it no
|
||||||
|
// longer demotes — otherwise every role promotion made through TeamManager
|
||||||
|
// would be reverted on the member's next login. Demotion runs through
|
||||||
|
// TeamManager; allow-list members cannot be demoted (the list wins on their
|
||||||
|
// next login).
|
||||||
|
onRecordAuthRequest(function (e) {
|
||||||
|
const { resolveRole } = require(`${__hooks}/utils.js`);
|
||||||
|
const email = e.record.get("email") || "";
|
||||||
|
if (resolveRole(email) === "admin" && e.record.get("role") !== "admin") {
|
||||||
|
e.record.set("role", "admin");
|
||||||
|
e.app.save(e.record);
|
||||||
|
}
|
||||||
|
e.next();
|
||||||
|
}, "team_members");
|
||||||
109
pb_hooks/utils.js
Normal file
109
pb_hooks/utils.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Shared helpers for pb_hooks/*.pb.js callbacks.
|
||||||
|
//
|
||||||
|
// PocketBase's JSVM serializes and runs each registered hook callback as its
|
||||||
|
// own isolated program, so a plain top-level function declared in a *.pb.js
|
||||||
|
// file is NOT in scope inside a callback at call time. Reusable helpers must
|
||||||
|
// instead live in a plain (non *.pb.js, so it isn't auto-loaded as a hook)
|
||||||
|
// module and be pulled in per-callback via require(`${__hooks}/utils.js`).
|
||||||
|
// See: https://github.com/pocketbase/pocketbase/discussions/3599
|
||||||
|
//
|
||||||
|
// Loaded modules use a shared registry across callback invocations, so keep
|
||||||
|
// this file stateless. (Deliberate exception: the warn-once latch below, which
|
||||||
|
// exists precisely BECAUSE the registry is shared — it dedupes the env-missing
|
||||||
|
// warning across the bootstrap hook and the per-minute cron tick.)
|
||||||
|
//
|
||||||
|
// Keep resolveRole in sync with src/lib/azureAuth.js's resolveRole (the
|
||||||
|
// canonical, unit-tested version used by the frontend).
|
||||||
|
|
||||||
|
let warnedEnvMissing = false;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
* Resolve a user's role from their e-mail and the ENTRA_ADMIN_EMAILS allow-list.
|
||||||
|
* @param {string} email
|
||||||
|
* @returns {"admin"|"user"}
|
||||||
|
*/
|
||||||
|
resolveRole: function (email) {
|
||||||
|
const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase();
|
||||||
|
const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
||||||
|
return allow.indexOf(String(email || "").toLowerCase()) !== -1 ? "admin" : "user";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the Entra (Azure) OIDC provider on the team_members auth
|
||||||
|
* collection from the ENTRA_* environment variables (issue #18).
|
||||||
|
*
|
||||||
|
* Idempotent: compares the desired provider config against the stored one
|
||||||
|
* and only saves when something actually changed, so it is safe to call on
|
||||||
|
* every bootstrap and cron tick. Keeping this OUT of the one-shot migration
|
||||||
|
* means late or rotated secrets are picked up on the next start/tick without
|
||||||
|
* any manual re-apply.
|
||||||
|
*
|
||||||
|
* @param {core.App} app
|
||||||
|
* @returns {"collection-missing"|"not-auth-yet"|"env-missing"|"in-sync"|"updated"}
|
||||||
|
*/
|
||||||
|
reconcileEntraOidc: function (app) {
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
return "collection-missing"; // migration has not created it yet
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
return "not-auth-yet"; // pre-conversion PIN-era collection
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
|
||||||
|
const clientId = $os.getenv("ENTRA_CLIENT_ID") || "";
|
||||||
|
const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET") || "";
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
if (!warnedEnvMissing) {
|
||||||
|
warnedEnvMissing = true;
|
||||||
|
console.log(
|
||||||
|
"WARN entra_oidc: ENTRA_CLIENT_ID / ENTRA_CLIENT_SECRET not set — Microsoft login stays " +
|
||||||
|
"disabled until the env vars are provided (they are reconciled automatically on startup)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "env-missing";
|
||||||
|
}
|
||||||
|
warnedEnvMissing = false; // env restored — re-arm the warning for future rotations
|
||||||
|
|
||||||
|
const desired = {
|
||||||
|
name: "oidc",
|
||||||
|
clientId: clientId,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
|
||||||
|
tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
|
||||||
|
// Deliberately empty: claims are read from the Entra id_token instead of
|
||||||
|
// Graph's /oidc/userinfo. The userinfo endpoint omits `email` for
|
||||||
|
// accounts without a mail attribute, while the id_token always carries
|
||||||
|
// `preferred_username` (UPN) which entra_oidc.pb.js uses as an e-mail
|
||||||
|
// fallback (issue #24).
|
||||||
|
userInfoURL: "",
|
||||||
|
displayName: "Microsoft",
|
||||||
|
pkce: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const providers = (col.oauth2 && col.oauth2.providers) || [];
|
||||||
|
const current = providers.length === 1 ? providers[0] : null;
|
||||||
|
const inSync = !!current &&
|
||||||
|
col.oauth2.enabled === true &&
|
||||||
|
current.name === desired.name &&
|
||||||
|
current.clientId === desired.clientId &&
|
||||||
|
current.clientSecret === desired.clientSecret &&
|
||||||
|
current.authURL === desired.authURL &&
|
||||||
|
current.tokenURL === desired.tokenURL &&
|
||||||
|
current.userInfoURL === desired.userInfoURL &&
|
||||||
|
current.displayName === desired.displayName;
|
||||||
|
if (inSync) {
|
||||||
|
return "in-sync";
|
||||||
|
}
|
||||||
|
|
||||||
|
col.oauth2.enabled = true;
|
||||||
|
col.oauth2.providers = [desired];
|
||||||
|
app.save(col);
|
||||||
|
return "updated";
|
||||||
|
},
|
||||||
|
};
|
||||||
138
pb_migrations/1000000000_baseline_ledger_sync.js
Normal file
138
pb_migrations/1000000000_baseline_ledger_sync.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #18 — Baseline ledger sync for out-of-band provisioned databases.
|
||||||
|
//
|
||||||
|
// The Labs/production PocketBase originally ran WITHOUT --migrationsDir: its
|
||||||
|
// schema was created by scripts/setup-pb-collections.mjs / the admin UI, so its
|
||||||
|
// _migrations ledger is empty. When the migrations dir was mounted for the
|
||||||
|
// first time (Azure SSO, PR #17), PocketBase tried to replay the entire
|
||||||
|
// migration history against that existing schema and crash-looped on the very
|
||||||
|
// first file ("Collection name must be unique").
|
||||||
|
//
|
||||||
|
// This migration sorts before every other file. When it detects that the
|
||||||
|
// legacy schema already exists but the ledger has never tracked it, it marks
|
||||||
|
// the historical migrations below as applied so the replay is skipped and the
|
||||||
|
// chain continues with the genuinely new migrations (team_members_to_auth,
|
||||||
|
// tighten_api_rules). On fresh databases and on databases that already have a
|
||||||
|
// populated ledger it is a no-op.
|
||||||
|
//
|
||||||
|
// NOTE for future agents: never rename a migration file after it has been
|
||||||
|
// applied anywhere — the ledger is filename-based. New migrations must sort
|
||||||
|
// after 1781100001 and be appended to environments through a normal deploy.
|
||||||
|
const HISTORICAL_MIGRATIONS = [
|
||||||
|
"1778948471_created_content.js",
|
||||||
|
"1778948471_created_quiz_banks.js",
|
||||||
|
"1778948471_created_relations.js",
|
||||||
|
"1778948471_created_sources.js",
|
||||||
|
"1778948471_created_team_members.js",
|
||||||
|
"1778948471_created_topics.js",
|
||||||
|
"1778948472_created_leaderboard.js",
|
||||||
|
"1778948472_created_learn_progress.js",
|
||||||
|
"1778948472_created_quiz_cache.js",
|
||||||
|
"1778948472_created_quiz_results.js",
|
||||||
|
"1778948472_created_settings.js",
|
||||||
|
"1778954289_created_test_col2.js",
|
||||||
|
"1778954310_deleted_relations.js",
|
||||||
|
"1778954310_deleted_topics.js",
|
||||||
|
"1778954310_deleted_users.js",
|
||||||
|
"1778954311_deleted_content.js",
|
||||||
|
"1778954311_deleted_leaderboard.js",
|
||||||
|
"1778954311_deleted_learn_progress.js",
|
||||||
|
"1778954311_deleted_quiz_banks.js",
|
||||||
|
"1778954311_deleted_quiz_cache.js",
|
||||||
|
"1778954311_deleted_quiz_results.js",
|
||||||
|
"1778954311_deleted_settings.js",
|
||||||
|
"1778954311_deleted_sources.js",
|
||||||
|
"1778954311_deleted_team_members.js",
|
||||||
|
"1778954311_deleted_test_col2.js",
|
||||||
|
"1778954317_created_content.js",
|
||||||
|
"1778954317_created_leaderboard.js",
|
||||||
|
"1778954317_created_learn_progress.js",
|
||||||
|
"1778954317_created_quiz_banks.js",
|
||||||
|
"1778954317_created_quiz_cache.js",
|
||||||
|
"1778954317_created_quiz_results.js",
|
||||||
|
"1778954317_created_relations.js",
|
||||||
|
"1778954317_created_settings.js",
|
||||||
|
"1778954317_created_sources.js",
|
||||||
|
"1778954317_created_team_members.js",
|
||||||
|
"1778954317_created_topics.js",
|
||||||
|
"1779005271_created_test_col3.js",
|
||||||
|
"1779005289_created_test_col4.js",
|
||||||
|
"1779005309_deleted_content.js",
|
||||||
|
"1779005309_deleted_learn_progress.js",
|
||||||
|
"1779005309_deleted_quiz_banks.js",
|
||||||
|
"1779005309_deleted_quiz_cache.js",
|
||||||
|
"1779005309_deleted_quiz_results.js",
|
||||||
|
"1779005309_deleted_relations.js",
|
||||||
|
"1779005309_deleted_sources.js",
|
||||||
|
"1779005309_deleted_team_members.js",
|
||||||
|
"1779005309_deleted_topics.js",
|
||||||
|
"1779005310_deleted_leaderboard.js",
|
||||||
|
"1779005310_deleted_settings.js",
|
||||||
|
"1779005310_deleted_test_col3.js",
|
||||||
|
"1779005310_deleted_test_col4.js",
|
||||||
|
"1779005316_created_content.js",
|
||||||
|
"1779005316_created_quiz_banks.js",
|
||||||
|
"1779005316_created_relations.js",
|
||||||
|
"1779005316_created_sources.js",
|
||||||
|
"1779005316_created_topics.js",
|
||||||
|
"1779005317_created_leaderboard.js",
|
||||||
|
"1779005317_created_learn_progress.js",
|
||||||
|
"1779005317_created_quiz_cache.js",
|
||||||
|
"1779005317_created_quiz_results.js",
|
||||||
|
"1779005317_created_settings.js",
|
||||||
|
"1779005317_created_team_members.js",
|
||||||
|
"1779127586_created_curriculum.js",
|
||||||
|
"1779127759_collections_snapshot.js",
|
||||||
|
"1779200000_updated_sources.js",
|
||||||
|
"1780000001_updated_topics.js",
|
||||||
|
"1780500000_updated_topics_relevance_locked.js",
|
||||||
|
"1780500001_normalize_relation_types.js",
|
||||||
|
"1780500002_created_llm_calls.js",
|
||||||
|
"1780600000_curriculum_v2.js",
|
||||||
|
"1780700000_sources_progress.js",
|
||||||
|
"1780800000_created_micro_learnings.js",
|
||||||
|
"1780800001_deleted_legacy_collections.js",
|
||||||
|
"1780800002_update_micro_learnings_rules.js",
|
||||||
|
"1780900000_team_members_enrollment.js",
|
||||||
|
"1780900001_created_test_results.js",
|
||||||
|
"1781000000_created_theme_sessions.js",
|
||||||
|
"1781100000_created_question_bank.js",
|
||||||
|
"1781100001_created_on_demand_attempts.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
// Fresh database? (no legacy schema) -> let the normal chain run.
|
||||||
|
try {
|
||||||
|
app.findCollectionByNameOrId("content");
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ledger already tracks the history? -> normally migrated DB, nothing to do.
|
||||||
|
const probe = new DynamicModel({ count: 0 });
|
||||||
|
app.db()
|
||||||
|
.newQuery("SELECT COUNT(*) AS count FROM _migrations WHERE file = {:file}")
|
||||||
|
.bind({ file: "1778948471_created_content.js" })
|
||||||
|
.one(probe);
|
||||||
|
if (probe.count > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out-of-band provisioned database: schema exists, ledger is empty.
|
||||||
|
// Mark the historical migrations as applied so they are not replayed.
|
||||||
|
const applied = Date.now();
|
||||||
|
for (const file of HISTORICAL_MIGRATIONS) {
|
||||||
|
app.db()
|
||||||
|
.newQuery("INSERT OR IGNORE INTO _migrations (file, applied) VALUES ({:file}, {:applied})")
|
||||||
|
.bind({ file: file, applied: applied })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"baseline_ledger_sync: detected out-of-band provisioned database — marked " +
|
||||||
|
HISTORICAL_MIGRATIONS.length + " historical migrations as applied"
|
||||||
|
);
|
||||||
|
}, (_app) => {
|
||||||
|
// Down: intentionally a no-op. The ledger rows describe environment-specific
|
||||||
|
// bookkeeping; removing them would re-trigger the replay this fix prevents.
|
||||||
|
});
|
||||||
359
pb_migrations/1781000000_team_members_to_auth.js
Normal file
359
pb_migrations/1781000000_team_members_to_auth.js
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #16 / #18 — Azure (Entra ID) login.
|
||||||
|
//
|
||||||
|
// Replaces the PIN-based `base` collection `team_members` with a PocketBase
|
||||||
|
// `auth` collection that authenticates against Azure Entra ID via OIDC.
|
||||||
|
//
|
||||||
|
// The existing PIN-based team_members are intentionally dropped (sign-off from
|
||||||
|
// the product owner — no migration of current users required). The knowledge
|
||||||
|
// base, generated tests and micro-learnings live in OTHER collections and are
|
||||||
|
// left completely untouched by this migration.
|
||||||
|
//
|
||||||
|
// This migration is STRUCTURE ONLY and does not depend on the environment:
|
||||||
|
// the OIDC provider credentials (ENTRA_TENANT_ID / ENTRA_CLIENT_ID /
|
||||||
|
// ENTRA_CLIENT_SECRET) are reconciled into the collection on every startup by
|
||||||
|
// pb_hooks/entra_oidc.pb.js. That keeps the one-shot migration deterministic:
|
||||||
|
// applying it while the secrets are absent can no longer permanently disable
|
||||||
|
// OAuth2 (issue #18). As a fast path the provider is also attached here when
|
||||||
|
// the env vars are already present at apply time.
|
||||||
|
migrate((app) => {
|
||||||
|
// Idempotency guard: if team_members is already an auth collection (e.g. the
|
||||||
|
// conversion happened through an earlier partial rollout), leave it alone.
|
||||||
|
let existing = null;
|
||||||
|
try {
|
||||||
|
existing = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
existing = null; // collection absent — nothing to convert, only to create
|
||||||
|
}
|
||||||
|
if (existing && existing.type === "auth") {
|
||||||
|
console.log("team_members_to_auth: team_members is already an auth collection — skipping conversion.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Detach relation fields that point at team_members. PocketBase refuses
|
||||||
|
// to delete a collection that other collections relate to, so before we
|
||||||
|
// can drop the old PIN-based team_members we convert those relations into
|
||||||
|
// plain text fields (the id is stored as text — consistent with how
|
||||||
|
// user_id is stored in test_results / on_demand_attempts). The column
|
||||||
|
// values are preserved by the conversion.
|
||||||
|
const deps = [
|
||||||
|
{ name: "micro_learning_completions", relId: "rel_team_member_id", textId: "txt_mlc_team_member" },
|
||||||
|
{ name: "theme_session_completions", relId: "rel_tsc_team_member", textId: "txt_tsc_team_member" },
|
||||||
|
];
|
||||||
|
for (const dep of deps) {
|
||||||
|
let c = null;
|
||||||
|
try {
|
||||||
|
c = app.findCollectionByNameOrId(dep.name);
|
||||||
|
} catch (_) {
|
||||||
|
console.log("team_members_to_auth: " + dep.name + " does not exist yet — nothing to detach.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!c.fields.getById(dep.relId)) {
|
||||||
|
console.log("team_members_to_auth: " + dep.name + "." + dep.relId + " already detached — skipping.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// PocketBase diffs fields by ID, so swapping the relation field for a text
|
||||||
|
// field drops and recreates the underlying column. Back the values up and
|
||||||
|
// restore them after the schema save — the ids must survive (issue #18).
|
||||||
|
const backup = "_issue18_tm_" + dep.name;
|
||||||
|
app.db().newQuery("DROP TABLE IF EXISTS `" + backup + "`").execute();
|
||||||
|
app.db().newQuery(
|
||||||
|
"CREATE TABLE `" + backup + "` AS SELECT `id`, `team_member_id` AS `v` FROM `" + dep.name + "`"
|
||||||
|
).execute();
|
||||||
|
|
||||||
|
// Drop any index that references the team_member_id column (it is rebuilt
|
||||||
|
// on the text column below).
|
||||||
|
c.indexes = (c.indexes || []).filter((idx) => idx.indexOf("team_member_id") === -1);
|
||||||
|
// Replace the relation field with a text field of the same name.
|
||||||
|
c.fields.removeById(dep.relId);
|
||||||
|
c.fields.add(new Field({
|
||||||
|
type: "text",
|
||||||
|
id: dep.textId,
|
||||||
|
name: "team_member_id",
|
||||||
|
required: false,
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
}));
|
||||||
|
app.save(c);
|
||||||
|
|
||||||
|
app.db().newQuery(
|
||||||
|
"UPDATE `" + dep.name + "` SET `team_member_id` = COALESCE(" +
|
||||||
|
"(SELECT `v` FROM `" + backup + "` WHERE `" + backup + "`.`id` = `" + dep.name + "`.`id`), '')"
|
||||||
|
).execute();
|
||||||
|
app.db().newQuery("DROP TABLE `" + backup + "`").execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recreate the unique (team_member_id, session_week) guard on the text column.
|
||||||
|
try {
|
||||||
|
const tsc = app.findCollectionByNameOrId("theme_session_completions");
|
||||||
|
const idx = "CREATE UNIQUE INDEX `idx_theme_session_completions_user_week` ON `theme_session_completions` (`team_member_id`, `session_week`)";
|
||||||
|
if ((tsc.indexes || []).indexOf(idx) === -1) {
|
||||||
|
tsc.indexes.push(idx);
|
||||||
|
app.save(tsc);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
console.log("team_members_to_auth: theme_session_completions does not exist yet — no index to rebuild.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Drop the old PIN-based team_members (now unreferenced). NOT wrapped in
|
||||||
|
// a try/catch: if this fails there is an unknown relation left and the
|
||||||
|
// migration must fail loudly instead of masking the error (issue #18).
|
||||||
|
if (existing) {
|
||||||
|
app.delete(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: attach the OIDC provider right away when the credentials are
|
||||||
|
// already present at apply time. When they are absent the collection is
|
||||||
|
// created without a provider and pb_hooks/entra_oidc.pb.js configures it on
|
||||||
|
// the next startup / cron tick once the ENTRA_* env vars are supplied.
|
||||||
|
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
|
||||||
|
const clientId = $os.getenv("ENTRA_CLIENT_ID") || "";
|
||||||
|
const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET") || "";
|
||||||
|
const oidcProviders = (clientId && clientSecret) ? [
|
||||||
|
{
|
||||||
|
name: "oidc",
|
||||||
|
clientId: clientId,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
|
||||||
|
tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
|
||||||
|
// Empty on purpose: claims come from the id_token (see utils.js, issue #24).
|
||||||
|
userInfoURL: "",
|
||||||
|
displayName: "Microsoft",
|
||||||
|
pkce: true,
|
||||||
|
},
|
||||||
|
] : [];
|
||||||
|
if (oidcProviders.length === 0) {
|
||||||
|
console.log(
|
||||||
|
"team_members_to_auth: ENTRA_CLIENT_ID / ENTRA_CLIENT_SECRET not set — creating the auth " +
|
||||||
|
"collection without an OIDC provider. pb_hooks/entra_oidc.pb.js will configure the provider " +
|
||||||
|
"automatically once the env vars are present (no re-apply needed)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Recreate `team_members` as an auth collection (same name → all existing
|
||||||
|
// `user_id` references in test_results, on_demand_attempts,
|
||||||
|
// micro_learning_completions, leaderboard, theme_session_completions keep
|
||||||
|
// working unchanged).
|
||||||
|
const collection = new Collection({
|
||||||
|
type: "auth",
|
||||||
|
name: "team_members",
|
||||||
|
system: false,
|
||||||
|
|
||||||
|
// Access rules: every legitimate user is authenticated (Azure-gated +
|
||||||
|
// OIDC). Profiles are readable by any authenticated user (leaderboard,
|
||||||
|
// dashboard); a user may only update their own record (onboarding flips
|
||||||
|
// enrollment_status). Record creation is ONLY allowed from within the
|
||||||
|
// OAuth2 sign-up flow: PocketBase applies the createRule to the automatic
|
||||||
|
// record creation on a first OIDC login, so `null` would make every first
|
||||||
|
// login fail with 403 "Only superusers can perform this action" (issue
|
||||||
|
// #22). Plain REST creates keep being rejected for non-superusers.
|
||||||
|
listRule: '@request.auth.id != ""',
|
||||||
|
viewRule: '@request.auth.id != ""',
|
||||||
|
createRule: '@request.context = "oauth2"',
|
||||||
|
// Self-update may not touch `role` (closes self-service privilege
|
||||||
|
// escalation); admins manage the roster incl. roles and deletion (#27).
|
||||||
|
updateRule: '(@request.auth.id = id && @request.body.role:isset = false) || @request.auth.role = "admin"',
|
||||||
|
deleteRule: '@request.auth.role = "admin"',
|
||||||
|
authRule: "",
|
||||||
|
manageRule: null,
|
||||||
|
|
||||||
|
// Disable password / OTP / MFA — login is exclusively via Entra OIDC.
|
||||||
|
passwordAuth: { enabled: false, identityFields: ["email"] },
|
||||||
|
otp: { enabled: false, duration: 180, length: 8 },
|
||||||
|
mfa: { enabled: false, duration: 600, rule: "" },
|
||||||
|
|
||||||
|
oauth2: {
|
||||||
|
enabled: oidcProviders.length > 0,
|
||||||
|
// Map the OIDC userinfo claims onto our fields.
|
||||||
|
mappedFields: {
|
||||||
|
id: "",
|
||||||
|
name: "name",
|
||||||
|
username: "",
|
||||||
|
avatarURL: "",
|
||||||
|
},
|
||||||
|
providers: oidcProviders,
|
||||||
|
},
|
||||||
|
|
||||||
|
fields: [
|
||||||
|
// ── System auth fields ──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cost": 0,
|
||||||
|
"hidden": true,
|
||||||
|
"id": "password901924565",
|
||||||
|
"max": 0,
|
||||||
|
"min": 8,
|
||||||
|
"name": "password",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-zA-Z0-9]{50}",
|
||||||
|
"hidden": true,
|
||||||
|
"id": "text2504183744",
|
||||||
|
"max": 60,
|
||||||
|
"min": 30,
|
||||||
|
"name": "tokenKey",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exceptDomains": null,
|
||||||
|
"hidden": false,
|
||||||
|
"id": "email3885137012",
|
||||||
|
"name": "email",
|
||||||
|
"onlyDomains": null,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool1547992806",
|
||||||
|
"name": "emailVisibility",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool256245529",
|
||||||
|
"name": "verified",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
// ── Application fields ───────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 255,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1466534506",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "role",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "date_curriculum_started_at",
|
||||||
|
"name": "curriculum_started_at",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_enrollment_status",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "enrollment_status",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX `idx_tokenKey_team_members` ON `team_members` (`tokenKey`)",
|
||||||
|
"CREATE UNIQUE INDEX `idx_email_team_members` ON `team_members` (`email`) WHERE `email` != ''"
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
// Down: drop the auth collection and recreate the original PIN-based base
|
||||||
|
// collection (without data — the original records are gone).
|
||||||
|
try {
|
||||||
|
const c = app.findCollectionByNameOrId("team_members");
|
||||||
|
app.delete(c);
|
||||||
|
} catch (_) { /* already gone */ }
|
||||||
|
|
||||||
|
const base = new Collection({
|
||||||
|
type: "base",
|
||||||
|
name: "team_members",
|
||||||
|
listRule: "",
|
||||||
|
viewRule: "",
|
||||||
|
createRule: "",
|
||||||
|
updateRule: "",
|
||||||
|
deleteRule: "",
|
||||||
|
fields: [
|
||||||
|
{ "type": "text", "name": "id", "system": true, "primaryKey": true, "required": true,
|
||||||
|
"min": 15, "max": 15, "pattern": "^[a-z0-9]+$", "autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"id": "text3208210256" },
|
||||||
|
{ "type": "text", "name": "name", "required": true, "min": 0, "max": 0, "id": "text1579384326" },
|
||||||
|
{ "type": "text", "name": "pin", "required": false, "min": 0, "max": 0, "id": "text3045404147" },
|
||||||
|
{ "type": "text", "name": "role", "required": false, "min": 0, "max": 0, "id": "text1466534506" },
|
||||||
|
{ "type": "date", "name": "curriculum_started_at", "required": false, "id": "date_curriculum_started_at" },
|
||||||
|
{ "type": "text", "name": "enrollment_status", "required": false, "min": 0, "max": 0, "id": "text_enrollment_status" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
app.save(base);
|
||||||
|
});
|
||||||
57
pb_migrations/1781000001_tighten_api_rules.js
Normal file
57
pb_migrations/1781000001_tighten_api_rules.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #16 — Lock down API rules now that authentication is real.
|
||||||
|
//
|
||||||
|
// Before Azure login the app had no real auth, so every collection rule was
|
||||||
|
// "" (public). With mandatory Entra OIDC login, every legitimate caller is
|
||||||
|
// authenticated, so we require `@request.auth.id != ""` on all non-system,
|
||||||
|
// non-`team_members` collections.
|
||||||
|
//
|
||||||
|
// This intentionally does NOT delete or restructure any data — it only changes
|
||||||
|
// access rules. The knowledge base, generated tests and micro-learnings stay
|
||||||
|
// exactly as they are; they simply become unreadable to anonymous callers.
|
||||||
|
//
|
||||||
|
// `team_members` is configured by its own migration (1781000000) and skipped
|
||||||
|
// here. System collections (`_superusers`, `_mfas`, `_otps`, …) are skipped.
|
||||||
|
//
|
||||||
|
// NOTE (follow-up): for least-privilege we could further restrict write/delete
|
||||||
|
// on the knowledge-authoring collections (topics, relations, content, sources,
|
||||||
|
// question_bank, curriculum_versions) to `@request.auth.role = "admin"`. That
|
||||||
|
// is deferred because micro_learnings / theme_sessions are written by regular
|
||||||
|
// users (generate-then-cache) and the full matrix needs runtime verification.
|
||||||
|
// The trust boundary today is: perimeter Azure gate + authenticated employees.
|
||||||
|
const AUTHED = '@request.auth.id != ""';
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
const collections = app.findAllCollections();
|
||||||
|
for (const c of collections) {
|
||||||
|
if (c.system) continue;
|
||||||
|
if (c.name === "team_members") continue;
|
||||||
|
|
||||||
|
c.viewRule = AUTHED;
|
||||||
|
c.listRule = AUTHED;
|
||||||
|
// View collections only support list/view rules.
|
||||||
|
if (c.type !== "view") {
|
||||||
|
c.createRule = AUTHED;
|
||||||
|
c.updateRule = AUTHED;
|
||||||
|
c.deleteRule = AUTHED;
|
||||||
|
}
|
||||||
|
app.save(c);
|
||||||
|
}
|
||||||
|
}, (app) => {
|
||||||
|
// Down: restore fully public rules (the pre-Azure baseline).
|
||||||
|
const collections = app.findAllCollections();
|
||||||
|
for (const c of collections) {
|
||||||
|
if (c.system) continue;
|
||||||
|
if (c.name === "team_members") continue;
|
||||||
|
|
||||||
|
c.viewRule = "";
|
||||||
|
c.listRule = "";
|
||||||
|
if (c.type !== "view") {
|
||||||
|
c.createRule = "";
|
||||||
|
c.updateRule = "";
|
||||||
|
c.deleteRule = "";
|
||||||
|
}
|
||||||
|
app.save(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
46
pb_migrations/1781000002_allow_oauth2_signup.js
Normal file
46
pb_migrations/1781000002_allow_oauth2_signup.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #22 — Allow OAuth2 sign-up on team_members.
|
||||||
|
//
|
||||||
|
// The collection was created with `createRule: null` on the assumption that
|
||||||
|
// the OAuth2 flow bypasses it. It does not: PocketBase applies the createRule
|
||||||
|
// to the automatic record creation on a first OIDC login, so every first
|
||||||
|
// login failed with 403 "Only superusers can perform this action". Since the
|
||||||
|
// pre-Azure records were intentionally dropped, EVERY user was a first login
|
||||||
|
// and nobody could sign in.
|
||||||
|
//
|
||||||
|
// `@request.context = "oauth2"` scopes record creation to the OAuth2 flow
|
||||||
|
// only — plain REST creates (e.g. anonymous POST /records, which could
|
||||||
|
// otherwise pre-seed rogue admin profiles) keep being rejected.
|
||||||
|
//
|
||||||
|
// Environments that have not applied 1781000000 yet get this rule directly
|
||||||
|
// from that (updated) migration; this follow-up exists for databases where it
|
||||||
|
// already ran with the old `null` rule (Labs). Applying it twice is harmless.
|
||||||
|
migrate((app) => {
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
console.log("allow_oauth2_signup: team_members does not exist — skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
console.log("allow_oauth2_signup: team_members is not an auth collection — skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
col.createRule = '@request.context = "oauth2"';
|
||||||
|
app.save(col);
|
||||||
|
}, (app) => {
|
||||||
|
// Down: restore the (broken) superuser-only rule.
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
col.createRule = null;
|
||||||
|
app.save(col);
|
||||||
|
});
|
||||||
52
pb_migrations/1781000003_team_members_admin_rules.js
Normal file
52
pb_migrations/1781000003_team_members_admin_rules.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #27 — TeamManager roster management + close role self-escalation.
|
||||||
|
//
|
||||||
|
// The collection shipped with `updateRule: '@request.auth.id = id'` and
|
||||||
|
// `deleteRule: null`. That broke the TeamManager admin panel (admins could
|
||||||
|
// not change roles or remove members) AND left a privilege escalation open:
|
||||||
|
// the self-update rule did not restrict fields, so any authenticated user
|
||||||
|
// could PATCH their own `role` to "admin".
|
||||||
|
//
|
||||||
|
// New rules:
|
||||||
|
// update — a user may update their own record but NOT the `role` field
|
||||||
|
// (onboarding only touches enrollment fields); admins may update
|
||||||
|
// anyone, including `role`.
|
||||||
|
// delete — admins only.
|
||||||
|
//
|
||||||
|
// Environments that have not applied 1781000000 yet get these rules directly
|
||||||
|
// from that (updated) migration; this follow-up exists for databases where it
|
||||||
|
// already ran (Labs). Applying both is harmless (same values).
|
||||||
|
const UPDATE_RULE = '(@request.auth.id = id && @request.body.role:isset = false) || @request.auth.role = "admin"';
|
||||||
|
const DELETE_RULE = '@request.auth.role = "admin"';
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
console.log("team_members_admin_rules: team_members does not exist — skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
console.log("team_members_admin_rules: team_members is not an auth collection — skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
col.updateRule = UPDATE_RULE;
|
||||||
|
col.deleteRule = DELETE_RULE;
|
||||||
|
app.save(col);
|
||||||
|
}, (app) => {
|
||||||
|
// Down: restore the previous (self-update only, superuser delete) rules.
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
col.updateRule = '@request.auth.id = id';
|
||||||
|
col.deleteRule = null;
|
||||||
|
app.save(col);
|
||||||
|
});
|
||||||
189
pb_migrations/1781200000_created_onboarding.js
Normal file
189
pb_migrations/1781200000_created_onboarding.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #30 — Onboarding track (5-day, theme-level introduction).
|
||||||
|
//
|
||||||
|
// Two collections:
|
||||||
|
// onboarding_overviews — cached per-theme overview content (one row per theme),
|
||||||
|
// keyed by the theme name, with a topics_fingerprint for
|
||||||
|
// staleness detection / regeneration.
|
||||||
|
// onboarding_completions — per-user, per-theme completion marker.
|
||||||
|
//
|
||||||
|
// Design notes (consistent with issues #18/#27):
|
||||||
|
// * team_member_id is a plain TEXT field, NOT a relation — admins can delete
|
||||||
|
// team_members, and a relation with cascadeDelete would otherwise block that
|
||||||
|
// delete / drag the collection into the migration graph. Completions are keyed
|
||||||
|
// by (team_member_id, theme); orphan rows after a member delete are harmless.
|
||||||
|
// * API rules are set EXPLICITLY to authenticated-only. The tighten-rules
|
||||||
|
// migration (1781000001) already ran against the collections that existed then,
|
||||||
|
// so new collections must lock themselves down or they default to public.
|
||||||
|
const AUTHED = '@request.auth.id != ""';
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
const overviews = new Collection({
|
||||||
|
"id": "pbc_onboarding_overviews0",
|
||||||
|
"name": "onboarding_overviews",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_id_oov",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_theme_oov",
|
||||||
|
"name": "theme",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "json_content_oov",
|
||||||
|
"name": "content",
|
||||||
|
"type": "json",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_fingerprint_oov",
|
||||||
|
"name": "topics_fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_created_oov",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_updated_oov",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_onboarding_overviews_theme` ON `onboarding_overviews` (`theme`)"
|
||||||
|
],
|
||||||
|
"listRule": AUTHED,
|
||||||
|
"viewRule": AUTHED,
|
||||||
|
"createRule": AUTHED,
|
||||||
|
"updateRule": AUTHED,
|
||||||
|
"deleteRule": AUTHED
|
||||||
|
});
|
||||||
|
|
||||||
|
app.save(overviews);
|
||||||
|
|
||||||
|
const completions = new Collection({
|
||||||
|
"id": "pbc_onboarding_completions0",
|
||||||
|
"name": "onboarding_completions",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_id_ocp",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_ocp_team_member",
|
||||||
|
"name": "team_member_id",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_ocp_theme",
|
||||||
|
"name": "theme",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_created_ocp",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_updated_ocp",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_onboarding_completions_user_theme` ON `onboarding_completions` (`team_member_id`, `theme`)"
|
||||||
|
],
|
||||||
|
"listRule": AUTHED,
|
||||||
|
"viewRule": AUTHED,
|
||||||
|
"createRule": AUTHED,
|
||||||
|
"updateRule": AUTHED,
|
||||||
|
"deleteRule": AUTHED
|
||||||
|
});
|
||||||
|
|
||||||
|
app.save(completions);
|
||||||
|
|
||||||
|
}, (app) => {
|
||||||
|
const completions = app.findCollectionByNameOrId("onboarding_completions");
|
||||||
|
if (completions) {
|
||||||
|
app.delete(completions);
|
||||||
|
}
|
||||||
|
const overviews = app.findCollectionByNameOrId("onboarding_overviews");
|
||||||
|
if (overviews) {
|
||||||
|
app.delete(overviews);
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -14,6 +14,8 @@ if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OPEN_RULES = { listRule: '', viewRule: '', createRule: '', updateRule: '', deleteRule: '' };
|
const OPEN_RULES = { listRule: '', viewRule: '', createRule: '', updateRule: '', deleteRule: '' };
|
||||||
|
const AUTHED = '@request.auth.id != ""';
|
||||||
|
const AUTHED_RULES = { listRule: AUTHED, viewRule: AUTHED, createRule: AUTHED, updateRule: AUTHED, deleteRule: AUTHED };
|
||||||
|
|
||||||
// PocketBase v0.23: created/updated must be explicitly defined as autodate fields
|
// PocketBase v0.23: created/updated must be explicitly defined as autodate fields
|
||||||
const AUTODATE_FIELDS = [
|
const AUTODATE_FIELDS = [
|
||||||
@@ -102,17 +104,28 @@ const COLLECTIONS = [
|
|||||||
...AUTODATE_FIELDS,
|
...AUTODATE_FIELDS,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// NOTE: `team_members` is an AUTH collection owned by the migration
|
||||||
|
// pb_migrations/1781000000_team_members_to_auth.js (Azure/Entra ID login).
|
||||||
|
// Migrations run on PocketBase start — before this script — so the entry
|
||||||
|
// below is only a fallback for environments where migrations have not run;
|
||||||
|
// when the auth collection already exists, this create is skipped.
|
||||||
{
|
{
|
||||||
name: 'team_members',
|
name: 'team_members',
|
||||||
type: 'base',
|
type: 'auth',
|
||||||
...OPEN_RULES,
|
...OPEN_RULES,
|
||||||
|
// Mirror of pb_migrations/1781000002: creation only via the OAuth2
|
||||||
|
// sign-up flow (issue #22).
|
||||||
|
createRule: '@request.context = "oauth2"',
|
||||||
|
// Mirror of pb_migrations/1781000003: self-update without role changes;
|
||||||
|
// roster management (incl. role/delete) is admin-only (issue #27).
|
||||||
|
updateRule: '(@request.auth.id = id && @request.body.role:isset = false) || @request.auth.role = "admin"',
|
||||||
|
deleteRule: '@request.auth.role = "admin"',
|
||||||
|
passwordAuth: { enabled: false, identityFields: ['email'] },
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'name', type: 'text', required: true },
|
{ name: 'name', type: 'text', required: false },
|
||||||
{ name: 'pin', type: 'text', required: false },
|
|
||||||
{ name: 'role', type: 'text', required: false },
|
{ name: 'role', type: 'text', required: false },
|
||||||
{ name: 'curriculum_started_at', type: 'date', required: false },
|
{ name: 'curriculum_started_at', type: 'date', required: false },
|
||||||
{ name: 'enrollment_status', type: 'text', required: false },
|
{ name: 'enrollment_status', type: 'text', required: false },
|
||||||
...AUTODATE_FIELDS,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -193,6 +206,32 @@ const COLLECTIONS = [
|
|||||||
...AUTODATE_FIELDS,
|
...AUTODATE_FIELDS,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// Onboarding track (issue #30). Owned by pb_migrations/1781200000; these are a
|
||||||
|
// fallback for envs where migrations have not run. Authenticated-only rules
|
||||||
|
// (mirrors the migration) — do NOT use OPEN_RULES here.
|
||||||
|
{
|
||||||
|
name: 'onboarding_overviews',
|
||||||
|
type: 'base',
|
||||||
|
...AUTHED_RULES,
|
||||||
|
fields: [
|
||||||
|
{ name: 'theme', type: 'text', required: true },
|
||||||
|
{ name: 'content', type: 'json', required: true },
|
||||||
|
{ name: 'topics_fingerprint', type: 'text', required: true },
|
||||||
|
...AUTODATE_FIELDS,
|
||||||
|
],
|
||||||
|
indexes: ['CREATE UNIQUE INDEX `idx_onboarding_overviews_theme` ON `onboarding_overviews` (`theme`)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onboarding_completions',
|
||||||
|
type: 'base',
|
||||||
|
...AUTHED_RULES,
|
||||||
|
fields: [
|
||||||
|
{ name: 'team_member_id', type: 'text', required: true },
|
||||||
|
{ name: 'theme', type: 'text', required: true },
|
||||||
|
...AUTODATE_FIELDS,
|
||||||
|
],
|
||||||
|
indexes: ['CREATE UNIQUE INDEX `idx_onboarding_completions_user_theme` ON `onboarding_completions` (`team_member_id`, `theme`)'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function post(path, body, token) {
|
async function post(path, body, token) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ChatLauncher from './components/chat/ChatLauncher'
|
|||||||
|
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Onboarding from './pages/Onboarding'
|
import Onboarding from './pages/Onboarding'
|
||||||
|
import OnboardingTrack from './pages/OnboardingTrack'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
|
|
||||||
import Admin from './pages/Admin'
|
import Admin from './pages/Admin'
|
||||||
@@ -106,6 +107,10 @@ function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
<Route path="/onboarding" element={<Onboarding />} />
|
||||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
|
{/* Onboarding track is available to any logged-in user, regardless of
|
||||||
|
enrollment — hence skipEnrollmentGate (distinct from /onboarding, the
|
||||||
|
enrollment page). */}
|
||||||
|
<Route path="/onboarding-track" element={<ProtectedRoute skipEnrollmentGate><OnboardingTrack /></ProtectedRoute>} />
|
||||||
<Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} />
|
<Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} />
|
||||||
<Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
|
<Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
|
||||||
<Route path="/topic-test" element={<ProtectedRoute><TopicTest /></ProtectedRoute>} />
|
<Route path="/topic-test" element={<ProtectedRoute><TopicTest /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { UserPlus, Trash2, Edit2, Shield, User, CheckCircle } from 'lucide-react';
|
import { Trash2, Shield, User, CheckCircle, Info } from 'lucide-react';
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
import Button from '../ui/Button';
|
|
||||||
import Input from '../ui/Input';
|
|
||||||
import Tag from '../ui/Tag';
|
import Tag from '../ui/Tag';
|
||||||
import * as db from '../../lib/db';
|
import * as db from '../../lib/db';
|
||||||
import { pb } from '../../lib/pb';
|
import { pb } from '../../lib/pb';
|
||||||
import { useApp } from '../../store/AppContext';
|
import { useApp } from '../../store/AppContext';
|
||||||
|
|
||||||
|
// Users are provisioned automatically on first Azure (Entra ID) login — admins
|
||||||
|
// no longer create them by hand. This panel lets an admin review the roster,
|
||||||
|
// switch a member between user/admin, and remove members. The
|
||||||
|
// ENTRA_ADMIN_EMAILS allow-list is escalate-only (see
|
||||||
|
// pb_hooks/team_members.pb.js): it guarantees admin for its members on every
|
||||||
|
// login, so promotions made here stick, but demoting an allow-list member is
|
||||||
|
// undone on their next sign-in — remove them from the list instead.
|
||||||
const TeamManager = () => {
|
const TeamManager = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [editingId, setEditingId] = useState(null);
|
|
||||||
const [formData, setFormData] = useState({ name: '', role: 'user', pin: '' });
|
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
@@ -23,29 +25,16 @@ const TeamManager = () => {
|
|||||||
|
|
||||||
useEffect(() => { loadUsers(); }, []);
|
useEffect(() => { loadUsers(); }, []);
|
||||||
|
|
||||||
const handleSave = async (e) => {
|
const flash = (msg) => {
|
||||||
e.preventDefault();
|
setMessage(msg);
|
||||||
if (!formData.name || !formData.pin) return;
|
|
||||||
|
|
||||||
if (editingId) {
|
|
||||||
await db.updateTeamMember(editingId, { name: formData.name, role: formData.role, pin: formData.pin });
|
|
||||||
setMessage('User updated successfully.');
|
|
||||||
} else {
|
|
||||||
await db.addTeamMember({ name: formData.name, role: formData.role, pin: formData.pin });
|
|
||||||
setMessage('User added successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadUsers();
|
|
||||||
setFormData({ name: '', role: 'user', pin: '' });
|
|
||||||
setIsEditing(false);
|
|
||||||
setEditingId(null);
|
|
||||||
setTimeout(() => setMessage(''), 3000);
|
setTimeout(() => setMessage(''), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (user) => {
|
const handleRoleChange = async (user, role) => {
|
||||||
setFormData({ name: user.name, role: user.role, pin: user.pin });
|
if (role === user.role) return;
|
||||||
setIsEditing(true);
|
await db.updateTeamMember(user.id, { role });
|
||||||
setEditingId(user.id);
|
await loadUsers();
|
||||||
|
flash(`${user.name} is now ${role}.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
@@ -56,24 +45,17 @@ const TeamManager = () => {
|
|||||||
if (confirm("Are you sure you want to delete this user?")) {
|
if (confirm("Are you sure you want to delete this user?")) {
|
||||||
await db.deleteTeamMember(id);
|
await db.deleteTeamMember(id);
|
||||||
|
|
||||||
// Also remove from leaderboard
|
// Also remove from leaderboard.
|
||||||
try {
|
try {
|
||||||
const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
|
const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
|
||||||
await pb.collection('leaderboard').delete(entry.id);
|
await pb.collection('leaderboard').delete(entry.id);
|
||||||
} catch { /* no leaderboard entry, nothing to do */ }
|
} catch { /* no leaderboard entry, nothing to do */ }
|
||||||
|
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
setMessage('User deleted.');
|
flash('User deleted.');
|
||||||
setTimeout(() => setMessage(''), 3000);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => {
|
|
||||||
setIsEditing(false);
|
|
||||||
setEditingId(null);
|
|
||||||
setFormData({ name: '', role: 'user', pin: '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{message && (
|
{message && (
|
||||||
@@ -82,58 +64,23 @@ const TeamManager = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm bg-bg-warm/30">
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2 mb-4">
|
<p className="text-sm text-fg-muted flex items-start gap-2">
|
||||||
{isEditing ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />}
|
<Info size={16} className="mt-0.5 shrink-0 text-teal" />
|
||||||
{isEditing ? 'Edit Team Member' : 'Add New Team Member'}
|
Team members are created automatically when they first sign in with their
|
||||||
</h2>
|
Microsoft account. Members of the <code>ENTRA_ADMIN_EMAILS</code> allow-list
|
||||||
|
are always admin; promotions made here stick, but demoting an allow-list
|
||||||
<form onSubmit={handleSave} className="flex flex-col sm:flex-row gap-4 items-end">
|
member is undone on their next sign-in.
|
||||||
<div className="flex-1 w-full">
|
</p>
|
||||||
<Input
|
|
||||||
label="Full Name"
|
|
||||||
placeholder="e.g. Jane Doe"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={e => setFormData({...formData, name: e.target.value})}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 w-full">
|
|
||||||
<label className="block text-sm font-medium mb-1.5 text-fg">Role</label>
|
|
||||||
<select
|
|
||||||
value={formData.role}
|
|
||||||
onChange={e => setFormData({...formData, role: e.target.value})}
|
|
||||||
className="w-full h-11 px-3 rounded-[var(--r-sm)] border border-bg-warm bg-paper text-sm focus:outline-none focus:ring-2 focus:ring-teal/30 focus:border-teal"
|
|
||||||
>
|
|
||||||
<option value="user">User</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 w-full">
|
|
||||||
<Input
|
|
||||||
label="Login PIN"
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. 1234"
|
|
||||||
value={formData.pin}
|
|
||||||
onChange={e => setFormData({...formData, pin: e.target.value})}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 w-full sm:w-auto">
|
|
||||||
<Button type="submit" className="w-full sm:w-auto h-11 px-6">
|
|
||||||
{isEditing ? 'Update' : 'Add'}
|
|
||||||
</Button>
|
|
||||||
{isEditing && (
|
|
||||||
<Button type="button" variant="outline" onClick={cancelEdit} className="h-11 px-4">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-0 border border-bg-warm overflow-hidden">
|
<Card className="p-0 border border-bg-warm overflow-hidden">
|
||||||
<div className="divide-y divide-bg-warm">
|
<div className="divide-y divide-bg-warm">
|
||||||
|
{users.length === 0 && (
|
||||||
|
<div className="p-6 text-sm text-fg-muted text-center">
|
||||||
|
No team members yet — they appear here after their first sign-in.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{users.map(user => (
|
{users.map(user => (
|
||||||
<div key={user.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
|
<div key={user.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -145,19 +92,26 @@ const TeamManager = () => {
|
|||||||
{user.name}
|
{user.name}
|
||||||
{user.id === state.currentUser?.id && <Tag variant="accent" className="text-[10px] py-0">You</Tag>}
|
{user.id === state.currentUser?.id && <Tag variant="accent" className="text-[10px] py-0">You</Tag>}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-fg-muted">PIN: {user.pin}</p>
|
<p className="text-sm text-fg-muted">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Tag variant={user.role === 'admin' ? 'dark' : 'default'} className="hidden sm:inline-flex">{user.role}</Tag>
|
<select
|
||||||
<button onClick={() => handleEdit(user)} className="p-2 text-fg-muted hover:text-teal transition-colors">
|
value={user.role || 'user'}
|
||||||
<Edit2 size={16} />
|
onChange={e => handleRoleChange(user, e.target.value)}
|
||||||
</button>
|
disabled={user.id === state.currentUser?.id}
|
||||||
|
className="h-9 px-2 rounded-[var(--r-sm)] border border-bg-warm bg-paper text-sm focus:outline-none focus:ring-2 focus:ring-teal/30 focus:border-teal disabled:opacity-40"
|
||||||
|
aria-label={`Role for ${user.name}`}
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(user.id)}
|
onClick={() => handleDelete(user.id)}
|
||||||
disabled={user.id === state.currentUser?.id}
|
disabled={user.id === state.currentUser?.id}
|
||||||
className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
|
className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
|
||||||
|
aria-label={`Delete ${user.name}`}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
import Mark from '../ui/Mark';
|
import Mark from '../ui/Mark';
|
||||||
import ChatMessage from './ChatMessage';
|
import ChatMessage from './ChatMessage';
|
||||||
import { useChat } from './useChat';
|
import { useChat } from './useChat';
|
||||||
@@ -6,7 +7,7 @@ import { kbStore } from '../../lib/kbStore';
|
|||||||
import { BOT_NAME, STRINGS } from './prompts';
|
import { BOT_NAME, STRINGS } from './prompts';
|
||||||
|
|
||||||
export default function ChatWindow({ user, isAdmin, onClose }) {
|
export default function ChatWindow({ user, isAdmin, onClose }) {
|
||||||
const { messages, isThinking, send } = useChat({ user, isAdmin });
|
const { messages, isThinking, send, clearThread } = useChat({ user, isAdmin });
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const bodyRef = useRef(null);
|
const bodyRef = useRef(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
@@ -60,6 +61,14 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
|
|||||||
setDecided(prev => ({ ...prev, [msgId]: 'rejected' }));
|
setDecided(prev => ({ ...prev, [msgId]: 'rejected' }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
if (isThinking) return;
|
||||||
|
if (!window.confirm(STRINGS.clearConfirm)) return;
|
||||||
|
setDecided({});
|
||||||
|
clearThread();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [isThinking, clearThread]);
|
||||||
|
|
||||||
const renderedMessages = messages.map(m => {
|
const renderedMessages = messages.map(m => {
|
||||||
if (!m.suggestion) return m;
|
if (!m.suggestion) return m;
|
||||||
const status = decided[m.id] || m.suggestion.status || 'pending';
|
const status = decided[m.id] || m.suggestion.status || 'pending';
|
||||||
@@ -81,14 +90,26 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
|
|||||||
<div className="r42-window-hd-name">{BOT_NAME}</div>
|
<div className="r42-window-hd-name">{BOT_NAME}</div>
|
||||||
<div className="r42-window-hd-status"><i /> {STRINGS.status}</div>
|
<div className="r42-window-hd-status"><i /> {STRINGS.status}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="r42-window-hd-actions">
|
||||||
type="button"
|
<button
|
||||||
className="r42-window-hd-x"
|
type="button"
|
||||||
onClick={onClose}
|
className="r42-window-hd-clear"
|
||||||
aria-label={STRINGS.closeAria}
|
onClick={handleClear}
|
||||||
>
|
disabled={isThinking}
|
||||||
×
|
aria-label={STRINGS.clearAria}
|
||||||
</button>
|
title={STRINGS.clearAria}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="r42-window-hd-x"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={STRINGS.closeAria}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="r42-window-body" ref={bodyRef}>
|
<div className="r42-window-body" ref={bodyRef}>
|
||||||
|
|||||||
63
src/components/chat/__tests__/rag.test.js
Normal file
63
src/components/chat/__tests__/rag.test.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// In-memory KB the mocked db serves from.
|
||||||
|
const store = {
|
||||||
|
topics: [],
|
||||||
|
relations: [],
|
||||||
|
content: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../lib/db', () => ({
|
||||||
|
getTopics: vi.fn(async () => store.topics),
|
||||||
|
getRelations: vi.fn(async () => store.relations),
|
||||||
|
getContent: vi.fn(async (id) => store.content.get(id) ?? null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { buildKbContext } from '../rag';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store.topics = [];
|
||||||
|
store.relations = [];
|
||||||
|
store.content = new Map();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildKbContext', () => {
|
||||||
|
it('reports an empty graph', async () => {
|
||||||
|
const { context, allTopics } = await buildKbContext('pensioen');
|
||||||
|
expect(context).toMatch(/leeg/);
|
||||||
|
expect(allTopics).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects deep content for a retrieved topic that is not named verbatim', async () => {
|
||||||
|
store.topics = [
|
||||||
|
{ id: 'pensioenregeling', label: 'Pensioenregeling', type: 'concept', description: 'De beschikbare premieregeling.' },
|
||||||
|
{ id: 'onboarding-buddy', label: 'Onboarding Buddy', type: 'role', description: 'Begeleidt nieuwe medewerkers.' },
|
||||||
|
];
|
||||||
|
store.content.set('pensioenregeling', {
|
||||||
|
article: 'De premie is 10% van de pensioengrondslag; werkgever en werknemer betalen elk 50%.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// "pensioen" is never a verbatim topic id/label, but the compound-word
|
||||||
|
// matching should retrieve pensioenregeling and pull its article body in.
|
||||||
|
const { context, retrievedTopics } = await buildKbContext('wat dekt mijn pensioen?');
|
||||||
|
|
||||||
|
expect(retrievedTopics.map(t => t.id)).toContain('pensioenregeling');
|
||||||
|
expect(context).toMatch(/DIEPERE INHOUD/);
|
||||||
|
expect(context).toMatch(/10% van de pensioengrondslag/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only includes relations whose endpoints are both in the selection', async () => {
|
||||||
|
store.topics = [
|
||||||
|
{ id: 'pensioenregeling', label: 'Pensioenregeling', type: 'concept', description: 'De beschikbare premieregeling.' },
|
||||||
|
{ id: 'partnerpensioen', label: 'Partnerpensioen', type: 'concept', description: 'Uitkering aan de partner.' },
|
||||||
|
];
|
||||||
|
store.relations = [
|
||||||
|
{ source: 'partnerpensioen', target: 'pensioenregeling', type: 'part_of' },
|
||||||
|
{ source: 'pensioenregeling', target: 'iets-anders', type: 'related_to' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { context } = await buildKbContext('pensioen');
|
||||||
|
expect(context).toMatch(/partnerpensioen --part_of--> pensioenregeling/);
|
||||||
|
expect(context).not.toMatch(/iets-anders/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,8 +87,13 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--sage);
|
background: var(--sage);
|
||||||
}
|
}
|
||||||
.r42-window-hd-x {
|
.r42-window-hd-actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.r42-window-hd-x {
|
||||||
color: rgba(236, 233, 233, 0.7);
|
color: rgba(236, 233, 233, 0.7);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -99,6 +104,18 @@
|
|||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
.r42-window-hd-x:hover { background: rgba(236, 233, 233, 0.1); }
|
.r42-window-hd-x:hover { background: rgba(236, 233, 233, 0.1); }
|
||||||
|
.r42-window-hd-clear {
|
||||||
|
color: rgba(236, 233, 233, 0.7);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.r42-window-hd-clear:hover { background: rgba(236, 233, 233, 0.1); }
|
||||||
|
.r42-window-hd-clear:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
.r42-window-body {
|
.r42-window-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const STRINGS = {
|
|||||||
suggestionDismissed: 'Oké, niets gedaan.',
|
suggestionDismissed: 'Oké, niets gedaan.',
|
||||||
closeAria: 'Sluit chatvenster',
|
closeAria: 'Sluit chatvenster',
|
||||||
openAria: 'Open R42 chatbot',
|
openAria: 'Open R42 chatbot',
|
||||||
|
clearAria: 'Wis gesprek',
|
||||||
|
clearConfirm: 'Dit gesprek wissen? Dit kan niet ongedaan worden gemaakt.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STABLE_PREAMBLE = [
|
const STABLE_PREAMBLE = [
|
||||||
@@ -35,11 +37,16 @@ const STABLE_PREAMBLE = [
|
|||||||
`JE KENNIS:`,
|
`JE KENNIS:`,
|
||||||
`Je kennis is beperkt tot de Respellion-kennisgraaf die hieronder volgt. Als een vraag duidelijk buiten dit bereik valt, zeg dat dan eerlijk en stel voor dat de gebruiker de bron toevoegt via Admin → Sources.`,
|
`Je kennis is beperkt tot de Respellion-kennisgraaf die hieronder volgt. Als een vraag duidelijk buiten dit bereik valt, zeg dat dan eerlijk en stel voor dat de gebruiker de bron toevoegt via Admin → Sources.`,
|
||||||
``,
|
``,
|
||||||
|
`NAUWKEURIGHEID (belangrijk):`,
|
||||||
|
`- Baseer je antwoord uitsluitend op de KENNISGRAAF en DIEPERE INHOUD hieronder; verzin niets.`,
|
||||||
|
`- Gebruik ALLE relevante feiten die daar staan. Bij een vraag om details, bedragen, percentages, voorwaarden of een opsomming: noem elk relevant feit — vat niet samen ten koste van volledigheid.`,
|
||||||
|
`- Als de samenvattende KENNISGRAAF te dun is om de vraag volledig te beantwoorden, roep dan éérst de tool "lookup_topic" aan (met het exacte topic-id) voordat je concludeert dat je het niet weet.`,
|
||||||
|
``,
|
||||||
`KENNISGRAAF VERFIJNEN:`,
|
`KENNISGRAAF VERFIJNEN:`,
|
||||||
`Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`,
|
`Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`,
|
||||||
``,
|
``,
|
||||||
`STIJL:`,
|
`STIJL:`,
|
||||||
`- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`,
|
`- Zo kort als kan, zo volledig als nodig: houd eenvoudige antwoorden onder de 4 zinnen, maar som bij details- of opsommingsvragen álle relevante feiten op (desnoods als korte lijst met streepjes).`,
|
||||||
`- Geen markdown-headers; gewone Nederlandse tekst.`,
|
`- Geen markdown-headers; gewone Nederlandse tekst.`,
|
||||||
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
|
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import * as db from '../../lib/db';
|
|||||||
import { buildIndex, retrieveTopK } from '../../lib/retrieval';
|
import { buildIndex, retrieveTopK } from '../../lib/retrieval';
|
||||||
|
|
||||||
const TOP_K = 10;
|
const TOP_K = 10;
|
||||||
|
// How many topics get their full article body injected (not just the short
|
||||||
|
// description). Verbatim-mentioned topics come first, then the highest-ranked
|
||||||
|
// retrieved ones, so a query that never names a topic exactly still gets rich
|
||||||
|
// content for what it matched.
|
||||||
|
const DEEP_CONTENT_LIMIT = 5;
|
||||||
|
const DEEP_SNIPPET_CHARS = 1000;
|
||||||
|
const DESC_SNIPPET_CHARS = 320;
|
||||||
|
|
||||||
async function sha256Hex(input) {
|
async function sha256Hex(input) {
|
||||||
const enc = new TextEncoder().encode(input);
|
const enc = new TextEncoder().encode(input);
|
||||||
@@ -71,7 +78,7 @@ export async function buildKbContext(userMessage = '') {
|
|||||||
const included = [...includedById.values()];
|
const included = [...includedById.values()];
|
||||||
|
|
||||||
const topicLines = included.map(t => {
|
const topicLines = included.map(t => {
|
||||||
const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, DESC_SNIPPET_CHARS);
|
||||||
return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`;
|
return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,19 +92,30 @@ export async function buildKbContext(userMessage = '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentionedDeepContent = [];
|
// Pick which topics get their full article body: verbatim mentions first,
|
||||||
for (const id of mentionedIds) {
|
// then the highest-ranked retrieved topics, capped at DEEP_CONTENT_LIMIT.
|
||||||
const t = includedById.get(id);
|
const deepIds = [];
|
||||||
if (!t) continue;
|
for (const id of mentionedIds) deepIds.push(id);
|
||||||
const content = await db.getContent(t.id).catch(() => null);
|
for (const t of retrieved) {
|
||||||
if (!content) continue;
|
if (deepIds.length >= DEEP_CONTENT_LIMIT) break;
|
||||||
let raw;
|
if (!mentionedIds.has(t.id)) deepIds.push(t.id);
|
||||||
if (typeof content === 'string') raw = content;
|
|
||||||
else if (content.article) raw = typeof content.article === 'string' ? content.article : JSON.stringify(content.article);
|
|
||||||
else raw = JSON.stringify(content);
|
|
||||||
const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200);
|
|
||||||
mentionedDeepContent.push(`### ${t.label}\n${snippet}`);
|
|
||||||
}
|
}
|
||||||
|
const deepBlocks = await Promise.all(
|
||||||
|
deepIds.slice(0, DEEP_CONTENT_LIMIT).map(async (id) => {
|
||||||
|
const t = includedById.get(id);
|
||||||
|
if (!t) return null;
|
||||||
|
const content = await db.getContent(id).catch(() => null);
|
||||||
|
if (!content) return null;
|
||||||
|
let raw;
|
||||||
|
if (typeof content === 'string') raw = content;
|
||||||
|
else if (content.article) raw = typeof content.article === 'string' ? content.article : JSON.stringify(content.article);
|
||||||
|
else raw = JSON.stringify(content);
|
||||||
|
const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, DEEP_SNIPPET_CHARS);
|
||||||
|
if (!snippet) return null;
|
||||||
|
return `### ${t.label}\n${snippet}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const mentionedDeepContent = deepBlocks.filter(Boolean);
|
||||||
|
|
||||||
const context = [
|
const context = [
|
||||||
`KENNISGRAAF — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`,
|
`KENNISGRAAF — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`,
|
||||||
@@ -106,7 +124,7 @@ export async function buildKbContext(userMessage = '') {
|
|||||||
`KENNISGRAAF — RELATIES (binnen deze selectie):`,
|
`KENNISGRAAF — RELATIES (binnen deze selectie):`,
|
||||||
relLines.length ? relLines.join('\n') : '(geen relaties binnen deze selectie)',
|
relLines.length ? relLines.join('\n') : '(geen relaties binnen deze selectie)',
|
||||||
mentionedDeepContent.length
|
mentionedDeepContent.length
|
||||||
? `\n\nDIEPERE INHOUD (voor genoemde topics):\n${mentionedDeepContent.join('\n\n')}`
|
? `\n\nDIEPERE INHOUD (volledige leerinhoud van de meest relevante topics — gebruik álle feiten hieruit die de vraag beantwoorden):\n${mentionedDeepContent.join('\n\n')}`
|
||||||
: '',
|
: '',
|
||||||
``,
|
``,
|
||||||
`Als de relevante context hierboven te beperkt is, gebruik dan de tool "lookup_topic" om de volledige beschrijving en eventuele leerinhoud van een specifiek topic op te halen.`,
|
`Als de relevante context hierboven te beperkt is, gebruik dan de tool "lookup_topic" om de volledige beschrijving en eventuele leerinhoud van een specifiek topic op te halen.`,
|
||||||
|
|||||||
@@ -92,6 +92,19 @@ export function useChat({ user, isAdmin }) {
|
|||||||
setMessages(prev => prev.map(m => (m.id === id ? { ...m, ...patch } : m)));
|
setMessages(prev => prev.map(m => (m.id === id ? { ...m, ...patch } : m)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** Wipe the persisted thread and reset to a fresh greeting. */
|
||||||
|
const clearThread = useCallback(() => {
|
||||||
|
seenDeltaKeys.current = new Set();
|
||||||
|
const greeting = {
|
||||||
|
id: `m_${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: STRINGS.greeting(user?.name || 'daar'),
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
setMessages([greeting]);
|
||||||
|
if (threadKey) storage.set(threadKey, [greeting]);
|
||||||
|
}, [user, threadKey]);
|
||||||
|
|
||||||
const send = useCallback(async (text) => {
|
const send = useCallback(async (text) => {
|
||||||
const trimmed = (text || '').trim();
|
const trimmed = (text || '').trim();
|
||||||
if (!trimmed || !user) return;
|
if (!trimmed || !user) return;
|
||||||
@@ -225,5 +238,6 @@ export function useChat({ user, isAdmin }) {
|
|||||||
errored,
|
errored,
|
||||||
send,
|
send,
|
||||||
updateMessage,
|
updateMessage,
|
||||||
|
clearThread,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/lib/__tests__/azureAuth.test.js
Normal file
57
src/lib/__tests__/azureAuth.test.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
OIDC_PROVIDER,
|
||||||
|
parseAdminEmails,
|
||||||
|
resolveRole,
|
||||||
|
deriveName,
|
||||||
|
} from '../azureAuth';
|
||||||
|
|
||||||
|
describe('azureAuth', () => {
|
||||||
|
it('exposes the OIDC provider name used by authWithOAuth2', () => {
|
||||||
|
expect(OIDC_PROVIDER).toBe('oidc');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseAdminEmails', () => {
|
||||||
|
it('returns [] for empty/undefined input', () => {
|
||||||
|
expect(parseAdminEmails()).toEqual([]);
|
||||||
|
expect(parseAdminEmails('')).toEqual([]);
|
||||||
|
expect(parseAdminEmails(' ')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lower-cases, trims, drops blanks and de-duplicates', () => {
|
||||||
|
expect(parseAdminEmails(' RVE@respellion.nl , admin@respellion.nl ,, rve@respellion.nl'))
|
||||||
|
.toEqual(['rve@respellion.nl', 'admin@respellion.nl']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveRole', () => {
|
||||||
|
it('returns admin for an allow-listed e-mail (case-insensitive)', () => {
|
||||||
|
expect(resolveRole('RVE@respellion.nl', 'rve@respellion.nl')).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns user for a non-listed e-mail', () => {
|
||||||
|
expect(resolveRole('jane@respellion.nl', 'rve@respellion.nl')).toBe('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns user when the allow-list is empty', () => {
|
||||||
|
expect(resolveRole('rve@respellion.nl', '')).toBe('user');
|
||||||
|
expect(resolveRole('rve@respellion.nl')).toBe('user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveName', () => {
|
||||||
|
it('prefers the OIDC name claim', () => {
|
||||||
|
expect(deriveName({ name: 'Raymond Verhoef' }, 'rve@respellion.nl')).toBe('Raymond Verhoef');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the e-mail prefix when no name claim', () => {
|
||||||
|
expect(deriveName({}, 'rve@respellion.nl')).toBe('rve');
|
||||||
|
expect(deriveName(undefined, 'jane.doe@respellion.nl')).toBe('jane.doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "Onbekend" with no usable input', () => {
|
||||||
|
expect(deriveName({}, '')).toBe('Onbekend');
|
||||||
|
expect(deriveName(null, null)).toBe('Onbekend');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
customTopicSchema,
|
customTopicSchema,
|
||||||
graphActionsSchema,
|
graphActionsSchema,
|
||||||
proposeGraphDeltaSchema,
|
proposeGraphDeltaSchema,
|
||||||
|
onboardingOverviewSchema,
|
||||||
} from '../llmSchemas';
|
} from '../llmSchemas';
|
||||||
|
|
||||||
const sampleTopic = {
|
const sampleTopic = {
|
||||||
@@ -192,3 +193,56 @@ describe('proposeGraphDeltaSchema', () => {
|
|||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('onboardingOverviewSchema (issue #32 — fast-tier stringified arrays)', () => {
|
||||||
|
const base = {
|
||||||
|
title: 'Privacy',
|
||||||
|
what_it_is: 'How we handle data.',
|
||||||
|
why_it_matters: 'You touch personal data weekly.',
|
||||||
|
};
|
||||||
|
const points = ['Point one', 'Point two', 'Point three'];
|
||||||
|
const topics = [{ topic_id: 'avg', label: 'AVG' }];
|
||||||
|
|
||||||
|
it('accepts well-formed output', () => {
|
||||||
|
const r = onboardingOverviewSchema.parse({ ...base, key_points: points, topics_covered: topics });
|
||||||
|
expect(r.key_points).toEqual(points);
|
||||||
|
expect(r.topics_covered).toEqual(topics);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces JSON-stringified arrays back to arrays (the exact #32 failure)', () => {
|
||||||
|
const r = onboardingOverviewSchema.parse({
|
||||||
|
...base,
|
||||||
|
key_points: JSON.stringify(points),
|
||||||
|
topics_covered: JSON.stringify(topics),
|
||||||
|
});
|
||||||
|
expect(r.key_points).toEqual(points);
|
||||||
|
expect(r.topics_covered).toEqual(topics);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits a bullet/newline string of key points as a fallback', () => {
|
||||||
|
const r = onboardingOverviewSchema.parse({
|
||||||
|
...base,
|
||||||
|
key_points: '- Point one\n• Point two\n3. Point three',
|
||||||
|
topics_covered: topics,
|
||||||
|
});
|
||||||
|
expect(r.key_points).toEqual(points);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the first 5 key points instead of failing on overage', () => {
|
||||||
|
const seven = Array.from({ length: 7 }, (_, i) => `P${i + 1}`);
|
||||||
|
const r = onboardingOverviewSchema.parse({ ...base, key_points: seven, topics_covered: topics });
|
||||||
|
expect(r.key_points).toEqual(['P1', 'P2', 'P3', 'P4', 'P5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects genuinely bad output', () => {
|
||||||
|
expect(() =>
|
||||||
|
onboardingOverviewSchema.parse({ ...base, key_points: ['only', 'two'], topics_covered: topics }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
onboardingOverviewSchema.parse({ ...base, key_points: points, topics_covered: 'not json at all' }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
onboardingOverviewSchema.parse({ ...base, key_points: 42, topics_covered: topics }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
152
src/lib/__tests__/onboardingService.test.js
Normal file
152
src/lib/__tests__/onboardingService.test.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
ONBOARDING_DAY_COUNT,
|
||||||
|
orderThemes,
|
||||||
|
distributeThemesIntoDays,
|
||||||
|
computeTopicsFingerprint,
|
||||||
|
computeOnboardingProgress,
|
||||||
|
buildOnboardingPlan,
|
||||||
|
} from '../onboardingService';
|
||||||
|
|
||||||
|
const sizes = (days) => days.map((d) => d.themes.length);
|
||||||
|
|
||||||
|
describe('onboardingService pure helpers', () => {
|
||||||
|
it('exposes a 5-day guideline', () => {
|
||||||
|
expect(ONBOARDING_DAY_COUNT).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('orderThemes', () => {
|
||||||
|
const kb = ['Governance', 'Culture', 'Privacy', 'Process'];
|
||||||
|
|
||||||
|
it('orders by first appearance in the schedule, then appends the rest alphabetically', () => {
|
||||||
|
const schedule = [{ theme: 'Privacy' }, { theme: 'Governance' }];
|
||||||
|
expect(orderThemes(kb, schedule)).toEqual(['Privacy', 'Governance', 'Culture', 'Process']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('de-duplicates repeated schedule labels', () => {
|
||||||
|
const schedule = [{ theme: 'Privacy' }, { theme: 'Privacy' }, { theme: 'Culture' }];
|
||||||
|
expect(orderThemes(kb, schedule)).toEqual(['Privacy', 'Culture', 'Governance', 'Process']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores schedule labels that are not real KB themes (e.g. merged-week labels)', () => {
|
||||||
|
const schedule = [{ theme: 'Privacy & Governance (merged)' }, { theme: 'Culture' }];
|
||||||
|
expect(orderThemes(kb, schedule)).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to pure alphabetical when the schedule is null/empty', () => {
|
||||||
|
expect(orderThemes(kb, null)).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||||
|
expect(orderThemes(kb, [])).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('distributeThemesIntoDays', () => {
|
||||||
|
const themes = (n) => Array.from({ length: n }, (_, i) => `T${i + 1}`);
|
||||||
|
|
||||||
|
it('splits 10 themes evenly across 5 days', () => {
|
||||||
|
expect(sizes(distributeThemesIntoDays(themes(10)))).toEqual([2, 2, 2, 2, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('front-loads the remainder (12 -> 3,3,2,2,2)', () => {
|
||||||
|
expect(sizes(distributeThemesIntoDays(themes(12)))).toEqual([3, 3, 2, 2, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 7 -> 2,2,1,1,1', () => {
|
||||||
|
expect(sizes(distributeThemesIntoDays(themes(7)))).toEqual([2, 2, 1, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses fewer days than the cap when there are fewer themes', () => {
|
||||||
|
const days = distributeThemesIntoDays(themes(3));
|
||||||
|
expect(sizes(days)).toEqual([1, 1, 1]);
|
||||||
|
expect(days.map((d) => d.day)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for no themes', () => {
|
||||||
|
expect(distributeThemesIntoDays([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves order and loses nothing', () => {
|
||||||
|
const input = themes(12);
|
||||||
|
const flat = distributeThemesIntoDays(input).flatMap((d) => d.themes);
|
||||||
|
expect(flat).toEqual(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeTopicsFingerprint', () => {
|
||||||
|
it('is order-independent', () => {
|
||||||
|
const a = computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }, { id: 'z' }]);
|
||||||
|
const b = computeTopicsFingerprint([{ id: 'z' }, { id: 'x' }, { id: 'y' }]);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes when a topic is added or removed', () => {
|
||||||
|
const base = computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }]);
|
||||||
|
expect(computeTopicsFingerprint([{ id: 'x' }])).not.toBe(base);
|
||||||
|
expect(computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }, { id: 'z' }])).not.toBe(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty input', () => {
|
||||||
|
expect(typeof computeTopicsFingerprint([])).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeOnboardingProgress', () => {
|
||||||
|
const days = [
|
||||||
|
{ day: 1, themes: ['A', 'B'] },
|
||||||
|
{ day: 2, themes: ['C'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('does not count a partially-done day as complete', () => {
|
||||||
|
const p = computeOnboardingProgress(days, new Set(['A']));
|
||||||
|
expect(p).toMatchObject({ themesTotal: 3, themesDone: 1, dayCount: 2, daysCompleted: 0, allDone: false });
|
||||||
|
expect(p.percentage).toBe(33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a fully-done day', () => {
|
||||||
|
const p = computeOnboardingProgress(days, new Set(['A', 'B']));
|
||||||
|
expect(p).toMatchObject({ themesDone: 2, daysCompleted: 1, allDone: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports allDone only when every theme is complete', () => {
|
||||||
|
const p = computeOnboardingProgress(days, new Set(['A', 'B', 'C']));
|
||||||
|
expect(p).toMatchObject({ themesDone: 3, daysCompleted: 2, percentage: 100, allDone: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an array as well as a Set', () => {
|
||||||
|
expect(computeOnboardingProgress(days, ['A', 'B', 'C']).allDone).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zeros for an empty plan', () => {
|
||||||
|
expect(computeOnboardingProgress([], new Set())).toEqual({
|
||||||
|
themesTotal: 0, themesDone: 0, dayCount: 0, daysCompleted: 0, percentage: 0, allDone: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildOnboardingPlan', () => {
|
||||||
|
const topics = [
|
||||||
|
{ id: 't1', label: 'T1', theme: 'Privacy', complexity_weight: 2 },
|
||||||
|
{ id: 't2', label: 'T2', theme: 'Culture', complexity_weight: 1 },
|
||||||
|
{ id: 'f1', label: 'F1', theme: 'Privacy', type: 'fact' }, // excluded by buildThemeTopicMap
|
||||||
|
];
|
||||||
|
|
||||||
|
it('builds themes (schedule-ordered), days, and a theme→topics map', () => {
|
||||||
|
const activeVersion = { schedule: [{ theme: 'Privacy' }, { theme: 'Culture' }] };
|
||||||
|
const plan = buildOnboardingPlan(topics, activeVersion);
|
||||||
|
expect(plan.themes).toEqual(['Privacy', 'Culture']);
|
||||||
|
expect(sizes(plan.days)).toEqual([1, 1]);
|
||||||
|
expect(plan.themeTopicMap.get('Privacy').map((t) => t.id)).toEqual(['t1']); // fact excluded
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a stringified schedule and a missing version', () => {
|
||||||
|
const stringified = { schedule: JSON.stringify([{ theme: 'Culture' }]) };
|
||||||
|
expect(buildOnboardingPlan(topics, stringified).themes).toEqual(['Culture', 'Privacy']);
|
||||||
|
expect(buildOnboardingPlan(topics, null).themes).toEqual(['Culture', 'Privacy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty plan for an empty KB', () => {
|
||||||
|
const plan = buildOnboardingPlan([], null);
|
||||||
|
expect(plan.themes).toEqual([]);
|
||||||
|
expect(plan.days).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,6 +50,31 @@ describe('buildIndex / retrieveTopK', () => {
|
|||||||
expect(retrieveTopK(idx, 'kwantumfysica raketten')).toEqual([]);
|
expect(retrieveTopK(idx, 'kwantumfysica raketten')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches Dutch compound words on a shared stem', () => {
|
||||||
|
const pensionTopics = [
|
||||||
|
{ id: 'pensioenregeling', label: 'Pensioenregeling', description: 'De beschikbare premieregeling bij a.s.r. Doen Pensioen.' },
|
||||||
|
{ id: 'partnerpensioen', label: 'Partnerpensioen', description: 'Uitkering aan de partner bij overlijden.' },
|
||||||
|
{ id: 'reiskostenvergoeding', label: 'Reiskostenvergoeding', description: 'EUR 0,23 per kilometer voor woon-werkverkeer.' },
|
||||||
|
];
|
||||||
|
const idx = buildIndex(pensionTopics);
|
||||||
|
// "pensioen" never appears as a standalone token in a label, yet the stem is
|
||||||
|
// a prefix of "pensioenregeling" and an infix of "partnerpensioen".
|
||||||
|
const hits = retrieveTopK(idx, 'wat dekt mijn pensioen?', 3).map(h => h.id);
|
||||||
|
expect(hits).toContain('pensioenregeling');
|
||||||
|
expect(hits).toContain('partnerpensioen');
|
||||||
|
expect(hits).not.toContain('reiskostenvergoeding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not partial-match on short shared prefixes', () => {
|
||||||
|
const topics = [
|
||||||
|
{ id: 'onderhoud', label: 'Onderhoud', description: 'Technisch beheer van systemen.' },
|
||||||
|
];
|
||||||
|
const idx = buildIndex(topics);
|
||||||
|
// "onderneming" shares only "onder" (5) with "onderhoud" — below the overlap
|
||||||
|
// needed for a query token this size to count.
|
||||||
|
expect(retrieveTopK(idx, 'onderneming')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('caches the index per topics array reference', () => {
|
it('caches the index per topics array reference', () => {
|
||||||
const idx1 = buildIndex(sampleTopics);
|
const idx1 = buildIndex(sampleTopics);
|
||||||
const idx2 = buildIndex(sampleTopics);
|
const idx2 = buildIndex(sampleTopics);
|
||||||
|
|||||||
56
src/lib/azureAuth.js
Normal file
56
src/lib/azureAuth.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Azure (Entra ID) authentication helpers.
|
||||||
|
*
|
||||||
|
* Canonical, unit-tested home for the small pieces of auth logic shared by the
|
||||||
|
* frontend. The PocketBase hook `pb_hooks/team_members.pb.js` re-implements
|
||||||
|
* `resolveRole` / `parseAdminEmails` in the JSVM runtime (it cannot import this
|
||||||
|
* module) — keep the two in sync.
|
||||||
|
*
|
||||||
|
* Login itself goes through PocketBase's built-in OAuth2 flow:
|
||||||
|
* pb.collection('team_members').authWithOAuth2({ provider: OIDC_PROVIDER })
|
||||||
|
* The OIDC provider ("oidc") is configured against Entra in migration
|
||||||
|
* 1781000000_team_members_to_auth.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Provider name registered on the `team_members` auth collection. */
|
||||||
|
export const OIDC_PROVIDER = 'oidc';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the ENTRA_ADMIN_EMAILS-style comma-separated allow-list into a
|
||||||
|
* normalised (lower-cased, trimmed, de-duplicated, empty-free) array.
|
||||||
|
* @param {string} [csv]
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function parseAdminEmails(csv) {
|
||||||
|
if (!csv) return [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const part of String(csv).toLowerCase().split(',')) {
|
||||||
|
const email = part.trim();
|
||||||
|
if (email) seen.add(email);
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a user's role from their e-mail and the admin allow-list.
|
||||||
|
* @param {string} email
|
||||||
|
* @param {string} [adminEmailsCsv]
|
||||||
|
* @returns {'admin'|'user'}
|
||||||
|
*/
|
||||||
|
export function resolveRole(email, adminEmailsCsv) {
|
||||||
|
const allow = parseAdminEmails(adminEmailsCsv);
|
||||||
|
return allow.includes(String(email || '').trim().toLowerCase()) ? 'admin' : 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a display name from OIDC claims, falling back to the e-mail prefix.
|
||||||
|
* @param {{ name?: string }} [claims]
|
||||||
|
* @param {string} [email]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function deriveName(claims, email) {
|
||||||
|
const fromClaim = claims && typeof claims.name === 'string' ? claims.name.trim() : '';
|
||||||
|
if (fromClaim) return fromClaim;
|
||||||
|
if (email && email.includes('@')) return email.split('@')[0];
|
||||||
|
return 'Onbekend';
|
||||||
|
}
|
||||||
@@ -406,6 +406,49 @@ export async function setThemeSessionCompletion(userId, themeSessionId, sessionW
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Onboarding track ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached onboarding overview for a theme, or null. `theme` is free text →
|
||||||
|
* use pb.filter() bindings so quotes/specials can't break the filter.
|
||||||
|
*/
|
||||||
|
export async function getOnboardingOverview(theme) {
|
||||||
|
try {
|
||||||
|
return await pb.collection('onboarding_overviews').getFirstListItem(
|
||||||
|
pb.filter('theme = {:theme}', { theme }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert the cached overview for a theme (keyed by theme). */
|
||||||
|
export async function setOnboardingOverview(theme, content, fingerprint) {
|
||||||
|
return pbUpsert(
|
||||||
|
'onboarding_overviews',
|
||||||
|
pb.filter('theme = {:theme}', { theme }),
|
||||||
|
{ content, topics_fingerprint: fingerprint },
|
||||||
|
{ theme, content, topics_fingerprint: fingerprint },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All onboarding-completion rows for a user (each has a `theme`). */
|
||||||
|
export async function getCompletedOnboardingThemes(userId) {
|
||||||
|
return pb.collection('onboarding_completions').getFullList({
|
||||||
|
filter: pb.filter('team_member_id = {:userId}', { userId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a theme complete for the user. Idempotent (unique on user+theme). */
|
||||||
|
export async function setOnboardingCompletion(userId, theme) {
|
||||||
|
return pbUpsert(
|
||||||
|
'onboarding_completions',
|
||||||
|
pb.filter('team_member_id = {:userId} && theme = {:theme}', { userId, theme }),
|
||||||
|
{ team_member_id: userId, theme },
|
||||||
|
{ team_member_id: userId, theme },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getLeaderboard() {
|
export async function getLeaderboard() {
|
||||||
|
|||||||
@@ -213,6 +213,13 @@ const SIMULATION_TOOL_STUBS = {
|
|||||||
},
|
},
|
||||||
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
|
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
|
||||||
set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
|
set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
|
||||||
|
emit_onboarding_overview: {
|
||||||
|
title: 'Voorbeeldthema',
|
||||||
|
what_it_is: 'Een korte simulatie-omschrijving van dit thema.',
|
||||||
|
why_it_matters: 'In simulatiemodus laat dit zien hoe de onboarding-overview eruitziet zonder de API te raken.',
|
||||||
|
key_points: ['Kernpunt één', 'Kernpunt twee', 'Kernpunt drie'],
|
||||||
|
topics_covered: [{ topic_id: 'sim-topic', label: 'Simulatie onderwerp' }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
|
function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
|
||||||
|
|||||||
@@ -229,6 +229,45 @@ export const themeSessionSchema = z.object({
|
|||||||
keyTakeaways: z.array(z.string().min(1)).min(3),
|
keyTakeaways: z.array(z.string().min(1)).min(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fast-tier models occasionally emit array tool fields as a JSON-encoded
|
||||||
|
// string ("[\"a\",\"b\"]") instead of a real array (issue #32). Parse those
|
||||||
|
// back to arrays before validating; leave anything else untouched so real
|
||||||
|
// type errors still fail validation.
|
||||||
|
function coerceStringifiedArray(v) {
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (s.startsWith('[')) {
|
||||||
|
try { return JSON.parse(s); } catch { /* keep original, let Zod report */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onboardingKeyPoints = z.preprocess((v) => {
|
||||||
|
let out = coerceStringifiedArray(v);
|
||||||
|
// Fallback: a bullet/newline list as one string → split into points.
|
||||||
|
if (typeof out === 'string' && out.includes('\n')) {
|
||||||
|
out = out
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^\s*[-•*\d.]+\s*/, '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
// Overage is trivial — keep the first 5 rather than failing the user.
|
||||||
|
if (Array.isArray(out) && out.length > 5) out = out.slice(0, 5);
|
||||||
|
return out;
|
||||||
|
}, z.array(z.string().min(1)).min(3).max(5));
|
||||||
|
|
||||||
|
export const onboardingOverviewSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
what_it_is: z.string().min(1),
|
||||||
|
why_it_matters: z.string().min(1),
|
||||||
|
key_points: onboardingKeyPoints,
|
||||||
|
topics_covered: z.preprocess(
|
||||||
|
coerceStringifiedArray,
|
||||||
|
z.array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) })).min(1),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry mapping known tool names to their input schemas. `callLLM`
|
* Registry mapping known tool names to their input schemas. `callLLM`
|
||||||
* consults this when the caller does not pass an explicit `toolSchemas`
|
* consults this when the caller does not pass an explicit `toolSchemas`
|
||||||
@@ -252,4 +291,5 @@ export const toolSchemaRegistry = {
|
|||||||
remove_section: removeSectionPatchSchema,
|
remove_section: removeSectionPatchSchema,
|
||||||
replace_takeaways: replaceTakeawaysPatchSchema,
|
replace_takeaways: replaceTakeawaysPatchSchema,
|
||||||
emit_theme_session: themeSessionSchema,
|
emit_theme_session: themeSessionSchema,
|
||||||
|
emit_onboarding_overview: onboardingOverviewSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -454,3 +454,39 @@ export const EMIT_THEME_SESSION_TOOL = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Onboarding overview (breadth-first, one theme) ───────────────────────────
|
||||||
|
|
||||||
|
export const EMIT_ONBOARDING_OVERVIEW_TOOL = {
|
||||||
|
name: 'emit_onboarding_overview',
|
||||||
|
description: 'Return a SHORT, breadth-first onboarding overview of ONE theme for a brand-new Respellion employee. This is a light introduction, NOT a deep lesson: what the theme is in plain language, why it matters for day-to-day and week-to-week work at Respellion, a few key points, and which topics belong to it. Keep it skimmable.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Learner-facing theme title, 2–6 words.' },
|
||||||
|
what_it_is: { type: 'string', description: '1–2 plain-language sentences defining what this theme is about.' },
|
||||||
|
why_it_matters: { type: 'string', description: '1–3 sentences on why this theme matters for a new employee\'s daily and weekly work at Respellion.' },
|
||||||
|
key_points: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
minItems: 3,
|
||||||
|
maxItems: 5,
|
||||||
|
description: '3–5 short, concrete takeaways a newcomer should remember about this theme. Must be a JSON array of strings — never a single string.',
|
||||||
|
},
|
||||||
|
topics_covered: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'The topics that make up this theme. Reuse the exact topic_id you were given so the UI can link back. Must be a JSON array of objects — never a string.',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
topic_id: { type: 'string', description: 'The id of the topic (must match one of the ids provided).' },
|
||||||
|
label: { type: 'string', description: 'The topic label, as provided.' },
|
||||||
|
},
|
||||||
|
required: ['topic_id', 'label'],
|
||||||
|
},
|
||||||
|
minItems: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['title', 'what_it_is', 'why_it_matters', 'key_points', 'topics_covered'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
233
src/lib/onboardingService.js
Normal file
233
src/lib/onboardingService.js
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import * as db from './db';
|
||||||
|
import { callLLM, cachedSystem } from './llm';
|
||||||
|
import { buildThemeTopicMap } from './curriculumService';
|
||||||
|
import { EMIT_ONBOARDING_OVERVIEW_TOOL } from './llmTools';
|
||||||
|
|
||||||
|
// The onboarding track introduces a new employee to every KB theme in breadth,
|
||||||
|
// spread over a guideline of 5 "days". A theme is the unit we track completion
|
||||||
|
// on; "days" are only a presentation grouping computed at read time, so
|
||||||
|
// re-chunking (e.g. when the theme set changes) never loses a user's progress.
|
||||||
|
export const ONBOARDING_DAY_COUNT = 5;
|
||||||
|
|
||||||
|
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order theme names by their first appearance in the active curriculum schedule
|
||||||
|
* (a pedagogical order), then append any KB themes the schedule never names,
|
||||||
|
* alphabetically. Schedule labels that aren't real KB themes (e.g. merged-week
|
||||||
|
* labels) are ignored. De-duplicated.
|
||||||
|
*
|
||||||
|
* @param {string[]} themeNames — canonical theme names present in the KB
|
||||||
|
* @param {Array<{theme?: string}>|null} schedule — active curriculum schedule
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function orderThemes(themeNames, schedule) {
|
||||||
|
const known = new Set(themeNames);
|
||||||
|
const ordered = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
if (Array.isArray(schedule)) {
|
||||||
|
for (const week of schedule) {
|
||||||
|
const theme = week && week.theme;
|
||||||
|
if (theme && known.has(theme) && !seen.has(theme)) {
|
||||||
|
seen.add(theme);
|
||||||
|
ordered.push(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = themeNames
|
||||||
|
.filter((t) => !seen.has(t))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return [...ordered, ...remaining];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split an ordered theme list into balanced, order-preserving day buckets.
|
||||||
|
* Uses at most `dayCount` days; with fewer themes than days it uses `N` days.
|
||||||
|
* The first `remainder` days get one extra theme.
|
||||||
|
*
|
||||||
|
* @param {string[]} orderedThemes
|
||||||
|
* @param {number} [dayCount=5]
|
||||||
|
* @returns {Array<{ day: number, themes: string[] }>} non-empty days only
|
||||||
|
*/
|
||||||
|
export function distributeThemesIntoDays(orderedThemes, dayCount = ONBOARDING_DAY_COUNT) {
|
||||||
|
const themes = Array.isArray(orderedThemes) ? orderedThemes : [];
|
||||||
|
const n = themes.length;
|
||||||
|
if (n === 0) return [];
|
||||||
|
|
||||||
|
const effectiveDays = Math.min(dayCount, n);
|
||||||
|
const base = Math.floor(n / effectiveDays);
|
||||||
|
const remainder = n % effectiveDays;
|
||||||
|
|
||||||
|
const days = [];
|
||||||
|
let idx = 0;
|
||||||
|
for (let d = 0; d < effectiveDays; d++) {
|
||||||
|
const size = base + (d < remainder ? 1 : 0);
|
||||||
|
days.push({ day: d + 1, themes: themes.slice(idx, idx + size) });
|
||||||
|
idx += size;
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable, order-independent fingerprint over a theme's topic ids. Changes when
|
||||||
|
* a topic is added to or removed from the theme, so cached overviews can be
|
||||||
|
* detected as stale and regenerated.
|
||||||
|
*
|
||||||
|
* @param {Array<{id?: string}>} topics
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function computeTopicsFingerprint(topics) {
|
||||||
|
const ids = (Array.isArray(topics) ? topics : [])
|
||||||
|
.map((t) => t && t.id)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
const joined = ids.join(',');
|
||||||
|
let h = 5381;
|
||||||
|
for (let i = 0; i < joined.length; i++) {
|
||||||
|
h = ((h << 5) + h + joined.charCodeAt(i)) >>> 0; // djb2, unsigned
|
||||||
|
}
|
||||||
|
return `${ids.length}:${h.toString(16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive progress from the plan's days and the set of completed themes.
|
||||||
|
*
|
||||||
|
* @param {Array<{ day: number, themes: string[] }>} days
|
||||||
|
* @param {Set<string>|string[]} completedThemeSet
|
||||||
|
* @returns {{ themesTotal:number, themesDone:number, dayCount:number, daysCompleted:number, percentage:number, allDone:boolean }}
|
||||||
|
*/
|
||||||
|
export function computeOnboardingProgress(days, completedThemeSet) {
|
||||||
|
const set = completedThemeSet instanceof Set
|
||||||
|
? completedThemeSet
|
||||||
|
: new Set(completedThemeSet || []);
|
||||||
|
const allThemes = (days || []).flatMap((d) => d.themes);
|
||||||
|
const themesTotal = allThemes.length;
|
||||||
|
const themesDone = allThemes.filter((t) => set.has(t)).length;
|
||||||
|
const dayCount = (days || []).length;
|
||||||
|
const daysCompleted = (days || []).filter(
|
||||||
|
(d) => d.themes.length > 0 && d.themes.every((t) => set.has(t)),
|
||||||
|
).length;
|
||||||
|
const percentage = themesTotal === 0 ? 0 : Math.round((themesDone / themesTotal) * 100);
|
||||||
|
const allDone = themesTotal > 0 && themesDone === themesTotal;
|
||||||
|
return { themesTotal, themesDone, dayCount, daysCompleted, percentage, allDone };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the onboarding plan from already-loaded topics + active curriculum version.
|
||||||
|
*
|
||||||
|
* @param {Array} topics — all topics (db.getTopics())
|
||||||
|
* @param {object|null} activeVersion — active curriculum_versions row (for schedule order)
|
||||||
|
* @returns {{ themes: string[], days: Array<{day:number,themes:string[]}>, themeTopicMap: Map<string, Array> }}
|
||||||
|
*/
|
||||||
|
export function buildOnboardingPlan(topics, activeVersion) {
|
||||||
|
const themeTopicMap = buildThemeTopicMap(topics || []);
|
||||||
|
|
||||||
|
let schedule = activeVersion && activeVersion.schedule ? activeVersion.schedule : null;
|
||||||
|
if (typeof schedule === 'string') {
|
||||||
|
try { schedule = JSON.parse(schedule); } catch { schedule = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const themes = orderThemes([...themeTopicMap.keys()], schedule);
|
||||||
|
const days = distributeThemesIntoDays(themes);
|
||||||
|
return { themes, days, themeTopicMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── I/O + generation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ONBOARDING_OVERVIEW_SYSTEM = `You are an onboarding guide for Respellion, an internal IT company.
|
||||||
|
You introduce a brand-new employee to ONE theme from the company knowledge base.
|
||||||
|
This is a breadth-first introduction, not a deep lesson: keep it short, plain and skimmable, and always connect the theme to how work actually happens day-to-day and week-to-week at Respellion.
|
||||||
|
Write in clear, professional English. Emit content only through the emit_onboarding_overview tool.`;
|
||||||
|
|
||||||
|
function buildOverviewPrompt(theme, topics) {
|
||||||
|
const topicLines = topics
|
||||||
|
.map((t, idx) => `${idx + 1}. id=${t.id} | label="${t.label}" | type=${t.type || '—'} | description=${t.description || '—'}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `Theme: ${theme}
|
||||||
|
|
||||||
|
Topics that make up this theme (reuse the exact topic_id for each entry in topics_covered):
|
||||||
|
${topicLines || '(no topics listed)'}
|
||||||
|
|
||||||
|
Write a short, breadth-first onboarding overview of this theme for a new employee's first week: what it is, why it matters for their daily and weekly work at Respellion, 3–5 key points, and the list of topics it covers.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load everything needed to render the track.
|
||||||
|
* @returns {Promise<{themes:string[], days:Array, themeTopicMap:Map}>}
|
||||||
|
*/
|
||||||
|
export async function getOnboardingPlan() {
|
||||||
|
const [topics, activeVersion] = await Promise.all([
|
||||||
|
db.getTopics(),
|
||||||
|
db.getActiveCurriculumVersion(),
|
||||||
|
]);
|
||||||
|
return buildOnboardingPlan(topics, activeVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cached onboarding overview for a theme, or generate + cache it.
|
||||||
|
* Regenerates when the theme's topic set has changed (fingerprint mismatch).
|
||||||
|
*
|
||||||
|
* @param {string} theme
|
||||||
|
* @param {Array} topicsForTheme — topics belonging to the theme
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.force=false]
|
||||||
|
*/
|
||||||
|
export async function getOrGenerateOnboardingOverview(theme, topicsForTheme, { force = false } = {}) {
|
||||||
|
if (!theme) throw new Error('Onboarding overview requires a theme.');
|
||||||
|
const topics = Array.isArray(topicsForTheme) ? topicsForTheme : [];
|
||||||
|
const fingerprint = computeTopicsFingerprint(topics);
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
const cached = await db.getOnboardingOverview(theme);
|
||||||
|
if (cached && cached.topics_fingerprint === fingerprint) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callLLM({
|
||||||
|
task: 'onboarding.overview',
|
||||||
|
tier: 'fast',
|
||||||
|
system: cachedSystem(ONBOARDING_OVERVIEW_SYSTEM),
|
||||||
|
user: buildOverviewPrompt(theme, topics),
|
||||||
|
tools: [EMIT_ONBOARDING_OVERVIEW_TOOL],
|
||||||
|
toolChoice: { type: 'tool', name: EMIT_ONBOARDING_OVERVIEW_TOOL.name },
|
||||||
|
maxTokens: 1500,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitted = result.toolUses[0]?.input;
|
||||||
|
if (!emitted) throw new Error('AI did not return an onboarding overview. Please try again.');
|
||||||
|
|
||||||
|
return db.setOnboardingOverview(theme, emitted, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of theme names the user has marked complete.
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {Promise<Set<string>>}
|
||||||
|
*/
|
||||||
|
export async function getCompletedThemes(userId) {
|
||||||
|
const rows = await db.getCompletedOnboardingThemes(userId);
|
||||||
|
return new Set(rows.map((r) => r.theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a theme complete for the user. Idempotent.
|
||||||
|
*/
|
||||||
|
export async function markThemeCompleted(userId, theme) {
|
||||||
|
return db.setOnboardingCompletion(userId, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress summary for the Dashboard chip.
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export async function getOnboardingSummary(userId) {
|
||||||
|
const [plan, completed] = await Promise.all([
|
||||||
|
getOnboardingPlan(),
|
||||||
|
getCompletedThemes(userId),
|
||||||
|
]);
|
||||||
|
return computeOnboardingProgress(plan.days, completed);
|
||||||
|
}
|
||||||
@@ -63,6 +63,29 @@ export function buildIndex(topics) {
|
|||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compound-word matching. Dutch is heavily compounding, so a user's word
|
||||||
|
// (`pensioenafspraken`) is a *different* token than the graph's labels
|
||||||
|
// (`pensioenregeling`, `partnerpensioen`), even though they share the stem
|
||||||
|
// `pensioen`. Exact TF-IDF scores those pairs at 0, so the relevant topics are
|
||||||
|
// never retrieved. These heuristics recover that recall at a reduced weight,
|
||||||
|
// so exact matches still dominate the ranking.
|
||||||
|
const PARTIAL_MIN_QUERY_LEN = 6; // only expand meaty query tokens
|
||||||
|
const PARTIAL_MIN_OVERLAP = 6; // shared stem / substring must be this long
|
||||||
|
const PARTIAL_WEIGHT = 0.4; // discount vs. an exact term hit
|
||||||
|
|
||||||
|
/** True when two distinct tokens share a long stem or one contains the other. */
|
||||||
|
function partialMatch(q, d) {
|
||||||
|
if (q === d) return false;
|
||||||
|
const shorter = q.length <= d.length ? q : d;
|
||||||
|
const longer = q.length <= d.length ? d : q;
|
||||||
|
if (shorter.length < PARTIAL_MIN_OVERLAP) return false;
|
||||||
|
if (longer.includes(shorter)) return true;
|
||||||
|
let n = 0;
|
||||||
|
const m = shorter.length;
|
||||||
|
while (n < m && q[n] === d[n]) n++;
|
||||||
|
return n >= PARTIAL_MIN_OVERLAP;
|
||||||
|
}
|
||||||
|
|
||||||
export function retrieveTopK(index, query, k = 10) {
|
export function retrieveTopK(index, query, k = 10) {
|
||||||
if (!index || !index.N || !query) return [];
|
if (!index || !index.N || !query) return [];
|
||||||
const qTokens = tokenize(query);
|
const qTokens = tokenize(query);
|
||||||
@@ -80,8 +103,19 @@ export function retrieveTopK(index, query, k = 10) {
|
|||||||
let s = 0;
|
let s = 0;
|
||||||
for (const t of qTokens) {
|
for (const t of qTokens) {
|
||||||
const f = tf.get(t);
|
const f = tf.get(t);
|
||||||
if (!f) continue;
|
if (f) {
|
||||||
s += (1 + Math.log(f)) * idf(t);
|
s += (1 + Math.log(f)) * idf(t);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// No exact hit — try a compound-word match against this doc's terms.
|
||||||
|
if (t.length < PARTIAL_MIN_QUERY_LEN) continue;
|
||||||
|
let best = 0;
|
||||||
|
for (const [term, tf2] of tf) {
|
||||||
|
if (!partialMatch(t, term)) continue;
|
||||||
|
const w = PARTIAL_WEIGHT * (1 + Math.log(tf2)) * idf(term);
|
||||||
|
if (w > best) best = w;
|
||||||
|
}
|
||||||
|
s += best;
|
||||||
}
|
}
|
||||||
scores[i] = s;
|
scores[i] = s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ const Admin = () => {
|
|||||||
{activeTab === 'team' && (
|
{activeTab === 'team' && (
|
||||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
|
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
|
||||||
<p className="text-fg-muted mb-8">Manage team members, roles, and login PINs.</p>
|
<p className="text-fg-muted mb-8">Review team members and manage their roles. Accounts are created automatically via Microsoft (Azure) sign-in.</p>
|
||||||
<TeamManager />
|
<TeamManager />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { BookOpen, CheckSquare, Trophy, Sparkles, X, HelpCircle } from 'lucide-react';
|
import { BookOpen, CheckSquare, Trophy, Sparkles, X, HelpCircle, Rocket, ArrowRight } from 'lucide-react';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
@@ -9,6 +9,7 @@ import * as db from '../lib/db';
|
|||||||
import { storage } from '../lib/storage';
|
import { storage } from '../lib/storage';
|
||||||
import { getAssignedTopic } from '../lib/learningService';
|
import { getAssignedTopic } from '../lib/learningService';
|
||||||
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService';
|
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService';
|
||||||
|
import { getOnboardingSummary } from '../lib/onboardingService';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
@@ -25,6 +26,7 @@ const Dashboard = () => {
|
|||||||
yearProgress: null,
|
yearProgress: null,
|
||||||
hasCurriculum: false,
|
hasCurriculum: false,
|
||||||
theme: '',
|
theme: '',
|
||||||
|
onboarding: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,6 +71,15 @@ const Dashboard = () => {
|
|||||||
console.warn('[Dashboard] Could not load curriculum data:', e.message);
|
console.warn('[Dashboard] Could not load curriculum data:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Onboarding-track progress (independent of enrollment/curriculum). Guarded
|
||||||
|
// so a missing collection or empty KB never breaks the dashboard.
|
||||||
|
let onboarding = null;
|
||||||
|
try {
|
||||||
|
onboarding = await getOnboardingSummary(currentUser.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Dashboard] Could not load onboarding summary:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
setDashData({
|
setDashData({
|
||||||
topic,
|
topic,
|
||||||
learnDone,
|
learnDone,
|
||||||
@@ -80,13 +91,19 @@ const Dashboard = () => {
|
|||||||
yearProgress,
|
yearProgress,
|
||||||
hasCurriculum: curriculumExists,
|
hasCurriculum: curriculumExists,
|
||||||
theme: topic?.theme || '',
|
theme: topic?.theme || '',
|
||||||
|
onboarding,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [currentUser, weekNumber]);
|
}, [currentUser, weekNumber]);
|
||||||
|
|
||||||
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive, theme } = dashData;
|
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive, onboarding } = dashData;
|
||||||
|
const onboardingLabel = onboarding
|
||||||
|
? (onboarding.allDone
|
||||||
|
? 'Review onboarding'
|
||||||
|
: onboarding.daysCompleted > 0 ? 'Continue onboarding' : 'Start onboarding')
|
||||||
|
: 'Start onboarding';
|
||||||
const currentCycle = getCurriculumCycle(weekNumber);
|
const currentCycle = getCurriculumCycle(weekNumber);
|
||||||
const currWeek = getCurriculumWeek(weekNumber);
|
const currWeek = getCurriculumWeek(weekNumber);
|
||||||
|
|
||||||
@@ -174,6 +191,31 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Onboarding track CTA — only when there are themes to introduce. */}
|
||||||
|
{onboarding && onboarding.themesTotal > 0 && (
|
||||||
|
<div className="mt-5 pt-5 border-t border-bg-warm flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-[var(--r-org)] bg-teal/10 text-teal flex items-center justify-center shrink-0">
|
||||||
|
<Rocket size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold flex items-center gap-2">
|
||||||
|
New here? Take the 5-day onboarding
|
||||||
|
<Tag variant={onboarding.allDone ? 'success' : 'accent'} className="text-[10px]">
|
||||||
|
{onboarding.allDone ? 'Onboarding complete' : `${onboarding.daysCompleted}/${onboarding.dayCount} days`}
|
||||||
|
</Tag>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-fg-muted">A light tour of every theme — how Respellion works day to day and week to week.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link to="/onboarding-track" className="shrink-0">
|
||||||
|
<Button variant="outline" className="whitespace-nowrap">
|
||||||
|
{onboardingLabel} <ArrowRight size={16} className="ml-1" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,37 +2,25 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import Input from '../components/ui/Input';
|
|
||||||
import Select from '../components/ui/Select';
|
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const { state, login } = useApp();
|
const { loginWithAzure } = useApp();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [selectedUser, setSelectedUser] = useState('');
|
|
||||||
const [pin, setPin] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const userOptions = [
|
const handleAzure = async () => {
|
||||||
{ value: '', label: 'Select a user...' },
|
|
||||||
...state.users.map(u => ({ value: u.id, label: u.name }))
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleLogin = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
setError('');
|
||||||
|
setBusy(true);
|
||||||
if (!selectedUser || !pin) {
|
try {
|
||||||
setError('Please fill in all fields.');
|
await loginWithAzure();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = login(selectedUser, pin);
|
|
||||||
if (success) {
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} else {
|
} catch (e) {
|
||||||
setError('Incorrect PIN.');
|
// PocketBase throws when the popup is closed or the token exchange fails.
|
||||||
|
setError(e?.message || 'Signing in with Microsoft failed. Please try again.');
|
||||||
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,31 +34,17 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm">
|
||||||
<form onSubmit={handleLogin} className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<Select
|
<p className="text-fg-muted text-sm text-center">
|
||||||
label="User"
|
Sign in with your Respellion Microsoft account.
|
||||||
options={userOptions}
|
</p>
|
||||||
value={selectedUser}
|
|
||||||
onChange={(e) => setSelectedUser(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
{error && <div className="text-red-500 text-sm font-medium text-center">{error}</div>}
|
||||||
label="PIN Code"
|
|
||||||
type="password"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
maxLength={4}
|
|
||||||
placeholder="••••"
|
|
||||||
value={pin}
|
|
||||||
onChange={(e) => setPin(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && <div className="text-red-500 text-sm font-medium">{error}</div>}
|
<Button onClick={handleAzure} disabled={busy} className="w-full">
|
||||||
|
{busy ? 'Signing in…' : 'Sign in with Microsoft'}
|
||||||
<Button type="submit" className="mt-2 w-full">
|
|
||||||
Sign In
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
326
src/pages/OnboardingTrack.jsx
Normal file
326
src/pages/OnboardingTrack.jsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Loader, Rocket, ArrowLeft, ChevronRight, CheckCircle2, Circle,
|
||||||
|
Sparkles, ListChecks, PartyPopper,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Card from '../components/ui/Card';
|
||||||
|
import Button from '../components/ui/Button';
|
||||||
|
import Tag from '../components/ui/Tag';
|
||||||
|
import { useApp } from '../store/AppContext';
|
||||||
|
import {
|
||||||
|
getOnboardingPlan,
|
||||||
|
getCompletedThemes,
|
||||||
|
getOrGenerateOnboardingOverview,
|
||||||
|
markThemeCompleted,
|
||||||
|
computeOnboardingProgress,
|
||||||
|
} from '../lib/onboardingService';
|
||||||
|
|
||||||
|
function normalizeContent(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try { return JSON.parse(raw); } catch { return null; }
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-theme overview view ──────────────────────────────────────────────────
|
||||||
|
function OnboardingThemeView({ theme, topics, done, onBack, onDone }) {
|
||||||
|
const [record, setRecord] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [marked, setMarked] = useState(done);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getOrGenerateOnboardingOverview(theme, topics)
|
||||||
|
.then((rec) => { if (!cancelled) setRecord(rec); })
|
||||||
|
.catch((err) => { if (!cancelled) setError(err.message || 'Failed to load the overview.'); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [theme]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const content = normalizeContent(record?.content);
|
||||||
|
|
||||||
|
const handleDone = async () => {
|
||||||
|
if (marked) return;
|
||||||
|
setMarked(true);
|
||||||
|
await onDone(theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="inline-flex items-center text-teal font-medium text-sm hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} className="mr-1" /> Back to overview
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Card className="w-full text-center py-16">
|
||||||
|
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||||
|
<p className="font-medium text-lg">Preparing this theme…</p>
|
||||||
|
<p className="text-fg-muted text-sm mt-2">This may take 10–30 seconds the first time — the result is cached.</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<Card className="w-full border border-red-200 bg-red-50 text-red-900 p-6">
|
||||||
|
<p className="font-bold mb-1">Could not load this theme</p>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && content && (
|
||||||
|
<>
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Sparkles size={18} className="text-teal" />
|
||||||
|
<Tag variant="dark" className="text-[10px] uppercase tracking-wide">Theme</Tag>
|
||||||
|
{marked && <Tag variant="success" className="text-[10px]">Done</Tag>}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-teal mb-2">{content.title}</h2>
|
||||||
|
<p className="text-fg-muted">{content.what_it_is}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-2">Why it matters here</h3>
|
||||||
|
<p className="text-fg-muted">{content.why_it_matters}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-3">Key points</h3>
|
||||||
|
<ul className="list-disc pl-5 space-y-1 text-sm">
|
||||||
|
{(content.key_points || []).map((p, i) => <li key={i}>{p}</li>)}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{Array.isArray(content.topics_covered) && content.topics_covered.length > 0 && (
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-3">Topics in this theme</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{content.topics_covered.map((t) => (
|
||||||
|
<Tag key={t.topic_id} variant="default" className="text-xs">{t.label}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleDone} disabled={marked}>
|
||||||
|
{marked
|
||||||
|
? <span className="flex items-center"><CheckCircle2 size={16} className="mr-2" /> Marked as done</span>
|
||||||
|
: 'Mark as done'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress ring ─────────────────────────────────────────────────────────────
|
||||||
|
function ProgressRing({ percentage }) {
|
||||||
|
return (
|
||||||
|
<div className="relative w-20 h-20 shrink-0">
|
||||||
|
<svg viewBox="0 0 36 36" className="w-20 h-20 -rotate-90">
|
||||||
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="var(--bg-warm)" strokeWidth="3" />
|
||||||
|
<circle
|
||||||
|
cx="18" cy="18" r="15.9155" fill="none" stroke="var(--teal)" strokeWidth="3"
|
||||||
|
strokeDasharray={`${percentage} 100`} strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-sm font-bold">{percentage}%</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
export default function OnboardingTrack() {
|
||||||
|
const { state } = useApp();
|
||||||
|
const { currentUser } = state;
|
||||||
|
|
||||||
|
const [plan, setPlan] = useState(null);
|
||||||
|
const [completed, setCompleted] = useState(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([getOnboardingPlan(), getCompletedThemes(currentUser.id)])
|
||||||
|
.then(([p, done]) => { if (!cancelled) { setPlan(p); setCompleted(done); } })
|
||||||
|
.catch((err) => { if (!cancelled) setError(err.message || 'Failed to load the onboarding track.'); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
const progress = useMemo(
|
||||||
|
() => computeOnboardingProgress(plan?.days || [], completed),
|
||||||
|
[plan, completed],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleThemeDone = async (theme) => {
|
||||||
|
await markThemeCompleted(currentUser.id, theme);
|
||||||
|
setCompleted((prev) => new Set(prev).add(theme));
|
||||||
|
setSelectedTheme(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10">
|
||||||
|
<Card className="w-full text-center py-16">
|
||||||
|
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||||
|
<p className="font-medium text-lg">Loading your onboarding track…</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10">
|
||||||
|
<Card className="w-full border border-red-200 bg-red-50 text-red-900 p-6">
|
||||||
|
<p className="font-bold mb-1">Could not load the onboarding track</p>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty KB → friendly empty state.
|
||||||
|
if (!plan || plan.themes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10 space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-2 flex items-center gap-3"><Rocket size={28} className="text-teal" /> Onboarding</h1>
|
||||||
|
</header>
|
||||||
|
<Card className="w-full p-8 text-center">
|
||||||
|
<p className="text-fg-muted">There are no themes to introduce yet. Once the knowledge base has content, your 5-day onboarding track will appear here.</p>
|
||||||
|
<div className="mt-4"><Link to="/"><Button variant="outline">Back to dashboard</Button></Link></div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeTopics = (theme) => plan.themeTopicMap.get(theme) || [];
|
||||||
|
|
||||||
|
if (selectedTheme) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<OnboardingThemeView
|
||||||
|
theme={selectedTheme}
|
||||||
|
topics={themeTopics(selectedTheme)}
|
||||||
|
done={completed.has(selectedTheme)}
|
||||||
|
onBack={() => setSelectedTheme(null)}
|
||||||
|
onDone={handleThemeDone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<header className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-2 flex items-center gap-3"><Rocket size={28} className="text-teal" /> Onboarding</h1>
|
||||||
|
<p className="text-fg-muted text-lg max-w-2xl">
|
||||||
|
A light, self-paced tour of every theme at Respellion, spread over about five days.
|
||||||
|
It gives you the breadth you need to understand how we work day to day and week to week —
|
||||||
|
you can go faster if you like.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="border border-bg-warm">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<ProgressRing percentage={progress.percentage} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold text-lg">
|
||||||
|
{progress.allDone
|
||||||
|
? 'Onboarding complete'
|
||||||
|
: `${progress.daysCompleted}/${progress.dayCount} days complete`}
|
||||||
|
</p>
|
||||||
|
<p className="text-fg-muted text-sm">{progress.themesDone} of {progress.themesTotal} themes done</p>
|
||||||
|
<div className="flex gap-1.5 mt-3">
|
||||||
|
{plan.days.map((d) => {
|
||||||
|
const dayDone = d.themes.every((t) => completed.has(t));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d.day}
|
||||||
|
title={`Day ${d.day}`}
|
||||||
|
className={`h-2 flex-1 rounded-full ${dayDone ? 'bg-teal' : 'bg-bg-warm'}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{progress.allDone && (
|
||||||
|
<Card className="w-full p-6 border border-teal/30 bg-sage/40">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<PartyPopper size={24} className="text-teal shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold">You've completed the onboarding track 🎉</h3>
|
||||||
|
<p className="text-fg-muted text-sm mt-1">
|
||||||
|
You've been introduced to every theme. You're ready to pick up a first assignment —
|
||||||
|
and you can revisit any theme below whenever you want a refresher.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3"><Link to="/"><Button>Back to dashboard</Button></Link></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{plan.days.map((d) => {
|
||||||
|
const doneCount = d.themes.filter((t) => completed.has(t)).length;
|
||||||
|
return (
|
||||||
|
<Card key={d.day} className="p-0 border border-bg-warm overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 bg-bg-warm/40 border-b border-bg-warm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag variant="dark" className="text-[10px] uppercase tracking-wide">Day {d.day}</Tag>
|
||||||
|
<ListChecks size={16} className="text-fg-muted" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-fg-muted">{doneCount}/{d.themes.length} done</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-bg-warm">
|
||||||
|
{d.themes.map((theme) => {
|
||||||
|
const isDone = completed.has(theme);
|
||||||
|
const count = themeTopics(theme).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={theme}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedTheme(theme)}
|
||||||
|
className="w-full flex items-center justify-between p-4 text-left hover:bg-bg-warm/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isDone
|
||||||
|
? <CheckCircle2 size={20} className="text-teal shrink-0" />
|
||||||
|
: <Circle size={20} className="text-fg-muted/40 shrink-0" />}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{theme}</p>
|
||||||
|
<p className="text-sm text-fg-muted">{count} {count === 1 ? 'topic' : 'topics'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={18} className="text-fg-muted" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useContext, useReducer, useEffect } from 'react';
|
import { createContext, useContext, useReducer, useEffect } from 'react';
|
||||||
import * as db from '../lib/db';
|
import * as db from '../lib/db';
|
||||||
import { pb } from '../lib/pb';
|
import { pb } from '../lib/pb';
|
||||||
|
import { OIDC_PROVIDER } from '../lib/azureAuth';
|
||||||
import { getPersonalWeekNumber } from '../lib/curriculumService';
|
import { getPersonalWeekNumber } from '../lib/curriculumService';
|
||||||
|
|
||||||
const AppContext = createContext();
|
const AppContext = createContext();
|
||||||
@@ -50,44 +51,40 @@ export function AppProvider({ children }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadState = async () => {
|
const loadState = async () => {
|
||||||
let members = await db.getTeamMembers();
|
// Restore the session from PocketBase's persistent auth store. The token
|
||||||
|
// is kept in localStorage by the SDK; authRefresh verifies it is still
|
||||||
if (!members || members.length === 0) {
|
// valid (and that the Entra account hasn't been revoked) and returns the
|
||||||
|
// up-to-date record.
|
||||||
|
if (pb.authStore.isValid && pb.authStore.record?.collectionName === 'team_members') {
|
||||||
try {
|
try {
|
||||||
const created = await db.addTeamMember({ name: 'Admin', role: 'admin', pin: '0000' });
|
const { record } = await pb.collection('team_members').authRefresh();
|
||||||
members = [created];
|
dispatch({ type: 'LOGIN', payload: record });
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('[AppContext] Could not auto-create admin user:', e.message,
|
// Token rejected (expired / account revoked) — drop the session.
|
||||||
'— Run scripts/setup-pb-collections.mjs to configure the database.');
|
pb.authStore.clear();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionUserId = sessionStorage.getItem('respellion_session');
|
|
||||||
if (sessionUserId) {
|
|
||||||
const user = members.find(u => u.id === sessionUserId);
|
|
||||||
if (user) {
|
|
||||||
dispatch({ type: 'LOGIN', payload: user });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The team list (leaderboard, dashboard) requires authentication now, so
|
||||||
|
// only load it once we have a valid session.
|
||||||
|
const members = pb.authStore.isValid ? await db.getTeamMembers() : [];
|
||||||
dispatch({ type: 'INIT_APP', payload: { users: members } });
|
dispatch({ type: 'INIT_APP', payload: { users: members } });
|
||||||
};
|
};
|
||||||
|
|
||||||
loadState().catch(console.error);
|
loadState().catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = (userId, pin) => {
|
// Start the Entra (OIDC) login flow. PocketBase opens the provider popup,
|
||||||
const user = state.users.find(u => u.id === userId && u.pin === pin);
|
// handles the token exchange server-side, and auto-provisions the record on
|
||||||
if (user) {
|
// first login (see pb_hooks/team_members.pb.js).
|
||||||
sessionStorage.setItem('respellion_session', user.id);
|
const loginWithAzure = async () => {
|
||||||
dispatch({ type: 'LOGIN', payload: user });
|
const authData = await pb.collection('team_members').authWithOAuth2({ provider: OIDC_PROVIDER });
|
||||||
return true;
|
dispatch({ type: 'LOGIN', payload: authData.record });
|
||||||
}
|
return authData.record;
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
sessionStorage.removeItem('respellion_session');
|
pb.authStore.clear();
|
||||||
dispatch({ type: 'LOGOUT' });
|
dispatch({ type: 'LOGOUT' });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,7 +104,7 @@ export function AppProvider({ children }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={{ state, dispatch, login, logout, enrollCurrentUser }}>
|
<AppContext.Provider value={{ state, dispatch, loginWithAzure, logout, enrollCurrentUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user