Compare commits

...

24 Commits

Author SHA1 Message Date
RaymondVerhoef
17cb30003d feat: add guide for calculating and processing pension premiums in proforma payslips
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 44s
On Pull Request to Main / publish (pull_request) Successful in 1m14s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m15s
2026-07-13 14:40:43 +02:00
RaymondVerhoef
85452f66a7 feat(r42): improve KB grounding accuracy and add clear-history
R42 was missing knowledge-graph information (e.g. pension questions)
because retrieval and context-building dropped relevant facts:

- retrieval: exact-token TF-IDF could not match Dutch compound words,
  so a "pensioen" query scored 0 against "pensioenregeling" /
  "partnerpensioen" and never retrieved them. Add a compound-word
  fallback (shared >=6-char stem or containment, 0.4x weight) alongside
  exact matching.
- rag: deep article content was only injected for verbatim-mentioned
  topics; retrieved topics contributed just a 200-char description.
  Inject ~1000 chars of content for up to 5 topics (mentions first,
  then top-ranked retrieved) and widen the description snippet to 320.
- prompts: add a NAUWKEURIGHEID block (use all relevant facts, call
  lookup_topic before giving up) and relax the 4-sentence cap for
  detail/list answers so complete facts aren't summarised away.

Also add a clear-history control: a trash button in the chat header
(confirm dialog) wipes chat🧵{userId} and reseeds the greeting
via clearThread() in useChat.

Tests: compound-word matching + rag deep-content injection. Spec updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:25:08 +02:00
e73700763d Merge pull request 'Fix #32: gestringifieerde arrays in onboarding-overview output normaliseren' (#33) from fix/issue-32-onboarding-schema into main
All checks were successful
On Push to Main / test (push) Successful in 35s
On Push to Main / publish (push) Successful in 1m12s
On Push to Main / deploy-dev (push) Successful in 3m15s
Reviewed-on: #33
2026-07-13 07:46:53 +00:00
RaymondVerhoef
182fb2357f fix: coerce stringified arrays in onboarding overview output (#32)
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 41s
On Pull Request to Main / publish (pull_request) Successful in 1m15s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m17s
Opening an onboarding theme could fail with "LLM output failed schema
validation": the fast-tier model occasionally emits the key_points and
topics_covered tool fields as JSON-encoded strings instead of real
arrays. Same failure class the quiz schema already guards against with
z.preprocess.

- onboardingOverviewSchema: parse "[...]" strings back to arrays before
  validation; split a bullet/newline string of key points as a fallback;
  keep the first 5 key points on overage instead of failing the user.
  Genuinely bad output (too few points, non-JSON strings, wrong types)
  still fails validation.
- emit_onboarding_overview tool: descriptions now state the fields must
  be JSON arrays, never strings (prompt-side nudge).
- 5 regression tests reproducing the exact #32 payload shapes
  (npm test 139/139).

Closes #32

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:38:46 +02:00
b1d3686d35 Merge pull request 'Onboarding-track: 5-daagse thema-introductie vanuit de "New here?"-kaart (#30)' (#31) from feat/onboarding-track into main
All checks were successful
On Push to Main / test (push) Successful in 34s
On Push to Main / publish (push) Successful in 1m11s
On Push to Main / deploy-dev (push) Successful in 3m19s
Reviewed-on: #31
2026-07-13 07:20:00 +00:00
RaymondVerhoef
b1330ebe3e ci: validate the production Caddyfile inside the built image (#26)
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 41s
On Pull Request to Main / publish (pull_request) Successful in 1m10s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m15s
The caddy-validate step added for #26 (and merged to main via #29) used a
`-v "$PWD":/workspace` bind mount, which does not propagate to the sibling
Docker daemon in the Gitea act runner — the container saw an empty dir and
every PR failed with "open Caddyfile: no such file or directory".

Validate instead inside the freshly built image, where the production
Caddyfile lives at /etc/caddy/Caddyfile: no bind mount, and it checks the
exact file that ships. Caddyfile.test keeps being covered by the homepage
smoke test (its container must serve 200).

Closes #26

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:13:12 +02:00
RaymondVerhoef
5214c9db3b feat: 5-day theme-level onboarding track from the "New here?" card (#30)
Some checks failed
On Pull Request to Main / test (pull_request) Failing after 24s
On Pull Request to Main / publish (pull_request) Has been skipped
On Pull Request to Main / deploy-dev (pull_request) Has been skipped
A self-paced onboarding track that introduces a new employee to every KB
theme in breadth (not depth), so they grasp how Respellion works day to
day and week to week. Offered as a CTA inside the Dashboard "New here?"
explainer card; always available regardless of enrollment.

Design:
- Theme is the trackable unit; the 5 "days" are a read-time presentation
  grouping, so re-chunking never loses progress. Completion is stored per
  theme in onboarding_completions.
- Per-theme overview generated lazily on first open (fast-tier
  emit_onboarding_overview tool), cached in onboarding_overviews keyed by
  theme + a topics_fingerprint that triggers regeneration when the theme's
  topic set changes.
- Reachable via /onboarding-track using the existing skipEnrollmentGate
  prop, decoupled from the 26-week curriculum (distinct from /onboarding,
  the enrollment page).

Backend:
- pb_migrations/1781200000_created_onboarding.js: two collections with
  authenticated-only rules and unique indexes; TEXT team_member_id (no
  relation) per the post-#18/#27 convention. Mirrored in
  scripts/setup-pb-collections.mjs.
- src/lib/onboardingService.js: pure helpers (orderThemes,
  distributeThemesIntoDays, computeTopicsFingerprint,
  computeOnboardingProgress, buildOnboardingPlan) + generation + I/O.
- db.js onboarding helpers use pb.filter() bindings (theme is free text).
- LLM tool + Zod schema + registry + simulation stub.

Frontend:
- src/pages/OnboardingTrack.jsx (day list, per-theme overview, completion
  banner, progress ring/day bar).
- Dashboard "New here?" card CTA + X/5-days progress chip (hidden when the
  KB has no themes).

Docs: data-model, generation-spec (§D), frontend-spec updated.

Verified: 22 new unit tests (npm test 134/134), eslint clean on changed
files, npm run build OK, PocketBase v0.30.4 boot applies the migration
(collections + unique indexes + authed rules confirmed), and a backend
contract check (upsert idempotency, unique-index guard, special-char
theme filtering).

Closes #30

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:08:38 +02:00
48715df147 Merge pull request 'Fix #27: TeamManager-rosterbeheer, role-escalatie gedicht, escalate-only resync' (#29) from fix/issue-27-teammanager-admin-rules into main
Some checks failed
On Push to Main / test (push) Failing after 25s
On Push to Main / publish (push) Has been skipped
On Push to Main / deploy-dev (push) Has been skipped
Reviewed-on: #29
2026-07-12 19:04:43 +00:00
85204938df Merge pull request 'CI: caddy validate van beide Caddyfiles in de test-job' (#28) from fix/issue-26-ci-caddy-validate into main
Some checks failed
On Push to Main / publish (push) Has been cancelled
On Push to Main / deploy-dev (push) Has been cancelled
On Push to Main / test (push) Has been cancelled
Reviewed-on: #28
2026-07-12 19:04:29 +00:00
RaymondVerhoef
cbce4555ff fix: TeamManager admin rules, close role self-escalation, escalate-only resync (#27)
Some checks failed
On Pull Request to Main / test (pull_request) Failing after 24s
On Pull Request to Main / publish (pull_request) Has been skipped
On Pull Request to Main / deploy-dev (pull_request) Has been skipped
TeamManager could not manage the roster: updateRule allowed self-update
only and deleteRule was superuser-only. The same self-update rule also
left a privilege escalation open — it did not restrict fields, so any
authenticated user could PATCH their own role to "admin".

- team_members rules (migration 1781000003 + base migration + setup
  script mirror):
    update: (@request.auth.id = id && @request.body.role:isset = false)
            || @request.auth.role = "admin"
    delete: @request.auth.role = "admin"
  Self-service onboarding keeps working (it never touches role); the
  role field is admin-only; deletion is admin-only.
- pb_hooks/team_members.pb.js: the ENTRA_ADMIN_EMAILS resync is now
  escalate-only — it guarantees admin for allow-list members on every
  login but no longer demotes, otherwise every TeamManager promotion
  would revert on the member's next sign-in (ADR-004 amended).
- TeamManager.jsx: info text updated to the new behaviour.

Verified with a two-identity mock-OIDC matrix (11/11): allow-list vs
regular login, self-escalation blocked while onboarding self-update
still works, cross-user update/delete blocked, admin promote/demote/
delete work, promotion survives the member's next login, allow-list
admin stays admin. Regression: #22 matrix 9/9, #24 matrix 8/8, #18
harness 15/15, npm test 112/112, eslint clean.

Closes #27

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:01:28 +02:00
RaymondVerhoef
6bf8bad5fb ci: validate both Caddyfiles in the test job (#26)
Some checks failed
On Pull Request to Main / test (pull_request) Failing after 26s
On Pull Request to Main / publish (pull_request) Has been skipped
On Pull Request to Main / deploy-dev (pull_request) Has been skipped
The test image swaps in Caddyfile.test, so the production Caddyfile was
never exercised by CI: the parse error from issue #20 sailed through
every check and only surfaced when the deploy gate failed — after the
broken container had already replaced the healthy one. Running
`caddy validate` (same caddy:2-alpine base as the Dockerfile) on both
files up front fails the PR check in seconds instead.

Touches the frozen .github/workflows/ per explicit product-owner
approval in #26.

Closes #26

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:55:35 +02:00
RaymondVerhoef
54137122a7 fix: read Entra claims from id_token + UPN e-mail fallback (#24)
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 38s
On Pull Request to Main / publish (pull_request) Successful in 1m10s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m7s
First Microsoft logins failed with 400 "email: Cannot be blank": Graph's
/oidc/userinfo endpoint omits the email claim for accounts without a
mail attribute, and team_members requires an e-mail (it also drives the
ENTRA_ADMIN_EMAILS role mapping). Reproduced against a mock Entra with
PocketBase v0.30.4; the user-supplied response body matched the
missing-email fingerprint exactly.

- provider config (reconciler + migration fast-path): userInfoURL is now
  empty, so PocketBase reads the claims from the Entra id_token, which
  always carries preferred_username (UPN) and email when available. The
  #18 reconciler flips the already-deployed Labs provider automatically
  on the next boot — no migration needed.
- pb_hooks/entra_oidc.pb.js: onRecordAuthWithOAuth2Request hook falls
  back to the lowercased UPN when the email claim is absent. Guest UPNs
  (ext_user#EXT#@tenant...) are excluded explicitly — "#" is RFC-valid
  in an e-mail local part, so both a naive regex AND PocketBase's own
  validation would accept them (caught by test V5). The hook also logs
  every failed OAuth2 attempt with the underlying error to the container
  log, which PocketBase otherwise only writes to its internal logs db.

Verified with a switchable mock-Entra matrix (8/8): no-email→UPN e-mail
+ allow-list role, email claim wins when present, no duplicate on
re-login, guest UPN yields a clean validation error, anonymous REST
create stays rejected, both log lines present. Regression: issue-22
matrix 9/9 (baseline pinned to 1a1351d now that #23 is merged), DoD
harness 15/15, npm test 112/112.

Closes #24

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:26:25 +02:00
8decdd454f Merge pull request 'Fix #22: OAuth2 sign-up toegestaan via @request.context-rule (eerste Microsoft-login werkt weer)' (#23) from fix/issue-22-oauth2-signup-rule into main
All checks were successful
On Push to Main / test (push) Successful in 33s
On Push to Main / publish (push) Successful in 1m4s
On Push to Main / deploy-dev (push) Successful in 3m3s
Reviewed-on: #23
2026-07-12 15:11:24 +00:00
RaymondVerhoef
897b46d4a1 fix: allow OAuth2 sign-up on team_members via @request.context rule (#22)
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 39s
On Pull Request to Main / publish (pull_request) Successful in 1m9s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m9s
Every first Microsoft login failed with 403 "Only superusers can perform
this action": PocketBase applies the collection createRule to the
automatic record creation during OAuth2 sign-up, and team_members was
created with createRule null (superuser-only) on the wrong assumption
that the OAuth2 flow bypasses it. Since the pre-Azure records were
deliberately dropped, every user was a first login and nobody could get
in. Reproduced locally against a mock OIDC provider (PocketBase v0.30.4).

createRule becomes '@request.context = "oauth2"': record creation is
allowed exclusively from within the OAuth2 flow. Anonymous REST creates
(which could otherwise pre-seed rogue admin profiles) remain rejected —
covered by an explicit test.

- pb_migrations/1781000002_allow_oauth2_signup.js: applies the rule on
  already-migrated environments (Labs); idempotent, guarded, with down
- pb_migrations/1781000000_team_members_to_auth.js: same rule for fresh
  environments (filename unchanged — ledger-safe)
- scripts/setup-pb-collections.mjs: fallback entry mirrored
- docs/auth-spec.md: ADR 009 + files table

Verified with a mock-OIDC matrix (9/9): baseline reproduces the 403 on
the old rule; upgrade path heals (first login 200, role/enrollment/name
defaults, second login no duplicate, anonymous create still rejected);
fresh chain + admin allow-list mapping OK. DoD harness regression 15/15,
npm test 112/112.

Closes #22

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:15:54 +02:00
RaymondVerhoef
80f738ddcb fix: valid Caddyfile handle_errors + frontend health-gate in deploys (#20)
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 39s
On Pull Request to Main / publish (pull_request) Successful in 1m7s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m6s
The learning-platform (Caddy) container crash-looped since this morning's
89d3395: `Content-Type application/json` is not a valid subdirective of
`respond` — a Caddyfile parse error aborts Caddy at startup, taking the
whole app offline for authenticated users while every CI run stayed green.
Verified with caddy v2.10.0: "unrecognized subdirective 'Content-Type',
at Caddyfile:53"; the fixed file validates clean.

CI was blind to this twice over: test.yml swaps the real Caddyfile for
Caddyfile.test in the test image, and the deploy health-gate from #18
only covers PocketBase.

- Caddyfile: set Content-Type via a `header @api` directive before the
  `respond`; behaviour of the JSON error response is unchanged
- infra/*/site/deploy-playbook.yml: frontend health-gate (docker exec
  wget --spider on the container) with log dump + abort on failure, and
  an always-on post-deploy smoke report (compose ps, proxy health,
  team_members auth-methods, PocketBase log tail) for visibility behind
  the auth perimeter

Closes #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:20:15 +02:00
1a1351ddcb Merge pull request 'Fix #18: migratie-ledger-sync + deploy-proof Azure SSO' (#19) from fix/issue-18-migration-ledger-sso into main
All checks were successful
On Push to Main / test (push) Successful in 32s
On Push to Main / publish (push) Successful in 1m8s
On Push to Main / deploy-dev (push) Successful in 3m0s
Reviewed-on: #19
2026-07-11 11:54:37 +00:00
RaymondVerhoef
d79e69aad2 fix: heal migration-ledger mismatch and make Azure SSO deploy-proof (#18)
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 39s
On Pull Request to Main / publish (pull_request) Successful in 1m9s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m1s
PocketBase on Labs crash-looped since the SSO deploy (PR #17): mounting
--migrationsDir for the first time replayed the entire migration history
against a database that was provisioned out-of-band (empty _migrations
ledger) and died on 1778948471_created_content.js. On top of that the
team_members->auth migration had its own crash paths and trapped OAuth2
config inside a one-shot, env-dependent migration.

- pb_migrations/1000000000_baseline_ledger_sync.js: detects an
  out-of-band provisioned DB (schema exists, ledger empty) and marks the
  79 historical migrations as applied; no-op on fresh or already-synced DBs
- pb_migrations/1781000000_team_members_to_auth.js: idempotency guard,
  relation->text conversion WITH data preservation (PocketBase diffs
  fields by id, so the column is backed up and restored via SQL), unique
  index rebuild, no silent catches, env only as fast-path
- pb_hooks/entra_oidc.pb.js + pb_hooks/utils.js: reconcile the Entra OIDC
  provider from ENTRA_* env on every bootstrap + cron tick
  (compare-before-save, warn-once); heals environments that migrated
  without secrets and supports secret rotation without re-apply
- pb_hooks/team_members.pb.js: require() pattern — JSVM runs callbacks as
  isolated programs, top-level helpers are not in scope (adopts Leroy's
  fix from the fix-sso branch)
- infra/*/site/deploy-playbook.yml: health-gate after compose up — the
  deploy fails loudly with container logs when PocketBase does not become
  healthy (runs #83-#88 were green while PB crash-looped)
- docker-compose.yml: .env.local is optional again
- docs/auth-spec.md + AI_AGENT.md: ledger/reconciler documentation, ADRs
  006-008, never-rename-applied-migrations warning

Verified locally against PocketBase v0.30.4 with a 7-scenario DoD matrix
(fresh DB +/- env, out-of-band DB with data incl. data preservation,
populated-ledger upgrade, late-secrets healing, re-run guard, hook
provisioning): 15/15 pass. npm test 112/112.

Closes #18

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:14:09 +02:00
RaymondVerhoef
89d3395a62 feat: enhance OIDC configuration for Azure login and improve error handling in API responses
All checks were successful
On Push to Main / test (push) Successful in 56s
On Push to Main / publish (push) Successful in 1m24s
On Push to Main / deploy-dev (push) Successful in 2m58s
2026-07-11 10:19:54 +02:00
RaymondVerhoef
d5191073f0 feat: migrate team_members to OIDC-based auth collection and update docker-compose environment configuration
All checks were successful
On Push to Main / test (push) Successful in 57s
On Push to Main / publish (push) Successful in 1m16s
On Push to Main / deploy-dev (push) Successful in 3m0s
2026-06-25 18:05:39 +02:00
fab7a12f7b Merge pull request 'fix/issue-16-azure-login' (#17) from fix/issue-16-azure-login into main
All checks were successful
On Push to Main / test (push) Successful in 30s
On Push to Main / publish (push) Successful in 1m15s
On Push to Main / deploy-dev (push) Successful in 2m54s
Reviewed-on: #17
2026-06-24 14:00:40 +00:00
RaymondVerhoef
fe99381cb7 ci: pass Entra (Azure) secrets to deploy playbooks (#16)
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 55s
On Pull Request to Main / publish (pull_request) Successful in 1m16s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m2s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:57:33 +02:00
RaymondVerhoef
3af105bccd feat: Azure (Entra ID) login as user login (#16)
Replaces the client-side PIN login with real authentication against Azure
Entra ID via PocketBase's built-in OAuth2 (OIDC) flow. Every Azure user is
auto-provisioned as a team_member on first login.

- pb_migrations: team_members becomes a PocketBase auth collection (OIDC
  provider configured from env); all collections require @request.auth.id
- pb_hooks: provisioning of role (ENTRA_ADMIN_EMAILS allow-list) and
  enrollment_status, with admin-role re-sync on every login
- frontend: "Sign in with Microsoft" login, pb.authStore-based session,
  TeamManager manages roster/roles (no PIN/manual create)
- infra: Entra env wiring + pb_hooks mount for dev (Labs) and prod
- src/lib/azureAuth.js + tests for the canonical role/allow-list logic
- docs/auth-spec.md

Knowledge base, generated tests and micro-learnings are left untouched.
Existing PIN users are dropped (no migration required, per sign-off).

Closes #16

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:41:08 +02:00
5f34a6f825 Merge pull request 'Bescherm excluded en locked topics tegen AI-deletion/merge' (#15) from fix/protect-excluded-locked-topics into main
All checks were successful
On Push to Main / test (push) Successful in 36s
On Push to Main / publish (push) Successful in 1m19s
On Push to Main / deploy-dev (push) Successful in 1m45s
Reviewed-on: #15
2026-06-04 06:29:33 +00:00
RaymondVerhoef
9395ea11fe fix: bescherm excluded en locked topics tegen AI-deletion/merge
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 46s
On Pull Request to Main / publish (pull_request) Successful in 1m26s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m49s
Topics met learning_relevance="exclude" of relevance_locked=true werden
door de Full Analysis stilletjes verwijderd: het model zag exclude als
"irrelevant" en stelde ze voor in actions.deletions / actions.merges,
en analyzeGraph paste die zonder filter toe. Het locked-vlaggetje werd
in full-scope payload niet eens meegestuurd, dus zelfs een nieuwe prompt
kon niet helpen.

Defense-in-depth fixes:

1. Pure graphGuard.filterAiActions strips elke deletion/merge waarvan de
   target excluded of locked is, vóór bulkSave. Merges waarin een
   protected topic juist de keepId is (canonical survivor) blijven door.

2. SYSTEM_PROMPTS.full krijgt een expliciete "PROTECTED TOPICS" sectie
   die exclude en locked topics als nooit-te-verwijderen markeert.

3. relevance_locked wordt nu ook in de full-scope compactTopics payload
   meegestuurd, zodat het model de vlag überhaupt ziet.

4. UI-feedback: GraphControls toont een ShieldCheck banner met aantal
   geblokkeerde acties na de analyze, en het excluded-aantal naast de
   "Show Excluded Nodes" toggle (visible/hidden).

5. 13 nieuwe Vitest cases dekken protected detection en alle drop/keep
   paden van filterAiActions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 08:22:12 +02:00
56 changed files with 3317 additions and 211 deletions

View File

@@ -28,4 +28,8 @@ jobs:
-i infra/development/hosts.ini \
-e "ansible_ssh_private_key_file=~/.ssh/deploy_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

View File

@@ -30,4 +30,8 @@ jobs:
-i infra/production/hosts.ini \
-e "ansible_ssh_private_key_file=~/.ssh/deploy_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

View File

@@ -14,6 +14,22 @@ jobs:
- name: Build Docker image
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
run: |
cat > Dockerfile.test <<'EOF'

View File

@@ -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.
* **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`.
* **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
The platform uses a **26-week perpetual curriculum cycle**. Every employee covers the knowledge base in focused, thematic weekly blocks, **starting whenever they enroll**.

View File

@@ -127,6 +127,7 @@ when the PB binary starts.
- `docs/generation-spec.md` — learning content + micro-learning generation
- `docs/curriculum-spec.md` — 26-week per-user curriculum engine
- `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/gamification-spec.md` — points, badges, leaderboard
- `micro-learning-spec.md` — micro-learning learner experience

View File

@@ -46,6 +46,16 @@
}
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
file_server
}

View File

@@ -12,7 +12,13 @@ services:
container_name: pocketbase-learning
restart: unless-stopped
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"]
ports:
- "8090:8090"
volumes:
- pb_data:/pb/pb_data
- ./pb_migrations:/pb/pb_migrations

122
docs/auth-spec.md Normal file
View 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).

View File

@@ -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
These existed in earlier iterations and have been removed. Their `db.js` helpers

View File

@@ -10,8 +10,9 @@ A React 19 SPA built with Vite, routed by React Router 7. Entry: `src/main.jsx`
| Route | Screen | Access |
|---|---|---|
| `/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 |
| `/onboarding-track` | `OnboardingTrack.jsx` | any logged-in user, **any enrollment** (`skipEnrollmentGate`) |
| `/learn` | `Leren.jsx` | enrolled user |
| `/test` | `Testen.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`:
- redirects to `/login` if not authenticated;
- 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`.
> `/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`.
`ChatLauncher` (R42) is mounted globally.
@@ -51,7 +57,17 @@ enrolled are redirected to `/`. See `docs/curriculum-spec.md`.
## Employee screens
- **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
knowledge library; opening a topic shows the micro-learning selector
(`src/components/micro_learning/`). Completing ≥1 micro-learning marks the week done.

View File

@@ -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[35], 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`)
- **Tiers:** `fast` (Haiku 4.5), `standard` (Sonnet 4.6), `reasoning` (Opus 4.7);

View File

@@ -18,6 +18,8 @@ client-side and is grounded by local TF-IDF retrieval — **no vector database**
context is truncated with a notice.
- A greeting message seeds an empty thread.
- 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`:
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.
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
content.
5. Inject up to ~1000 chars of generated content for up to **5** topics —
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.
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
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.

View File

@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'pb_migrations']),
globalIgnores(['dist', 'pb_migrations', 'pb_hooks']),
{
files: ['**/*.{js,jsx}'],
extends: [

View File

@@ -4,6 +4,9 @@ networks:
learning-platform:
external: true
volumes:
pb_data:
services:
learning-platform:
image: git.labs.respellion.tech/respellion/learning-platform/learning-platform:latest
@@ -18,11 +21,18 @@ services:
image: ghcr.io/muchobien/pocketbase:latest
container_name: pocketbase-learning
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:
- pb_data:/pb/pb_data
- ./pb_migrations:/pb/pb_migrations
- ./pb_hooks:/pb/pb_hooks
networks:
- learning-platform
volumes:
pb_data:

View File

@@ -36,11 +36,27 @@
src: 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
ansible.builtin.copy:
dest: "{{ dest_dir }}/.env"
content: |
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'
- name: Pull latest image
@@ -56,3 +72,81 @@
state: present
files: compose.yml
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 }}"

View File

@@ -22,9 +22,17 @@ services:
container_name: pocketbase-learning
restart: unless-stopped
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:
- pb_data:/pb/pb_data
- ./pb_migrations:/pb/pb_migrations
- ./pb_hooks:/pb/pb_hooks
networks:
- learning-platform

View File

@@ -42,11 +42,21 @@
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
ansible.builtin.copy:
dest: "{{ dest_dir }}/.env"
content: |
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'
- name: Pull latest image
@@ -62,3 +72,81 @@
state: present
files: compose.yml
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 }}"

View 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
View 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");

View 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
View 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";
},
};

View 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.
});

View 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);
});

View 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);
}
});

View 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);
});

View 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);
});

View 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);
}
})

View File

@@ -14,6 +14,8 @@ if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
}
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
const AUTODATE_FIELDS = [
@@ -102,17 +104,28 @@ const COLLECTIONS = [
...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',
type: 'base',
type: 'auth',
...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: [
{ name: 'name', type: 'text', required: true },
{ name: 'pin', type: 'text', required: false },
{ name: 'name', type: 'text', required: false },
{ name: 'role', type: 'text', required: false },
{ name: 'curriculum_started_at', type: 'date', required: false },
{ name: 'enrollment_status', type: 'text', required: false },
...AUTODATE_FIELDS,
],
},
{
@@ -193,6 +206,32 @@ const COLLECTIONS = [
...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) {

View File

@@ -7,6 +7,7 @@ import ChatLauncher from './components/chat/ChatLauncher'
import Login from './pages/Login'
import Onboarding from './pages/Onboarding'
import OnboardingTrack from './pages/OnboardingTrack'
import Dashboard from './pages/Dashboard'
import Admin from './pages/Admin'
@@ -106,6 +107,10 @@ function App() {
<Route path="/login" element={<Login />} />
<Route path="/onboarding" element={<Onboarding />} />
<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="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
<Route path="/topic-test" element={<ProtectedRoute><TopicTest /></ProtectedRoute>} />

View File

@@ -5,6 +5,7 @@ import { callLLM } from '../../lib/llm';
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
import { Network, Table2 } from 'lucide-react';
import { useGraphData } from '../../hooks/useGraphData';
import { filterAiActions } from '../../lib/graphGuard';
import GraphControls from './graph/GraphControls';
import NodeDetailPanel from './graph/NodeDetailPanel';
import GraphTable from './graph/GraphTable';
@@ -25,11 +26,21 @@ const SYSTEM_PROMPTS = {
full: `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
PROTECTED TOPICS — NEVER include these in merges (as deleteId) or in deletions:
• Topics with learning_relevance="exclude" are REFERENCE MATERIAL, kept on purpose
so R42 can still answer questions about them. They are intentionally outside
the theme-learning flow. Do not propose deleting or merging them away.
• Topics with relevance_locked=true are admin-pinned. Do not change their
learning_relevance and do not delete or merge them.
These topics may appear as the keepId in a merge (others fold into them), and
may appear as source/target of newRelations, but their own row must survive.
Rules:
1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges).
2. Identify topics that are too vague, irrelevant, or malformed (deletions).
3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations).
4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates).
Skip topics where relevance_locked=true — omit them from relevanceUpdates entirely.
Do not return the entire graph — only the actions to take.`,
@@ -73,6 +84,7 @@ const KnowledgeGraph = () => {
const [showExcludeNodes, setShowExcludeNodes] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState(null);
const [analyzeNotice, setAnalyzeNotice] = useState(null); // info-level message from last analyze
const [isRestoring, setIsRestoring] = useState(false);
const [viewMode, setViewMode] = useState('graph'); // 'graph' | 'table'
@@ -305,6 +317,7 @@ const KnowledgeGraph = () => {
setIsAnalyzing(true);
setAnalyzeError(null);
setAnalyzeNotice(null);
try {
const [currentTopics, currentRelations] = await Promise.all([
@@ -314,13 +327,16 @@ const KnowledgeGraph = () => {
const tier = scope === 'full' ? 'reasoning' : 'standard';
// For relevance scope, include relevance_locked so the model can skip those.
// For full + relevance scopes the model needs to see relevance_locked so
// it can honor the "do not touch protected topics" rule. (relations-scope
// only emits newRelations, so the flag is irrelevant there.)
const sendLocked = scope === 'full' || scope === 'relevance';
const compactTopics = currentTopics.map(t => ({
id: t.id,
label: t.label,
type: t.type,
learning_relevance: t.learning_relevance,
...(scope === 'relevance' && t.relevance_locked ? { relevance_locked: true } : {}),
...(sendLocked && t.relevance_locked ? { relevance_locked: true } : {}),
}));
const compactRelations = currentRelations.map(({ source, target, type }) => ({
source, target, type,
@@ -336,8 +352,26 @@ const KnowledgeGraph = () => {
maxTokens: scope === 'full' ? 4096 : 2048,
});
const actions = llmResult.toolUses[0]?.input;
if (!actions) throw new Error('Graph analysis did not emit a tool result.');
const rawActions = llmResult.toolUses[0]?.input;
if (!rawActions) throw new Error('Graph analysis did not emit a tool result.');
// ── Safety guard ─────────────────────────────────────────────────────
// Strip merges/deletions that target excluded or locked topics. Excluded
// topics are reference material kept on purpose for R42; locked topics
// are admin-pinned. The AI may suggest removing them anyway — we drop
// those suggestions before they reach bulkSave.
const { filtered: actions, dropped } = filterAiActions(currentTopics, rawActions);
const droppedCount = dropped.deletions.length + dropped.merges.length;
if (droppedCount > 0) {
// Visible feedback so the admin knows the AI tried to touch protected rows.
console.warn(
`[graph guard] Blocked ${droppedCount} destructive action(s) on protected topics`,
dropped,
);
setAnalyzeNotice(
`Guard blocked ${droppedCount} destructive AI action${droppedCount === 1 ? '' : 's'} on excluded or locked topics. ${dropped.deletions.length} deletion${dropped.deletions.length === 1 ? '' : 's'}, ${dropped.merges.length} merge${dropped.merges.length === 1 ? '' : 's'}.`,
);
}
let updatedTopics = [...currentTopics];
let updatedRelations = [...currentRelations];
@@ -460,9 +494,11 @@ const KnowledgeGraph = () => {
<GraphControls
showExcludeNodes={showExcludeNodes}
onShowExcludeChange={setShowExcludeNodes}
excludedCount={topics.filter(t => t.learning_relevance === 'exclude').length}
onAnalyze={analyzeGraph}
isAnalyzing={isAnalyzing}
analyzeError={analyzeError}
analyzeNotice={analyzeNotice}
disabled={topics.length === 0}
onApplied={reload}
snapshotMeta={snapshotMeta}

View File

@@ -1,19 +1,21 @@
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 Button from '../ui/Button';
import Input from '../ui/Input';
import Tag from '../ui/Tag';
import * as db from '../../lib/db';
import { pb } from '../../lib/pb';
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 { state } = useApp();
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 loadUsers = async () => {
@@ -23,29 +25,16 @@ const TeamManager = () => {
useEffect(() => { loadUsers(); }, []);
const handleSave = async (e) => {
e.preventDefault();
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);
const flash = (msg) => {
setMessage(msg);
setTimeout(() => setMessage(''), 3000);
};
const handleEdit = (user) => {
setFormData({ name: user.name, role: user.role, pin: user.pin });
setIsEditing(true);
setEditingId(user.id);
const handleRoleChange = async (user, role) => {
if (role === user.role) return;
await db.updateTeamMember(user.id, { role });
await loadUsers();
flash(`${user.name} is now ${role}.`);
};
const handleDelete = async (id) => {
@@ -56,24 +45,17 @@ const TeamManager = () => {
if (confirm("Are you sure you want to delete this user?")) {
await db.deleteTeamMember(id);
// Also remove from leaderboard
// Also remove from leaderboard.
try {
const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
await pb.collection('leaderboard').delete(entry.id);
} catch { /* no leaderboard entry, nothing to do */ }
await loadUsers();
setMessage('User deleted.');
setTimeout(() => setMessage(''), 3000);
flash('User deleted.');
}
};
const cancelEdit = () => {
setIsEditing(false);
setEditingId(null);
setFormData({ name: '', role: 'user', pin: '' });
};
return (
<div className="space-y-8">
{message && (
@@ -82,58 +64,23 @@ const TeamManager = () => {
</div>
)}
<Card className="border border-bg-warm">
<h2 className="text-xl font-bold flex items-center gap-2 mb-4">
{isEditing ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />}
{isEditing ? 'Edit Team Member' : 'Add New Team Member'}
</h2>
<form onSubmit={handleSave} className="flex flex-col sm:flex-row gap-4 items-end">
<div className="flex-1 w-full">
<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 className="border border-bg-warm bg-bg-warm/30">
<p className="text-sm text-fg-muted flex items-start gap-2">
<Info size={16} className="mt-0.5 shrink-0 text-teal" />
Team members are created automatically when they first sign in with their
Microsoft account. Members of the <code>ENTRA_ADMIN_EMAILS</code> allow-list
are always admin; promotions made here stick, but demoting an allow-list
member is undone on their next sign-in.
</p>
</Card>
<Card className="p-0 border border-bg-warm overflow-hidden">
<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 => (
<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">
@@ -145,19 +92,26 @@ const TeamManager = () => {
{user.name}
{user.id === state.currentUser?.id && <Tag variant="accent" className="text-[10px] py-0">You</Tag>}
</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 className="flex items-center gap-3">
<Tag variant={user.role === 'admin' ? 'dark' : 'default'} className="hidden sm:inline-flex">{user.role}</Tag>
<button onClick={() => handleEdit(user)} className="p-2 text-fg-muted hover:text-teal transition-colors">
<Edit2 size={16} />
</button>
<select
value={user.role || 'user'}
onChange={e => handleRoleChange(user, e.target.value)}
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
onClick={() => handleDelete(user.id)}
disabled={user.id === state.currentUser?.id}
className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
aria-label={`Delete ${user.name}`}
>
<Trash2 size={16} />
</button>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { RotateCcw, RefreshCw, AlertCircle, ChevronDown } from 'lucide-react';
import { RotateCcw, RefreshCw, AlertCircle, ChevronDown, ShieldCheck } from 'lucide-react';
import Button from '../../ui/Button';
import SuggestionsQueue from '../SuggestionsQueue';
@@ -24,9 +24,11 @@ const SCOPE_OPTIONS = [
export default function GraphControls({
showExcludeNodes,
onShowExcludeChange,
excludedCount = 0,
onAnalyze,
isAnalyzing,
analyzeError,
analyzeNotice,
disabled,
onApplied,
snapshotMeta,
@@ -50,7 +52,14 @@ export default function GraphControls({
onChange={e => onShowExcludeChange(e.target.checked)}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
Show Excluded Nodes (Reference Material)
<span>
Show Excluded Nodes (Reference Material)
{excludedCount > 0 && (
<span className="ml-1 text-xs text-fg-muted/80">
· {excludedCount} {showExcludeNodes ? 'visible' : 'hidden'}
</span>
)}
</span>
</label>
<div className="space-y-2">
@@ -106,6 +115,16 @@ export default function GraphControls({
{analyzeError}
</p>
)}
{analyzeNotice && (
<p
className="text-xs text-teal flex items-start gap-1 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)] p-2"
title="The AI suggested removing excluded/locked topics. Those suggestions were dropped client-side before saving."
>
<ShieldCheck size={14} className="shrink-0 mt-0.5" />
{analyzeNotice}
</p>
)}
</div>
<SuggestionsQueue onApplied={onApplied} />

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Trash2 } from 'lucide-react';
import Mark from '../ui/Mark';
import ChatMessage from './ChatMessage';
import { useChat } from './useChat';
@@ -6,7 +7,7 @@ import { kbStore } from '../../lib/kbStore';
import { BOT_NAME, STRINGS } from './prompts';
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 bodyRef = useRef(null);
const inputRef = useRef(null);
@@ -60,6 +61,14 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
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 => {
if (!m.suggestion) return m;
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-status"><i /> {STRINGS.status}</div>
</div>
<button
type="button"
className="r42-window-hd-x"
onClick={onClose}
aria-label={STRINGS.closeAria}
>
×
</button>
<div className="r42-window-hd-actions">
<button
type="button"
className="r42-window-hd-clear"
onClick={handleClear}
disabled={isThinking}
aria-label={STRINGS.clearAria}
title={STRINGS.clearAria}
>
<Trash2 size={16} />
</button>
<button
type="button"
className="r42-window-hd-x"
onClick={onClose}
aria-label={STRINGS.closeAria}
>
×
</button>
</div>
</header>
<div className="r42-window-body" ref={bodyRef}>

View 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/);
});
});

View File

@@ -87,8 +87,13 @@
border-radius: 999px;
background: var(--sage);
}
.r42-window-hd-x {
.r42-window-hd-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 2px;
}
.r42-window-hd-x {
color: rgba(236, 233, 233, 0.7);
background: transparent;
border: none;
@@ -99,6 +104,18 @@
border-radius: var(--r-sm);
}
.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 {
flex: 1;

View File

@@ -21,6 +21,8 @@ export const STRINGS = {
suggestionDismissed: 'Oké, niets gedaan.',
closeAria: 'Sluit chatvenster',
openAria: 'Open R42 chatbot',
clearAria: 'Wis gesprek',
clearConfirm: 'Dit gesprek wissen? Dit kan niet ongedaan worden gemaakt.',
};
const STABLE_PREAMBLE = [
@@ -35,11 +37,16 @@ const STABLE_PREAMBLE = [
`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.`,
``,
`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:`,
`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:`,
`- 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.`,
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
].join('\n');

View File

@@ -2,6 +2,13 @@ import * as db from '../../lib/db';
import { buildIndex, retrieveTopK } from '../../lib/retrieval';
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) {
const enc = new TextEncoder().encode(input);
@@ -71,7 +78,7 @@ export async function buildKbContext(userMessage = '') {
const included = [...includedById.values()];
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}`;
});
@@ -85,19 +92,30 @@ export async function buildKbContext(userMessage = '') {
}
}
const mentionedDeepContent = [];
for (const id of mentionedIds) {
const t = includedById.get(id);
if (!t) continue;
const content = await db.getContent(t.id).catch(() => null);
if (!content) continue;
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, 1200);
mentionedDeepContent.push(`### ${t.label}\n${snippet}`);
// Pick which topics get their full article body: verbatim mentions first,
// then the highest-ranked retrieved topics, capped at DEEP_CONTENT_LIMIT.
const deepIds = [];
for (const id of mentionedIds) deepIds.push(id);
for (const t of retrieved) {
if (deepIds.length >= DEEP_CONTENT_LIMIT) break;
if (!mentionedIds.has(t.id)) deepIds.push(t.id);
}
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 = [
`KENNISGRAAF — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`,
@@ -106,7 +124,7 @@ export async function buildKbContext(userMessage = '') {
`KENNISGRAAF — RELATIES (binnen deze selectie):`,
relLines.length ? relLines.join('\n') : '(geen relaties binnen deze selectie)',
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.`,

View File

@@ -92,6 +92,19 @@ export function useChat({ user, isAdmin }) {
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 trimmed = (text || '').trim();
if (!trimmed || !user) return;
@@ -225,5 +238,6 @@ export function useChat({ user, isAdmin }) {
errored,
send,
updateMessage,
clearThread,
};
}

View 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');
});
});
});

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import { isProtectedTopic, filterAiActions } from '../graphGuard';
const topic = (id, extras = {}) => ({
id,
label: `Topic ${id}`,
type: 'concept',
learning_relevance: 'standard',
relevance_locked: false,
...extras,
});
describe('isProtectedTopic', () => {
it('flags excluded topics', () => {
expect(isProtectedTopic(topic('a', { learning_relevance: 'exclude' }))).toBe(true);
});
it('flags locked topics', () => {
expect(isProtectedTopic(topic('a', { relevance_locked: true }))).toBe(true);
});
it('flags topics that are both excluded and locked', () => {
expect(
isProtectedTopic(topic('a', { learning_relevance: 'exclude', relevance_locked: true })),
).toBe(true);
});
it('does not flag standard / core / peripheral topics', () => {
expect(isProtectedTopic(topic('a', { learning_relevance: 'core' }))).toBe(false);
expect(isProtectedTopic(topic('a', { learning_relevance: 'standard' }))).toBe(false);
expect(isProtectedTopic(topic('a', { learning_relevance: 'peripheral' }))).toBe(false);
});
it('treats null/undefined as not protected', () => {
expect(isProtectedTopic(null)).toBe(false);
expect(isProtectedTopic(undefined)).toBe(false);
});
});
describe('filterAiActions', () => {
const topics = [
topic('safe'),
topic('excluded', { learning_relevance: 'exclude' }),
topic('locked', { relevance_locked: true }),
topic('both', { learning_relevance: 'exclude', relevance_locked: true }),
topic('other'),
];
it('drops deletions that target excluded topics', () => {
const { filtered, dropped } = filterAiActions(topics, {
deletions: ['safe', 'excluded', 'other'],
merges: [],
});
expect(filtered.deletions).toEqual(['safe', 'other']);
expect(dropped.deletions).toEqual(['excluded']);
});
it('drops deletions that target locked topics', () => {
const { filtered, dropped } = filterAiActions(topics, {
deletions: ['locked'],
merges: [],
});
expect(filtered.deletions).toEqual([]);
expect(dropped.deletions).toEqual(['locked']);
});
it('drops merges whose deleteId is protected', () => {
const merges = [
{ keepId: 'safe', deleteId: 'other' }, // ok
{ keepId: 'safe', deleteId: 'excluded' }, // drop — excluded
{ keepId: 'other', deleteId: 'locked' }, // drop — locked
{ keepId: 'safe', deleteId: 'both' }, // drop — both flags
];
const { filtered, dropped } = filterAiActions(topics, { merges, deletions: [] });
expect(filtered.merges).toEqual([{ keepId: 'safe', deleteId: 'other' }]);
expect(dropped.merges).toHaveLength(3);
expect(dropped.merges.map(m => m.deleteId)).toEqual(['excluded', 'locked', 'both']);
});
it('keeps merges where a protected topic is the keepId (canonical survivor)', () => {
const merges = [
{ keepId: 'excluded', deleteId: 'safe' }, // ok — protected is survivor
{ keepId: 'locked', deleteId: 'other' }, // ok — protected is survivor
];
const { filtered, dropped } = filterAiActions(topics, { merges, deletions: [] });
expect(filtered.merges).toEqual(merges);
expect(dropped.merges).toEqual([]);
});
it('passes through other action keys untouched', () => {
const actions = {
deletions: [],
merges: [],
newRelations: [{ source: 'a', target: 'b', type: 'related_to' }],
relevanceUpdates: [{ id: 'safe', learning_relevance: 'peripheral' }],
};
const { filtered } = filterAiActions(topics, actions);
expect(filtered.newRelations).toEqual(actions.newRelations);
expect(filtered.relevanceUpdates).toEqual(actions.relevanceUpdates);
});
it('tolerates missing actions keys', () => {
const { filtered, dropped } = filterAiActions(topics, {});
expect(filtered.deletions).toEqual([]);
expect(filtered.merges).toEqual([]);
expect(dropped.deletions).toEqual([]);
expect(dropped.merges).toEqual([]);
});
it('tolerates null actions', () => {
const { filtered, dropped } = filterAiActions(topics, null);
expect(filtered.deletions).toEqual([]);
expect(filtered.merges).toEqual([]);
expect(dropped.deletions).toEqual([]);
expect(dropped.merges).toEqual([]);
});
it('drops references to unknown ids by treating them as not protected (delete pass-through)', () => {
// If the AI hallucinates an id, the deletion is harmless downstream (no
// topic by that id exists). We do NOT block on unknown ids — that would
// mask real bugs. Verify pass-through behavior.
const { filtered } = filterAiActions(topics, { deletions: ['ghost-id'], merges: [] });
expect(filtered.deletions).toEqual(['ghost-id']);
});
});

View File

@@ -9,6 +9,7 @@ import {
customTopicSchema,
graphActionsSchema,
proposeGraphDeltaSchema,
onboardingOverviewSchema,
} from '../llmSchemas';
const sampleTopic = {
@@ -192,3 +193,56 @@ describe('proposeGraphDeltaSchema', () => {
).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();
});
});

View 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([]);
});
});
});

View File

@@ -50,6 +50,31 @@ describe('buildIndex / retrieveTopK', () => {
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', () => {
const idx1 = buildIndex(sampleTopics);
const idx2 = buildIndex(sampleTopics);

56
src/lib/azureAuth.js Normal file
View 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';
}

View File

@@ -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 ───────────────────────────────────────────────────────────────
export async function getLeaderboard() {

81
src/lib/graphGuard.js Normal file
View File

@@ -0,0 +1,81 @@
/**
* Knowledge-graph safety guards.
*
* The "Full Analysis" pass lets the LLM propose `merges` (collapse two topics
* into one) and `deletions` (drop a topic entirely). Both are destructive and
* persisted via bulkSave → db.saveTopics, which wipes-and-rewrites the
* `topics` collection.
*
* Two classes of topic must NEVER be removed by an AI suggestion:
*
* 1. `learning_relevance === 'exclude'`
* Reference material — kept on purpose, surfaced only to R42 search,
* intentionally excluded from theme topics. An admin made this choice;
* the AI's "irrelevant" judgement must not override it.
*
* 2. `relevance_locked === true`
* Admin pinned this row's relevance. By extension the row itself is
* also pinned — deleting it would be a louder change than re-scoring it.
*
* `filterAiActions` strips any merge/deletion that would touch a protected id
* BEFORE it reaches bulkSave. The model still gets to suggest them (we can't
* stop it generating), but those suggestions are dropped client-side.
*
* The function is intentionally pure so it is trivially unit-testable.
*/
/**
* @param {{ learning_relevance?: string, relevance_locked?: boolean }} topic
*/
export function isProtectedTopic(topic) {
if (!topic) return false;
if (topic.learning_relevance === 'exclude') return true;
if (topic.relevance_locked === true) return true;
return false;
}
/**
* Strip protected topics out of `actions.deletions` and `actions.merges`.
*
* Merges have two ids:
* - `keepId` — the survivor
* - `deleteId` — gets removed and its relations re-pointed
*
* If `deleteId` is protected we drop the entire merge (we will not delete a
* protected topic, even to consolidate it). If `keepId` is protected we keep
* the merge — folding others into a protected canonical topic is fine.
*
* @param {object[]} currentTopics
* @param {{ merges?: {keepId: string, deleteId: string}[], deletions?: string[], newRelations?: object[], relevanceUpdates?: object[] }} actions
* @returns {{ filtered: object, dropped: { deletions: string[], merges: object[] } }}
*/
export function filterAiActions(currentTopics, actions) {
const byId = new Map(currentTopics.map(t => [t.id, t]));
const isProtected = (id) => isProtectedTopic(byId.get(id));
const droppedDeletions = [];
const keptDeletions = [];
for (const id of actions?.deletions ?? []) {
if (isProtected(id)) droppedDeletions.push(id);
else keptDeletions.push(id);
}
const droppedMerges = [];
const keptMerges = [];
for (const m of actions?.merges ?? []) {
if (isProtected(m?.deleteId)) droppedMerges.push(m);
else keptMerges.push(m);
}
return {
filtered: {
...(actions || {}),
deletions: keptDeletions,
merges: keptMerges,
},
dropped: {
deletions: droppedDeletions,
merges: droppedMerges,
},
};
}

View File

@@ -213,6 +213,13 @@ const SIMULATION_TOOL_STUBS = {
},
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
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 = [] }) {

View File

@@ -229,6 +229,45 @@ export const themeSessionSchema = z.object({
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`
* consults this when the caller does not pass an explicit `toolSchemas`
@@ -252,4 +291,5 @@ export const toolSchemaRegistry = {
remove_section: removeSectionPatchSchema,
replace_takeaways: replaceTakeawaysPatchSchema,
emit_theme_session: themeSessionSchema,
emit_onboarding_overview: onboardingOverviewSchema,
};

View File

@@ -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, 26 words.' },
what_it_is: { type: 'string', description: '12 plain-language sentences defining what this theme is about.' },
why_it_matters: { type: 'string', description: '13 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: '35 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'],
},
};

View 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, 35 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);
}

View File

@@ -63,6 +63,29 @@ export function buildIndex(topics) {
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) {
if (!index || !index.N || !query) return [];
const qTokens = tokenize(query);
@@ -80,8 +103,19 @@ export function retrieveTopK(index, query, k = 10) {
let s = 0;
for (const t of qTokens) {
const f = tf.get(t);
if (!f) continue;
s += (1 + Math.log(f)) * idf(t);
if (f) {
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;
}

View File

@@ -197,7 +197,7 @@ const Admin = () => {
{activeTab === 'team' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<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 />
</div>
)}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
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 Card from '../components/ui/Card';
import Button from '../components/ui/Button';
@@ -9,6 +9,7 @@ import * as db from '../lib/db';
import { storage } from '../lib/storage';
import { getAssignedTopic } from '../lib/learningService';
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService';
import { getOnboardingSummary } from '../lib/onboardingService';
const Dashboard = () => {
const { state } = useApp();
@@ -25,6 +26,7 @@ const Dashboard = () => {
yearProgress: null,
hasCurriculum: false,
theme: '',
onboarding: null,
});
useEffect(() => {
@@ -69,6 +71,15 @@ const Dashboard = () => {
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({
topic,
learnDone,
@@ -80,13 +91,19 @@ const Dashboard = () => {
yearProgress,
hasCurriculum: curriculumExists,
theme: topic?.theme || '',
onboarding,
});
};
load();
}, [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 currWeek = getCurriculumWeek(weekNumber);
@@ -174,6 +191,31 @@ const Dashboard = () => {
</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>
)}

View File

@@ -2,37 +2,25 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApp } from '../store/AppContext';
import Card from '../components/ui/Card';
import Input from '../components/ui/Input';
import Select from '../components/ui/Select';
import Button from '../components/ui/Button';
const Login = () => {
const { state, login } = useApp();
const { loginWithAzure } = useApp();
const navigate = useNavigate();
const [selectedUser, setSelectedUser] = useState('');
const [pin, setPin] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const userOptions = [
{ value: '', label: 'Select a user...' },
...state.users.map(u => ({ value: u.id, label: u.name }))
];
const handleLogin = (e) => {
e.preventDefault();
const handleAzure = async () => {
setError('');
if (!selectedUser || !pin) {
setError('Please fill in all fields.');
return;
}
const success = login(selectedUser, pin);
if (success) {
setBusy(true);
try {
await loginWithAzure();
navigate('/');
} else {
setError('Incorrect PIN.');
} catch (e) {
// 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>
<Card className="border border-bg-warm">
<form onSubmit={handleLogin} className="flex flex-col gap-5">
<Select
label="User"
options={userOptions}
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
/>
<div className="flex flex-col gap-5">
<p className="text-fg-muted text-sm text-center">
Sign in with your Respellion Microsoft account.
</p>
<Input
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 text-center">{error}</div>}
{error && <div className="text-red-500 text-sm font-medium">{error}</div>}
<Button type="submit" className="mt-2 w-full">
Sign In
<Button onClick={handleAzure} disabled={busy} className="w-full">
{busy ? 'Signing in…' : 'Sign in with Microsoft'}
</Button>
</form>
</div>
</Card>
</div>
</div>

View 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 1030 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>
);
}

View File

@@ -1,6 +1,7 @@
import { createContext, useContext, useReducer, useEffect } from 'react';
import * as db from '../lib/db';
import { pb } from '../lib/pb';
import { OIDC_PROVIDER } from '../lib/azureAuth';
import { getPersonalWeekNumber } from '../lib/curriculumService';
const AppContext = createContext();
@@ -50,44 +51,40 @@ export function AppProvider({ children }) {
useEffect(() => {
const loadState = async () => {
let members = await db.getTeamMembers();
if (!members || members.length === 0) {
// Restore the session from PocketBase's persistent auth store. The token
// is kept in localStorage by the SDK; authRefresh verifies it is still
// 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 {
const created = await db.addTeamMember({ name: 'Admin', role: 'admin', pin: '0000' });
members = [created];
} catch (e) {
console.warn('[AppContext] Could not auto-create admin user:', e.message,
'— Run scripts/setup-pb-collections.mjs to configure the database.');
}
}
const sessionUserId = sessionStorage.getItem('respellion_session');
if (sessionUserId) {
const user = members.find(u => u.id === sessionUserId);
if (user) {
dispatch({ type: 'LOGIN', payload: user });
const { record } = await pb.collection('team_members').authRefresh();
dispatch({ type: 'LOGIN', payload: record });
} catch {
// Token rejected (expired / account revoked) — drop the session.
pb.authStore.clear();
}
}
// 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 } });
};
loadState().catch(console.error);
}, []);
const login = (userId, pin) => {
const user = state.users.find(u => u.id === userId && u.pin === pin);
if (user) {
sessionStorage.setItem('respellion_session', user.id);
dispatch({ type: 'LOGIN', payload: user });
return true;
}
return false;
// Start the Entra (OIDC) login flow. PocketBase opens the provider popup,
// handles the token exchange server-side, and auto-provisions the record on
// first login (see pb_hooks/team_members.pb.js).
const loginWithAzure = async () => {
const authData = await pb.collection('team_members').authWithOAuth2({ provider: OIDC_PROVIDER });
dispatch({ type: 'LOGIN', payload: authData.record });
return authData.record;
};
const logout = () => {
sessionStorage.removeItem('respellion_session');
pb.authStore.clear();
dispatch({ type: 'LOGOUT' });
};
@@ -107,7 +104,7 @@ export function AppProvider({ children }) {
};
return (
<AppContext.Provider value={{ state, dispatch, login, logout, enrollCurrentUser }}>
<AppContext.Provider value={{ state, dispatch, loginWithAzure, logout, enrollCurrentUser }}>
{children}
</AppContext.Provider>
);