Compare commits
16 Commits
fix/issue-
...
b1d3686d35
| Author | SHA1 | Date | |
|---|---|---|---|
| b1d3686d35 | |||
|
|
b1330ebe3e | ||
|
|
5214c9db3b | ||
| 48715df147 | |||
| 85204938df | |||
|
|
cbce4555ff | ||
|
|
6bf8bad5fb | ||
|
|
54137122a7 | ||
| 8decdd454f | |||
|
|
897b46d4a1 | ||
|
|
80f738ddcb | ||
| 1a1351ddcb | |||
|
|
d79e69aad2 | ||
|
|
89d3395a62 | ||
|
|
d5191073f0 | ||
| fab7a12f7b |
16
.github/workflows/test.yml
vendored
16
.github/workflows/test.yml
vendored
@@ -14,6 +14,22 @@ jobs:
|
|||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: docker build -t learning-platform .
|
run: docker build -t learning-platform .
|
||||||
|
|
||||||
|
# The production Caddyfile is otherwise never exercised by CI — the test
|
||||||
|
# image below swaps in Caddyfile.test — so a parse error only surfaced at
|
||||||
|
# deploy time, after the broken container had already replaced the healthy
|
||||||
|
# one (issue #20/#26). Validate it here, inside the freshly built image
|
||||||
|
# where it lives at /etc/caddy/Caddyfile. This runs caddy against the
|
||||||
|
# exact file that ships and needs no bind mount — a `-v "$PWD":...` mount
|
||||||
|
# does not propagate to the sibling Docker daemon in the Gitea runner,
|
||||||
|
# which is why the earlier mount-based step failed with
|
||||||
|
# "open Caddyfile: no such file or directory" (#26).
|
||||||
|
# Caddyfile.test needs no separate check: the "Test homepage" step below
|
||||||
|
# runs the test image and fails if that config can't serve.
|
||||||
|
- name: Validate production Caddyfile
|
||||||
|
run: |
|
||||||
|
docker run --rm --entrypoint caddy learning-platform \
|
||||||
|
validate --adapter caddyfile --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
- name: Build test image
|
- name: Build test image
|
||||||
run: |
|
run: |
|
||||||
cat > Dockerfile.test <<'EOF'
|
cat > Dockerfile.test <<'EOF'
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ The platform ships a global chatbot avatar called **R42**, rendered as the Respe
|
|||||||
* **PocketBase auto-cancellation is OFF.** Set in `src/lib/pb.js`; never re-enable.
|
* **PocketBase auto-cancellation is OFF.** Set in `src/lib/pb.js`; never re-enable.
|
||||||
* **Go through `callLLM`.** Never call the Anthropic proxy directly; you lose retry, schema validation, and telemetry.
|
* **Go through `callLLM`.** Never call the Anthropic proxy directly; you lose retry, schema validation, and telemetry.
|
||||||
* **AI token budget.** Truncation surfaces as `LLMTruncatedError` (`stop_reason: max_tokens`). For extraction, tighten the prompt's topic cap before raising `max_tokens`.
|
* **AI token budget.** Truncation surfaces as `LLMTruncatedError` (`stop_reason: max_tokens`). For extraction, tighten the prompt's topic cap before raising `max_tokens`.
|
||||||
|
* **PocketBase migrations & the `_migrations` ledger (issue #18).** The deployed Labs/prod databases were originally provisioned WITHOUT `--migrationsDir` (schema came from `scripts/setup-pb-collections.mjs`), so their ledger started empty. `pb_migrations/1000000000_baseline_ledger_sync.js` marks the historical files as applied on such databases — never remove it, never rename an applied migration file (the ledger is filename-based), and give new migrations a timestamp that sorts after the existing ones. A failed migration aborts `pocketbase serve` → container crash-loop → 502 on every `/api/*` call; the deploy playbooks gate on `/api/health` to catch this.
|
||||||
|
* **OIDC provider config lives in a hook, not a migration.** `pb_hooks/entra_oidc.pb.js` reconciles the Entra provider from `ENTRA_*` env on every bootstrap + cron tick. Don't move provider credentials back into a one-shot migration — an apply while the env is empty would permanently disable OAuth2.
|
||||||
|
* **JSVM hook scope.** PocketBase runs each hook callback as an isolated program: top-level functions in a `*.pb.js` file are NOT in scope inside callbacks. Shared helpers live in `pb_hooks/utils.js` and are pulled in per-callback via `require(`${__hooks}/utils.js`)`.
|
||||||
|
|
||||||
## 13. 26-Week Per-User Curriculum System
|
## 13. 26-Week Per-User Curriculum System
|
||||||
The platform uses a **26-week perpetual curriculum cycle**. Every employee covers the knowledge base in focused, thematic weekly blocks, **starting whenever they enroll**.
|
The platform uses a **26-week perpetual curriculum cycle**. Every employee covers the knowledge base in focused, thematic weekly blocks, **starting whenever they enroll**.
|
||||||
|
|||||||
10
Caddyfile
10
Caddyfile
@@ -46,6 +46,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle_errors {
|
handle_errors {
|
||||||
|
# Don't mask API errors with the SPA shell — return a proper
|
||||||
|
# JSON error so the frontend can display a meaningful message.
|
||||||
|
# NOTE: `respond` only accepts `body`/`close` subdirectives; the
|
||||||
|
# Content-Type must be set via a separate `header` directive. An
|
||||||
|
# invalid subdirective is a Caddyfile parse error and crash-loops
|
||||||
|
# the container at startup (issue #20).
|
||||||
|
@api path /api/*
|
||||||
|
header @api Content-Type application/json
|
||||||
|
respond @api `{"code":{err.status_code},"message":"Backend unavailable"}` {err.status_code}
|
||||||
|
|
||||||
rewrite * /index.html
|
rewrite * /index.html
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ services:
|
|||||||
container_name: pocketbase-learning
|
container_name: pocketbase-learning
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /pb
|
working_dir: /pb
|
||||||
|
env_file:
|
||||||
|
# Optional: absent .env.local must not block `docker compose up` (issue #18).
|
||||||
|
- path: .env.local
|
||||||
|
required: false
|
||||||
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]
|
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
volumes:
|
volumes:
|
||||||
- pb_data:/pb/pb_data
|
- pb_data:/pb/pb_data
|
||||||
- ./pb_migrations:/pb/pb_migrations
|
- ./pb_migrations:/pb/pb_migrations
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Authentication — Azure (Entra ID) login
|
# Authentication — Azure (Entra ID) login
|
||||||
|
|
||||||
> Implemented for issue #16. Replaces the previous client-side PIN login.
|
> Implemented for issue #16. Replaces the previous client-side PIN login.
|
||||||
|
> Hardened for issue #18 (migratie-ledger-sync + env-onafhankelijke provider-reconciliatie).
|
||||||
|
|
||||||
## Doel
|
## Doel
|
||||||
|
|
||||||
@@ -38,15 +39,26 @@ Browser (SPA) PocketBase (auth) Entra ID
|
|||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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`), re-synced elke login | Geen handmatig beheer; allowlist-wijziging werkt bij volgende login |
|
| 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 |
|
| 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
|
## Bestanden
|
||||||
|
|
||||||
| Bestand | Rol |
|
| Bestand | Rol |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pb_migrations/1781000000_team_members_to_auth.js` | Dropt de oude PIN-collection, maakt `team_members` als auth-collection met de OIDC-provider (creds uit env). |
|
| `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/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. |
|
| `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/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/store/AppContext.jsx` | `loginWithAzure()` / `logout()` op basis van `pb.authStore` + `authRefresh()`. |
|
||||||
@@ -100,6 +112,11 @@ Ansible-variabelen (vault): `entra_tenant_id`, `entra_client_id`,
|
|||||||
eerste deploy.
|
eerste deploy.
|
||||||
- Verweesde voortgangsrecords van verwijderde (pre-Azure) users zijn acceptabel:
|
- Verweesde voortgangsrecords van verwijderde (pre-Azure) users zijn acceptabel:
|
||||||
zonder geldig Entra-account kan niemand inloggen, dus die records zijn onbereikbaar.
|
zonder geldig Entra-account kan niemand inloggen, dus die records zijn onbereikbaar.
|
||||||
- Credential-rotatie: update env + herconfigureer de provider via de PocketBase
|
- Credential-rotatie: update de `ENTRA_*` secrets (Gitea → Ansible `.env`) en
|
||||||
admin-UI (Settings → Auth providers) of ship een follow-up migratie — de
|
herstart/redeploy — `pb_hooks/entra_oidc.pb.js` reconcilieert de provider
|
||||||
create-migratie draait maar één keer.
|
automatisch bij elke start en elke cron-minuut. Geen handmatige re-apply meer.
|
||||||
|
- **Migratiebestanden nooit hernoemen nadat ze ergens applied zijn** — de
|
||||||
|
`_migrations`-ledger is filename-based; hernoemen triggert een re-apply.
|
||||||
|
- De deploy-playbooks bevatten een health-gate: als PocketBase na de deploy niet
|
||||||
|
healthy wordt, faalt de pipeline zichtbaar en print hij de containerlogs
|
||||||
|
(issue #18 bleef 2 weken onzichtbaar doordat de deploy "groen" was).
|
||||||
|
|||||||
@@ -212,6 +212,34 @@ Best-effort telemetry for every Anthropic call (written by `callLLM`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `onboarding_overviews`
|
||||||
|
Cached, breadth-first per-theme overview for the onboarding track (issue #30).
|
||||||
|
One row per theme, shared across all users. Generated lazily on first open.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| theme | text | canonical theme name (from `topics.theme`); **unique** |
|
||||||
|
| content | json | validated `onboardingOverviewSchema` payload (title, what_it_is, why_it_matters, key_points, topics_covered) |
|
||||||
|
| topics_fingerprint | text | stable hash of the theme's sorted topic ids; a mismatch triggers regeneration |
|
||||||
|
|
||||||
|
Unique index on `(theme)`. Rules: authenticated-only (`@request.auth.id != ""`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `onboarding_completions`
|
||||||
|
Per-user, per-theme completion marker for the onboarding track (issue #30).
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| team_member_id | text | the employee — **plain text**, not a relation (admins can delete members; orphan rows are harmless) |
|
||||||
|
| theme | text | the theme marked complete |
|
||||||
|
|
||||||
|
Unique index on `(team_member_id, theme)` → idempotent. Rules: authenticated-only.
|
||||||
|
Completion is tracked per **theme**, not per day; the 5 "days" are a read-time
|
||||||
|
presentation grouping, so re-chunking never loses progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Dropped / legacy collections
|
## Dropped / legacy collections
|
||||||
|
|
||||||
These existed in earlier iterations and have been removed. Their `db.js` helpers
|
These existed in earlier iterations and have been removed. Their `db.js` helpers
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ A React 19 SPA built with Vite, routed by React Router 7. Entry: `src/main.jsx`
|
|||||||
| Route | Screen | Access |
|
| Route | Screen | Access |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/login` | `Login.jsx` | public |
|
| `/login` | `Login.jsx` | public |
|
||||||
| `/onboarding` | `Onboarding.jsx` | logged-in, not yet enrolled |
|
| `/onboarding` | `Onboarding.jsx` | logged-in, not yet enrolled (enrollment page) |
|
||||||
| `/` | `Dashboard.jsx` | enrolled user |
|
| `/` | `Dashboard.jsx` | enrolled user |
|
||||||
|
| `/onboarding-track` | `OnboardingTrack.jsx` | any logged-in user, **any enrollment** (`skipEnrollmentGate`) |
|
||||||
| `/learn` | `Leren.jsx` | enrolled user |
|
| `/learn` | `Leren.jsx` | enrolled user |
|
||||||
| `/test` | `Testen.jsx` | enrolled user |
|
| `/test` | `Testen.jsx` | enrolled user |
|
||||||
| `/leaderboard` | `Leaderboard.jsx` | enrolled user |
|
| `/leaderboard` | `Leaderboard.jsx` | enrolled user |
|
||||||
@@ -20,9 +21,14 @@ A React 19 SPA built with Vite, routed by React Router 7. Entry: `src/main.jsx`
|
|||||||
`ProtectedRoute`:
|
`ProtectedRoute`:
|
||||||
- redirects to `/login` if not authenticated;
|
- redirects to `/login` if not authenticated;
|
||||||
- redirects to `/onboarding` if `enrollment_status !== 'active'` — **except** admins
|
- redirects to `/onboarding` if `enrollment_status !== 'active'` — **except** admins
|
||||||
heading to the admin panel, who are exempt;
|
heading to the admin panel, and routes passing `skipEnrollmentGate` (the onboarding
|
||||||
|
track), which are exempt;
|
||||||
- enforces `requireAdmin` for `/admin`.
|
- enforces `requireAdmin` for `/admin`.
|
||||||
|
|
||||||
|
> `/onboarding` (enrollment) and `/onboarding-track` (the 5-day theme intro) are
|
||||||
|
> distinct. The track is decoupled from enrollment on purpose so it also works as a
|
||||||
|
> refresher for existing staff.
|
||||||
|
|
||||||
Navigation chrome (top bar + mobile bottom nav) is rendered by `ProtectedRoute`.
|
Navigation chrome (top bar + mobile bottom nav) is rendered by `ProtectedRoute`.
|
||||||
`ChatLauncher` (R42) is mounted globally.
|
`ChatLauncher` (R42) is mounted globally.
|
||||||
|
|
||||||
@@ -51,7 +57,17 @@ enrolled are redirected to `/`. See `docs/curriculum-spec.md`.
|
|||||||
## Employee screens
|
## Employee screens
|
||||||
|
|
||||||
- **Dashboard** — current cycle/week, assigned topic, cycle progress ring, quick
|
- **Dashboard** — current cycle/week, assigned topic, cycle progress ring, quick
|
||||||
links to Learn and Test, mini leaderboard, recent activity.
|
links to Learn and Test, mini leaderboard, recent activity. The dismissible
|
||||||
|
"New here?" explainer card also carries the **onboarding-track CTA** (a progress
|
||||||
|
chip `X/5 days` + a Start/Continue/Review button linking to `/onboarding-track`),
|
||||||
|
shown only when the KB has themes to introduce.
|
||||||
|
- **Onboarding Track (`/onboarding-track`)** — a self-paced, breadth-first tour of
|
||||||
|
every theme, grouped into ≤5 day cards (`OnboardingTrack.jsx`). Opening a theme
|
||||||
|
lazily generates a short overview (what it is / why it matters for daily & weekly
|
||||||
|
work / key points / topics) and offers "Mark as done"; a progress ring + day bar
|
||||||
|
track completion, and a completion banner shows when every theme is done. Available
|
||||||
|
to any logged-in user regardless of enrollment; completion is stored per theme in
|
||||||
|
`onboarding_completions`. See `docs/generation-spec.md §D`.
|
||||||
- **Learning Station (`/learn`)** — the week's required topic + the rest of the
|
- **Learning Station (`/learn`)** — the week's required topic + the rest of the
|
||||||
knowledge library; opening a topic shows the micro-learning selector
|
knowledge library; opening a topic shows the micro-learning selector
|
||||||
(`src/components/micro_learning/`). Completing ≥1 micro-learning marks the week done.
|
(`src/components/micro_learning/`). Completing ≥1 micro-learning marks the week done.
|
||||||
|
|||||||
@@ -74,6 +74,29 @@ explanation, difficulty }`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## D. Onboarding overviews — `src/lib/onboardingService.js`
|
||||||
|
|
||||||
|
Powers the 5-day onboarding track (issue #30): a short, breadth-first overview of
|
||||||
|
**one theme** for a brand-new employee — deliberately light, not a deep lesson.
|
||||||
|
|
||||||
|
- **Tool:** `emit_onboarding_overview` · **tier:** `fast` (Haiku) · `maxTokens: 1500`,
|
||||||
|
60s timeout · schema `onboardingOverviewSchema`.
|
||||||
|
- **Shape:** `{ title, what_it_is, why_it_matters, key_points[3–5], topics_covered[{topic_id,label}] }`.
|
||||||
|
`why_it_matters` is framed around day-to-day / week-to-week work at Respellion.
|
||||||
|
- **Cache:** `onboarding_overviews`, one row per theme, keyed by theme name plus a
|
||||||
|
`topics_fingerprint` (stable hash of the theme's sorted topic ids).
|
||||||
|
`getOrGenerateOnboardingOverview(theme, topics, {force})` returns the cached row when
|
||||||
|
the fingerprint matches; a mismatch (topic added/removed) or `force` regenerates.
|
||||||
|
- **Simulation:** `emit_onboarding_overview` has a stub in `SIMULATION_TOOL_STUBS`.
|
||||||
|
|
||||||
|
Theme ordering + day grouping are pure helpers in the same module
|
||||||
|
(`orderThemes`, `distributeThemesIntoDays`, `computeTopicsFingerprint`,
|
||||||
|
`computeOnboardingProgress`, `buildOnboardingPlan`), unit-tested in
|
||||||
|
`src/lib/__tests__/onboardingService.test.js`. Completion is recorded per **theme**
|
||||||
|
in `onboarding_completions` (`{ team_member_id, theme }`), not per day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Shared infrastructure (`src/lib/llm.js`)
|
## Shared infrastructure (`src/lib/llm.js`)
|
||||||
|
|
||||||
- **Tiers:** `fast` (Haiku 4.5), `standard` (Sonnet 4.6), `reasoning` (Opus 4.7);
|
- **Tiers:** `fast` (Haiku 4.5), `standard` (Sonnet 4.6), `reasoning` (Opus 4.7);
|
||||||
|
|||||||
@@ -72,3 +72,81 @@
|
|||||||
state: present
|
state: present
|
||||||
files: compose.yml
|
files: compose.yml
|
||||||
recreate: always
|
recreate: always
|
||||||
|
|
||||||
|
# A failed migration aborts `pocketbase serve` and the container crash-loops
|
||||||
|
# while `docker compose up` still reports success (issue #18). Gate the
|
||||||
|
# deploy on PocketBase actually serving its health endpoint.
|
||||||
|
- name: Wait for PocketBase to become healthy
|
||||||
|
ansible.builtin.command: docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/health
|
||||||
|
register: pb_health
|
||||||
|
until: pb_health.rc == 0
|
||||||
|
retries: 18
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Collect PocketBase logs for diagnosis
|
||||||
|
ansible.builtin.command: docker logs --tail 80 pocketbase-learning
|
||||||
|
register: pb_logs
|
||||||
|
when: pb_health is failed
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Abort deploy — PocketBase is not healthy
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: |
|
||||||
|
PocketBase did not become healthy after the deploy (health endpoint unreachable).
|
||||||
|
Recent container logs:
|
||||||
|
{{ pb_logs.stdout | default('') }}
|
||||||
|
{{ pb_logs.stderr | default('') }}
|
||||||
|
when: pb_health is failed
|
||||||
|
|
||||||
|
# The frontend container carries the SPA + Caddy. An invalid Caddyfile
|
||||||
|
# crash-loops it at startup while the PocketBase gate stays green — that
|
||||||
|
# outage (issue #20) was invisible to CI because the test job swaps in
|
||||||
|
# Caddyfile.test. Gate on the real container actually serving.
|
||||||
|
- name: Wait for the frontend (Caddy) to become healthy
|
||||||
|
ansible.builtin.command: docker exec learning-platform wget -q --spider http://127.0.0.1:80/
|
||||||
|
register: fe_health
|
||||||
|
until: fe_health.rc == 0
|
||||||
|
retries: 18
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Collect frontend logs for diagnosis
|
||||||
|
ansible.builtin.command: docker logs --tail 80 learning-platform
|
||||||
|
register: fe_logs
|
||||||
|
when: fe_health is failed
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Abort deploy — frontend is not healthy
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: |
|
||||||
|
The frontend (Caddy) container did not become healthy after the deploy.
|
||||||
|
Recent container logs:
|
||||||
|
{{ fe_logs.stdout | default('') }}
|
||||||
|
{{ fe_logs.stderr | default('') }}
|
||||||
|
when: fe_health is failed
|
||||||
|
|
||||||
|
# Observability behind the auth perimeter: surface the end-to-end state
|
||||||
|
# in the CI log on every deploy.
|
||||||
|
- name: Post-deploy smoke report
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
cd /opt/learning-platform
|
||||||
|
echo '--- docker compose ps ---'
|
||||||
|
docker compose ps
|
||||||
|
echo '--- frontend -> pocketbase proxy health ---'
|
||||||
|
docker exec learning-platform wget -q -O - http://127.0.0.1:80/api/health || echo 'PROXY HEALTH FAILED'
|
||||||
|
echo '--- team_members auth methods (OIDC provider present?) ---'
|
||||||
|
docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/collections/team_members/auth-methods || echo 'AUTH-METHODS FAILED'
|
||||||
|
echo '--- pocketbase logs (tail 30) ---'
|
||||||
|
docker compose logs --tail=30 pocketbase-learning
|
||||||
|
register: smoke_report
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Show smoke report
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ smoke_report.stdout_lines }}"
|
||||||
|
|||||||
@@ -72,3 +72,81 @@
|
|||||||
state: present
|
state: present
|
||||||
files: compose.yml
|
files: compose.yml
|
||||||
recreate: always
|
recreate: always
|
||||||
|
|
||||||
|
# A failed migration aborts `pocketbase serve` and the container crash-loops
|
||||||
|
# while `docker compose up` still reports success (issue #18). Gate the
|
||||||
|
# deploy on PocketBase actually serving its health endpoint.
|
||||||
|
- name: Wait for PocketBase to become healthy
|
||||||
|
ansible.builtin.command: docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/health
|
||||||
|
register: pb_health
|
||||||
|
until: pb_health.rc == 0
|
||||||
|
retries: 18
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Collect PocketBase logs for diagnosis
|
||||||
|
ansible.builtin.command: docker logs --tail 80 pocketbase-learning
|
||||||
|
register: pb_logs
|
||||||
|
when: pb_health is failed
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Abort deploy — PocketBase is not healthy
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: |
|
||||||
|
PocketBase did not become healthy after the deploy (health endpoint unreachable).
|
||||||
|
Recent container logs:
|
||||||
|
{{ pb_logs.stdout | default('') }}
|
||||||
|
{{ pb_logs.stderr | default('') }}
|
||||||
|
when: pb_health is failed
|
||||||
|
|
||||||
|
# The frontend container carries the SPA + Caddy. An invalid Caddyfile
|
||||||
|
# crash-loops it at startup while the PocketBase gate stays green — that
|
||||||
|
# outage (issue #20) was invisible to CI because the test job swaps in
|
||||||
|
# Caddyfile.test. Gate on the real container actually serving.
|
||||||
|
- name: Wait for the frontend (Caddy) to become healthy
|
||||||
|
ansible.builtin.command: docker exec learning-platform wget -q --spider http://127.0.0.1:80/
|
||||||
|
register: fe_health
|
||||||
|
until: fe_health.rc == 0
|
||||||
|
retries: 18
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Collect frontend logs for diagnosis
|
||||||
|
ansible.builtin.command: docker logs --tail 80 learning-platform
|
||||||
|
register: fe_logs
|
||||||
|
when: fe_health is failed
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Abort deploy — frontend is not healthy
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: |
|
||||||
|
The frontend (Caddy) container did not become healthy after the deploy.
|
||||||
|
Recent container logs:
|
||||||
|
{{ fe_logs.stdout | default('') }}
|
||||||
|
{{ fe_logs.stderr | default('') }}
|
||||||
|
when: fe_health is failed
|
||||||
|
|
||||||
|
# Observability behind the auth perimeter: surface the end-to-end state
|
||||||
|
# in the CI log on every deploy.
|
||||||
|
- name: Post-deploy smoke report
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
cd /opt/learning-platform
|
||||||
|
echo '--- docker compose ps ---'
|
||||||
|
docker compose ps
|
||||||
|
echo '--- frontend -> pocketbase proxy health ---'
|
||||||
|
docker exec learning-platform wget -q -O - http://127.0.0.1:80/api/health || echo 'PROXY HEALTH FAILED'
|
||||||
|
echo '--- team_members auth methods (OIDC provider present?) ---'
|
||||||
|
docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/collections/team_members/auth-methods || echo 'AUTH-METHODS FAILED'
|
||||||
|
echo '--- pocketbase logs (tail 30) ---'
|
||||||
|
docker compose logs --tail=30 pocketbase-learning
|
||||||
|
register: smoke_report
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Show smoke report
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ smoke_report.stdout_lines }}"
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
Diversiteit en Inclusiviteit bij Respellion
|
|
||||||
|
|
||||||
Diversiteit en Inclusie Initiatieven
|
|
||||||
* De spreker is betrokken bij een groep genaamd Diverse Leaders in Tech, die zich richt op het bevorderen van diversiteit en inclusie in de technologie-industrie.
|
|
||||||
* Deze groep heeft een enquête van ongeveer 30 vragen ontwikkeld om te beoordelen hoe bedrijven diversiteit en inclusieproblemen aanpakken.
|
|
||||||
* De enquête behandelt onderwerpen zoals beleid, trainingsprogramma's en andere relevante initiatieven.
|
|
||||||
* De groep werkt momenteel samen met Booking.com om hun aanpak te implementeren.
|
|
||||||
* De spreker is geïnteresseerd in het begrijpen van hoe dergelijke initiatieven effectief kunnen worden opgeschaald en geïmplementeerd in grotere organisaties.
|
|
||||||
* Ze verzamelen informatie en ervaringen uit verschillende bronnen om een meer omvattend begrip te ontwikkelen van de uitdagingen en best practices op dit gebied.
|
|
||||||
Bedrijfsmatige Benaderingen van Diversiteit en Inclusie
|
|
||||||
* De spreker bespreekt hun ervaring met diversiteit en inclusie-initiatieven bij CGI, waar ze deelnamen aan de Diversity Inclusion Committee.
|
|
||||||
* Ze vermelden een tweedaagse training voor leiderschap over diversiteit en inclusie, die aanvankelijk aanzienlijke weerstand ondervond van sommige managers die de relevantie ervan voor hun primaire verantwoordelijkheden in twijfel trokken.
|
|
||||||
* Deze weerstand creëerde bezorgdheid op het niveau van de directie over de mogelijke onpopulariteit van dergelijke initiatieven.
|
|
||||||
* De spreker benadrukt het belang van betrokkenheid bij resistente individuen om hun zorgen te begrijpen en hen te helpen inzien hoe ze mogelijk al bijdragen aan inclusiviteit op manieren die ze zich niet realiseerden.
|
|
||||||
Respellion's Benadering van Inclusiviteit
|
|
||||||
* Raymond, van Respellion, beschrijft de unieke benadering van hun bedrijf om inclusiviteit te bevorderen.
|
|
||||||
* Respellion, een jaar oud bedrijf met 15 medewerkers, combineert ervaringen van zowel kleine organisaties als multinationale ondernemingen.
|
|
||||||
* Ze hebben een zelforganiserende cultuur geïmplementeerd met hoge autonomie voor medewerkers en zonder hiërarchische structuur.
|
|
||||||
* Het bedrijf is verdeeld in drie 'huddles' van elk vijf personen, die dienen als vertrouwensgroepen.
|
|
||||||
* Deze huddles hebben regelmatige check-ins en bi-monthly ontwikkelingsplanbesprekingen, die aspecten van professioneel, persoonlijk en fysiek welzijn behandelen.
|
|
||||||
* Deze structuur biedt een veilige ruimte waar kwesties, inclusief die met betrekking tot inclusiviteit en diversiteit, kunnen worden aangekaart en aangepakt.
|
|
||||||
Proactieve Inclusiviteitsmaatregelen
|
|
||||||
* Raymond benadrukt het belang van het proactief aanpakken van inclusiviteitskwesties.
|
|
||||||
* Hij geeft een voorbeeld van hoe Respellion omgaat met dieetvoorkeuren: ze hebben een standaardbeleid van vegetarische opties voor alle evenementen, met de mogelijkheid voor individuen om alternatieven aan te vragen indien nodig.
|
|
||||||
* Deze aanpak verschuift de last van minderheidsgroepen die constant voor zichzelf moeten pleiten.
|
|
||||||
* Raymond benadrukt het belang van het maken van duidelijke uitspraken en het creëren van wrijving voor degenen die niet direct door inclusiviteitskwesties worden getroffen, aangezien dit noodzakelijk is om verandering te stimuleren.
|
|
||||||
Omgaan met Microagressies en Ongepaste Opmerkingen
|
|
||||||
* De discussie behandelt het belang van het onmiddellijk aanpakken van microagressies of ongepaste opmerkingen op de werkplek.
|
|
||||||
* Raymond geeft een voorbeeld van hoe hij een situatie heeft aangepakt waarin een oudere medewerker een seksistische opmerking maakte over secretariële werkzaamheden.
|
|
||||||
* Hij benadrukt de noodzaak voor leiders om dergelijke kwesties openlijk in groepsinstellingen aan te pakken, zelfs als dit ongemak creëert.
|
|
||||||
* Het doel is om het aanpakken van deze kwesties een normaal onderdeel van de werkcultuur te maken, in plaats van ze te vermijden of privé aan te pakken.
|
|
||||||
Ervaringsgericht Leren voor Inclusiviteit
|
|
||||||
* Beide sprekers zijn het erover eens dat ervaringsgericht leren belangrijk is om een echt begrip van inclusiviteitskwesties te bevorderen.
|
|
||||||
* Ze bespreken de beperkingen van traditionele trainingsprogramma's, vooral die via online platforms worden aangeboden.
|
|
||||||
* In plaats daarvan pleiten ze voor benaderingen die individuen in staat stellen te ervaren hoe het voelt om uitgesloten te worden of discriminatie te ondervinden.
|
|
||||||
* Dit kan inhouden dat er rollenspellen, familieconstellaties of andere methoden worden gebruikt die viscerale ervaringen van ongelijkheid of uitsluiting creëren.
|
|
||||||
* De sprekers geloven dat dergelijke ervaringen cruciaal zijn voor het ontwikkelen van empathie en het stimuleren van echte verandering in attitudes en gedragingen.
|
|
||||||
Leiderschap en Coaching voor Inclusiviteit
|
|
||||||
* Raymond benadrukt de rol van leiders als coaches in het bevorderen van een inclusieve omgeving.
|
|
||||||
* Hij suggereert dat organisaties in plaats van grootschalige trainingsprogramma's, zich moeten richten op kleinschalige, intieme coachingsessies.
|
|
||||||
* Dit kan inhouden dat teamcoaches in real-time situaties observeren en ingrijpen, waardoor leiders en teamleden in staat worden gesteld om inclusiviteitskwesties aan te pakken zodra ze zich voordoen.
|
|
||||||
* Het doel is om discussies over inclusiviteit een natuurlijk onderdeel van dagelijkse interacties op de werkplek te maken, in plaats van geïsoleerde trainingsevenementen.
|
|
||||||
Uitdagingen bij het Implementeren van Inclusiviteitsinitiatieven
|
|
||||||
* De sprekers bespreken verschillende uitdagingen bij het implementeren van effectieve inclusiviteitsinitiatieven, waaronder weerstand van sommige medewerkers, de moeilijkheid om ingesleten culturele normen te veranderen, en de beperkingen van een one-size-fits-all benadering van training.
|
|
||||||
* Ze benadrukken de noodzaak van op maat gemaakte, contextspecifieke interventies die rekening houden met de unieke dynamiek van elk team of organisatie.
|
|
||||||
* De conversatie raakt ook het belang van het aanpakken van neurodiversiteit en verschillende leerstijlen bij het ontwerpen van inclusiviteitsprogramma's.
|
|
||||||
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# Respellion: Responsible Rebellion in Softwareontwikkeling
|
|
||||||
|
|
||||||
Respellion is een softwareontwikkelingsbedrijf opgericht door voormalige leiders van QDelft (dat later werd overgenomen door Netcompany Denemarken). Het bedrijf positioneert zichzelf als "responsible rebels", waarbij innovatieve benaderingen worden gecombineerd met een sterk gevoel voor maatschappelijke verantwoordelijkheid. Deze onderscheidende combinatie wordt weerspiegeld in hun naam - een samenvoeging van "verantwoordelijkheid" en "rebellie".
|
|
||||||
|
|
||||||
### Doel en Waarden
|
|
||||||
|
|
||||||
De kern van Respellion's identiteit is hun doel: "Een maatschappij waarin open-source technologie wordt ingezet om op een duurzame manier digitalisering mogelijk te maken." Dit doel is ontstaan uit brainstormsessies waarin de oprichters overwogen wat werkelijk belangrijk voor hen was. Raymond Verhoef beschrijft dit doel als "bestendig", omdat het consequent aansluit bij het werk dat ze uitvoeren.
|
|
||||||
|
|
||||||
Dit doel manifesteert zich concreet in hun klanten en projectkeuzes. Hun werk met het CIBG, diverse Ministeries en de gemeente Den Haag omvat het ontwikkelen van producten die de samenleving direct ten goede komen. Raymond legde uit: "We bouwen software die burgers in staat stelt om elkaar te helpen." Dit concept van helpen strekt zich uit tot zowel het ondersteunen van klanten als het creëren van software die burgers in staat stelt elkaar te ondersteunen.
|
|
||||||
|
|
||||||
Het bedrijf opereert volgens vier kernwaarden die het dagelijks gedrag sturen:
|
|
||||||
|
|
||||||
**Vertrouwen** wordt gedemonstreerd door transparantie en openheid. Tijdens de onboarding deelde Raymond een voorbeeld waarin Patrick te laat kwam op een vergadering met Thomas, die vroeg was opgestaan om aanwezig te zijn. In plaats van de situatie te negeren, erkenden ze het probleem onmiddellijk: "We legden het direct op tafel... omdat we voor elkaar moeten zorgen." Deze transparantie voorkomt dat kleine irritaties zich in de loop van de tijd opbouwen.
|
|
||||||
|
|
||||||
**Moed** houdt in dat men durft af te wijken van traditionele benaderingen en fouten omarmt als leermogelijkheden. De "rebelse" aard van het bedrijf komt tot uiting in hun bereidheid om gevestigde praktijken ter discussie te stellen wanneer ze geloven dat er een betere manier is.
|
|
||||||
|
|
||||||
**Zelfdiscipline** komt tot uiting in het nakomen van toezeggingen en het nemen van verantwoordelijkheid. Raymond benadrukte het belang hiervan "omdat het echt moeilijk is om in lijn te blijven met bepaalde waarden." Bij Respellion betekent dit zich goed voorbereiden op vergaderingen, op tijd komen en toezeggingen nakomen.
|
|
||||||
|
|
||||||
**Ondernemerschap** wordt aangemoedigd omdat iedereen "een stukje van de organisatie" op zich neemt. Met minimale managementrollen moeten teamleden een ondernemende mentaliteit aannemen. Zoals Raymond uitlegde: "Je moet bereid zijn om zelf een stukje van de organisatie op je te nemen."
|
|
||||||
|
|
||||||
Deze waarden komen tot uiting in hun manifest, dat vier gedragsprincipes schetst:
|
|
||||||
|
|
||||||
1. **Voortdurend in beweging blijven om wendbaar te blijven**: Dit wordt belichaamd in hun holacratische structuur, die zich regelmatig aanpast aan veranderende behoeften.
|
|
||||||
|
|
||||||
2. **Samenwerken op basis van gemeenschappelijke belangen in plaats van individueel gewin**: Raymond gaf een concreet voorbeeld: "Als we iets dubbel hebben gefactureerd, moet ik daar dan over zwijgen? Nee. Zou ik dat zelf willen? Nee. Dus ik werk samen op basis van ons gemeenschappelijk belang." Dit principe strekt zich uit tot klantrelaties, waar transparantie soms betekent dat ze hun eigen fouten aanwijzen.
|
|
||||||
|
|
||||||
3. **Werken met een glimlach en een zorgende houding**: Kleine gebaren demonstreren dit principe, zoals bloemen meenemen voor verjaardagen of koekjes voor vergaderingen. Raymond vermeldde: "Ik wil een organisatie zijn waar mensen zich verzorgd voelen."
|
|
||||||
|
|
||||||
4. **Streven naar evenwicht tussen mensen, planeet en winst**: Dit principe beïnvloedt hun klantenselectieproces. Tijdens de onboarding besprak Raymond hoe ze zorgvuldig overwegen of ze werk van bepaalde klanten accepteren op basis van afstemming met hun waarden. Ze wezen bijvoorbeeld een kans bij Holland Casino af tijdens Raymonds vakantie, wat hij waardeerde: "Dat zou ik ook niet hebben gedaan." Ook bespraken ze of ze werk van Shell zouden accepteren, concluderend dat dergelijke beslissingen een expliciete redenering vereisen, ongeacht de uitkomst.
|
|
||||||
|
|
||||||
### Holacracy: Een Dynamische Organisatiestructuur
|
|
||||||
|
|
||||||
Respellion heeft Holacracy geïmplementeerd, een zelfsturend organisatiesysteem dat traditionële hiërarchie vervangt door een flexibelere, adaptieve structuur. Raymond Verhoef, een van de oprichters, beschrijft deze benadering als vergelijkbaar met tuinieren in plaats van bouwen - condities creëren voor groei in plaats van bouwen volgens rigide plannen.
|
|
||||||
|
|
||||||
Belangrijke kenmerken van hun holacratische benadering zijn:
|
|
||||||
- Organisatie via rollen in plaats van functietitels
|
|
||||||
- Besluitvorming gebaseerd op commitment in plaats van consensus
|
|
||||||
- Regelmatige governance-vergaderingen om de organisatiestructuur aan te passen
|
|
||||||
- Tactische vergaderingen om operationele problemen aan te pakken
|
|
||||||
|
|
||||||
Het Holacracy-model stelt Respellion in staat om wendbaar te blijven terwijl het teamleden autonomie biedt. Zoals Raymond uitlegt, wordt "de stem van de minderheid altijd in aanmerking genomen" in besluitvormingsprocessen. Wijzigingen in rollen worden gemaakt tijdens governance-vergaderingen wanneer iemand een kloof identificeert tussen de huidige structuur en wat nodig is om verantwoordelijkheden effectief te vervullen.
|
|
||||||
|
|
||||||
### Coaching Cultuur
|
|
||||||
|
|
||||||
Respellion heeft een onderscheidende coachingcultuur ontwikkeld waarbij professionele en persoonlijke ontwikkeling zijn geïntegreerd. Deze cultuur manifesteert zich door verschillende gestructureerde praktijken:
|
|
||||||
|
|
||||||
1. **Huddle Check-ins**: Tweewekelijkse bijeenkomsten met kleine, consistente groepen voor snelle verbindingen en ondersteuning
|
|
||||||
2. **Elevator Sessies**: Tweemaandelijkse gefaciliteerde coachingsessies gericht op persoonlijke ontwikkeling
|
|
||||||
3. **Celebrations**: Tweemaandelijkse bijeenkomsten om successen te delen en, opmerkelijk, de "meest rebelse mislukking van de maand" - wat leren door falen aanmoedigt
|
|
||||||
4. **Persoonlijke Ontwikkelingsplannen**: Gebouwd rond drie capaciteiten:
|
|
||||||
- Professionele/intellectuele groei
|
|
||||||
- Emotionele ontwikkeling (communicatie, feedback, triggers begrijpen)
|
|
||||||
- Fysiek welzijn
|
|
||||||
|
|
||||||
Het persoonlijke ontwikkelingsproces is gebaseerd op Daniel Ofman's Kernkwaliteiten model, wat teamleden helpt hun sterke punten, valkuilen, uitdagingen en allergieën (reacties op gedragingen tegenovergesteld aan hun uitdagingen) te identificeren. Dit raamwerk biedt een gemeenschappelijke taal voor ontwikkelingsgesprekken.
|
|
||||||
|
|
||||||
### Diensten en Expertise
|
|
||||||
|
|
||||||
Respellion richt zich op drie primaire dienstgebieden:
|
|
||||||
1. **Resultaatgerichte projecten**: Gebruik van Agile methodologieën gecombineerd met gestructureerd projectmanagement
|
|
||||||
2. **High-performance teams**: Het leveren van multidisciplinaire teams met de technische en soft skills om complete oplossingen te leveren
|
|
||||||
3. **Consultancy**: Het bieden van strategisch advies en technische expertise
|
|
||||||
|
|
||||||
Hun expertisegebieden omvatten:
|
|
||||||
- Missiekritische systemen met hoge beschikbaarheid
|
|
||||||
- Continuous Integration/Continuous Deployment (CI/CD)
|
|
||||||
- Privacy en Security by Design
|
|
||||||
- Digitale toegankelijkheid en bruikbaarheid
|
|
||||||
- Agile projectmanagement en coaching
|
|
||||||
- Duurzaam applicatiebeheer
|
|
||||||
|
|
||||||
### Werkomgeving en Medewerkerservaring
|
|
||||||
|
|
||||||
Respellion biedt een onderscheidende werkomgeving voor professionals die autonomie en zingeving zoeken in hun carrière. De combinatie van professionele excellentie met mensgerichte waarden creëert een unieke werkcultuur.
|
|
||||||
|
|
||||||
De medewerkerservaring bij Respellion wordt gekenmerkt door verschillende sleutelelementen:
|
|
||||||
|
|
||||||
- **Echte autonomie**: Via Holacracy nemen teamleden rollen op zich die aansluiten bij hun sterke punten en interesses, in plaats van in voorgedefinieerde functieomschrijvingen te passen. Dit stelt mensen in staat hun werk vorm te geven volgens hun talenten en ambities.
|
|
||||||
|
|
||||||
- **Groeigericht cultuur**: De coachingbenadering zorgt voor continue persoonlijke en professionele ontwikkeling. Regelmatige feedback en gestructureerde ontwikkelingssessies helpen teamleden zich in meerdere dimensies te ontwikkelen.
|
|
||||||
|
|
||||||
- **Doelgericht werk**: Teamleden dragen bij aan maatschappelijk waardevolle projecten met duurzame, open-source technologie, wat betekenis creëert die verder gaat dan commercieel succes.
|
|
||||||
|
|
||||||
- **Veilige leeromgeving**: Het vieren van "meest rebelse mislukkingen" toont een oprechte toewijding aan leren en versterkt psychologische veiligheid binnen teams.
|
|
||||||
|
|
||||||
Teamleden die gedijen bij Respellion waarderen typisch vertrouwen, tonen moed, beoefenen zelfdiscipline en omarmen ondernemerschap. Ze excelleren wanneer ze initiatiefmogelijkheden krijgen en zijn comfortabel met de verantwoordelijkheid die met autonomie komt.
|
|
||||||
|
|
||||||
Voor specialisten in open-source ontwikkeling die willen dat hun werk een bredere positieve impact heeft, biedt Respellion een alternatief voor traditionele bedrijfsstructuren terwijl het professionele nauwkeurigheid en marktconcurrentievermogen behoudt.
|
|
||||||
|
|
||||||
### Open-Source Filosofie
|
|
||||||
|
|
||||||
Respellion is diep toegewijd aan open-source technologie, wat aansluit bij hun duurzaamheidsdoelen. Ze waarderen open-source voor:
|
|
||||||
- Transparantie en vertrouwensopbouwend potentieel
|
|
||||||
- Samenwerkingsmogelijkheden met partners
|
|
||||||
- Verbeterde beveiliging door community toezicht
|
|
||||||
- Kostenefficiëntie en versnelling van innovatie
|
|
||||||
- Flexibiliteit en onafhankelijkheid van leveranciers
|
|
||||||
|
|
||||||
### Huidige Status en Groeiplannen
|
|
||||||
|
|
||||||
Per 2025 heeft Respellion 15 teamleden bereikt en is van plan om te groeien naar 30 collega's in 2026. Deze gerichte groei is ontworpen om hen in staat te stellen complete ontwikkelteams te vormen die kunnen worden ingezet bij klantorganisaties.
|
|
||||||
|
|
||||||
Het bedrijf heeft zich gevestigd bij klanten waaronder gemeenten (Den Haag), overheidsinstanties (CBG, VWS), en andere organisaties zoals IKTU en Vooron. Ze opereren vanuit Haktech, een technologiegericht kantoorgebouw met andere innovatieve bedrijven.
|
|
||||||
Binary file not shown.
62
pb_hooks/entra_oidc.pb.js
Normal file
62
pb_hooks/entra_oidc.pb.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #18 — Reconcile the Entra (Azure) OIDC provider from the environment.
|
||||||
|
//
|
||||||
|
// The team_members→auth migration (pb_migrations/1781000000) is structure-only
|
||||||
|
// and runs exactly once. Provider CONFIGURATION lives here instead, so that:
|
||||||
|
//
|
||||||
|
// * an environment whose migration applied while the ENTRA_* secrets were
|
||||||
|
// absent gets its provider enabled on the next startup (no re-apply),
|
||||||
|
// * rotating ENTRA_CLIENT_SECRET / changing the tenant only requires new env
|
||||||
|
// values and a container restart,
|
||||||
|
// * a fresh database gets its provider on the first cron tick right after
|
||||||
|
// the migrations created the collection (onBootstrap fires BEFORE the
|
||||||
|
// migrations run — there is no post-migration lifecycle hook in the JSVM,
|
||||||
|
// hence the cron fallback).
|
||||||
|
//
|
||||||
|
// The reconciler compares before saving, so both hooks are cheap no-ops when
|
||||||
|
// everything is already in sync.
|
||||||
|
|
||||||
|
onBootstrap((e) => {
|
||||||
|
e.next();
|
||||||
|
const { reconcileEntraOidc } = require(`${__hooks}/utils.js`);
|
||||||
|
// reconcileEntraOidc logs a warn-once itself when the ENTRA_* env is absent.
|
||||||
|
console.log("entra_oidc reconcile (bootstrap): " + reconcileEntraOidc(e.app));
|
||||||
|
});
|
||||||
|
|
||||||
|
cronAdd("entraOidcReconcile", "* * * * *", () => {
|
||||||
|
const { reconcileEntraOidc } = require(`${__hooks}/utils.js`);
|
||||||
|
const result = reconcileEntraOidc($app);
|
||||||
|
// Only log state changes — this tick runs every minute.
|
||||||
|
if (result === "updated") {
|
||||||
|
console.log("entra_oidc reconcile (cron): OIDC provider (re)configured from ENTRA_* env");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Entra does not always supply an `email` claim: the id_token only carries it
|
||||||
|
// when the account has a mail attribute (issue #24). The UPN in
|
||||||
|
// `preferred_username` is always present and equals the primary e-mail for
|
||||||
|
// Respellion organisation accounts, so fall back to it when it looks like a
|
||||||
|
// real address. Guest UPNs ("user_ext#EXT#@tenant...") fail the regex on
|
||||||
|
// purpose — those still get a clear validation error instead of a bogus email.
|
||||||
|
// The catch also surfaces the underlying OAuth2 failure in the container log,
|
||||||
|
// which PocketBase otherwise only writes to its internal logs database.
|
||||||
|
onRecordAuthWithOAuth2Request((e) => {
|
||||||
|
const u = e.oAuth2User;
|
||||||
|
if (u && !u.email) {
|
||||||
|
const upn = String((u.rawUser && u.rawUser.preferred_username) || u.username || "").trim().toLowerCase();
|
||||||
|
// `#` is RFC-valid in an e-mail local part, so guest UPNs
|
||||||
|
// ("ext_user#EXT#@tenant.onmicrosoft.com") pass a naive e-mail check AND
|
||||||
|
// PocketBase's own validation — exclude them explicitly.
|
||||||
|
if (/^[^@\s#]+@[^@\s#]+\.[^@\s#]+$/.test(upn)) {
|
||||||
|
u.email = upn;
|
||||||
|
console.log("entra_oidc: email claim absent — falling back to UPN " + upn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
e.next();
|
||||||
|
} catch (err) {
|
||||||
|
console.log("entra_oidc auth-with-oauth2 FAILED:", String(err));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, "team_members");
|
||||||
@@ -15,17 +15,16 @@
|
|||||||
// The allow-list is a comma-separated list of e-mail addresses, e.g.:
|
// The allow-list is a comma-separated list of e-mail addresses, e.g.:
|
||||||
// ENTRA_ADMIN_EMAILS=rve@respellion.nl,admin@respellion.nl
|
// ENTRA_ADMIN_EMAILS=rve@respellion.nl,admin@respellion.nl
|
||||||
//
|
//
|
||||||
// Keep this logic in sync with src/lib/azureAuth.js (resolveRole / parseAdminEmails),
|
// resolveRole itself lives in pb_hooks/utils.js and is pulled in per-callback
|
||||||
// which carries the canonical, unit-tested version for the frontend/tests.
|
// 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
|
||||||
function resolveRole(email) {
|
// at call time. See pb_hooks/utils.js for details. Keep the logic in sync with
|
||||||
const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase();
|
// src/lib/azureAuth.js (resolveRole / parseAdminEmails), which carries the
|
||||||
const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
// canonical, unit-tested version for the frontend/tests.
|
||||||
return allow.indexOf((email || "").toLowerCase()) !== -1 ? "admin" : "user";
|
|
||||||
}
|
|
||||||
|
|
||||||
// On creation (first login): set defaults before the record is persisted.
|
// On creation (first login): set defaults before the record is persisted.
|
||||||
onRecordCreate(function (e) {
|
onRecordCreate(function (e) {
|
||||||
|
const { resolveRole } = require(`${__hooks}/utils.js`);
|
||||||
const email = e.record.get("email") || "";
|
const email = e.record.get("email") || "";
|
||||||
|
|
||||||
e.record.set("role", resolveRole(email));
|
e.record.set("role", resolveRole(email));
|
||||||
@@ -42,13 +41,17 @@ onRecordCreate(function (e) {
|
|||||||
e.next();
|
e.next();
|
||||||
}, "team_members");
|
}, "team_members");
|
||||||
|
|
||||||
// On every successful auth (incl. OIDC): keep the admin role in sync with the
|
// On every successful auth (incl. OIDC): guarantee the admin role for
|
||||||
// allow-list, so promoting/demoting an admin only requires an env change.
|
// 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) {
|
onRecordAuthRequest(function (e) {
|
||||||
|
const { resolveRole } = require(`${__hooks}/utils.js`);
|
||||||
const email = e.record.get("email") || "";
|
const email = e.record.get("email") || "";
|
||||||
const want = resolveRole(email);
|
if (resolveRole(email) === "admin" && e.record.get("role") !== "admin") {
|
||||||
if (e.record.get("role") !== want) {
|
e.record.set("role", "admin");
|
||||||
e.record.set("role", want);
|
|
||||||
e.app.save(e.record);
|
e.app.save(e.record);
|
||||||
}
|
}
|
||||||
e.next();
|
e.next();
|
||||||
|
|||||||
109
pb_hooks/utils.js
Normal file
109
pb_hooks/utils.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Shared helpers for pb_hooks/*.pb.js callbacks.
|
||||||
|
//
|
||||||
|
// PocketBase's JSVM serializes and runs each registered hook callback as its
|
||||||
|
// own isolated program, so a plain top-level function declared in a *.pb.js
|
||||||
|
// file is NOT in scope inside a callback at call time. Reusable helpers must
|
||||||
|
// instead live in a plain (non *.pb.js, so it isn't auto-loaded as a hook)
|
||||||
|
// module and be pulled in per-callback via require(`${__hooks}/utils.js`).
|
||||||
|
// See: https://github.com/pocketbase/pocketbase/discussions/3599
|
||||||
|
//
|
||||||
|
// Loaded modules use a shared registry across callback invocations, so keep
|
||||||
|
// this file stateless. (Deliberate exception: the warn-once latch below, which
|
||||||
|
// exists precisely BECAUSE the registry is shared — it dedupes the env-missing
|
||||||
|
// warning across the bootstrap hook and the per-minute cron tick.)
|
||||||
|
//
|
||||||
|
// Keep resolveRole in sync with src/lib/azureAuth.js's resolveRole (the
|
||||||
|
// canonical, unit-tested version used by the frontend).
|
||||||
|
|
||||||
|
let warnedEnvMissing = false;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
* Resolve a user's role from their e-mail and the ENTRA_ADMIN_EMAILS allow-list.
|
||||||
|
* @param {string} email
|
||||||
|
* @returns {"admin"|"user"}
|
||||||
|
*/
|
||||||
|
resolveRole: function (email) {
|
||||||
|
const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase();
|
||||||
|
const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
||||||
|
return allow.indexOf(String(email || "").toLowerCase()) !== -1 ? "admin" : "user";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the Entra (Azure) OIDC provider on the team_members auth
|
||||||
|
* collection from the ENTRA_* environment variables (issue #18).
|
||||||
|
*
|
||||||
|
* Idempotent: compares the desired provider config against the stored one
|
||||||
|
* and only saves when something actually changed, so it is safe to call on
|
||||||
|
* every bootstrap and cron tick. Keeping this OUT of the one-shot migration
|
||||||
|
* means late or rotated secrets are picked up on the next start/tick without
|
||||||
|
* any manual re-apply.
|
||||||
|
*
|
||||||
|
* @param {core.App} app
|
||||||
|
* @returns {"collection-missing"|"not-auth-yet"|"env-missing"|"in-sync"|"updated"}
|
||||||
|
*/
|
||||||
|
reconcileEntraOidc: function (app) {
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
return "collection-missing"; // migration has not created it yet
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
return "not-auth-yet"; // pre-conversion PIN-era collection
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
|
||||||
|
const clientId = $os.getenv("ENTRA_CLIENT_ID") || "";
|
||||||
|
const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET") || "";
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
if (!warnedEnvMissing) {
|
||||||
|
warnedEnvMissing = true;
|
||||||
|
console.log(
|
||||||
|
"WARN entra_oidc: ENTRA_CLIENT_ID / ENTRA_CLIENT_SECRET not set — Microsoft login stays " +
|
||||||
|
"disabled until the env vars are provided (they are reconciled automatically on startup)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "env-missing";
|
||||||
|
}
|
||||||
|
warnedEnvMissing = false; // env restored — re-arm the warning for future rotations
|
||||||
|
|
||||||
|
const desired = {
|
||||||
|
name: "oidc",
|
||||||
|
clientId: clientId,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
|
||||||
|
tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
|
||||||
|
// Deliberately empty: claims are read from the Entra id_token instead of
|
||||||
|
// Graph's /oidc/userinfo. The userinfo endpoint omits `email` for
|
||||||
|
// accounts without a mail attribute, while the id_token always carries
|
||||||
|
// `preferred_username` (UPN) which entra_oidc.pb.js uses as an e-mail
|
||||||
|
// fallback (issue #24).
|
||||||
|
userInfoURL: "",
|
||||||
|
displayName: "Microsoft",
|
||||||
|
pkce: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const providers = (col.oauth2 && col.oauth2.providers) || [];
|
||||||
|
const current = providers.length === 1 ? providers[0] : null;
|
||||||
|
const inSync = !!current &&
|
||||||
|
col.oauth2.enabled === true &&
|
||||||
|
current.name === desired.name &&
|
||||||
|
current.clientId === desired.clientId &&
|
||||||
|
current.clientSecret === desired.clientSecret &&
|
||||||
|
current.authURL === desired.authURL &&
|
||||||
|
current.tokenURL === desired.tokenURL &&
|
||||||
|
current.userInfoURL === desired.userInfoURL &&
|
||||||
|
current.displayName === desired.displayName;
|
||||||
|
if (inSync) {
|
||||||
|
return "in-sync";
|
||||||
|
}
|
||||||
|
|
||||||
|
col.oauth2.enabled = true;
|
||||||
|
col.oauth2.providers = [desired];
|
||||||
|
app.save(col);
|
||||||
|
return "updated";
|
||||||
|
},
|
||||||
|
};
|
||||||
138
pb_migrations/1000000000_baseline_ledger_sync.js
Normal file
138
pb_migrations/1000000000_baseline_ledger_sync.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #18 — Baseline ledger sync for out-of-band provisioned databases.
|
||||||
|
//
|
||||||
|
// The Labs/production PocketBase originally ran WITHOUT --migrationsDir: its
|
||||||
|
// schema was created by scripts/setup-pb-collections.mjs / the admin UI, so its
|
||||||
|
// _migrations ledger is empty. When the migrations dir was mounted for the
|
||||||
|
// first time (Azure SSO, PR #17), PocketBase tried to replay the entire
|
||||||
|
// migration history against that existing schema and crash-looped on the very
|
||||||
|
// first file ("Collection name must be unique").
|
||||||
|
//
|
||||||
|
// This migration sorts before every other file. When it detects that the
|
||||||
|
// legacy schema already exists but the ledger has never tracked it, it marks
|
||||||
|
// the historical migrations below as applied so the replay is skipped and the
|
||||||
|
// chain continues with the genuinely new migrations (team_members_to_auth,
|
||||||
|
// tighten_api_rules). On fresh databases and on databases that already have a
|
||||||
|
// populated ledger it is a no-op.
|
||||||
|
//
|
||||||
|
// NOTE for future agents: never rename a migration file after it has been
|
||||||
|
// applied anywhere — the ledger is filename-based. New migrations must sort
|
||||||
|
// after 1781100001 and be appended to environments through a normal deploy.
|
||||||
|
const HISTORICAL_MIGRATIONS = [
|
||||||
|
"1778948471_created_content.js",
|
||||||
|
"1778948471_created_quiz_banks.js",
|
||||||
|
"1778948471_created_relations.js",
|
||||||
|
"1778948471_created_sources.js",
|
||||||
|
"1778948471_created_team_members.js",
|
||||||
|
"1778948471_created_topics.js",
|
||||||
|
"1778948472_created_leaderboard.js",
|
||||||
|
"1778948472_created_learn_progress.js",
|
||||||
|
"1778948472_created_quiz_cache.js",
|
||||||
|
"1778948472_created_quiz_results.js",
|
||||||
|
"1778948472_created_settings.js",
|
||||||
|
"1778954289_created_test_col2.js",
|
||||||
|
"1778954310_deleted_relations.js",
|
||||||
|
"1778954310_deleted_topics.js",
|
||||||
|
"1778954310_deleted_users.js",
|
||||||
|
"1778954311_deleted_content.js",
|
||||||
|
"1778954311_deleted_leaderboard.js",
|
||||||
|
"1778954311_deleted_learn_progress.js",
|
||||||
|
"1778954311_deleted_quiz_banks.js",
|
||||||
|
"1778954311_deleted_quiz_cache.js",
|
||||||
|
"1778954311_deleted_quiz_results.js",
|
||||||
|
"1778954311_deleted_settings.js",
|
||||||
|
"1778954311_deleted_sources.js",
|
||||||
|
"1778954311_deleted_team_members.js",
|
||||||
|
"1778954311_deleted_test_col2.js",
|
||||||
|
"1778954317_created_content.js",
|
||||||
|
"1778954317_created_leaderboard.js",
|
||||||
|
"1778954317_created_learn_progress.js",
|
||||||
|
"1778954317_created_quiz_banks.js",
|
||||||
|
"1778954317_created_quiz_cache.js",
|
||||||
|
"1778954317_created_quiz_results.js",
|
||||||
|
"1778954317_created_relations.js",
|
||||||
|
"1778954317_created_settings.js",
|
||||||
|
"1778954317_created_sources.js",
|
||||||
|
"1778954317_created_team_members.js",
|
||||||
|
"1778954317_created_topics.js",
|
||||||
|
"1779005271_created_test_col3.js",
|
||||||
|
"1779005289_created_test_col4.js",
|
||||||
|
"1779005309_deleted_content.js",
|
||||||
|
"1779005309_deleted_learn_progress.js",
|
||||||
|
"1779005309_deleted_quiz_banks.js",
|
||||||
|
"1779005309_deleted_quiz_cache.js",
|
||||||
|
"1779005309_deleted_quiz_results.js",
|
||||||
|
"1779005309_deleted_relations.js",
|
||||||
|
"1779005309_deleted_sources.js",
|
||||||
|
"1779005309_deleted_team_members.js",
|
||||||
|
"1779005309_deleted_topics.js",
|
||||||
|
"1779005310_deleted_leaderboard.js",
|
||||||
|
"1779005310_deleted_settings.js",
|
||||||
|
"1779005310_deleted_test_col3.js",
|
||||||
|
"1779005310_deleted_test_col4.js",
|
||||||
|
"1779005316_created_content.js",
|
||||||
|
"1779005316_created_quiz_banks.js",
|
||||||
|
"1779005316_created_relations.js",
|
||||||
|
"1779005316_created_sources.js",
|
||||||
|
"1779005316_created_topics.js",
|
||||||
|
"1779005317_created_leaderboard.js",
|
||||||
|
"1779005317_created_learn_progress.js",
|
||||||
|
"1779005317_created_quiz_cache.js",
|
||||||
|
"1779005317_created_quiz_results.js",
|
||||||
|
"1779005317_created_settings.js",
|
||||||
|
"1779005317_created_team_members.js",
|
||||||
|
"1779127586_created_curriculum.js",
|
||||||
|
"1779127759_collections_snapshot.js",
|
||||||
|
"1779200000_updated_sources.js",
|
||||||
|
"1780000001_updated_topics.js",
|
||||||
|
"1780500000_updated_topics_relevance_locked.js",
|
||||||
|
"1780500001_normalize_relation_types.js",
|
||||||
|
"1780500002_created_llm_calls.js",
|
||||||
|
"1780600000_curriculum_v2.js",
|
||||||
|
"1780700000_sources_progress.js",
|
||||||
|
"1780800000_created_micro_learnings.js",
|
||||||
|
"1780800001_deleted_legacy_collections.js",
|
||||||
|
"1780800002_update_micro_learnings_rules.js",
|
||||||
|
"1780900000_team_members_enrollment.js",
|
||||||
|
"1780900001_created_test_results.js",
|
||||||
|
"1781000000_created_theme_sessions.js",
|
||||||
|
"1781100000_created_question_bank.js",
|
||||||
|
"1781100001_created_on_demand_attempts.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
// Fresh database? (no legacy schema) -> let the normal chain run.
|
||||||
|
try {
|
||||||
|
app.findCollectionByNameOrId("content");
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ledger already tracks the history? -> normally migrated DB, nothing to do.
|
||||||
|
const probe = new DynamicModel({ count: 0 });
|
||||||
|
app.db()
|
||||||
|
.newQuery("SELECT COUNT(*) AS count FROM _migrations WHERE file = {:file}")
|
||||||
|
.bind({ file: "1778948471_created_content.js" })
|
||||||
|
.one(probe);
|
||||||
|
if (probe.count > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out-of-band provisioned database: schema exists, ledger is empty.
|
||||||
|
// Mark the historical migrations as applied so they are not replayed.
|
||||||
|
const applied = Date.now();
|
||||||
|
for (const file of HISTORICAL_MIGRATIONS) {
|
||||||
|
app.db()
|
||||||
|
.newQuery("INSERT OR IGNORE INTO _migrations (file, applied) VALUES ({:file}, {:applied})")
|
||||||
|
.bind({ file: file, applied: applied })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"baseline_ledger_sync: detected out-of-band provisioned database — marked " +
|
||||||
|
HISTORICAL_MIGRATIONS.length + " historical migrations as applied"
|
||||||
|
);
|
||||||
|
}, (_app) => {
|
||||||
|
// Down: intentionally a no-op. The ledger rows describe environment-specific
|
||||||
|
// bookkeeping; removing them would re-trigger the replay this fix prevents.
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
//
|
//
|
||||||
// Issue #16 — Azure (Entra ID) login.
|
// Issue #16 / #18 — Azure (Entra ID) login.
|
||||||
//
|
//
|
||||||
// Replaces the PIN-based `base` collection `team_members` with a PocketBase
|
// Replaces the PIN-based `base` collection `team_members` with a PocketBase
|
||||||
// `auth` collection that authenticates against Azure Entra ID via OIDC.
|
// `auth` collection that authenticates against Azure Entra ID via OIDC.
|
||||||
@@ -10,34 +10,106 @@
|
|||||||
// base, generated tests and micro-learnings live in OTHER collections and are
|
// base, generated tests and micro-learnings live in OTHER collections and are
|
||||||
// left completely untouched by this migration.
|
// left completely untouched by this migration.
|
||||||
//
|
//
|
||||||
// OIDC provider credentials are read from the environment at apply time, so no
|
// This migration is STRUCTURE ONLY and does not depend on the environment:
|
||||||
// secrets are committed to git:
|
// the OIDC provider credentials (ENTRA_TENANT_ID / ENTRA_CLIENT_ID /
|
||||||
// ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET
|
// ENTRA_CLIENT_SECRET) are reconciled into the collection on every startup by
|
||||||
// (set via the Ansible-rendered .env, see infra/*/site/compose.yml).
|
// 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
|
||||||
// To rotate credentials or change the tenant later, update the env and either
|
// OAuth2 (issue #18). As a fast path the provider is also attached here when
|
||||||
// re-configure the provider from the PocketBase admin UI (Settings → Auth
|
// the env vars are already present at apply time.
|
||||||
// providers) or ship a follow-up migration.
|
|
||||||
migrate((app) => {
|
migrate((app) => {
|
||||||
// 1. Drop the old PIN-based collection (takes the PIN records with it).
|
// 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 {
|
try {
|
||||||
const old = app.findCollectionByNameOrId("team_members");
|
existing = app.findCollectionByNameOrId("team_members");
|
||||||
app.delete(old);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Collection did not exist — nothing to drop.
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
|
// 1. Detach relation fields that point at team_members. PocketBase refuses
|
||||||
const clientId = $os.getenv("ENTRA_CLIENT_ID");
|
// to delete a collection that other collections relate to, so before we
|
||||||
const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET");
|
// 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();
|
||||||
|
|
||||||
// Only configure the OIDC provider when credentials are actually present.
|
// Drop any index that references the team_member_id column (it is rebuilt
|
||||||
// PocketBase rejects an enabled OAuth2 provider with an empty clientId /
|
// on the text column below).
|
||||||
// clientSecret, which would abort this migration and prevent PocketBase from
|
c.indexes = (c.indexes || []).filter((idx) => idx.indexOf("team_member_id") === -1);
|
||||||
// starting at all (502 on every /api call). When the secrets are absent the
|
// Replace the relation field with a text field of the same name.
|
||||||
// collection is created without a provider; once the ENTRA_* secrets are set,
|
c.fields.removeById(dep.relId);
|
||||||
// configure the provider via the PocketBase admin UI
|
c.fields.add(new Field({
|
||||||
// (Settings → Auth providers → OIDC) or re-apply this migration.
|
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) ? [
|
const oidcProviders = (clientId && clientSecret) ? [
|
||||||
{
|
{
|
||||||
name: "oidc",
|
name: "oidc",
|
||||||
@@ -45,13 +117,21 @@ migrate((app) => {
|
|||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
|
authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
|
||||||
tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
|
tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
|
||||||
userInfoURL: "https://graph.microsoft.com/oidc/userinfo",
|
// Empty on purpose: claims come from the id_token (see utils.js, issue #24).
|
||||||
|
userInfoURL: "",
|
||||||
displayName: "Microsoft",
|
displayName: "Microsoft",
|
||||||
pkce: true,
|
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)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Recreate `team_members` as an auth collection (same name → all existing
|
// 3. Recreate `team_members` as an auth collection (same name → all existing
|
||||||
// `user_id` references in test_results, on_demand_attempts,
|
// `user_id` references in test_results, on_demand_attempts,
|
||||||
// micro_learning_completions, leaderboard, theme_session_completions keep
|
// micro_learning_completions, leaderboard, theme_session_completions keep
|
||||||
// working unchanged).
|
// working unchanged).
|
||||||
@@ -63,12 +143,18 @@ migrate((app) => {
|
|||||||
// Access rules: every legitimate user is authenticated (Azure-gated +
|
// Access rules: every legitimate user is authenticated (Azure-gated +
|
||||||
// OIDC). Profiles are readable by any authenticated user (leaderboard,
|
// OIDC). Profiles are readable by any authenticated user (leaderboard,
|
||||||
// dashboard); a user may only update their own record (onboarding flips
|
// dashboard); a user may only update their own record (onboarding flips
|
||||||
// enrollment_status). Creation/deletion happen via OAuth2 / superuser only.
|
// 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 != ""',
|
listRule: '@request.auth.id != ""',
|
||||||
viewRule: '@request.auth.id != ""',
|
viewRule: '@request.auth.id != ""',
|
||||||
createRule: null,
|
createRule: '@request.context = "oauth2"',
|
||||||
updateRule: '@request.auth.id = id',
|
// Self-update may not touch `role` (closes self-service privilege
|
||||||
deleteRule: null,
|
// 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: "",
|
authRule: "",
|
||||||
manageRule: null,
|
manageRule: null,
|
||||||
|
|
||||||
|
|||||||
46
pb_migrations/1781000002_allow_oauth2_signup.js
Normal file
46
pb_migrations/1781000002_allow_oauth2_signup.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #22 — Allow OAuth2 sign-up on team_members.
|
||||||
|
//
|
||||||
|
// The collection was created with `createRule: null` on the assumption that
|
||||||
|
// the OAuth2 flow bypasses it. It does not: PocketBase applies the createRule
|
||||||
|
// to the automatic record creation on a first OIDC login, so every first
|
||||||
|
// login failed with 403 "Only superusers can perform this action". Since the
|
||||||
|
// pre-Azure records were intentionally dropped, EVERY user was a first login
|
||||||
|
// and nobody could sign in.
|
||||||
|
//
|
||||||
|
// `@request.context = "oauth2"` scopes record creation to the OAuth2 flow
|
||||||
|
// only — plain REST creates (e.g. anonymous POST /records, which could
|
||||||
|
// otherwise pre-seed rogue admin profiles) keep being rejected.
|
||||||
|
//
|
||||||
|
// Environments that have not applied 1781000000 yet get this rule directly
|
||||||
|
// from that (updated) migration; this follow-up exists for databases where it
|
||||||
|
// already ran with the old `null` rule (Labs). Applying it twice is harmless.
|
||||||
|
migrate((app) => {
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
console.log("allow_oauth2_signup: team_members does not exist — skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
console.log("allow_oauth2_signup: team_members is not an auth collection — skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
col.createRule = '@request.context = "oauth2"';
|
||||||
|
app.save(col);
|
||||||
|
}, (app) => {
|
||||||
|
// Down: restore the (broken) superuser-only rule.
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
col.createRule = null;
|
||||||
|
app.save(col);
|
||||||
|
});
|
||||||
52
pb_migrations/1781000003_team_members_admin_rules.js
Normal file
52
pb_migrations/1781000003_team_members_admin_rules.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #27 — TeamManager roster management + close role self-escalation.
|
||||||
|
//
|
||||||
|
// The collection shipped with `updateRule: '@request.auth.id = id'` and
|
||||||
|
// `deleteRule: null`. That broke the TeamManager admin panel (admins could
|
||||||
|
// not change roles or remove members) AND left a privilege escalation open:
|
||||||
|
// the self-update rule did not restrict fields, so any authenticated user
|
||||||
|
// could PATCH their own `role` to "admin".
|
||||||
|
//
|
||||||
|
// New rules:
|
||||||
|
// update — a user may update their own record but NOT the `role` field
|
||||||
|
// (onboarding only touches enrollment fields); admins may update
|
||||||
|
// anyone, including `role`.
|
||||||
|
// delete — admins only.
|
||||||
|
//
|
||||||
|
// Environments that have not applied 1781000000 yet get these rules directly
|
||||||
|
// from that (updated) migration; this follow-up exists for databases where it
|
||||||
|
// already ran (Labs). Applying both is harmless (same values).
|
||||||
|
const UPDATE_RULE = '(@request.auth.id = id && @request.body.role:isset = false) || @request.auth.role = "admin"';
|
||||||
|
const DELETE_RULE = '@request.auth.role = "admin"';
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
console.log("team_members_admin_rules: team_members does not exist — skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
console.log("team_members_admin_rules: team_members is not an auth collection — skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
col.updateRule = UPDATE_RULE;
|
||||||
|
col.deleteRule = DELETE_RULE;
|
||||||
|
app.save(col);
|
||||||
|
}, (app) => {
|
||||||
|
// Down: restore the previous (self-update only, superuser delete) rules.
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
col.updateRule = '@request.auth.id = id';
|
||||||
|
col.deleteRule = null;
|
||||||
|
app.save(col);
|
||||||
|
});
|
||||||
189
pb_migrations/1781200000_created_onboarding.js
Normal file
189
pb_migrations/1781200000_created_onboarding.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #30 — Onboarding track (5-day, theme-level introduction).
|
||||||
|
//
|
||||||
|
// Two collections:
|
||||||
|
// onboarding_overviews — cached per-theme overview content (one row per theme),
|
||||||
|
// keyed by the theme name, with a topics_fingerprint for
|
||||||
|
// staleness detection / regeneration.
|
||||||
|
// onboarding_completions — per-user, per-theme completion marker.
|
||||||
|
//
|
||||||
|
// Design notes (consistent with issues #18/#27):
|
||||||
|
// * team_member_id is a plain TEXT field, NOT a relation — admins can delete
|
||||||
|
// team_members, and a relation with cascadeDelete would otherwise block that
|
||||||
|
// delete / drag the collection into the migration graph. Completions are keyed
|
||||||
|
// by (team_member_id, theme); orphan rows after a member delete are harmless.
|
||||||
|
// * API rules are set EXPLICITLY to authenticated-only. The tighten-rules
|
||||||
|
// migration (1781000001) already ran against the collections that existed then,
|
||||||
|
// so new collections must lock themselves down or they default to public.
|
||||||
|
const AUTHED = '@request.auth.id != ""';
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
const overviews = new Collection({
|
||||||
|
"id": "pbc_onboarding_overviews0",
|
||||||
|
"name": "onboarding_overviews",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_id_oov",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_theme_oov",
|
||||||
|
"name": "theme",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "json_content_oov",
|
||||||
|
"name": "content",
|
||||||
|
"type": "json",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_fingerprint_oov",
|
||||||
|
"name": "topics_fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_created_oov",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_updated_oov",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_onboarding_overviews_theme` ON `onboarding_overviews` (`theme`)"
|
||||||
|
],
|
||||||
|
"listRule": AUTHED,
|
||||||
|
"viewRule": AUTHED,
|
||||||
|
"createRule": AUTHED,
|
||||||
|
"updateRule": AUTHED,
|
||||||
|
"deleteRule": AUTHED
|
||||||
|
});
|
||||||
|
|
||||||
|
app.save(overviews);
|
||||||
|
|
||||||
|
const completions = new Collection({
|
||||||
|
"id": "pbc_onboarding_completions0",
|
||||||
|
"name": "onboarding_completions",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_id_ocp",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_ocp_team_member",
|
||||||
|
"name": "team_member_id",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_ocp_theme",
|
||||||
|
"name": "theme",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_created_ocp",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_updated_ocp",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_onboarding_completions_user_theme` ON `onboarding_completions` (`team_member_id`, `theme`)"
|
||||||
|
],
|
||||||
|
"listRule": AUTHED,
|
||||||
|
"viewRule": AUTHED,
|
||||||
|
"createRule": AUTHED,
|
||||||
|
"updateRule": AUTHED,
|
||||||
|
"deleteRule": AUTHED
|
||||||
|
});
|
||||||
|
|
||||||
|
app.save(completions);
|
||||||
|
|
||||||
|
}, (app) => {
|
||||||
|
const completions = app.findCollectionByNameOrId("onboarding_completions");
|
||||||
|
if (completions) {
|
||||||
|
app.delete(completions);
|
||||||
|
}
|
||||||
|
const overviews = app.findCollectionByNameOrId("onboarding_overviews");
|
||||||
|
if (overviews) {
|
||||||
|
app.delete(overviews);
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -14,6 +14,8 @@ if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OPEN_RULES = { listRule: '', viewRule: '', createRule: '', updateRule: '', deleteRule: '' };
|
const OPEN_RULES = { listRule: '', viewRule: '', createRule: '', updateRule: '', deleteRule: '' };
|
||||||
|
const AUTHED = '@request.auth.id != ""';
|
||||||
|
const AUTHED_RULES = { listRule: AUTHED, viewRule: AUTHED, createRule: AUTHED, updateRule: AUTHED, deleteRule: AUTHED };
|
||||||
|
|
||||||
// PocketBase v0.23: created/updated must be explicitly defined as autodate fields
|
// PocketBase v0.23: created/updated must be explicitly defined as autodate fields
|
||||||
const AUTODATE_FIELDS = [
|
const AUTODATE_FIELDS = [
|
||||||
@@ -111,6 +113,13 @@ const COLLECTIONS = [
|
|||||||
name: 'team_members',
|
name: 'team_members',
|
||||||
type: 'auth',
|
type: 'auth',
|
||||||
...OPEN_RULES,
|
...OPEN_RULES,
|
||||||
|
// Mirror of pb_migrations/1781000002: creation only via the OAuth2
|
||||||
|
// sign-up flow (issue #22).
|
||||||
|
createRule: '@request.context = "oauth2"',
|
||||||
|
// Mirror of pb_migrations/1781000003: self-update without role changes;
|
||||||
|
// roster management (incl. role/delete) is admin-only (issue #27).
|
||||||
|
updateRule: '(@request.auth.id = id && @request.body.role:isset = false) || @request.auth.role = "admin"',
|
||||||
|
deleteRule: '@request.auth.role = "admin"',
|
||||||
passwordAuth: { enabled: false, identityFields: ['email'] },
|
passwordAuth: { enabled: false, identityFields: ['email'] },
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'name', type: 'text', required: false },
|
{ name: 'name', type: 'text', required: false },
|
||||||
@@ -197,6 +206,32 @@ const COLLECTIONS = [
|
|||||||
...AUTODATE_FIELDS,
|
...AUTODATE_FIELDS,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// Onboarding track (issue #30). Owned by pb_migrations/1781200000; these are a
|
||||||
|
// fallback for envs where migrations have not run. Authenticated-only rules
|
||||||
|
// (mirrors the migration) — do NOT use OPEN_RULES here.
|
||||||
|
{
|
||||||
|
name: 'onboarding_overviews',
|
||||||
|
type: 'base',
|
||||||
|
...AUTHED_RULES,
|
||||||
|
fields: [
|
||||||
|
{ name: 'theme', type: 'text', required: true },
|
||||||
|
{ name: 'content', type: 'json', required: true },
|
||||||
|
{ name: 'topics_fingerprint', type: 'text', required: true },
|
||||||
|
...AUTODATE_FIELDS,
|
||||||
|
],
|
||||||
|
indexes: ['CREATE UNIQUE INDEX `idx_onboarding_overviews_theme` ON `onboarding_overviews` (`theme`)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onboarding_completions',
|
||||||
|
type: 'base',
|
||||||
|
...AUTHED_RULES,
|
||||||
|
fields: [
|
||||||
|
{ name: 'team_member_id', type: 'text', required: true },
|
||||||
|
{ name: 'theme', type: 'text', required: true },
|
||||||
|
...AUTODATE_FIELDS,
|
||||||
|
],
|
||||||
|
indexes: ['CREATE UNIQUE INDEX `idx_onboarding_completions_user_theme` ON `onboarding_completions` (`team_member_id`, `theme`)'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function post(path, body, token) {
|
async function post(path, body, token) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ChatLauncher from './components/chat/ChatLauncher'
|
|||||||
|
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Onboarding from './pages/Onboarding'
|
import Onboarding from './pages/Onboarding'
|
||||||
|
import OnboardingTrack from './pages/OnboardingTrack'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
|
|
||||||
import Admin from './pages/Admin'
|
import Admin from './pages/Admin'
|
||||||
@@ -106,6 +107,10 @@ function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
<Route path="/onboarding" element={<Onboarding />} />
|
||||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
|
{/* Onboarding track is available to any logged-in user, regardless of
|
||||||
|
enrollment — hence skipEnrollmentGate (distinct from /onboarding, the
|
||||||
|
enrollment page). */}
|
||||||
|
<Route path="/onboarding-track" element={<ProtectedRoute skipEnrollmentGate><OnboardingTrack /></ProtectedRoute>} />
|
||||||
<Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} />
|
<Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} />
|
||||||
<Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
|
<Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
|
||||||
<Route path="/topic-test" element={<ProtectedRoute><TopicTest /></ProtectedRoute>} />
|
<Route path="/topic-test" element={<ProtectedRoute><TopicTest /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import { useApp } from '../../store/AppContext';
|
|||||||
|
|
||||||
// Users are provisioned automatically on first Azure (Entra ID) login — admins
|
// 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,
|
// no longer create them by hand. This panel lets an admin review the roster,
|
||||||
// switch a member between user/admin, and remove members. The admin role is
|
// switch a member between user/admin, and remove members. The
|
||||||
// also re-synced from the ENTRA_ADMIN_EMAILS allow-list on every login (see
|
// ENTRA_ADMIN_EMAILS allow-list is escalate-only (see
|
||||||
// pb_hooks/team_members.pb.js), so a manual change here can be overridden on
|
// pb_hooks/team_members.pb.js): it guarantees admin for its members on every
|
||||||
// the member's next sign-in if their e-mail is on/off that list.
|
// login, so promotions made here stick, but demoting an allow-list member is
|
||||||
|
// undone on their next sign-in — remove them from the list instead.
|
||||||
const TeamManager = () => {
|
const TeamManager = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
@@ -67,8 +68,9 @@ const TeamManager = () => {
|
|||||||
<p className="text-sm text-fg-muted flex items-start gap-2">
|
<p className="text-sm text-fg-muted flex items-start gap-2">
|
||||||
<Info size={16} className="mt-0.5 shrink-0 text-teal" />
|
<Info size={16} className="mt-0.5 shrink-0 text-teal" />
|
||||||
Team members are created automatically when they first sign in with their
|
Team members are created automatically when they first sign in with their
|
||||||
Microsoft account. Admins are granted via the <code>ENTRA_ADMIN_EMAILS</code>{' '}
|
Microsoft account. Members of the <code>ENTRA_ADMIN_EMAILS</code> allow-list
|
||||||
allow-list and re-synced on each login.
|
are always admin; promotions made here stick, but demoting an allow-list
|
||||||
|
member is undone on their next sign-in.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
152
src/lib/__tests__/onboardingService.test.js
Normal file
152
src/lib/__tests__/onboardingService.test.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
ONBOARDING_DAY_COUNT,
|
||||||
|
orderThemes,
|
||||||
|
distributeThemesIntoDays,
|
||||||
|
computeTopicsFingerprint,
|
||||||
|
computeOnboardingProgress,
|
||||||
|
buildOnboardingPlan,
|
||||||
|
} from '../onboardingService';
|
||||||
|
|
||||||
|
const sizes = (days) => days.map((d) => d.themes.length);
|
||||||
|
|
||||||
|
describe('onboardingService pure helpers', () => {
|
||||||
|
it('exposes a 5-day guideline', () => {
|
||||||
|
expect(ONBOARDING_DAY_COUNT).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('orderThemes', () => {
|
||||||
|
const kb = ['Governance', 'Culture', 'Privacy', 'Process'];
|
||||||
|
|
||||||
|
it('orders by first appearance in the schedule, then appends the rest alphabetically', () => {
|
||||||
|
const schedule = [{ theme: 'Privacy' }, { theme: 'Governance' }];
|
||||||
|
expect(orderThemes(kb, schedule)).toEqual(['Privacy', 'Governance', 'Culture', 'Process']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('de-duplicates repeated schedule labels', () => {
|
||||||
|
const schedule = [{ theme: 'Privacy' }, { theme: 'Privacy' }, { theme: 'Culture' }];
|
||||||
|
expect(orderThemes(kb, schedule)).toEqual(['Privacy', 'Culture', 'Governance', 'Process']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores schedule labels that are not real KB themes (e.g. merged-week labels)', () => {
|
||||||
|
const schedule = [{ theme: 'Privacy & Governance (merged)' }, { theme: 'Culture' }];
|
||||||
|
expect(orderThemes(kb, schedule)).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to pure alphabetical when the schedule is null/empty', () => {
|
||||||
|
expect(orderThemes(kb, null)).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||||
|
expect(orderThemes(kb, [])).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('distributeThemesIntoDays', () => {
|
||||||
|
const themes = (n) => Array.from({ length: n }, (_, i) => `T${i + 1}`);
|
||||||
|
|
||||||
|
it('splits 10 themes evenly across 5 days', () => {
|
||||||
|
expect(sizes(distributeThemesIntoDays(themes(10)))).toEqual([2, 2, 2, 2, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('front-loads the remainder (12 -> 3,3,2,2,2)', () => {
|
||||||
|
expect(sizes(distributeThemesIntoDays(themes(12)))).toEqual([3, 3, 2, 2, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 7 -> 2,2,1,1,1', () => {
|
||||||
|
expect(sizes(distributeThemesIntoDays(themes(7)))).toEqual([2, 2, 1, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses fewer days than the cap when there are fewer themes', () => {
|
||||||
|
const days = distributeThemesIntoDays(themes(3));
|
||||||
|
expect(sizes(days)).toEqual([1, 1, 1]);
|
||||||
|
expect(days.map((d) => d.day)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for no themes', () => {
|
||||||
|
expect(distributeThemesIntoDays([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves order and loses nothing', () => {
|
||||||
|
const input = themes(12);
|
||||||
|
const flat = distributeThemesIntoDays(input).flatMap((d) => d.themes);
|
||||||
|
expect(flat).toEqual(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeTopicsFingerprint', () => {
|
||||||
|
it('is order-independent', () => {
|
||||||
|
const a = computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }, { id: 'z' }]);
|
||||||
|
const b = computeTopicsFingerprint([{ id: 'z' }, { id: 'x' }, { id: 'y' }]);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes when a topic is added or removed', () => {
|
||||||
|
const base = computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }]);
|
||||||
|
expect(computeTopicsFingerprint([{ id: 'x' }])).not.toBe(base);
|
||||||
|
expect(computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }, { id: 'z' }])).not.toBe(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty input', () => {
|
||||||
|
expect(typeof computeTopicsFingerprint([])).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeOnboardingProgress', () => {
|
||||||
|
const days = [
|
||||||
|
{ day: 1, themes: ['A', 'B'] },
|
||||||
|
{ day: 2, themes: ['C'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('does not count a partially-done day as complete', () => {
|
||||||
|
const p = computeOnboardingProgress(days, new Set(['A']));
|
||||||
|
expect(p).toMatchObject({ themesTotal: 3, themesDone: 1, dayCount: 2, daysCompleted: 0, allDone: false });
|
||||||
|
expect(p.percentage).toBe(33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a fully-done day', () => {
|
||||||
|
const p = computeOnboardingProgress(days, new Set(['A', 'B']));
|
||||||
|
expect(p).toMatchObject({ themesDone: 2, daysCompleted: 1, allDone: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports allDone only when every theme is complete', () => {
|
||||||
|
const p = computeOnboardingProgress(days, new Set(['A', 'B', 'C']));
|
||||||
|
expect(p).toMatchObject({ themesDone: 3, daysCompleted: 2, percentage: 100, allDone: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an array as well as a Set', () => {
|
||||||
|
expect(computeOnboardingProgress(days, ['A', 'B', 'C']).allDone).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zeros for an empty plan', () => {
|
||||||
|
expect(computeOnboardingProgress([], new Set())).toEqual({
|
||||||
|
themesTotal: 0, themesDone: 0, dayCount: 0, daysCompleted: 0, percentage: 0, allDone: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildOnboardingPlan', () => {
|
||||||
|
const topics = [
|
||||||
|
{ id: 't1', label: 'T1', theme: 'Privacy', complexity_weight: 2 },
|
||||||
|
{ id: 't2', label: 'T2', theme: 'Culture', complexity_weight: 1 },
|
||||||
|
{ id: 'f1', label: 'F1', theme: 'Privacy', type: 'fact' }, // excluded by buildThemeTopicMap
|
||||||
|
];
|
||||||
|
|
||||||
|
it('builds themes (schedule-ordered), days, and a theme→topics map', () => {
|
||||||
|
const activeVersion = { schedule: [{ theme: 'Privacy' }, { theme: 'Culture' }] };
|
||||||
|
const plan = buildOnboardingPlan(topics, activeVersion);
|
||||||
|
expect(plan.themes).toEqual(['Privacy', 'Culture']);
|
||||||
|
expect(sizes(plan.days)).toEqual([1, 1]);
|
||||||
|
expect(plan.themeTopicMap.get('Privacy').map((t) => t.id)).toEqual(['t1']); // fact excluded
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a stringified schedule and a missing version', () => {
|
||||||
|
const stringified = { schedule: JSON.stringify([{ theme: 'Culture' }]) };
|
||||||
|
expect(buildOnboardingPlan(topics, stringified).themes).toEqual(['Culture', 'Privacy']);
|
||||||
|
expect(buildOnboardingPlan(topics, null).themes).toEqual(['Culture', 'Privacy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty plan for an empty KB', () => {
|
||||||
|
const plan = buildOnboardingPlan([], null);
|
||||||
|
expect(plan.themes).toEqual([]);
|
||||||
|
expect(plan.days).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -406,6 +406,49 @@ export async function setThemeSessionCompletion(userId, themeSessionId, sessionW
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Onboarding track ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached onboarding overview for a theme, or null. `theme` is free text →
|
||||||
|
* use pb.filter() bindings so quotes/specials can't break the filter.
|
||||||
|
*/
|
||||||
|
export async function getOnboardingOverview(theme) {
|
||||||
|
try {
|
||||||
|
return await pb.collection('onboarding_overviews').getFirstListItem(
|
||||||
|
pb.filter('theme = {:theme}', { theme }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert the cached overview for a theme (keyed by theme). */
|
||||||
|
export async function setOnboardingOverview(theme, content, fingerprint) {
|
||||||
|
return pbUpsert(
|
||||||
|
'onboarding_overviews',
|
||||||
|
pb.filter('theme = {:theme}', { theme }),
|
||||||
|
{ content, topics_fingerprint: fingerprint },
|
||||||
|
{ theme, content, topics_fingerprint: fingerprint },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All onboarding-completion rows for a user (each has a `theme`). */
|
||||||
|
export async function getCompletedOnboardingThemes(userId) {
|
||||||
|
return pb.collection('onboarding_completions').getFullList({
|
||||||
|
filter: pb.filter('team_member_id = {:userId}', { userId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a theme complete for the user. Idempotent (unique on user+theme). */
|
||||||
|
export async function setOnboardingCompletion(userId, theme) {
|
||||||
|
return pbUpsert(
|
||||||
|
'onboarding_completions',
|
||||||
|
pb.filter('team_member_id = {:userId} && theme = {:theme}', { userId, theme }),
|
||||||
|
{ team_member_id: userId, theme },
|
||||||
|
{ team_member_id: userId, theme },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getLeaderboard() {
|
export async function getLeaderboard() {
|
||||||
|
|||||||
@@ -213,6 +213,13 @@ const SIMULATION_TOOL_STUBS = {
|
|||||||
},
|
},
|
||||||
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
|
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
|
||||||
set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
|
set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
|
||||||
|
emit_onboarding_overview: {
|
||||||
|
title: 'Voorbeeldthema',
|
||||||
|
what_it_is: 'Een korte simulatie-omschrijving van dit thema.',
|
||||||
|
why_it_matters: 'In simulatiemodus laat dit zien hoe de onboarding-overview eruitziet zonder de API te raken.',
|
||||||
|
key_points: ['Kernpunt één', 'Kernpunt twee', 'Kernpunt drie'],
|
||||||
|
topics_covered: [{ topic_id: 'sim-topic', label: 'Simulatie onderwerp' }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
|
function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
|
||||||
|
|||||||
@@ -229,6 +229,16 @@ export const themeSessionSchema = z.object({
|
|||||||
keyTakeaways: z.array(z.string().min(1)).min(3),
|
keyTakeaways: z.array(z.string().min(1)).min(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: z.array(z.string().min(1)).min(3).max(5),
|
||||||
|
topics_covered: z
|
||||||
|
.array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) }))
|
||||||
|
.min(1),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry mapping known tool names to their input schemas. `callLLM`
|
* Registry mapping known tool names to their input schemas. `callLLM`
|
||||||
* consults this when the caller does not pass an explicit `toolSchemas`
|
* consults this when the caller does not pass an explicit `toolSchemas`
|
||||||
@@ -252,4 +262,5 @@ export const toolSchemaRegistry = {
|
|||||||
remove_section: removeSectionPatchSchema,
|
remove_section: removeSectionPatchSchema,
|
||||||
replace_takeaways: replaceTakeawaysPatchSchema,
|
replace_takeaways: replaceTakeawaysPatchSchema,
|
||||||
emit_theme_session: themeSessionSchema,
|
emit_theme_session: themeSessionSchema,
|
||||||
|
emit_onboarding_overview: onboardingOverviewSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -454,3 +454,39 @@ export const EMIT_THEME_SESSION_TOOL = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Onboarding overview (breadth-first, one theme) ───────────────────────────
|
||||||
|
|
||||||
|
export const EMIT_ONBOARDING_OVERVIEW_TOOL = {
|
||||||
|
name: 'emit_onboarding_overview',
|
||||||
|
description: 'Return a SHORT, breadth-first onboarding overview of ONE theme for a brand-new Respellion employee. This is a light introduction, NOT a deep lesson: what the theme is in plain language, why it matters for day-to-day and week-to-week work at Respellion, a few key points, and which topics belong to it. Keep it skimmable.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Learner-facing theme title, 2–6 words.' },
|
||||||
|
what_it_is: { type: 'string', description: '1–2 plain-language sentences defining what this theme is about.' },
|
||||||
|
why_it_matters: { type: 'string', description: '1–3 sentences on why this theme matters for a new employee\'s daily and weekly work at Respellion.' },
|
||||||
|
key_points: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
minItems: 3,
|
||||||
|
maxItems: 5,
|
||||||
|
description: '3–5 short, concrete takeaways a newcomer should remember about this theme.',
|
||||||
|
},
|
||||||
|
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.',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
topic_id: { type: 'string', description: 'The id of the topic (must match one of the ids provided).' },
|
||||||
|
label: { type: 'string', description: 'The topic label, as provided.' },
|
||||||
|
},
|
||||||
|
required: ['topic_id', 'label'],
|
||||||
|
},
|
||||||
|
minItems: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['title', 'what_it_is', 'why_it_matters', 'key_points', 'topics_covered'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
233
src/lib/onboardingService.js
Normal file
233
src/lib/onboardingService.js
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import * as db from './db';
|
||||||
|
import { callLLM, cachedSystem } from './llm';
|
||||||
|
import { buildThemeTopicMap } from './curriculumService';
|
||||||
|
import { EMIT_ONBOARDING_OVERVIEW_TOOL } from './llmTools';
|
||||||
|
|
||||||
|
// The onboarding track introduces a new employee to every KB theme in breadth,
|
||||||
|
// spread over a guideline of 5 "days". A theme is the unit we track completion
|
||||||
|
// on; "days" are only a presentation grouping computed at read time, so
|
||||||
|
// re-chunking (e.g. when the theme set changes) never loses a user's progress.
|
||||||
|
export const ONBOARDING_DAY_COUNT = 5;
|
||||||
|
|
||||||
|
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order theme names by their first appearance in the active curriculum schedule
|
||||||
|
* (a pedagogical order), then append any KB themes the schedule never names,
|
||||||
|
* alphabetically. Schedule labels that aren't real KB themes (e.g. merged-week
|
||||||
|
* labels) are ignored. De-duplicated.
|
||||||
|
*
|
||||||
|
* @param {string[]} themeNames — canonical theme names present in the KB
|
||||||
|
* @param {Array<{theme?: string}>|null} schedule — active curriculum schedule
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function orderThemes(themeNames, schedule) {
|
||||||
|
const known = new Set(themeNames);
|
||||||
|
const ordered = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
if (Array.isArray(schedule)) {
|
||||||
|
for (const week of schedule) {
|
||||||
|
const theme = week && week.theme;
|
||||||
|
if (theme && known.has(theme) && !seen.has(theme)) {
|
||||||
|
seen.add(theme);
|
||||||
|
ordered.push(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = themeNames
|
||||||
|
.filter((t) => !seen.has(t))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return [...ordered, ...remaining];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split an ordered theme list into balanced, order-preserving day buckets.
|
||||||
|
* Uses at most `dayCount` days; with fewer themes than days it uses `N` days.
|
||||||
|
* The first `remainder` days get one extra theme.
|
||||||
|
*
|
||||||
|
* @param {string[]} orderedThemes
|
||||||
|
* @param {number} [dayCount=5]
|
||||||
|
* @returns {Array<{ day: number, themes: string[] }>} non-empty days only
|
||||||
|
*/
|
||||||
|
export function distributeThemesIntoDays(orderedThemes, dayCount = ONBOARDING_DAY_COUNT) {
|
||||||
|
const themes = Array.isArray(orderedThemes) ? orderedThemes : [];
|
||||||
|
const n = themes.length;
|
||||||
|
if (n === 0) return [];
|
||||||
|
|
||||||
|
const effectiveDays = Math.min(dayCount, n);
|
||||||
|
const base = Math.floor(n / effectiveDays);
|
||||||
|
const remainder = n % effectiveDays;
|
||||||
|
|
||||||
|
const days = [];
|
||||||
|
let idx = 0;
|
||||||
|
for (let d = 0; d < effectiveDays; d++) {
|
||||||
|
const size = base + (d < remainder ? 1 : 0);
|
||||||
|
days.push({ day: d + 1, themes: themes.slice(idx, idx + size) });
|
||||||
|
idx += size;
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable, order-independent fingerprint over a theme's topic ids. Changes when
|
||||||
|
* a topic is added to or removed from the theme, so cached overviews can be
|
||||||
|
* detected as stale and regenerated.
|
||||||
|
*
|
||||||
|
* @param {Array<{id?: string}>} topics
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function computeTopicsFingerprint(topics) {
|
||||||
|
const ids = (Array.isArray(topics) ? topics : [])
|
||||||
|
.map((t) => t && t.id)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
const joined = ids.join(',');
|
||||||
|
let h = 5381;
|
||||||
|
for (let i = 0; i < joined.length; i++) {
|
||||||
|
h = ((h << 5) + h + joined.charCodeAt(i)) >>> 0; // djb2, unsigned
|
||||||
|
}
|
||||||
|
return `${ids.length}:${h.toString(16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive progress from the plan's days and the set of completed themes.
|
||||||
|
*
|
||||||
|
* @param {Array<{ day: number, themes: string[] }>} days
|
||||||
|
* @param {Set<string>|string[]} completedThemeSet
|
||||||
|
* @returns {{ themesTotal:number, themesDone:number, dayCount:number, daysCompleted:number, percentage:number, allDone:boolean }}
|
||||||
|
*/
|
||||||
|
export function computeOnboardingProgress(days, completedThemeSet) {
|
||||||
|
const set = completedThemeSet instanceof Set
|
||||||
|
? completedThemeSet
|
||||||
|
: new Set(completedThemeSet || []);
|
||||||
|
const allThemes = (days || []).flatMap((d) => d.themes);
|
||||||
|
const themesTotal = allThemes.length;
|
||||||
|
const themesDone = allThemes.filter((t) => set.has(t)).length;
|
||||||
|
const dayCount = (days || []).length;
|
||||||
|
const daysCompleted = (days || []).filter(
|
||||||
|
(d) => d.themes.length > 0 && d.themes.every((t) => set.has(t)),
|
||||||
|
).length;
|
||||||
|
const percentage = themesTotal === 0 ? 0 : Math.round((themesDone / themesTotal) * 100);
|
||||||
|
const allDone = themesTotal > 0 && themesDone === themesTotal;
|
||||||
|
return { themesTotal, themesDone, dayCount, daysCompleted, percentage, allDone };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the onboarding plan from already-loaded topics + active curriculum version.
|
||||||
|
*
|
||||||
|
* @param {Array} topics — all topics (db.getTopics())
|
||||||
|
* @param {object|null} activeVersion — active curriculum_versions row (for schedule order)
|
||||||
|
* @returns {{ themes: string[], days: Array<{day:number,themes:string[]}>, themeTopicMap: Map<string, Array> }}
|
||||||
|
*/
|
||||||
|
export function buildOnboardingPlan(topics, activeVersion) {
|
||||||
|
const themeTopicMap = buildThemeTopicMap(topics || []);
|
||||||
|
|
||||||
|
let schedule = activeVersion && activeVersion.schedule ? activeVersion.schedule : null;
|
||||||
|
if (typeof schedule === 'string') {
|
||||||
|
try { schedule = JSON.parse(schedule); } catch { schedule = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const themes = orderThemes([...themeTopicMap.keys()], schedule);
|
||||||
|
const days = distributeThemesIntoDays(themes);
|
||||||
|
return { themes, days, themeTopicMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── I/O + generation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ONBOARDING_OVERVIEW_SYSTEM = `You are an onboarding guide for Respellion, an internal IT company.
|
||||||
|
You introduce a brand-new employee to ONE theme from the company knowledge base.
|
||||||
|
This is a breadth-first introduction, not a deep lesson: keep it short, plain and skimmable, and always connect the theme to how work actually happens day-to-day and week-to-week at Respellion.
|
||||||
|
Write in clear, professional English. Emit content only through the emit_onboarding_overview tool.`;
|
||||||
|
|
||||||
|
function buildOverviewPrompt(theme, topics) {
|
||||||
|
const topicLines = topics
|
||||||
|
.map((t, idx) => `${idx + 1}. id=${t.id} | label="${t.label}" | type=${t.type || '—'} | description=${t.description || '—'}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `Theme: ${theme}
|
||||||
|
|
||||||
|
Topics that make up this theme (reuse the exact topic_id for each entry in topics_covered):
|
||||||
|
${topicLines || '(no topics listed)'}
|
||||||
|
|
||||||
|
Write a short, breadth-first onboarding overview of this theme for a new employee's first week: what it is, why it matters for their daily and weekly work at Respellion, 3–5 key points, and the list of topics it covers.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load everything needed to render the track.
|
||||||
|
* @returns {Promise<{themes:string[], days:Array, themeTopicMap:Map}>}
|
||||||
|
*/
|
||||||
|
export async function getOnboardingPlan() {
|
||||||
|
const [topics, activeVersion] = await Promise.all([
|
||||||
|
db.getTopics(),
|
||||||
|
db.getActiveCurriculumVersion(),
|
||||||
|
]);
|
||||||
|
return buildOnboardingPlan(topics, activeVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cached onboarding overview for a theme, or generate + cache it.
|
||||||
|
* Regenerates when the theme's topic set has changed (fingerprint mismatch).
|
||||||
|
*
|
||||||
|
* @param {string} theme
|
||||||
|
* @param {Array} topicsForTheme — topics belonging to the theme
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.force=false]
|
||||||
|
*/
|
||||||
|
export async function getOrGenerateOnboardingOverview(theme, topicsForTheme, { force = false } = {}) {
|
||||||
|
if (!theme) throw new Error('Onboarding overview requires a theme.');
|
||||||
|
const topics = Array.isArray(topicsForTheme) ? topicsForTheme : [];
|
||||||
|
const fingerprint = computeTopicsFingerprint(topics);
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
const cached = await db.getOnboardingOverview(theme);
|
||||||
|
if (cached && cached.topics_fingerprint === fingerprint) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callLLM({
|
||||||
|
task: 'onboarding.overview',
|
||||||
|
tier: 'fast',
|
||||||
|
system: cachedSystem(ONBOARDING_OVERVIEW_SYSTEM),
|
||||||
|
user: buildOverviewPrompt(theme, topics),
|
||||||
|
tools: [EMIT_ONBOARDING_OVERVIEW_TOOL],
|
||||||
|
toolChoice: { type: 'tool', name: EMIT_ONBOARDING_OVERVIEW_TOOL.name },
|
||||||
|
maxTokens: 1500,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitted = result.toolUses[0]?.input;
|
||||||
|
if (!emitted) throw new Error('AI did not return an onboarding overview. Please try again.');
|
||||||
|
|
||||||
|
return db.setOnboardingOverview(theme, emitted, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of theme names the user has marked complete.
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {Promise<Set<string>>}
|
||||||
|
*/
|
||||||
|
export async function getCompletedThemes(userId) {
|
||||||
|
const rows = await db.getCompletedOnboardingThemes(userId);
|
||||||
|
return new Set(rows.map((r) => r.theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a theme complete for the user. Idempotent.
|
||||||
|
*/
|
||||||
|
export async function markThemeCompleted(userId, theme) {
|
||||||
|
return db.setOnboardingCompletion(userId, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress summary for the Dashboard chip.
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export async function getOnboardingSummary(userId) {
|
||||||
|
const [plan, completed] = await Promise.all([
|
||||||
|
getOnboardingPlan(),
|
||||||
|
getCompletedThemes(userId),
|
||||||
|
]);
|
||||||
|
return computeOnboardingProgress(plan.days, completed);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { BookOpen, CheckSquare, Trophy, Sparkles, X, HelpCircle } from 'lucide-react';
|
import { BookOpen, CheckSquare, Trophy, Sparkles, X, HelpCircle, Rocket, ArrowRight } from 'lucide-react';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
@@ -9,6 +9,7 @@ import * as db from '../lib/db';
|
|||||||
import { storage } from '../lib/storage';
|
import { storage } from '../lib/storage';
|
||||||
import { getAssignedTopic } from '../lib/learningService';
|
import { getAssignedTopic } from '../lib/learningService';
|
||||||
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService';
|
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService';
|
||||||
|
import { getOnboardingSummary } from '../lib/onboardingService';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
@@ -25,6 +26,7 @@ const Dashboard = () => {
|
|||||||
yearProgress: null,
|
yearProgress: null,
|
||||||
hasCurriculum: false,
|
hasCurriculum: false,
|
||||||
theme: '',
|
theme: '',
|
||||||
|
onboarding: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,6 +71,15 @@ const Dashboard = () => {
|
|||||||
console.warn('[Dashboard] Could not load curriculum data:', e.message);
|
console.warn('[Dashboard] Could not load curriculum data:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Onboarding-track progress (independent of enrollment/curriculum). Guarded
|
||||||
|
// so a missing collection or empty KB never breaks the dashboard.
|
||||||
|
let onboarding = null;
|
||||||
|
try {
|
||||||
|
onboarding = await getOnboardingSummary(currentUser.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Dashboard] Could not load onboarding summary:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
setDashData({
|
setDashData({
|
||||||
topic,
|
topic,
|
||||||
learnDone,
|
learnDone,
|
||||||
@@ -80,13 +91,19 @@ const Dashboard = () => {
|
|||||||
yearProgress,
|
yearProgress,
|
||||||
hasCurriculum: curriculumExists,
|
hasCurriculum: curriculumExists,
|
||||||
theme: topic?.theme || '',
|
theme: topic?.theme || '',
|
||||||
|
onboarding,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [currentUser, weekNumber]);
|
}, [currentUser, weekNumber]);
|
||||||
|
|
||||||
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive, theme } = dashData;
|
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive, onboarding } = dashData;
|
||||||
|
const onboardingLabel = onboarding
|
||||||
|
? (onboarding.allDone
|
||||||
|
? 'Review onboarding'
|
||||||
|
: onboarding.daysCompleted > 0 ? 'Continue onboarding' : 'Start onboarding')
|
||||||
|
: 'Start onboarding';
|
||||||
const currentCycle = getCurriculumCycle(weekNumber);
|
const currentCycle = getCurriculumCycle(weekNumber);
|
||||||
const currWeek = getCurriculumWeek(weekNumber);
|
const currWeek = getCurriculumWeek(weekNumber);
|
||||||
|
|
||||||
@@ -174,6 +191,31 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Onboarding track CTA — only when there are themes to introduce. */}
|
||||||
|
{onboarding && onboarding.themesTotal > 0 && (
|
||||||
|
<div className="mt-5 pt-5 border-t border-bg-warm flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-[var(--r-org)] bg-teal/10 text-teal flex items-center justify-center shrink-0">
|
||||||
|
<Rocket size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold flex items-center gap-2">
|
||||||
|
New here? Take the 5-day onboarding
|
||||||
|
<Tag variant={onboarding.allDone ? 'success' : 'accent'} className="text-[10px]">
|
||||||
|
{onboarding.allDone ? 'Onboarding complete' : `${onboarding.daysCompleted}/${onboarding.dayCount} days`}
|
||||||
|
</Tag>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-fg-muted">A light tour of every theme — how Respellion works day to day and week to week.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link to="/onboarding-track" className="shrink-0">
|
||||||
|
<Button variant="outline" className="whitespace-nowrap">
|
||||||
|
{onboardingLabel} <ArrowRight size={16} className="ml-1" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
326
src/pages/OnboardingTrack.jsx
Normal file
326
src/pages/OnboardingTrack.jsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Loader, Rocket, ArrowLeft, ChevronRight, CheckCircle2, Circle,
|
||||||
|
Sparkles, ListChecks, PartyPopper,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Card from '../components/ui/Card';
|
||||||
|
import Button from '../components/ui/Button';
|
||||||
|
import Tag from '../components/ui/Tag';
|
||||||
|
import { useApp } from '../store/AppContext';
|
||||||
|
import {
|
||||||
|
getOnboardingPlan,
|
||||||
|
getCompletedThemes,
|
||||||
|
getOrGenerateOnboardingOverview,
|
||||||
|
markThemeCompleted,
|
||||||
|
computeOnboardingProgress,
|
||||||
|
} from '../lib/onboardingService';
|
||||||
|
|
||||||
|
function normalizeContent(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try { return JSON.parse(raw); } catch { return null; }
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-theme overview view ──────────────────────────────────────────────────
|
||||||
|
function OnboardingThemeView({ theme, topics, done, onBack, onDone }) {
|
||||||
|
const [record, setRecord] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [marked, setMarked] = useState(done);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getOrGenerateOnboardingOverview(theme, topics)
|
||||||
|
.then((rec) => { if (!cancelled) setRecord(rec); })
|
||||||
|
.catch((err) => { if (!cancelled) setError(err.message || 'Failed to load the overview.'); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [theme]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const content = normalizeContent(record?.content);
|
||||||
|
|
||||||
|
const handleDone = async () => {
|
||||||
|
if (marked) return;
|
||||||
|
setMarked(true);
|
||||||
|
await onDone(theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="inline-flex items-center text-teal font-medium text-sm hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} className="mr-1" /> Back to overview
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Card className="w-full text-center py-16">
|
||||||
|
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||||
|
<p className="font-medium text-lg">Preparing this theme…</p>
|
||||||
|
<p className="text-fg-muted text-sm mt-2">This may take 10–30 seconds the first time — the result is cached.</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<Card className="w-full border border-red-200 bg-red-50 text-red-900 p-6">
|
||||||
|
<p className="font-bold mb-1">Could not load this theme</p>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && content && (
|
||||||
|
<>
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Sparkles size={18} className="text-teal" />
|
||||||
|
<Tag variant="dark" className="text-[10px] uppercase tracking-wide">Theme</Tag>
|
||||||
|
{marked && <Tag variant="success" className="text-[10px]">Done</Tag>}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-teal mb-2">{content.title}</h2>
|
||||||
|
<p className="text-fg-muted">{content.what_it_is}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-2">Why it matters here</h3>
|
||||||
|
<p className="text-fg-muted">{content.why_it_matters}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-3">Key points</h3>
|
||||||
|
<ul className="list-disc pl-5 space-y-1 text-sm">
|
||||||
|
{(content.key_points || []).map((p, i) => <li key={i}>{p}</li>)}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{Array.isArray(content.topics_covered) && content.topics_covered.length > 0 && (
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-3">Topics in this theme</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{content.topics_covered.map((t) => (
|
||||||
|
<Tag key={t.topic_id} variant="default" className="text-xs">{t.label}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleDone} disabled={marked}>
|
||||||
|
{marked
|
||||||
|
? <span className="flex items-center"><CheckCircle2 size={16} className="mr-2" /> Marked as done</span>
|
||||||
|
: 'Mark as done'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress ring ─────────────────────────────────────────────────────────────
|
||||||
|
function ProgressRing({ percentage }) {
|
||||||
|
return (
|
||||||
|
<div className="relative w-20 h-20 shrink-0">
|
||||||
|
<svg viewBox="0 0 36 36" className="w-20 h-20 -rotate-90">
|
||||||
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="var(--bg-warm)" strokeWidth="3" />
|
||||||
|
<circle
|
||||||
|
cx="18" cy="18" r="15.9155" fill="none" stroke="var(--teal)" strokeWidth="3"
|
||||||
|
strokeDasharray={`${percentage} 100`} strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-sm font-bold">{percentage}%</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
export default function OnboardingTrack() {
|
||||||
|
const { state } = useApp();
|
||||||
|
const { currentUser } = state;
|
||||||
|
|
||||||
|
const [plan, setPlan] = useState(null);
|
||||||
|
const [completed, setCompleted] = useState(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([getOnboardingPlan(), getCompletedThemes(currentUser.id)])
|
||||||
|
.then(([p, done]) => { if (!cancelled) { setPlan(p); setCompleted(done); } })
|
||||||
|
.catch((err) => { if (!cancelled) setError(err.message || 'Failed to load the onboarding track.'); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
const progress = useMemo(
|
||||||
|
() => computeOnboardingProgress(plan?.days || [], completed),
|
||||||
|
[plan, completed],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleThemeDone = async (theme) => {
|
||||||
|
await markThemeCompleted(currentUser.id, theme);
|
||||||
|
setCompleted((prev) => new Set(prev).add(theme));
|
||||||
|
setSelectedTheme(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10">
|
||||||
|
<Card className="w-full text-center py-16">
|
||||||
|
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||||
|
<p className="font-medium text-lg">Loading your onboarding track…</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10">
|
||||||
|
<Card className="w-full border border-red-200 bg-red-50 text-red-900 p-6">
|
||||||
|
<p className="font-bold mb-1">Could not load the onboarding track</p>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty KB → friendly empty state.
|
||||||
|
if (!plan || plan.themes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10 space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-2 flex items-center gap-3"><Rocket size={28} className="text-teal" /> Onboarding</h1>
|
||||||
|
</header>
|
||||||
|
<Card className="w-full p-8 text-center">
|
||||||
|
<p className="text-fg-muted">There are no themes to introduce yet. Once the knowledge base has content, your 5-day onboarding track will appear here.</p>
|
||||||
|
<div className="mt-4"><Link to="/"><Button variant="outline">Back to dashboard</Button></Link></div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeTopics = (theme) => plan.themeTopicMap.get(theme) || [];
|
||||||
|
|
||||||
|
if (selectedTheme) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<OnboardingThemeView
|
||||||
|
theme={selectedTheme}
|
||||||
|
topics={themeTopics(selectedTheme)}
|
||||||
|
done={completed.has(selectedTheme)}
|
||||||
|
onBack={() => setSelectedTheme(null)}
|
||||||
|
onDone={handleThemeDone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<header className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-2 flex items-center gap-3"><Rocket size={28} className="text-teal" /> Onboarding</h1>
|
||||||
|
<p className="text-fg-muted text-lg max-w-2xl">
|
||||||
|
A light, self-paced tour of every theme at Respellion, spread over about five days.
|
||||||
|
It gives you the breadth you need to understand how we work day to day and week to week —
|
||||||
|
you can go faster if you like.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="border border-bg-warm">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<ProgressRing percentage={progress.percentage} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold text-lg">
|
||||||
|
{progress.allDone
|
||||||
|
? 'Onboarding complete'
|
||||||
|
: `${progress.daysCompleted}/${progress.dayCount} days complete`}
|
||||||
|
</p>
|
||||||
|
<p className="text-fg-muted text-sm">{progress.themesDone} of {progress.themesTotal} themes done</p>
|
||||||
|
<div className="flex gap-1.5 mt-3">
|
||||||
|
{plan.days.map((d) => {
|
||||||
|
const dayDone = d.themes.every((t) => completed.has(t));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d.day}
|
||||||
|
title={`Day ${d.day}`}
|
||||||
|
className={`h-2 flex-1 rounded-full ${dayDone ? 'bg-teal' : 'bg-bg-warm'}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{progress.allDone && (
|
||||||
|
<Card className="w-full p-6 border border-teal/30 bg-sage/40">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<PartyPopper size={24} className="text-teal shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold">You've completed the onboarding track 🎉</h3>
|
||||||
|
<p className="text-fg-muted text-sm mt-1">
|
||||||
|
You've been introduced to every theme. You're ready to pick up a first assignment —
|
||||||
|
and you can revisit any theme below whenever you want a refresher.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3"><Link to="/"><Button>Back to dashboard</Button></Link></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{plan.days.map((d) => {
|
||||||
|
const doneCount = d.themes.filter((t) => completed.has(t)).length;
|
||||||
|
return (
|
||||||
|
<Card key={d.day} className="p-0 border border-bg-warm overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 bg-bg-warm/40 border-b border-bg-warm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag variant="dark" className="text-[10px] uppercase tracking-wide">Day {d.day}</Tag>
|
||||||
|
<ListChecks size={16} className="text-fg-muted" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-fg-muted">{doneCount}/{d.themes.length} done</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-bg-warm">
|
||||||
|
{d.themes.map((theme) => {
|
||||||
|
const isDone = completed.has(theme);
|
||||||
|
const count = themeTopics(theme).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={theme}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedTheme(theme)}
|
||||||
|
className="w-full flex items-center justify-between p-4 text-left hover:bg-bg-warm/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isDone
|
||||||
|
? <CheckCircle2 size={20} className="text-teal shrink-0" />
|
||||||
|
: <Circle size={20} className="text-fg-muted/40 shrink-0" />}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{theme}</p>
|
||||||
|
<p className="text-sm text-fg-muted">{count} {count === 1 ? 'topic' : 'topics'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={18} className="text-fg-muted" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user