Ashab al-Jannah:面向协会会员管理与捐赠登记的 AI Web 应用
muratbalasar/ashab-al-jannah
Ashab al-Jannah 是一个面向协会会员管理与捐赠登记的响应式 Web 应用,管理员、财务和理事可通过手机、平板或笔记本使用,MVP 已包含登记、报表和趋势洞察,账务对接与银行对账单导入后续通过现有扩展点接入。
Ledenadministratie met AI. Ashab al-Jannah is een eenvoudige, responsieve webapp voor ledenadministratie en donatieregistratie van een vereniging. Beheerders, penningmeesters en bestuurders gebruiken de applicatie vanaf mobiel, tablet of laptop. Registratie, rapportage en trendinzichten zitten in de MVP. Boekhoudkoppelingen en het inlezen van bankafschriften volgen later via de bestaande uitbreidingspunten.
Tip
🌐 Probeer de demo: ashab-al-jannah.blackmushroom-9f4102d8.westeurope.azurecontainerapps.io
- Kies op de inlogpagina Geen account? Maak er een en log in met een eenmalige code die je per e-mail krijgt.
- Klik op Nieuwe stichting aanmaken. Je bent meteen beheerder van je eigen demo-organisatie.
- Voeg leden, categorieën en donaties toe en bekijk de rapportage.
Het is een demo: gebruik geen echte persoonsgegevens. De eerste keer laden kan 30–60 seconden duren, omdat de app opstart als hij een tijd niet gebruikt is.
| Document | Inhoud |
|---|---|
| 1-UserStories | User stories en acceptatiecriteria (US01–US10) |
| 2-TestScenarios | Gherkin-scenario's, 1-op-1 geautomatiseerd in tests/functional |
| 3-MultiTenantOntwerp | Ontwerp voor meerdere stichtingen, hosting en kosten |
| 4-EntraExternalID | Inloggen met Entra External ID (Google, Microsoft, e-mailcode) en Easy Auth |
| .env.example | Alle configuratie-instellingen |
| ci-cd.yml | CI/CD-workflow: tests, scans, image en deploy naar Azure |
| LICENSE | PolyForm Noncommercial License 1.0.0 |
Inhoud
- Over de naam
- Snel starten (lokaal)
- Techstack
- Architectuur
- Gebruikers en autorisatie
- Functionele requirements
- Niet-functionele requirements
- Configuratie
- Tests en kwaliteit
- Deployment naar Azure
- Eerste deployment stap voor stap
- Stap 0 – Variabelen vastleggen
- Stap 1 – Resource group
- Stap 2 – Resource providers registreren
- Stap 3 – Opslag voor de back-up
- Stap 4 – GitHub laat inloggen bij Azure met OIDC
- Stap 5 – Secrets en variabele in GitHub
- Stap 6 – Eerste deploy
- Stap 7 – App toegang geven tot de opslag
- Stap 8 – Inloggen met Entra External ID (Easy Auth) (achtergrond: 4-EntraExternalID)
- Stap 9 – Jezelf superadmin maken
- Instellingen later wijzigen
- Helpassistent aanzetten (Azure OpenAI)
- Helpassistent met Google Gemini
- Wisselen tussen Azure OpenAI en Gemini
- Troubleshooting deployment
- Back-up en herstel
- Overstap van Azure SQL
- Eerste deployment stap voor stap
- Uitbreidingen na de MVP
- Open besluiten
- Licentie
- Online doneren (Mollie)
Over de naam
Ashab al-Jannah (أَصْحَابُ الْجَنَّةِ) betekent letterlijk "de metgezellen van de tuin" en
wordt in de Koran vooral gebruikt voor de bewoners van het Paradijs (bijv. 59:20). De
naam staat centraal in de code (APP_NAME in
__init__.py), zodat UI, API-titel en paginatitels
consistent blijven. De Python-package heet intern ledenadmin.
Snel starten (lokaal)
Er zijn twee manieren: direct met Python via start.ps1 (handig om te ontwikkelen) of
met de container-image via Docker (zonder Python op je machine).
Optie A – Met Python en start.ps1
-
Installeer de benodigdheden
- Git, bijv.
winget install Git.Git. - Python 3.12 of hoger, bijv.
winget install Python.Python.3.12. Vink bij de installer Add python.exe to PATH aan. - Op Linux/macOS:
gitenpython3(metvenv) via de pakketbeheerder.start.ps1is Windows-only; gebruik daar de commando's onder stap 3.
- Git, bijv.
-
Clone de repository
git clone https://github.com/muratbalasar/ashab-al-jannah.git cd ashab-al-jannah
-
Start de app (de eerste keer duurt het installeren even):
.\start.ps1 -Install -InitWithDummyData # venv + dependencies, migraties, demodata, server
Weigert Windows het script, sta dan lokale scripts toe voor deze sessie:
Set-ExecutionPolicy -Scope Process RemoteSigned.Linux/macOS (zelfde stappen als
start.ps1):python3 -m venv .venv && . .venv/bin/activate pip install -r requirements-dev.txt && pip install -e . --no-deps export AUTH_MODE=dev DATABASE_URL=sqlite:///./ledenadmin.db alembic upgrade head python -m ledenadmin.dummy_data # optioneel: demodata uvicorn --factory ledenadmin.main:create_app --reload --port 8000
-
Open http://127.0.0.1:8000 (API-documentatie: http://127.0.0.1:8000/api/docs). Stoppen doe je met
Ctrl+C. Daarna is.\start.ps1genoeg om opnieuw te starten.
start.ps1 zet AUTH_MODE=dev: iedereen is dan automatisch aangemeld met alle rollen.
Met .\start.ps1 -InitWithDummyData worden 120 dummy leden (e-mail *.dummy@voorbeeld.nl)
met donaties verspreid over de afgelopen 18 maanden en alle subcategorieën toegevoegd. Bestaande
dummy leden worden overgeslagen, dus de optie kan veilig vaker worden gebruikt.
De lokale database (ledenadmin.db) blijft bewaard bij stoppen en herstarten. Met
.\start.ps1 -ResetDatabase begin je met een lege database (alleen voor SQLite). Combineer
met -InitWithDummyData voor een schone demo-omgeving.
Gebruik dit alleen lokaal; met APP_ENV=production weigert de applicatie te starten.
Lokaal en multi-tenant
Lokaal hoeft u niets aan organisaties in te richten. Bij het starten wordt automatisch de
organisatie standaard aangemaakt; http://localhost:8000/ en oude URL's zoals /leden
sturen door naar /o/standaard/.... De dev-gebruiker krijgt daar de rollen uit
DEV_USER_ROLES (standaard alle). Zonder SECRET_ENCRYPTION_KEY, BREVO_API_KEY en
KVK_API_KEY staan online doneren, mailen en de KVK-check uit; de rest werkt gewoon.
Meerdere organisaties of rollen lokaal testen:
- Nieuwe organisatie: ga naar
/aanmelden(KVK-nummer alleen op formaat gecontroleerd). - Andere gebruiker: klik op de DEV-badge rechtsboven (of ga naar
/dev/gebruiker) en vul een naam of e-mailadres in. Zo accepteert u lokaal een uitnodiging: meld u aan met het e-mailadres waarvoor de uitnodiging is gemaakt (de uitnodigingspagina linkt er ook naar). Een gewisselde gebruiker krijgt geen rollen uitDEV_USER_ROLES, alleen die uit uitnodigingen; met Terug naar … wordt u weer de standaardgebruiker. De keuze geldt per browser (cookie); gebruik een privévenster om twee gebruikers naast elkaar te testen. - Rollen via headers: stuur
X-Dev-UserenX-Dev-Rolesmee (bijv. met een browserextensie), of zetDEV_USER_NAME/DEV_USER_ROLESen herstart. Uitnodigingslinks verschijnen op het scherm om te kopiëren. - Superadmin (
/platform):SUPERADMIN_SUBJECTS=dev|ontwikkelaar(zie hieronder). - Mollie testen: zet
SECRET_ENCRYPTION_KEYen koppel eentest_-sleutel; zonder publieke URL wordt de status bijgewerkt als u terugkeert van de betaalpagina.
Platformbeheer en meerdere organisaties lokaal testen
-
Start als superadmin (stop eerst een draaiende server met
Ctrl+C):$env:SUPERADMIN_SUBJECTS = "dev|ontwikkelaar" # meer: "dev|ontwikkelaar,dev|beheerder-a" .\start.ps1 # of: .\start.ps1 -ResetDatabase
In het menu staat dan Platform (
/platform). -
Maak organisaties aan met verschillende gebruikers. Eén gebruiker mag één organisatie per 24 uur aanmaken (maximaal
MAX_ORGANIZATIONS_PER_USER, standaard 3). Wissel daarom per organisatie van gebruiker via de DEV-badge (/dev/gebruiker), bijv.beheerder-a,beheerder-b, en meld een organisatie aan op/aanmeldenmet een eigen KVK-nummer van 8 cijfers (11111111,22222222, …). Voeg eventueel leden en donaties toe. Met Terug naar ontwikkelaar bent u weer superadmin. -
Test op
/platform:Functie Hoe Overzicht KVK, plaats, gebruikers, leden, laatste activiteit, status; totalen en fouten (24 uur) Blokkeren / deblokkeren Blokkeer, wissel naar de beheerder: /o/<slug>/geeft 404Verwijderen en herstellen Als beheerder: Instellingen → Organisatie verwijderen (typ de slug). Op /platformstaat ze als verwijderd met de wisdatum; Herstellen of Nu wissenPrivacy Als superadmin zonder rol geeft /o/<slug>/ledeneen 404Scheiding Nodig beheerder-auit in organisatie B: op/ziet hij beide, de gegevens blijven gescheidenGebruik een privévenster voor een tweede gebruiker naast het superadmin-venster. Dezelfde uitleg staat lokaal in de app onder Help → Lokaal testen (ontwikkelmodus).
Demo met Playwright
.\demo.ps1 geeft een geautomatiseerde rondleiding in de browser (Edge), met een ondertitel
per stap: een nieuwe stichting aanmelden, drie leden met donaties registreren, een donatie via
het zoekveld invoeren, de rapportage bekijken, een penningmeester uitnodigen die in een tweede
venster de uitnodiging accepteert en zelf een donatie invoert. De demo draait op poort 8770
met een eigen, verse database (output\demo\demo.db); ledenadmin.db en .env blijven
ongemoeid. Playwright wordt zo nodig geïnstalleerd (requirements-demo.txt).
.\demo.ps1 # 1,5 seconde tussen de acties .\demo.ps1 -Pauze 3 -Platform # langzamer, en aan het eind /platform als superadmin .\demo.ps1 -Browser chromium # zonder Edge: installeert de Chromium van Playwright
Optie B – Container-image met Docker (Windows en Linux)
Docker installeren
Windows 10/11
-
Zet WSL 2 aan (PowerShell als administrator), en herstart daarna de pc:
wsl --install -
Installeer Docker Desktop:
winget install -e --id Docker.DockerDesktop
Of download het via docker.com.
-
Start Docker Desktop en wacht tot onderin "Engine running" staat.
-
Controleer:
docker run --rm hello-world.
Linux (Ubuntu/Debian)
curl -fsSL https://get.docker.com | sudo sh # officieel installatiescript sudo usermod -aG docker $USER # docker zonder sudo gebruiken newgrp docker # of opnieuw inloggen docker run --rm hello-world # controle
Andere distributies: zie Docker Engine installeren.
B1 – Kant-en-klare image van GitHub (zonder clonen)
Elke push op main waarvan de tests slagen, publiceert de image als
package op GitHub
(ghcr.io/muratbalasar/ashab-al-jannah:latest). Docker haalt hem bij de eerste start
automatisch op; bijwerken naar de nieuwste versie doe je met
docker pull ghcr.io/muratbalasar/ashab-al-jannah:latest.
Linux (bash):
docker run --rm -p 8000:8000 \ -e APP_ENV=development -e AUTH_MODE=dev \ -v ashab-data:/data \ --name ashab ghcr.io/muratbalasar/ashab-al-jannah:latest
Windows (PowerShell):
docker run --rm -p 8000:8000 ` -e APP_ENV=development -e AUTH_MODE=dev ` -v ashab-data:/data ` --name ashab ghcr.io/muratbalasar/ashab-al-jannah:latest
Open daarna http://127.0.0.1:8000. De instellingen worden hieronder bij B2, stap 3, uitgelegd.
B2 – Zelf bouwen vanuit de broncode
-
Clone de repository zoals bij optie A, stap 2.
-
Bouw de image (in de map
ashab-al-jannah):docker build -t ashab-al-jannah . -
Start de container. De image staat standaard op productie (echte aanmelding, en SQLite alleen met een back-up via Litestream). Voor lokaal gebruik zet je ontwikkelmodus aan en bewaar je de SQLite-database (
/data/ledenadmin.db) in een Docker-volume, zodat de gegevens een herstart overleven.Linux (bash):
docker run --rm -p 8000:8000 \ -e APP_ENV=development -e AUTH_MODE=dev \ -v ashab-data:/data \ --name ashab ashab-al-jannah
Windows (PowerShell):
docker run --rm -p 8000:8000 ` -e APP_ENV=development -e AUTH_MODE=dev ` -v ashab-data:/data ` --name ashab ashab-al-jannah
-
Open http://127.0.0.1:8000. Wil je demodata, voer dan in een tweede terminal uit:
docker exec ashab python -m ledenadmin.dummy_data. Stoppen doe je metCtrl+Cofdocker stop ashab. Een lege database krijg je metdocker volume rm ashab-data.
Waar staan de gegevens?
De SQLite-database (/data/ledenadmin.db) staat niet in de container zelf, maar in het
Docker-volume ashab-data (-v ashab-data:/data). Docker beheert dat volume buiten de
container.
-
Stoppen en opnieuw starten:
--rmverwijdert de container bij het stoppen, maar het volume blijft bestaan. Start je opnieuw met hetzelfde commando, dan zijn alle leden en donaties er nog. -
Nieuwe image (
docker pull): de gegevens blijven behouden. Bij het opstarten werkt de app het databaseschema automatisch bij (RUN_MIGRATIONS=true). -
Gegevens kwijt: als je
-v ashab-data:/dataweglaat (de database staat dan in de container en verdwijnt bij het stoppen), of nadocker volume rm ashab-data. -
Eerdere versie met
-v ashab-data:/home/app? Dat blijft werken als je ook-e DATABASE_URL=sqlite:////home/app/ledenadmin.dbmeegeeft, zoals voorheen. -
Back-up maken (Linux; gebruik in PowerShell
${PWD}):docker run --rm -v ashab-data:/data -v "$PWD":/backup alpine cp /data/ledenadmin.db /backup/
Gebruik AUTH_MODE=dev nooit op een publiek bereikbare server: iedereen heeft dan alle
rechten. Zie Configuratie en Deployment naar Azure
voor productie.
Techstack
| Laag | Keuze | Waarom |
|---|---|---|
| Backend | Python 3.12, FastAPI, Pydantic v2 | Licht, snel, automatische validatie en OpenAPI; Python past bij AI/data |
| UI | Jinja2 + HTMX + Chart.js + Pico CSS (gevendord) | Minimale, responsieve UI zonder build-stap of SPA; één deployment; strikte CSP |
| Data | SQLAlchemy 2 + Alembic, SQLite | Typed ORM, migraties; lokaal en in productie hetzelfde SQLite-bestand |
| Back-up | Litestream naar Azure Blob Storage | Continue back-up (± 1 s), terugzetten bij het opstarten; een paar cent per maand |
| Rapportage | pandas | Aggregaties per categorie, subcategorie, maand en lid; CSV-export |
| AI | Provider-interface: lokaal (standaard), OpenAI/Azure OpenAI, Google Gemini, Anthropic; wisselen met AI_PROVIDER |
Werkt zonder externe dienst; alleen geaggregeerde data naar buiten. Optionele helpassistent (US16) met alleen de handleiding als kennis |
| Auth | Azure Container Apps-authenticatie (Easy Auth, Entra ID) + rollen in de app | Geen eigen wachtwoordbeheer; autorisatie server-side |
| Hosting | Azure Container Apps (één container, scale-to-zero) | HTTPS, gratis maandtegoed; zie 3-MultiTenantOntwerp.md § 11.1 |
| Tests | pytest (unit/API/web), Robot Framework (acceptatie, Gherkin-stijl) | Snelle feedback + gebruikersgerichte scenario's |
| CI/CD | GitHub Actions, ghcr.io, OIDC-login naar Azure | Zie ci-cd.yml |
Bewust níet gekozen: seaborn (statische plaatjes passen niet bij interactieve filters; Chart.js rendert in de browser) en scikit-learn (weinig meerwaarde bij de datavolumes van een vereniging, zware dependency met trage cold starts). Beide kunnen later worden toegevoegd, bijvoorbeeld voor PDF-rapporten of voorspellingen.
Architectuur
src/ledenadmin/
├── main.py Applicatiefabriek (create_app): wiring, middleware, routers
├── config.py Settings (omgevingsvariabelen) + veiligheidscontroles
├── db.py Database-klasse, UTC-datetimetype, naamconventies, SQLite-instellingen (WAL)
├── copy_database.py Alle gegevens overzetten naar een andere database (bijv. Azure SQL → SQLite)
├── container.py ServiceContainer: stelt per request de services samen
├── domain/ ORM-modellen, enums (rollen/rechten), domeinfouten, geldfuncties
├── repositories/ Data-toegang per aggregaat (leden, categorieën, donaties)
├── schemas/ Pydantic-contracten voor in- en uitvoer
├── services/ Bedrijfslogica
│ ├── member_service.py, donation_service.py, category_service.py, report_service.py
│ ├── ai/ InsightProvider-protocol + lokaal/OpenAI/Anthropic + InsightService
│ └── exporters/ ReportExporter-protocol + CSV (later Moneybird/Exact Online)
├── auth/ Principal, rol→rechten, Easy Auth- en dev-provider
├── api/ REST API /api/v1 (routers, dependencies, foutafhandeling)
└── web/ Server-side UI (routes, templates, CSRF/CSP, statische bestanden)
migrations/ Alembic-migraties
tests/unit/ pytest: services, auth, API, web, migraties
tests/functional/ Robot Framework-suites per user story
Principes:
- Lagen: routes (API én web) valideren invoer en vertalen naar HTTP. Services bevatten de regels. Repositories doen de data-toegang. API en web delen dezelfde services.
- Uitbreidbaar via interfaces:
InsightProvidervoor AI,ReportExportervoor exports enAuthProvidervoor identiteit. Een nieuwe leverancier is een nieuwe klasse; de domeinlogica verandert niet. - Geld wordt opgeslagen als integer centen en getoond als
Decimal(geen float-afrondingsfouten). - Tijd wordt opgeslagen in UTC en getoond in
Europe/Amsterdam. Een periodefilter "t/m 28-02" bevat alles tot en met 28-02 23:59 lokale tijd. - Tekst is Unicode (
NVARCHARop SQL Server), zodat namen als "Ayşe Öztürk" correct blijven.
De diagrammen hieronder zijn in Mermaid; GitHub en VS Code (met een Mermaid-extensie) tonen ze als afbeelding.
Softwarelagen
Elke request gaat van boven naar beneden door dezelfde lagen. Web-UI en REST API delen
de services; alleen de bovenste laag verschilt. De tenancy-laag zorgt dat elke query
automatisch gefilterd wordt op de organisatie uit het pad (/o/{org}/...).
flowchart TB
subgraph Clients["Clients"]
Browser["Browser<br/>Jinja2-pagina's + HTMX + Chart.js"]
ApiClient["API-client<br/>REST /api/v1 (OpenAPI)"]
end
EasyAuth["Easy Auth (Azure-platform)<br/>inloggen, zet X-MS-CLIENT-PRINCIPAL"]
subgraph App["FastAPI-app (src/ledenadmin)"]
direction TB
MW["Middleware<br/>SecurityMiddleware: CSP, CSRF, secure cookies<br/>AuditMiddleware: logboek, foutcodes"]
subgraph Routes["Routes: invoer valideren, vertalen naar HTTP"]
direction LR
Web["web/<br/>pagina's /o/{org}/..."]
Api["api/<br/>REST /o/{org}/api/v1"]
Platform["web/platform.py<br/>/platform (superadmin)"]
Webhook["web/payments.py<br/>Mollie-webhook"]
end
Deps["api/deps.py + auth/<br/>AuthProvider → Identity → Principal<br/>organisatie uit pad, rollen, rechten"]
Services["services/ via ServiceContainer<br/>leden, donaties, categorieën, rapportage,<br/>gebruikers, organisaties, betalingen, mail, KVK,<br/>helpassistent (optioneel)"]
Ports["Uitbreidingspunten (adapters)<br/>InsightProvider (AI), ReportExporter (CSV),<br/>mail (Brevo), Mollie, KVK"]
Repos["repositories/<br/>data-toegang per aggregaat"]
Domain["domain/ + tenancy.py<br/>ORM-modellen, rollen/rechten, geld in centen,<br/>automatisch filter op organization_id"]
DB["db.py<br/>SQLAlchemy 2, UTC-tijden, SQLite in WAL-modus"]
end
SQLite[("SQLite<br/>/data/ledenadmin.db")]
Ext["Externe diensten<br/>Azure OpenAI/OpenAI/Anthropic, Mollie, Brevo, KVK"]
Browser --> EasyAuth
ApiClient --> EasyAuth
EasyAuth --> MW --> Routes
Routes --> Deps
Routes --> Services
Services --> Repos --> Domain --> DB --> SQLite
Services --> Ports --> Ext
Loading
Deploymentmodel (infrastructuur)
Eén container in Azure Container Apps, met de database op de eigen schijf en een continue back-up naar Blob Storage. Inloggen gebeurt bij een aparte Entra External ID-tenant. Inrichten: zie Eerste deployment stap voor stap.
Runtime: wat draait er en hoe lopen requests en gegevens.
flowchart TB
User["Gebruiker<br/>browser"]
subgraph ExtId["Entra External ID: tenant ashab-login"]
Flow["User flow signup_signin<br/>e-mailcode, later Google/Facebook"]
LoginApp["App-registratie Ashab al-Jannah"]
Flow --- LoginApp
end
subgraph Azure["Azure: resource group rg-ashab-al-jannah (westeurope)"]
subgraph CAE["Container Apps-omgeving cae-ashab-al-jannah"]
subgraph CA["Container App ashab-al-jannah: 0-1 replica, 0,5 vCPU, 1 GiB"]
Ingress["HTTPS-ingress<br/>+ Easy Auth"]
Container["Container<br/>Litestream + uvicorn/FastAPI"]
Disk[("Lokale schijf<br/>/data/ledenadmin.db")]
end
end
LA["Log Analytics<br/>app- en systeemlogs"]
MI{{"Managed identity<br/>Storage Blob Data Contributor"}}
SA[("Storage account<br/>container ledenadmin<br/>back-up + 14 dagen soft delete")]
end
Partners["Externe diensten<br/>Mollie, Brevo, KVK,<br/>Azure OpenAI (EU, optioneel)"]
User -->|HTTPS| Ingress
Ingress <-->|niet ingelogd: inloggen| Flow
Ingress -->|ingelogd: request + claims| Container
Container --> Disk
Container -->|back-up, ± 1 s| MI --> SA
Container --> Partners
CA -.->|logs| LA
Loading
Levering (CI/CD): GitHub bouwt en test het image en deployt zonder wachtwoord (OIDC).
flowchart LR
subgraph GitHub["GitHub"]
Repo["Repository"] -->|push of Run workflow| Actions["GitHub Actions<br/>ci-cd.yml"]
Actions -->|tests en scans geslaagd:<br/>image pushen| GHCR[("ghcr.io<br/>container-image")]
end
subgraph Entra["Entra ID: eigen tenant"]
GhApp["App-registratie github-ashab-al-jannah<br/>federated credential (OIDC)"]
end
subgraph Azure["Azure: rg-ashab-al-jannah"]
CA["Container App<br/>ashab-al-jannah"]
end
Actions -->|1. OIDC-token, geen wachtwoord| GhApp
GhApp -.->|rol Contributor op de resource group| Azure
Actions -->|2. oude revision stoppen,<br/>az containerapp update| CA
CA -->|3. image ophalen met GHCR_PULL_TOKEN| GHCR
Loading
Back-up en herstel (sequence)
docker-entrypoint.sh zet bij elke start de database terug uit Blob Storage en start de app als subproces van Litestream. Litestream stuurt elke wijziging binnen ongeveer een seconde naar de opslag. Bij een nieuwe versie stopt de workflow eerst de oude container (blok Stoppen) en start daarna de nieuwe (blok Opstarten), zodat er nooit twee schrijvers zijn. Zie ook Back-up en herstel.
sequenceDiagram
autonumber
participant ACA as Azure Container Apps
participant EP as docker-entrypoint.sh
participant LS as Litestream
participant App as uvicorn + FastAPI
participant DB as SQLite /data
participant Blob as Blob Storage
Note over ACA,Blob: Opstarten: eerste request na scale-to-zero, herstart of nieuwe versie
ACA->>EP: container starten (schijf is leeg)
EP->>LS: litestream restore -if-db-not-exists -if-replica-exists
LS->>Blob: laatste snapshot + WAL ophalen (managed identity)
alt back-up bereikbaar
Blob-->>LS: gegevens
LS->>DB: database terugzetten
EP->>LS: litestream replicate -exec serve
LS->>App: start subproces: alembic upgrade head, dan uvicorn
else geen toegang tot de opslag
Blob-->>LS: geweigerd
LS-->>EP: fout
EP-->>ACA: container stopt: nooit een lege database als nieuwe back-up
end
Note over ACA,Blob: Tijdens gebruik
loop elke wijziging
App->>DB: schrijven (WAL)
LS->>DB: nieuwe WAL-frames lezen
LS->>Blob: binnen ongeveer 1 s repliceren
end
LS->>Blob: elke 24 uur een volledige snapshot, ouder dan LITESTREAM_RETENTION opruimen
Note over ACA,Blob: Stoppen: scale-to-zero of nieuwe versie
ACA->>LS: SIGTERM
LS->>App: app stoppen
LS->>Blob: laatste synchronisatie
LS-->>ACA: container gestopt
Loading
Inloggen en autorisatie (sequence)
De app heeft geen eigen wachtwoorden. Easy Auth regelt het inloggen bij Entra External ID en
geeft de claims door in de header X-MS-CLIENT-PRINCIPAL; het platform overschrijft die
header bij elke request, zodat een client hem niet kan vervalsen. De app herkent een gebruiker
aan issuer + subject en haalt de rollen per organisatie uit de database. Zie ook
4-EntraExternalID.md en Gebruikers en autorisatie.
sequenceDiagram
autonumber
actor U as Gebruiker (browser)
participant EA as Easy Auth (ingress)
participant CIAM as Entra External ID (ciamlogin.com)
participant App as FastAPI-app
participant DB as SQLite
U->>EA: GET /o/stichting/... zonder sessiecookie
EA-->>U: 302 naar /.auth/login/aad en door naar CIAM
U->>CIAM: authorize via user flow signup_signin
CIAM-->>U: e-mailadres vragen en eenmalige code mailen
U->>CIAM: code invoeren (eerste keer: account aanmaken + weergavenaam)
CIAM-->>U: 302 naar /.auth/login/aad/callback met code + id_token
U->>EA: callback
EA->>CIAM: token valideren (issuer, client-id, client secret)
EA-->>U: sessiecookie + 302 naar de oorspronkelijke pagina
U->>EA: GET /o/stichting/... met sessiecookie
EA->>App: request + X-MS-CLIENT-PRINCIPAL (claims)
App->>App: EasyAuthProvider maakt Identity: issuer + subject (oid of sub), naam, e-mail (alleen als geverifieerd)
App->>DB: UserService.upsert: gebruiker vastleggen of bijwerken
App->>DB: organisatie uit het pad, rollen uit memberships
App->>App: Principal met rechten, superadmin als issuer + subject in SUPERADMIN_SUBJECTS staat
alt geen rol in deze organisatie, of organisatie onbekend of geblokkeerd
App-->>U: 404 Organisatie niet gevonden
else rol aanwezig, maar recht ontbreekt
App-->>U: 403
else recht aanwezig
App-->>U: 200 pagina, alleen gegevens van deze organisatie
end
Note over EA,App: /api/v1/health is uitgezonderd en werkt zonder inloggen. Lokaal (AUTH_MODE=dev) vervangt DevAuthProvider Easy Auth.
Loading
Gebruikers en autorisatie
| Recht | Beheerder | Penningmeester | Bestuurder | Lid |
|---|---|---|---|---|
| Leden inzien | ✔ | ✔ | ||
| Leden aanmaken/wijzigen | ✔ | |||
| Donaties inzien | ✔ | ✔ | ||
| Donaties registreren | ✔ | ✔ | ||
| Donaties bewerken (met wijzigingsgeschiedenis) | ✔ | |||
| Donaties verwijderen | ✔ | |||
| Leden verwijderen (inclusief hun donaties) | ✔ | |||
| Categorieën en subcategorieën beheren | ✔ | |||
| Rapportage (totalen, grafieken) | ✔ | ✔ | ✔ | |
| Rapportage per lid / met namen | ✔ | ✔ | ||
| Eigen gegevens, jaaroverzicht en donaties | ✔ | |||
| AI-analyse | ✔ | ✔ | ✔ | |
| CSV-export | ✔ | ✔ | ||
| Logboek inzien | ✔ |
De beheerder heeft alle rechten, ook rechten die later worden toegevoegd. De rechten staan centraal in principal.py en worden server-side afgedwongen. De UI toont alleen toegestane acties. Een bestuurder krijgt rapporten zonder uitsplitsing per lid en zonder losse donaties. Een lid ziet alleen het eigen gekoppelde ledenrecord en jaaroverzicht op Mijn omgeving; het lid kan geen organisatiebrede rapportages of gegevens van andere leden inzien. Deze verdeling is een startpunt dat met het bestuur moet worden bevestigd.
Superadmin (platformbeheer)
De rollen hierboven gelden per organisatie en staan in de database (memberships). De
superadmin is daarnaast een platformrol voor wie de installatie beheert. Die rol staat niet
in de database maar in de instelling SUPERADMIN_SUBJECTS. Daarin staan een of meer
gebruikers als issuer|subject, gescheiden door komma's. Een superadmin hoeft dus geen lid te
zijn van een organisatie.
| Superadmin | Kan | Kan niet |
|---|---|---|
/platform |
Overzicht van alle organisaties: KVK, plaats, aantal gebruikers en leden, laatste activiteit, status; totalen en het aantal fouten in de laatste 24 uur | |
| Organisaties | Blokkeren en deblokkeren: een geblokkeerde organisatie geeft voor iedereen een 404. Een door de beheerder verwijderde organisatie herstellen of nu wissen (anders wordt ze na de wachttijd automatisch gewist) | Zelf een organisatie verwijderen; dat doet de beheerder in Instellingen |
| Gegevens van organisaties | Leden, donaties, rapporten of het logboek van een organisatie inzien zonder daar een rol te hebben: /o/<slug>/... geeft een 404 (privacy) |
|
| Meldingen | Krijgt een mail bij elke nieuwe organisatie op SUPERADMIN_EMAIL (alleen als e-mail via Brevo is ingesteld) |
Werking:
- Instellen: in productie zie
Stap 9 – Jezelf superadmin maken; lokaal
SUPERADMIN_SUBJECTS=dev|ontwikkelaar(zie Platformbeheer en meerdere organisaties lokaal testen). - Herkenning: bij elke request vergelijkt de app de
issuer|subjectvan de ingelogde gebruiker metSUPERADMIN_SUBJECTS(UserService.is_superadmin). De routes onder/platformeisen dit server-side (require_superadminin deps.py); anders volgt een 403. - Menu: een superadmin ziet het menu-item Platform en onder Help het onderdeel
Platformbeheer (superadmin). Heeft hij nog geen organisatie, dan stuurt
/hem direct door naar/platform. - Combineren: wil een superadmin ook in een organisatie werken, dan laat hij zich daar uitnodigen of maakt hij zelf een organisatie aan. Hij krijgt dan de gewone rol in die organisatie, los van het platformbeheer.
- Wijzigen of intrekken: pas
SUPERADMIN_SUBJECTSaan volgens Instellingen later wijzigen. Wie inlogt met een andere provider (bijv. Google in plaats van een e-mailcode), heeft een anderissuer|subjecten is dus niet automatisch superadmin.
Ledenvelden
De beheerder kan op /ledenvelden zelf extra velden voor leden toevoegen. De types zijn
Tekst, Nummer, Mobiel nummer, IBAN en Ja/nee (bijv. Nieuwsbrief). De velden verschijnen
automatisch op het formulier voor een nieuw lid en op de detailpagina van een lid.
De waarden worden per type gecontroleerd en genormaliseerd:
- Mobiel nummer: 06-nummer of internationaal nummer.
- IBAN: met controle van het controlegetal; wordt opgeslagen in blokken van vier.
- Nummer: alleen cijfers.
Een veld deactiveren verbergt het, maar de waarden blijven bewaard. Verwijderen wist het veld én alle ingevulde waarden. Gevoelige gegevens worden geblokkeerd (AVG):
- Veldnamen die duiden op BSN/sofinummer, ID-bewijs, wachtwoord/pincode, gezondheid, geloof, afkomst, seksuele geaardheid, politiek, vakbond, strafrecht of biometrie worden geweigerd.
- Waarden die de BSN-elfproef halen, worden in tekst- en nummervelden geweigerd.
Logboek
Elke actie wordt vastgelegd in de tabel audit_log en via de logger ledenadmin.audit:
aanmeldingen (eerste verzoek per browsersessie), weergaven (GET), kliks (via
/logboek/klik), wijzigingen (POST/PUT/PATCH) en verwijderingen. Per regel worden het
tijdstip, de gebruiker, het verzoek, de status, het IP-adres en de details opgeslagen.
De details zijn de formulierdata of de zoekparameters, zonder CSRF-token. De beheerder
bekijkt het logboek op /logboek en kan filteren op gebruiker en actie.
Let op (AVG): de details kunnen persoonsgegevens bevatten, zoals namen en e-mailadressen.
Regels ouder dan 41 dagen (RETENTION in audit.py) worden automatisch verwijderd. Er is
geen aparte scheduler: na het opslaan van een nieuwe logregel ruimt de app op, hooguit één
keer per uur per instantie. Zonder verkeer wordt er dus niet opgeruimd, en na een herstart
gebeurt dat bij de eerstvolgende logregel.
Functionele requirements
Leden (US01)
- Een lid heeft een uniek ID, een naam, een uniek e-mailadres (case-insensitive, opgeslagen in kleine letters), een status (actief/inactief) en aanmaak-/wijzigingsgegevens.
- Zoeken op naam en e-mail, en filteren op status (live via HTMX).
- Inactief zetten verwijdert niets; de donatiehistorie blijft gekoppeld.
Donaties (US02)
- Een donatie heeft een bestaand lid, een subcategorie (en daarmee een categorie), een bedrag > 0 met maximaal 2 decimalen, een datum/tijd (standaard nu) en een optionele omschrijving.
- Bedragen in Nederlandse notatie worden geaccepteerd ("€ 1.234,50").
- Datums in de toekomst worden geweigerd (5 minuten speling).
- Nieuwe donaties voor inactieve leden worden standaard geweigerd. Instelbaar met
ALLOW_DONATIONS_FOR_INACTIVE_MEMBERS. - Bewerken (alleen beheerder): via het potlood in elke donatietabel (Donaties, ledenpagina,
Rapportage) past de beheerder lid, categorie, bedrag, datum en omschrijving aan. Elk
gewijzigd veld wordt met oude en nieuwe waarde, gebruiker en tijdstip vastgelegd in
donation_changes; de geschiedenis staat op de bewerkpagina en zit in de AVG-export. Bij een online betaling (Mollie) liggen lid, bedrag en datum vast. Valt de donatie in een voorbij jaar, dan toont het formulier een waarschuwing en vraagt de app bij opslaan om bevestiging; raakt een wijziging een voorbij jaar (oude of nieuwe datum), dan noemt de melding dat jaar, omdat eerder verstuurde overzichten dan niet meer kloppen. Een lid of subcategorie die inmiddels inactief is, blijft staan zolang u die niet wijzigt. - Standaardcategorieën: Contributie (Jaarlijks, Maandelijks), Donatie (Algemeen, Project) en Sponsoring (MKB, Particulier).
Categorieën beheren
- Alleen de beheerder beheert categorieën en subcategorieën via de pagina Categorieën
(
/categorieen) of de API (POST/PATCH /api/v1/categories, en/api/v1/categories/{id}/subcategories[/{sub_id}]). - Aanmaken, hernoemen en (de)activeren. Namen zijn uniek (subcategorieën binnen hun categorie), maximaal 100 tekens.
- Verwijderen kan alleen zolang er geen donaties op geboekt zijn (knop Verwijderen, met
bevestiging; API
DELETE). Een categorie wordt dan met haar subcategorieën verwijderd. Is een (sub)categorie al gebruikt, dan blijft alleen deactiveren over; bestaande donaties blijven zo aan hun (sub)categorie gekoppeld. Een inactieve categorie of subcategorie blijft zichtbaar in rapportages, maar is niet te kiezen bij nieuwe donaties. Een hernoeming geldt ook voor bestaande donaties.
Rapportage (US04–US06)
- Filters: begin- en einddatum (beide inclusief), lid, categorie en subcategorie. Standaard: dit kalenderjaar tot vandaag.
- Totaal, aantal en gemiddelde, met uitsplitsing per categorie, subcategorie, maand en lid. Tabellen en grafieken gebruiken exact dezelfde selectie.
- Een lege selectie toont "Geen gegevens voor deze periode", zonder grafiek.
- CSV-export (puntkomma, decimale komma, UTF-8 met BOM voor Excel). Waarden die met
=,+,-of@beginnen worden geneutraliseerd tegen formule-injectie.
AI-inzichten (US07)
- De gebruiker start een analyse expliciet. Die volgt de actieve rapportfilters.
- De provider ontvangt alleen aggregaties (totalen per categorie en maand), nooit namen, e-mailadressen of omschrijvingen. Dit is met tests geborgd.
- Zonder data wordt de provider niet aangeroepen. Als een externe dienst faalt, valt de app terug op de lokale analyse en meldt dat.
AI_PROVIDER=local(standaard) gebruikt een deterministische, regelgebaseerde samenvatting. Vooropenai(ook Azure OpenAI viaOPENAI_BASE_URL),geminienanthropiczijn een model (<DIENST>_MODELofAI_MODEL) en een API-sleutel verplicht.
Helpassistent (US16)
Gebruikers stellen in de app een vraag over het gebruik of de werking van Ashab al-Jannah (menu Assistent, en een verwijzing bovenaan Help). Een AI-model beantwoordt die op basis van de handleiding. De code staat in services/assistant en web/assistant.py; aanzetten: zie Helpassistent aanzetten.
- Standaard uit. Alleen met
ASSISTANT_ENABLED=trueén een externeAI_PROVIDER(openai/Azure OpenAI,geminiofanthropic). Anders is er geen menu-item, geen verwijzing in Help en geeft/o/<slug>/assistenteen 404. - Kennis: alleen de help-secties die bij de rol van de gebruiker horen (zoals op de
Help-pagina) plus vaste kennisbestanden in
kennis/:
functies.mdentechniek.mdvoor iedereen,platform-techniek.mdalleen voor de superadmin. Nieuwe kennis = deze bestanden of de help-templates aanvullen.
Grenzen:
| Grens | Hoe |
|---|---|
| Alleen over de app | Instructies met een strikte afbakening; het model moet op_onderwerp invullen. Bij false toont de app een vaste weigering, niet de tekst van het model. |
| Alleen uit de handleiding | Het model krijgt alleen de kennis hierboven en moet zeggen als het antwoord er niet in staat. Bronnen moeten uit die kennis komen (JSON-schema met een vaste lijst); de app toont ze als link naar de Help. |
| Geen gegevens | Er gaan nooit leden, donaties, gebruikers of organisatiegegevens naar de AI-dienst. E-mailadressen, IBAN's, telefoon- en andere lange nummers in de vraag worden vóór verzending vervangen. |
| Niets bewaard | Vragen en antwoorden worden niet opgeslagen of gelogd (ook niet in het logboek); alleen het aantal per gebruiker per dag. Bij OpenAI/Azure OpenAI met store=false; Gemini gebruikt ze met een gekoppeld betaalaccount niet voor training (zie Gemini). |
| Prompt-injectie | De vraag staat apart van de instructies en wordt als gegevens behandeld; één vraag per keer zonder geschiedenis. Een geheime controlecode in de instructies: komt die in een antwoord terug, dan volgt de weigering. |
| Uitvoer | Alleen een geldig JSON-object volgens een strikt schema; anders een vaste melding. Antwoord hoogstens 2000 tekens, getoond als platte tekst (alinea's en lijsten, ge-escaped). |
| Omvang en kosten | Vraag max. ASSISTANT_MAX_QUESTION_CHARS, antwoord max. ASSISTANT_MAX_OUTPUT_TOKENS, daglimiet per gebruiker en voor het hele platform. Een mislukte aanroep telt niet mee. |
| Toegang | Alleen ingelogde gebruikers met een rol in de organisatie; CSRF-controle op elke vraag. |
Een AI-model kan zich ondanks deze grenzen vergissen. Daarom staat bij elk antwoord dat het van een AI-assistent komt, met een link naar de Help als bron.
Niet-functionele requirements
- Responsive en toegankelijk: labels bij alle velden,
aria-invalidenaria-describedbybij fouten, een skip-link, grotere aanraakdoelen op touchscreens en geen horizontaal scrollen op mobiel. - Beveiliging: CSRF-token (double submit) op alle formulieren, Content Security Policy
zonder inline scripts/styles,
X-Frame-Options, secure cookies in productie, geheimen alleen via omgevingsvariabelen/Container Apps-secrets en geen persoonsgegevens in logs. - Betrouwbaarheid: transacties per actie, databaseconstraints (uniek e-mailadres,
bedrag > 0, foreign keys met
RESTRICT) en migraties die met tests worden gecontroleerd. - Productieveiligheid: de app start niet met
APP_ENV=productionin combinatie metAUTH_MODE=dev, of met SQLite zonder back-up (LITESTREAM_REPLICA_URL, of bewustALLOW_SQLITE_IN_PRODUCTION=true).
Configuratie
Alle instellingen staan in .env.example. De belangrijkste:
| Variabele | Standaard | Toelichting |
|---|---|---|
APP_ENV |
development |
production activeert de veiligheidscontroles en secure cookies |
DATABASE_URL |
sqlite:///./ledenadmin.db (container: sqlite:////data/ledenadmin.db) |
SQLAlchemy-URL; in de container altijd een absoluut pad op de lokale schijf |
LITESTREAM_REPLICA_URL |
leeg | Back-up via Litestream, bijv. abs://<opslagaccount>@<container>/ledenadmin; verplicht voor SQLite in productie (zie Back-up en herstel) |
LITESTREAM_RETENTION |
168h |
Hoe ver terug de back-up gaat (alleen uren: 168h = 7 dagen) |
AUTH_MODE |
easyauth |
dev alleen lokaal; rollen via DEV_USER_ROLES of de header X-Dev-Roles |
ROLE_CLAIM_TYPE |
roles |
Claim met de Entra ID-app-rollen (alleen voor de standaardorganisatie) |
SUPERADMIN_SUBJECTS |
leeg | Platformbeheerders als issuer|subject, komma-gescheiden; geeft toegang tot /platform |
EASYAUTH_LOGIN_URL |
/.auth/login/aad |
Inlogpagina van Easy Auth; inrichting van Entra External ID: zie 4-EntraExternalID.md |
SUPERADMIN_EMAIL |
leeg | Ontvangt een mail bij elke nieuwe organisatie |
PUBLIC_BASE_URL |
http://localhost:8000 |
Basis-URL voor uitnodigingslinks |
KVK_API_KEY |
leeg | Optioneel: KVK-nummer opzoeken in het Handelsregister bij aanmelden |
MAX_ORGANIZATIONS_PER_USER |
3 |
Maximaal aantal organisaties dat één gebruiker aanmaakt (en 1 per 24 uur) |
BREVO_API_KEY |
leeg | Optioneel: uitnodigingen mailen via Brevo; zonder sleutel alleen een kopieerbare link |
MAIL_SENDER_EMAIL / MAIL_SENDER_NAME |
noreply@example.nl / Ashab al-Jannah |
Afzender van e-mails |
MAIL_DAILY_LIMIT |
300 |
E-mails per dag (gratis Brevo-limiet); waarschuwing vanaf 80% |
TIMEZONE |
Europe/Amsterdam |
Weergave en periodegrenzen |
AI_PROVIDER |
local |
local, openai (ook Azure OpenAI), gemini of anthropic. Wisselen = alleen deze waarde aanpassen; zie Wisselen |
AI_MODEL / AI_TIMEOUT_SECONDS |
leeg / 20 |
Model (terugval als <DIENST>_MODEL leeg is) en time-out |
OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL |
leeg | OpenAI of Azure OpenAI (OPENAI_MODEL = naam van de deployment) |
GEMINI_API_KEY / GEMINI_MODEL |
leeg | Google Gemini via het OpenAI-compatibele endpoint, bijv. gemini-3.5-flash-lite |
GEMINI_REASONING_EFFORT |
minimal |
Hoeveel Gemini nadenkt: none (alleen 2.5-modellen), minimal, low, medium, high; leeg = standaard van het model |
GEMINI_BASE_URL |
https://generativelanguage.googleapis.com/v1beta/openai/ |
Alleen wijzigen voor een eigen gateway |
ANTHROPIC_API_KEY / ANTHROPIC_MODEL |
leeg | Anthropic Claude |
ASSISTANT_ENABLED |
false |
Helpassistent (US16) aan; werkt alleen met AI_PROVIDER openai, gemini of anthropic, anders blijft hij onzichtbaar. Zie Helpassistent aanzetten |
ASSISTANT_DAILY_LIMIT_PER_USER |
20 |
Vragen per gebruiker per dag (1–100) |
ASSISTANT_DAILY_LIMIT_TOTAL |
300 |
Vragen per dag voor het hele platform (1–5000); begrenst de kosten |
ASSISTANT_MAX_QUESTION_CHARS |
500 |
Maximale lengte van een vraag (50–1000) |
ASSISTANT_MAX_OUTPUT_TOKENS |
600 |
Maximale lengte van een antwoord in tokens (100–1500) |
KVK_API_URL |
https://api.kvk.nl/api/v2/zoeken |
Endpoint van de KVK Zoeken-API |
SECRET_ENCRYPTION_KEY |
leeg | Fernet-sleutel voor de Mollie-sleutels van stichtingen; leeg = online doneren uit (zie Online doneren) |
MOLLIE_API_URL |
https://api.mollie.com/v2 |
Mollie-API |
ALLOW_DONATIONS_FOR_INACTIVE_MEMBERS |
false |
Donaties toestaan voor inactieve leden |
SEED_DEFAULT_CATEGORIES |
true |
Standaardcategorieën aanmaken bij een nieuwe organisatie |
ALLOW_SQLITE_IN_PRODUCTION |
false |
SQLite in productie zónder Litestream toestaan (alleen met een eigen back-up) |
DEV_USER_NAME / DEV_USER_ROLES |
ontwikkelaar / leeg |
Alleen bij AUTH_MODE=dev |
Checklist productie (alle fases)
- Fase 1–2 –
APP_ENV=production,AUTH_MODE=easyauth,LITESTREAM_REPLICA_URL(back-up; zie Deployment naar Azure), precies één container (maxReplicas=1). - Fase 3 –
SUPERADMIN_SUBJECTS,SUPERADMIN_EMAIL,PUBLIC_BASE_URL(publiek https-adres); optioneelKVK_API_KEY,BREVO_API_KEY+MAIL_SENDER_EMAIL(geverifieerd afzenderdomein in Brevo). - Fase 4 – Entra External ID met Google/Microsoft/Apple/Facebook en Easy Auth: zie
4-EntraExternalID.md;
EASYAUTH_LOGIN_URL. - Fase 5–6 – geen extra instellingen (rol lid, export/verwijderen werken direct).
- Fase 7 –
SECRET_ENCRYPTION_KEYgenereren en veilig bewaren; elke stichting koppelt zelf Mollie in Instellingen. - Optioneel: helpassistent – Azure OpenAI in de EU (
AI_PROVIDER=openai,OPENAI_MODEL,OPENAI_BASE_URL,OPENAI_API_KEY) of Google Gemini (AI_PROVIDER=gemini,GEMINI_MODEL,GEMINI_API_KEY), plusASSISTANT_ENABLED=true; zie Helpassistent aanzetten en Gemini.
Tests en kwaliteit
.\kwaliteitscontrole.ps1 # black, isort, flake8, pytest + coverage (≥ 90%) .\kwaliteitscontrole.ps1 -Fix # formatteert eerst .\functionele-tests.ps1 # Robot Framework tegen een verse database en server .\functionele-tests.ps1 -Include US02
- pytest (tests/unit) test services, randgevallen (tijdzones, afronding, privacy), autorisatie, API-contracten, web-formulieren met CSRF en migraties (upgrade/downgrade en schema gelijk aan de modellen).
- Robot Framework (tests/functional): elke testnaam en
Given/When/Then-stap komt overeen met een scenario in 2-TestScenarios.
Tags koppelen aan user stories (
US01…US08);smokedraait ook na een deploy. De scenario's voor US11–US16 (meerdere stichtingen, uitnodigen, online doneren, donaties corrigeren, platformbeheer, helpassistent) zijn met pytest geautomatiseerd; bij elk scenario staat de test. De helpassistent wordt getest met een nep-AI-dienst, dus zonder kosten of sleutel. - De UI is handmatig gecontroleerd op desktop en mobiel (390 px). Geautomatiseerde browsertests (robotframework-browser) zijn een mogelijke volgende stap.
Deployment naar Azure
De workflow ci-cd.yml voert bij elke push of PR uit: kwaliteit en unit tests, pip-audit, Robot-tests, een Docker-build met containerrooktest (inclusief een back-up- en hersteltest met Litestream) en security-tests:
| Soort | Tool | Wat |
|---|---|---|
| SCA | pip-audit, Trivy | Kwetsbare Python-packages; kwetsbaarheden in de container-image (HIGH/CRITICAL) |
| SAST | Bandit, CodeQL | Onveilige code in Python en JavaScript (resultaten onder Security → Code scanning) |
| Secrets | Gitleaks | Geheimen in code en git-historie |
| DAST | OWASP ZAP baseline | Passieve scan van de draaiende container; rapport als artifact (nog niet blokkerend) |
Op main wordt het image pas naar ghcr.io gepusht als alle tests en scans slagen.
Deployen gebeurt handmatig via Run workflow met deploy_azure.
Aandachtspunten:
- Er draait altijd precies één container (
maxReplicas=1): twee containers die dezelfde database en back-up beschrijven, maken de gegevens kapot. Bij een nieuwe versie stopt de workflow daarom eerst de oude container en start dan de nieuwe; de app is dan enkele seconden onbereikbaar. Wijzig je zelf instellingen, volg dan Instellingen later wijzigen. - Door scale-to-zero duurt de eerste request na een stille periode een paar seconden: de
container start en Litestream zet de database terug. Migraties draaien daarna bij het
opstarten (
RUN_MIGRATIONS=true). - Controleer vóór ingebruikname de actuele limieten, kosten en regio van de gratis tegoeden.
Eerste deployment stap voor stap
Eenmalig, reken op ongeveer een uur, waarvan een kwartier wachten op Azure. Je werkt op drie plekken:
| Waar | Waarvoor |
|---|---|
Cloud Shell (Bash) in portal.azure.com (knop >_ bovenin) |
Alle az-commando's; de Azure CLI is daar al aangemeld |
PowerShell op je eigen pc met de GitHub CLI (gh auth login) |
Secrets, variabelen en de workflow in GitHub |
| Portal (entra.microsoft.com) | Alleen de externe tenant en de user flow (stap 8a en 8c) |
Je hebt een Azure-abonnement nodig waarop je Owner bent; Contributor is niet genoeg, want in stap 4 en 7 wijs je rollen toe.
| Stap | Wat | Waar |
|---|---|---|
| 0 | Variabelen vastleggen | Cloud Shell |
| 1 | Resource group | Cloud Shell |
| 2 | Resource providers registreren | Cloud Shell |
| 3 | Opslag voor de back-up | Cloud Shell |
| 4 | GitHub laat inloggen bij Azure (OIDC) | Cloud Shell |
| 5 | Secrets en variabele in GitHub | PowerShell + browser |
| 6 | Eerste deploy | PowerShell |
| 7 | App toegang geven tot de opslag | Cloud Shell |
| 8 | Inloggen met Entra External ID (Easy Auth) | Portal + Cloud Shell + browser |
| 9 | Jezelf superadmin maken | Cloud Shell + browser |
Stap 0 – Variabelen vastleggen (Cloud Shell)
Variabelen in Cloud Shell verdwijnen als de browser herlaadt; je home-map blijft wel
bewaard. Daarom staan alle niet-geheime waarden in ~/ashab.env. Begin elke nieuwe
Cloud Shell-sessie met source ~/ashab.env.
Kies een naam voor het opslagaccount: wereldwijd uniek, 3–24 kleine letters of cijfers.
az storage account check-name -n <opslagaccount> --query nameAvailable # moet true zijn cat > ~/ashab.env <<EOF RG=rg-ashab-al-jannah LOC=westeurope APPNAME=ashab-al-jannah SA=<opslagaccount> GH_REPO=<github-eigenaar>/ashab-al-jannah SUB=$(az account show --query id -o tsv) TENANT=$(az account show --query tenantId -o tsv) EOF source ~/ashab.env; cat ~/ashab.env
Controle: cat toont alle waarden ingevuld. Klopt SUB niet (meerdere abonnementen),
kies dan eerst het juiste met az account set --subscription <naam-of-id> en maak het bestand
opnieuw. RG, LOC en APPNAME moeten gelijk zijn aan de env:-waarden in
ci-cd.yml.
Stap 1 – Resource group (Cloud Shell)
Eén map voor alle onderdelen; de GitHub-identiteit krijgt straks alleen rechten hierop.
az group create -n $RG -l $LOC --tags project=ashab-al-jannah
Controle: az group show -n $RG --query properties.provisioningState -o tsv → Succeeded.
Stap 2 – Resource providers registreren (Cloud Shell)
Eenmalig per abonnement. Dit moet met jouw Owner-account: de GitHub-identiteit heeft alleen rechten op de resource group en kan dit zelf niet (anders faalt de deploy op het aanmaken van de Container Apps-omgeving).
for P in Microsoft.App Microsoft.OperationalInsights Microsoft.Storage; do az provider register -n $P --wait done
Controle: alle drie Registered:
az provider list --query "[?namespace=='Microsoft.App' || namespace=='Microsoft.OperationalInsights' || namespace=='Microsoft.Storage'].{ns:namespace, state:registrationState}" -o tableStap 3 – Opslag voor de back-up (Cloud Shell)
Litestream schrijft hier continu een kopie van de SQLite-database naartoe (een paar cent per maand). Zonder bereikbare back-up start de app in productie niet.
az storage account create -n $SA -g $RG -l $LOC \ --sku Standard_LRS --kind StorageV2 --min-tls-version TLS1_2 --allow-blob-public-access false az storage container-rm create --storage-account $SA -g $RG -n ledenadmin # Extra vangnet: verwijderde back-upbestanden blijven 14 dagen terug te halen. az storage account blob-service-properties update --account-name $SA -g $RG \ --enable-delete-retention true --delete-retention-days 14
container-rm create loopt via Azure Resource Manager. Gebruik niet
az storage container create --auth-mode login: als Owner heb je geen rechten op de
blobgegevens zelf (AuthorizationFailure).
Controle: az storage container-rm list --storage-account $SA -g $RG --query "[].name" -o tsv
→ ledenadmin.
Stap 4 – GitHub laat inloggen bij Azure met OIDC (Cloud Shell)
De workflow meldt zich zonder wachtwoord aan bij Azure: Entra vertrouwt een token van GitHub
Actions, maar alleen voor deze repo en de environment production.
GitHub zet in dat token een subject met de numerieke id's van eigenaar en repo
(repo:<eigenaar>@<eigenaar-id>/<repo>@<repo-id>:environment:production). Het subject in Entra
moet daar letterlijk mee overeenkomen. Haal de id's op:
read OWNER_ID REPO_ID < <(curl -s https://api.github.com/repos/$GH_REPO | jq -r '"\(.owner.id) \(.id)"') OWNER=${GH_REPO%%/*}; REPO=${GH_REPO##*/} SUBJECT="repo:$OWNER@$OWNER_ID/$REPO@$REPO_ID:environment:production"; echo $SUBJECT
App-registratie, federated credential en de rol Contributor op alleen de resource group:
APP=$(az ad app create --display-name github-ashab-al-jannah --query appId -o tsv) SP=$(az ad sp create --id $APP --query id -o tsv) echo "GITHUB_APP=$APP" >> ~/ashab.env az ad app federated-credential create --id $APP --parameters "{ \"name\": \"github-production\", \"issuer\": \"https://token.actions.githubusercontent.com\", \"subject\": \"$SUBJECT\", \"audiences\": [\"api://AzureADTokenExchange\"] }" az role assignment create --assignee-object-id $SP --assignee-principal-type ServicePrincipal \ --role Contributor --scope $(az group show -n $RG --query id -o tsv) echo "AZURE_CLIENT_ID=$APP"; echo "AZURE_TENANT_ID=$TENANT"; echo "AZURE_SUBSCRIPTION_ID=$SUB"
Noteer de drie waarden van de laatste regel voor stap 5 (dit zijn id's, geen wachtwoorden; zet ze toch niet in de code).
Controle:
az ad app federated-credential list --id $APP --query "[].subject" -o tsv az role assignment list --assignee $SP --all --query "[].{rol:roleDefinitionName, scope:scope}" -o table
Je ziet je $SUBJECT en één regel Contributor met scope .../resourceGroups/rg-ashab-al-jannah.
Stap 5 – Secrets en variabele in GitHub (PowerShell op je pc + browser)
De deploy-job leest deze waarden uit de environment production. De workflow haalt het
image van ghcr.io; daarvoor heeft Azure een token met alleen leesrechten nodig.
cd <map-van-de-repo> gh api -X PUT repos/<github-eigenaar>/ashab-al-jannah/environments/production gh secret set AZURE_CLIENT_ID --env production --body "<AZURE_CLIENT_ID>" gh secret set AZURE_TENANT_ID --env production --body "<AZURE_TENANT_ID>" gh secret set AZURE_SUBSCRIPTION_ID --env production --body "<AZURE_SUBSCRIPTION_ID>" gh variable set LITESTREAM_REPLICA_URL --env production --body "abs://<opslagaccount>@ledenadmin/ledenadmin"
GHCR_PULL_TOKEN (in de browser): open
github.com/settings/tokens/new (classic token),
note ghcr-pull-azure, kies een vervaldatum en vink alleen read:packages aan. Kopieer de
token en sla hem op; gh vraagt zelf om de waarde, zodat hij niet in je
PowerShell-geschiedenis komt:
gh secret set GHCR_PULL_TOKEN --env productionOptioneel (AI): variabelen AI_PROVIDER, OPENAI_MODEL, OPENAI_BASE_URL, GEMINI_MODEL en
secrets OPENAI_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY op dezelfde manier; zie
Helpassistent aanzetten.
Controle:
gh secret list --env production # AZURE_CLIENT_ID, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, GHCR_PULL_TOKEN gh variable list --env production # LITESTREAM_REPLICA_URL
Stap 6 – Eerste deploy (PowerShell op je pc)
De workflow draait alle tests en scans, pusht het image naar ghcr.io, maakt de Container
Apps-omgeving en de Container App aan en doet een rooktest op de live URL. Zorg dat je code
op main staat (git status → gelijk aan origin/main).
gh workflow run ci-cd.yml --ref main -f deploy_azure=true Start-Sleep 5 $RUN = gh run list --workflow ci-cd.yml -L 1 --json databaseId -q '.[0].databaseId' gh run watch $RUN
Duur: 10–20 minuten voor tests en image. De eerste keer staat de job Deploy naar Azure Container Apps daarna nog 5–20 minuten op Resource group en Container Apps-omgeving: Azure maakt de omgeving en een Log Analytics-workspace aan. Volgen in Cloud Shell:
source ~/ashab.env while true; do S=$(az containerapp env show -n cae-ashab-al-jannah -g $RG --query properties.provisioningState -o tsv) echo "$(date +%T) $S"; [ "$S" = "Succeeded" ] || [ "$S" = "Failed" ] && break; sleep 30 done
Waiting of InProgress is normaal. Zodra de Container App bestaat, ga je direct door met
stap 7: zonder toegang tot de opslag start de container niet, en de rooktest wacht maximaal
5 minuten. Mislukt bij deze eerste keer alleen de rooktest, dan is dat geen probleem; de
controle in stap 7 telt.
Bewaar de URL:
echo "URL=https://$(az containerapp show -n $APPNAME -g $RG --query properties.configuration.ingress.fqdn -o tsv)" >> ~/ashab.env source ~/ashab.env; echo $URL
Mislukt een job, bekijk dan de fout met gh run view $RUN --log-failed en herhaal na het
oplossen alleen de mislukte jobs met gh run rerun $RUN --failed (zie
Troubleshooting).
Stap 7 – App toegang geven tot de opslag (Cloud Shell)
De app meldt zich met haar managed identity aan bij de opslag; er is dus geen opslagsleutel nodig. Die identiteit bestaat pas na de eerste deploy, daarom gebeurt dit nu.
source ~/ashab.env az containerapp show -n $APPNAME -g $RG --query properties.provisioningState -o tsv # Succeeded PRINCIPAL=$(az containerapp show -n $APPNAME -g $RG --query identity.principalId -o tsv) SCOPE=$(az storage account show -n $SA -g $RG --query id -o tsv)/blobServices/default/containers/ledenadmin az role assignment create --assignee-object-id $PRINCIPAL --assignee-principal-type ServicePrincipal \ --role "Storage Blob Data Contributor" --scope $SCOPE sleep 60 # rol laten doorwerken az containerapp revision restart -n $APPNAME -g $RG \ --revision $(az containerapp revision list -n $APPNAME -g $RG --query "[0].name" -o tsv)
Controle (de eerste aanroep kan 30–60 s duren; probeer het zo nodig nog eens):
curl -s $URL/api/v1/health # {"status":"healthy",...} az storage blob list --account-name $SA -c ledenadmin --auth-mode key --query "[].name" -o tsv | head
De tweede regel toont bestanden onder ledenadmin/: de back-up loopt. Tot stap 8 klaar is,
is de app voor iedereen open; deel de URL nog niet.
Stap 8 – Inloggen met Entra External ID (Easy Auth)
Bezoekers loggen in bij een aparte externe tenant (gratis tot 50.000 maandelijkse gebruikers). Easy Auth van Container Apps handelt het inloggen af en geeft de identiteit door aan de app. Achtergrond en extra inlogmethoden (Google, Facebook, Apple): 4-EntraExternalID.md.
8a – Externe tenant aanmaken (portal). In entra.microsoft.com
(Nederlandse portal: Entra ID → Overzicht → Tenants beheren → Maken; Engels: Manage tenants →
Create): kies External, naam ashab-login, een uniek domein (bijv. ashablogin),
locatie Europa, jouw abonnement en resource group rg-ashab-al-jannah. Wacht een paar minuten
tot de tenant klaar is.
8b – App-registratie in de externe tenant (Cloud Shell).
source ~/ashab.env EXT_DOMAIN=<domein> # zonder .onmicrosoft.com az login --tenant $EXT_DOMAIN.onmicrosoft.com --allow-no-subscriptions --use-device-code EXT_TENANT=$(az account show --query tenantId -o tsv) EXT_APP=$(az ad app create --display-name "Ashab al-Jannah" --sign-in-audience AzureADMyOrg \ --web-redirect-uris "$URL/.auth/login/aad/callback" --enable-id-token-issuance true \ --optional-claims '{"idToken":[{"name":"email","essential":false}]}' --query appId -o tsv) az ad sp create --id $EXT_APP >/dev/null printf 'EXT_DOMAIN=%s\nEXT_TENANT=%s\nEXT_APP=%s\n' $EXT_DOMAIN $EXT_TENANT $EXT_APP >> ~/ashab.env # Microsoft Graph: openid, profile, email, offline_access + admin consent # (gebruikers in een externe tenant kunnen zelf geen toestemming geven) az ad app permission add --id $EXT_APP --api 00000003-0000-0000-c000-000000000000 --api-permissions \ 37f7f235-527c-4136-accd-4a02d197296e=Scope 14dad69e-099b-42c9-810b-d002981feec1=Scope \ 64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0=Scope 7427e0e9-2fba-42fe-b0c0-848c9e6a8182=Scope sleep 30 az ad app permission admin-consent --id $EXT_APP
Controle: az ad app show --id $EXT_APP --query "{redirect:web.redirectUris, idtoken:web.implicitGrantSettings.enableIdTokenIssuance}"
→ de callback-URL van je app en true.
8c – User flow (portal, in de externe tenant). Open
https://entra.microsoft.com/?tenant=<domein>.onmicrosoft.com en controleer rechtsboven dat je
in ashab-login zit. Dan Externe identiteiten → Gebruikersstromen → Nieuwe
gebruikersstroom (External Identities → User flows → New user flow):
- Naam
signup_signin. - Id-providers: onder E-mailaccounts Eenmalige wachtwoordcode voor e-mail (Email one-time passcode). Google en andere providers kunnen later.
- Gebruikerskenmerken: Weergavenaam (Display Name). Maken.
- Open de stroom → Toepassingen → Toepassing toevoegen → Ashab al-Jannah → Selecteren.
Controle: onder Toepassingen van de stroom staat Ashab al-Jannah.
8d – Easy Auth op de Container App (Cloud Shell). Het client secret komt alleen in een
variabele en wordt niet getoond. Voer dit in één sessie uit; is $EXT_SECRET weg, maak dan
gewoon een nieuw (het vervangt het vorige).
source ~/ashab.env az login --tenant $EXT_DOMAIN.onmicrosoft.com --allow-no-subscriptions --use-device-code # als je niet meer in de externe tenant bent aangemeld EXT_SECRET=$(az ad app credential reset --id $EXT_APP --display-name easyauth --years 2 --query password -o tsv 2>/dev/null) [ -n "$EXT_SECRET" ] && echo "Secret aangemaakt" az account set --subscription $SUB az containerapp auth microsoft update -g $RG -n $APPNAME \ --client-id $EXT_APP --client-secret "$EXT_SECRET" \ --issuer "https://$EXT_DOMAIN.ciamlogin.com/$EXT_TENANT/v2.0" --yes >/dev/null az containerapp auth update -g $RG -n $APPNAME --enabled true \ --unauthenticated-client-action RedirectToLoginPage --excluded-paths "/api/v1/health" >/dev/null
Het secret verloopt na 2 jaar; herhaal dan 8d.
Controle:
az containerapp auth show -g $RG -n $APPNAME \ --query "{aan:platform.enabled, actie:globalValidation.unauthenticatedClientAction, uitgezonderd:globalValidation.excludedPaths, issuer:identityProviders.azureActiveDirectory.registration.openIdIssuer}" -o json
→ true, RedirectToLoginPage, ["/api/v1/health"] en je ciamlogin.com-issuer.
8e – Inloggen testen (browser). Open $URL in een privévenster. Je komt op de
inlogpagina van <domein>.ciamlogin.com. Nieuwe gebruikers (ook jij: je beheerdersaccount van
de tenant is geen klantaccount) kiezen Geen account? Maak er een, vullen hun e-mailadres en
de gemailde code in en een weergavenaam. Daarna zie je de app met Nieuwe stichting
aanmaken.
Stap 9 – Jezelf superadmin maken (Cloud Shell)
Platformbeheerders (/platform) staan in SUPERADMIN_SUBJECTS als issuer|subject. Die
waarde staat in de database zodra je één keer hebt ingelogd (/.auth/me werkt niet: de token
store van Easy Auth staat uit).
9a – Je issuer en subject ophalen. Laat de app open in de browser (dan draait de container) en open een shell in de container:
source ~/ashab.env; az account set --subscription $SUB az containerapp exec -n $APPNAME -g $RG --command sh
In de container (alleen lezen):
python -c "import sqlite3;[print(f'{i}|{s} <- {e}') for i,s,e in sqlite3.connect('/data/ledenadmin.db').execute('select issuer,subject,email from users')]" exit
Kopieer bij jouw e-mailadres alles vóór <-. Let op: de issuer begint met het
tenant-id (https://<tenant-id>.ciamlogin.com/<tenant-id>/v2.0), niet met het domein.
9b – Instellingen zetten. Volgens Instellingen later wijzigen: eerst de draaiende container stoppen, dan bijwerken.
source ~/ashab.env SUPER='<issuer>|<subject>' for REV in $(az containerapp revision list -n $APPNAME -g $RG --query "[?properties.active].name" -o tsv); do az containerapp revision deactivate -n $APPNAME -g $RG --revision $REV done sleep 20 az containerapp update -n $APPNAME -g $RG --set-env-vars \ "SUPERADMIN_SUBJECTS=$SUPER" SUPERADMIN_EMAIL=<jouw-e-mailadres> \ PUBLIC_BASE_URL=$URL EASYAUTH_LOGIN_URL=/.auth/login/aad >/dev/null
Controle:
az containerapp show -n $APPNAME -g $RG --query "properties.template.containers[0].env[?name=='SUPERADMIN_SUBJECTS'].value" -o tsv curl -s $URL/api/v1/health
Ververs de app en open $URL/platform: je ziet het platformbeheer, en je eerder aangemaakte
stichting staat er nog (Litestream heeft de database na de herstart teruggezet).
Volgende versies deploy je met
gh workflow run ci-cd.yml --ref main -f deploy_azure=true; handmatig gezette instellingen
blijven behouden. Zie verder de Checklist productie voor
e-mail (Brevo), KVK en online doneren (Mollie).
Instellingen later wijzigen
Elke wijziging van omgevingsvariabelen (az containerapp update --set-env-vars) start een
nieuwe container, en Azure laat de oude dan nog even draaien. Twee containers op dezelfde
database en back-up maken gegevens kapot. Stop daarom altijd eerst de actieve revision,
zoals de workflow doet:
source ~/ashab.env; az account set --subscription $SUB for REV in $(az containerapp revision list -n $APPNAME -g $RG --query "[?properties.active].name" -o tsv); do az containerapp revision deactivate -n $APPNAME -g $RG --revision $REV done sleep 20 az containerapp update -n $APPNAME -g $RG --set-env-vars NAAM=waarde
Geheimen (API-sleutels) zet je als Container Apps-secret en verwijs je ernaar:
az containerapp secret set -n $APPNAME -g $RG --secrets brevo-api-key=<sleutel> en daarna
--set-env-vars BREVO_API_KEY=secretref:brevo-api-key (met de stappen hierboven).
Wijzigingen in Easy Auth (az containerapp auth ...) starten geen nieuwe container.
Helpassistent aanzetten (Azure OpenAI)
De helpassistent (US16) staat standaard uit. Deze stappen zetten hem aan
met Azure OpenAI in de EU. Doe ze na de eerste deployment; je werkt weer in Cloud Shell
(source ~/ashab.env) en PowerShell met gh. Reken op 20 minuten.
Liever Google Gemini? Volg dan Helpassistent met Google Gemini.
Je kunt ook beide instellen en later met één variabele (AI_PROVIDER) wisselen; zie
Wisselen tussen Azure OpenAI en Gemini.
Azure OpenAI (openai) |
Google Gemini (gemini) |
|
|---|---|---|
| Waar verwerkt | Binnen de EU (DataZoneStandard) |
Wereldwijd; geen EU-garantie |
| Gebruik voor training | Nee | Nee, mits een betaalaccount gekoppeld is |
| Kosten per vraag | Fractie van een cent | Fractie van een cent; nieuw Google Cloud-account krijgt startkrediet |
| Harde grens bij Azure/Google | Tokens per minuut per deployment | Quotum per project (aanpasbaar in Google Cloud) |
| Instellen | A1 t/m A7 (± 20 min) | G1 t/m G4 (± 10 min) |
Let op: met
AI_PROVIDER=openaigebruikt ook de bestaande AI Analyse in Rapportage Azure OpenAI. Die stuurt alleen totalen, nooit namen of e-mailadressen.
A1 – Resource provider registreren (Cloud Shell)
Eenmalig per abonnement, net als stap 2.
source ~/ashab.env; az account set --subscription $SUB az provider register -n Microsoft.CognitiveServices --wait
Controle: az provider show -n Microsoft.CognitiveServices --query registrationState -o tsv → Registered.
A2 – Azure OpenAI-resource in de EU (Cloud Shell)
Kies een naam: wereldwijd uniek, kleine letters, cijfers en -. Hij wordt ook het adres
https://<naam>.openai.azure.com. De regio Sweden Central heeft de meeste modellen en ligt in
de EU.
AOAI=<naam> # bijv. aoai-ashab-1234 AOAI_LOC=swedencentral az cognitiveservices account create -n $AOAI -g $RG -l $AOAI_LOC \ --kind OpenAI --sku S0 --custom-domain $AOAI --yes printf 'AOAI=%s\nAOAI_LOC=%s\n' $AOAI $AOAI_LOC >> ~/ashab.env
Controle: az cognitiveservices account show -n $AOAI -g $RG --query "{status:properties.provisioningState, endpoint:properties.endpoint}" -o table
→ Succeeded en https://<naam>.openai.azure.com/.
A3 – Model deployen met harde grenzen (Cloud Shell)
Gebruik een klein chatmodel zonder redeneerstap dat structured outputs ondersteunt,
bijvoorbeeld gpt-4.1-mini. Bekijk welke versies en SKU's er in de regio zijn:
az cognitiveservices model list -l $AOAI_LOC \ --query "[?model.name=='gpt-4.1-mini'].{versie:model.version, skus:join(',', model.skus[].name)}" -o table
Deploy met SKU DataZoneStandard: de verwerking blijft dan binnen de EU. Kies de versie uit
de lijst hierboven.
az cognitiveservices account deployment create -n $AOAI -g $RG \ --deployment-name assistent --model-name gpt-4.1-mini --model-version <versie> \ --model-format OpenAI --sku-name DataZoneStandard --sku-capacity 20
--sku-capacity 20 is een harde grens van 20.000 tokens per minuut: genoeg voor ongeveer vier
vragen per minuut. Daarboven weigert Azure (de app meldt dan "niet bereikbaar"), dus de kosten
kunnen nooit uit de hand lopen. Azure filtert in- en uitvoer daarnaast standaard op schadelijke
inhoud.
Controle:
az cognitiveservices account deployment show -n $AOAI -g $RG --deployment-name assistent \ --query "{model:properties.model.name, versie:properties.model.version, sku:sku.name, capaciteit:sku.capacity, status:properties.provisioningState}" -o table
A4 – Proefvraag aan het model (Cloud Shell)
Zo weet je zeker dat endpoint, sleutel en deployment kloppen, voordat de app ze gebruikt.
KEY=$(az cognitiveservices account keys list -n $AOAI -g $RG --query key1 -o tsv) curl -s "https://$AOAI.openai.azure.com/openai/v1/responses" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"model":"assistent","input":"Antwoord alleen met: ok","max_output_tokens":16,"store":false}' \ | jq -r '.output[0].content[0].text // .error.message'
Controle: je ziet ok. Een foutmelding? Zie Troubleshooting.
A5 – Instellingen in GitHub (PowerShell op je pc)
De sleutel komt als secret in GitHub; de workflow zet hem als Container Apps-secret. Haal hem op
met az cognitiveservices account keys list -n <naam> -g rg-ashab-al-jannah --query key1 -o tsv
(in Cloud Shell) en plak hem alleen in het commando hieronder, nergens anders.
cd <map-van-de-repo> gh secret set OPENAI_API_KEY --env production # plak key1 als daarom gevraagd wordt gh variable set AI_PROVIDER --env production --body openai gh variable set OPENAI_MODEL --env production --body assistent # naam van de deployment (A3) gh variable set OPENAI_BASE_URL --env production --body "https://<naam>.openai.azure.com/openai/v1/" gh variable set ASSISTANT_ENABLED --env production --body true # Optioneel strenger dan de standaard (20 per gebruiker, 300 totaal per dag): gh variable set ASSISTANT_DAILY_LIMIT_PER_USER --env production --body 10 gh variable set ASSISTANT_DAILY_LIMIT_TOTAL --env production --body 100
Controle: gh variable list --env production toont AI_PROVIDER, OPENAI_MODEL,
OPENAI_BASE_URL en ASSISTANT_ENABLED; gh secret list --env production toont OPENAI_API_KEY.
OPENAI_MODEL gaat voor op het algemene AI_MODEL. Gebruik het per-dienst-model, dan kun je
later naar Gemini wisselen zonder het model van Azure OpenAI te verliezen.
Ongeldige waarden (bijvoorbeeld ASSISTANT_ENABLED=ja of een limiet buiten de grenzen in de
Configuratie) laten de deploy stoppen vóórdat de draaiende app wordt geraakt.
Wil je later terug naar een standaardwaarde, zet de variabele dan op die waarde (bijv.
--body 20). Een variabele verwijderen laat de oude waarde op de Container App staan.
A6 – Deployen (PowerShell)
De workflow controleert eerst of de AI-instellingen compleet zijn en stopt anders vóórdat de draaiende app wordt geraakt.
gh workflow run ci-cd.yml --ref main -f deploy_azure=true Start-Sleep 5 $RUN = gh run list --workflow ci-cd.yml -L 1 --json databaseId -q '.[0].databaseId' gh run watch $RUN
Controle (Cloud Shell):
az containerapp show -n $APPNAME -g $RG \ --query "properties.template.containers[0].env[?starts_with(name,'ASSISTANT') || starts_with(name,'AI_') || starts_with(name,'OPENAI_') || starts_with(name,'GEMINI_')].{naam:name, waarde:value, secret:secretRef}" -o table
A7 – Testen (browser)
- Open de app: in het menu staat Assistent, en bovenaan Help staat Vraag het de assistent.
- Vraag bijvoorbeeld "Hoe nodig ik een penningmeester uit?". Je krijgt een kort antwoord in stappen, met een link naar de Help, en Nog 19 van 20 vragen vandaag.
- Vraag iets anders, bijvoorbeeld "Wat is de hoofdstad van Frankrijk?". Je krijgt de vaste weigering.
- Logs (Cloud Shell):
az containerapp logs show -n $APPNAME -g $RG --tail 50 | grep Helpassistenttoont regels als vraag beantwoord (1 bron(nen)), zonder de vraag zelf.
Kosten en extra grenzen
- Per vraag gaan ongeveer 5.000 tokens heen (vooral de handleiding) en hoogstens 600 terug: met
gpt-4.1-minieen fractie van een cent. Met de standaardlimiet van 300 vragen per dag is het maximum enkele euro's per week; in de praktijk veel minder. Controleer de actuele prijzen op de prijspagina van Azure OpenAI. - Zet in de portal een budget met waarschuwing: Kostenbeheer → Budgetten → Toevoegen, scope
rg-ashab-al-jannah, bijvoorbeeld € 10 per maand met een melding bij 80%.
Uitzetten
- Normaal:
gh variable set ASSISTANT_ENABLED --env production --body falseen deploy (A6). Het menu-item verdwijnt;/assistentgeeft 404. - Direct (noodstop): verwijder de modeldeployment; de app meldt dan "niet bereikbaar" tot je
hem weer aanmaakt of de assistent uitzet:
az cognitiveservices account deployment delete -n $AOAI -g $RG --deployment-name assistent. - Sleutel vervangen (bijvoorbeeld na een lek):
az cognitiveservices account keys regenerate -n $AOAI -g $RG --key-name key1, dan A5 (OPENAI_API_KEY) en A6.
Helpassistent met Google Gemini
De app praat met Gemini via het
OpenAI-compatibele endpoint van Google, met
hetzelfde strikte JSON-schema en dezelfde grenzen als bij Azure OpenAI. Er is geen extra pakket
nodig. Met AI_PROVIDER=gemini gebruikt ook de AI Analyse in Rapportage Gemini (alleen totalen).
Reken op 10 minuten.
Let op: gratis quotum en de EU. De voorwaarden van de Gemini API staan voor een app met gebruikers in de EER, Zwitserland of het VK alleen betaalde diensten toe. Koppel daarom in G1 een betaalaccount (Cloud Billing) aan het project van de sleutel. Een nieuw Google Cloud-account krijgt meestal startkrediet, zodat de eerste periode niets kost; controleer de actuele voorwaarden. Met een betaalaccount gebruikt Google vragen en antwoorden niet voor training, maar bewaart ze kort om misbruik op te sporen, en de verwerking kan buiten de EU gebeuren. Het quotum zonder betaalaccount is alleen geschikt om lokaal te proberen (
.env).
G1 – API-sleutel in Google AI Studio (browser)
- Open aistudio.google.com/apikey en log in, bij voorkeur met een Google-account van de stichting.
- Create API key → kies of maak een project, bijv.
ashab-al-jannah. Kopieer de sleutel nog niet naar een bestand; je plakt hem straks alleen in G2 en G3. - Betaalaccount koppelen: klik bij het project op Set up billing (of ga naar console.cloud.google.com/billing) en koppel een betaalaccount.
- Budget met waarschuwing (aanbevolen): Google Cloud console → Billing → Budgets & alerts → Create budget, bijvoorbeeld € 5 per maand met een melding bij 80%.
- Sleutel beperken (aanbevolen): Google Cloud console → APIs & Services → Credentials → de sleutel → API restrictions → alleen Generative Language API.
Controle: in AI Studio staat bij het project een betaalde laag (bijv. Tier 1), niet Free.
G2 – Model kiezen en proefvraag (Cloud Shell)
De sleutel komt niet in je geschiedenis: read -s vraagt hem onzichtbaar.
read -rs GEMINI_KEY; echo # plak de sleutel uit G1 en druk Enter curl -s https://generativelanguage.googleapis.com/v1beta/openai/models \ -H "Authorization: Bearer $GEMINI_KEY" | jq -r '.data[].id' | sed 's#^models/##' | grep flash | sort
Kies een Flash-Lite-model van generatie 3 of nieuwer, bijvoorbeeld gemini-3.5-flash-lite:
snel, goedkoop en geschikt voor structured output. Proefvraag met een JSON-schema, zoals de app
dat doet:
GEMINI_MODEL=gemini-3.5-flash-lite curl -s https://generativelanguage.googleapis.com/v1beta/openai/chat/completions \ -H "Authorization: Bearer $GEMINI_KEY" -H "Content-Type: application/json" \ -d '{"model":"'"$GEMINI_MODEL"'","reasoning_effort":"minimal","max_tokens":100, "messages":[{"role":"user","content":"Antwoord met: ok"}], "response_format":{"type":"json_schema","json_schema":{"name":"proef","strict":true, "schema":{"type":"object","properties":{"antwoord":{"type":"string"}}, "required":["antwoord"],"additionalProperties":false}}}}' \ | jq -r '.choices[0].message.content // .error.message // .[0].error.message'
Controle: je ziet iets als {"antwoord": "ok"}. Een foutmelding? Zie
Troubleshooting.
G3 – Instellingen in GitHub (PowerShell op je pc)
cd <map-van-de-repo> gh secret set GEMINI_API_KEY --env production # plak de sleutel uit G1 gh variable set GEMINI_MODEL --env production --body gemini-3.5-flash-lite # model uit G2 gh variable set AI_PROVIDER --env production --body gemini gh variable set ASSISTANT_ENABLED --env production --body true
Optioneel: GEMINI_REASONING_EFFORT (standaard minimal, zo weinig mogelijk "nadenken", dus
snel en goedkoop). Gebruik je een Gemini 2.5-model, zet hem dan op none.
Controle: gh variable list --env production toont AI_PROVIDER=gemini, GEMINI_MODEL en
ASSISTANT_ENABLED; gh secret list --env production toont GEMINI_API_KEY.
G4 – Deployen en testen
Deploy zoals in A6 en test zoals in A7. In de logs staat bij een fout AI-dienst 'gemini' faalde.
Uitzetten gaat zoals bij Azure OpenAI (ASSISTANT_ENABLED=false). Noodstop: verwijder of
beperk de sleutel in AI Studio; de app meldt dan "niet bereikbaar". Sleutel vervangen: nieuwe
sleutel in AI Studio, dan G3 (GEMINI_API_KEY) en deployen.
Wisselen tussen Azure OpenAI en Gemini
Zet beide diensten één keer in (A1–A5 en G1–G3). Sleutels en modellen staan dan naast elkaar
(OPENAI_* en GEMINI_*); alleen AI_PROVIDER bepaalt welke dienst de app gebruikt.
Via de workflow (normaal):
gh variable set AI_PROVIDER --env production --body gemini # of: openai gh workflow run ci-cd.yml --ref main -f deploy_azure=true
Direct in Cloud Shell (zonder nieuwe build, ± 1 minuut): kan alleen als de sleutel en het
model van de andere dienst al op de Container App staan. Dat is zo na één deploy met beide
ingesteld (de workflow zet altijd alle ingevulde OPENAI_*- en GEMINI_*-waarden mee); controleer
het met de controle uit A6. Ontbreken ze, dan start de app niet. Stop
eerst de actieve revision (zie Instellingen later wijzigen), dan:
source ~/ashab.env; az account set --subscription $SUB for REV in $(az containerapp revision list -n $APPNAME -g $RG --query "[?properties.active].name" -o tsv); do az containerapp revision deactivate -n $APPNAME -g $RG --revision $REV done sleep 20 az containerapp update -n $APPNAME -g $RG --set-env-vars AI_PROVIDER=gemini # of: openai
Zet daarna ook de GitHub-variabele (eerste blok), anders zet de volgende deploy de oude waarde terug.
Controle:
az containerapp show -n $APPNAME -g $RG \ --query "properties.template.containers[0].env[?name=='AI_PROVIDER'].value" -o tsv az containerapp logs show -n $APPNAME -g $RG --tail 50 | grep Helpassistent
Stel daarna een vraag in de app (A7). Bij een fout noemt het log de dienst: AI-dienst 'gemini' faalde of AI-dienst 'openai' faalde.
Troubleshooting deployment
Algemene hulpmiddelen:
az containerapp logs show -n $APPNAME -g $RG --tail 100 # uitvoer van de app az containerapp logs show -n $APPNAME -g $RG --type system --tail 50 # opstarten, image ophalen az containerapp revision list -n $APPNAME -g $RG -o table # welke revision actief/gezond is
gh run view <run-id> --log-failed # fout van een mislukte workflow-job gh run rerun <run-id> --failed # alleen de mislukte jobs opnieuw (zelfde image)
| Symptoom | Oorzaak | Oplossing |
|---|---|---|
ERROR: argument --name/-n: expected one argument |
Cloud Shell is herladen; variabelen zijn leeg | source ~/ashab.env en opnieuw |
AuthorizationFailure bij az storage container create |
Owner heeft geen rechten op blobgegevens | az storage container-rm create (stap 3) |
Deploy-job: AADSTS700213: No matching federated identity record found for presented assertion subject '...' |
Subject in Entra wijkt af van wat GitHub stuurt | Kopieer het subject uit de foutmelding en werk de credential bij: az ad app federated-credential update --id $GITHUB_APP --federated-credential-id github-production --parameters '{"name":"github-production","issuer":"https://token.actions.githubusercontent.com","subject":"<subject>","audiences":["api://AzureADTokenExchange"]}'; dan gh run rerun <run-id> --failed |
Deploy-job: MissingSubscriptionRegistration of geen rechten op Microsoft.App/register |
Resource providers niet geregistreerd | Stap 2 |
Job staat lang op Resource group en Container Apps-omgeving; omgeving Waiting |
Eerste keer aanmaken duurt 5–20 min | Wachten (lus in stap 6); bij Failed: az containerapp env show -n cae-ashab-al-jannah -g $RG -o json |
| Rooktest op Azure faalt na de eerste deploy; health geeft geen antwoord; systeemlog toont herstarts | App mag de back-up niet lezen (Litestream stopt bewust) | Stap 7; controleer met az role assignment list --assignee $PRINCIPAL --all -o table |
Systeemlog: unauthorized / image pull mislukt |
GHCR_PULL_TOKEN verlopen of zonder read:packages |
Nieuwe token (stap 5), gh secret set GHCR_PULL_TOKEN --env production, deploy opnieuw |
| Deploy stopt met "De app gebruikt nog DATABASE_URL (Azure SQL)" | Oude installatie | Overstap van Azure SQL |
Portal geeft errorCode 401 met je subscriptionId in de externe tenant |
Portal gebruikt nog de context van je gewone tenant | https://entra.microsoft.com/?tenant=<domein>.onmicrosoft.com; niet via Abonnementen of Resourcegroepen |
| Inlogpagina: "Er is geen account met dit e-mailadres gevonden" | Nog geen klantaccount in de externe tenant | Geen account? Maak er een (stap 8e) |
Na inloggen AADSTS65001 / "toestemming van beheerder nodig" |
Geen admin consent | az ad app permission admin-consent --id $EXT_APP in de externe tenant (stap 8b) |
Na inloggen een fout van Easy Auth (401/403) of een lus terug naar de inlogpagina |
Verkeerde issuer, client-id of secret | Controle van 8d; secret opnieuw maken met 8d |
| Client secret per ongeluk getoond of gedeeld | Secret niet meer geheim | 8d opnieuw: credential reset vervangt het oude secret |
/.auth/me geeft 404 |
Token store van Easy Auth staat uit (standaard) | Niet nodig; issuer en subject uit de database halen (stap 9a) |
/platform geeft geen toegang |
SUPERADMIN_SUBJECTS wijkt af (bijv. domein i.p.v. tenant-id in de issuer) |
Waarde exact overnemen uit stap 9a, dan 9b |
| Mollie-betalingen blijven op "open" staan | Easy Auth stuurt de webhook naar de inlogpagina | Pad /betalingen/webhook/<slug> toevoegen aan --excluded-paths (naast /api/v1/health); controle: curl -s -o /dev/null -w "%{http_code}" -X POST $URL/betalingen/webhook/<slug> geeft 200, geen 302 |
| Deploy stopt met "… ontbreekt" (bijv. "OPENAI_MODEL (of AI_MODEL) ontbreekt", "Secret GEMINI_API_KEY ontbreekt") | AI-instellingen voor de gekozen AI_PROVIDER onvolledig; de workflow stopt vóórdat de draaiende app wordt geraakt |
A5 of G3 aanvullen en opnieuw deployen |
| Deploy stopt met "ASSISTANT_… moet …" of "GEMINI_REASONING_EFFORT moet …" | Ongeldige waarde | Variabele op een geldige waarde zetten (zie Configuratie) en opnieuw deployen |
| Geen menu-item Assistent na het aanzetten | ASSISTANT_ENABLED niet true, of AI_PROVIDER is local (log: ASSISTANT_ENABLED=true, maar AI_PROVIDER=local) |
Controle van A6; variabelen uit A5 zetten en deployen |
| Assistent meldt "De assistent is nu niet bereikbaar" | Log Helpassistent: AI-dienst 'openai' faalde (…): NotFoundError = verkeerde OPENAI_MODEL (moet de deploymentnaam zijn) of OPENAI_BASE_URL zonder /openai/v1/; AuthenticationError = verkeerde sleutel; RateLimitError = capaciteit (TPM) op |
Proefvraag uit A4; zo nodig --sku-capacity verhogen met az cognitiveservices account deployment create (zelfde naam) |
| Idem met AI-dienst 'gemini' faalde (…) | NotFoundError = onbekend GEMINI_MODEL; AuthenticationError/PermissionDeniedError = verkeerde of beperkte sleutel; RateLimitError = quotum van het project op (of geen betaalaccount); BadRequestError = model kent GEMINI_REASONING_EFFORT niet (bijv. none bij Gemini 3) |
Proefvraag uit G2 met dezelfde waarden; quotum in Google Cloud console → IAM & Admin → Quotas |
| Lokaal of op Azure: app start niet, "GEMINI_API_KEY is verplicht bij AI_PROVIDER=gemini" | AI_PROVIDER=gemini gezet zonder sleutel op de Container App (bijv. direct gewisseld in Cloud Shell) |
Terug naar de vorige AI_PROVIDER met de stappen uit Wisselen, dan G3 en deployen |
| Assistent meldt vaak "Ik kon geen betrouwbaar antwoord maken" | Het model ondersteunt geen structured outputs, of een redeneermodel gebruikt de tokens al voor het nadenken | Een model als gpt-4.1-mini of een Gemini Flash-Lite gebruiken, GEMINI_REASONING_EFFORT laag houden (minimal), of ASSISTANT_MAX_OUTPUT_TOKENS verhogen (max. 1500) |
az cognitiveservices account deployment create: model of SKU niet beschikbaar |
Niet elk model heeft DataZoneStandard in elke regio |
Lijst uit A3 bekijken; een andere versie of EU-regio kiezen |
az cognitiveservices account create: naam of subdomein bezet |
De naam is wereldwijd uniek | Andere naam kiezen in A2 |
Back-up en herstel
- docker-entrypoint.sh zet bij het opstarten de database terug uit
de back-up als die nog niet op de schijf van de container staat, en start de app daarna
onder
litestream replicate(litestream.yml). Elke wijziging staat binnen ongeveer een seconde in Blob Storage; bij een harde crash kan dus hooguit de laatste seconde verloren gaan. - Litestream maakt elke dag een volledige kopie en bewaart
LITESTREAM_RETENTION(standaard 7 dagen). Binnen die periode kun je naar elk moment terug. - Kan de container de back-up niet lezen (bijv. geen rechten), dan start hij niet. Zo wordt nooit een lege database als nieuwe back-up weggeschreven.
Een eerdere stand terugzetten (bijv. na een fout van een gebruiker). Installeer
Litestream, bij voorkeur op Linux, macOS of in WSL (de
Windows-versie meldt na afloop onterecht "Access is denied", al is het bestand wel goed), en
meld je aan met az login (je account heeft de rol Storage Blob Data Contributor op de
container nodig):
# 1. Stand van een bepaald moment (UTC) ophalen en controleren litestream restore -timestamp 2026-10-08T12:00:00Z -o herstel.db abs://<opslagaccount>@ledenadmin/ledenadmin # 2. Als nieuwe back-up wegzetten onder een nieuw pad (de oude back-up blijft ongemoeid) litestream replicate -once herstel.db abs://<opslagaccount>@ledenadmin/herstel-20261008
Zet daarna de variabele LITESTREAM_REPLICA_URL op het nieuwe pad en draai de workflow met
deploy_azure: de nieuwe container start leeg en zet herstel.db terug.
Een kopie voor onderzoek maak je met alleen stap 1, zonder -timestamp voor de laatste
stand. Open die nooit met een tweede app die naar dezelfde back-up schrijft.
Overstap van Azure SQL
Draaide de app eerder op Azure SQL (DATABASE_URL=mssql+pyodbc:...), zet dan eenmalig de
gegevens over. De workflow weigert te deployen zolang de app nog DATABASE_URL heeft.
-
Lokaal: installeer ODBC Driver 18 en
pip install pyodbc, en maak een lege database met het actuele schema:$env:DATABASE_URL="sqlite:///./overstap.db"; alembic upgrade head. -
Kopieer alles (gebruik in de Azure SQL-URL
Authentication=ActiveDirectoryInteractivein plaats vanActiveDirectoryMsi):python -m ledenadmin.copy_database "<azure-sql-url>" "sqlite:///./overstap.db"
-
Zet het bestand als eerste back-up weg en verwijder de oude instelling:
litestream replicate -once overstap.db abs://<opslagaccount>@ledenadmin/ledenadmin az containerapp update -n ashab-al-jannah -g rg-ashab-al-jannah --remove-env-vars DATABASE_URL
-
Volg de stappen uit Eerste deployment stap voor stap (opslag, variabele, rol: stap 3, 5 en 7) en deploy. Bewaar de Azure SQL-database nog even als vangnet en verwijder hem daarna.
Uitbreidingen na de MVP
- Boekhouding (US09): implementeer
ReportExportervoor Moneybird of Exact Online, met een preview en expliciete bevestiging. Herhaalde exports mogen geen dubbele boekingen veroorzaken. - Bankafschriften (US10): begin bij voorkeur met CAMT.053- of CSV-exports van de bank (betrouwbaarder dan PDF); pdfplumber kan als fallback. AI mag matches voorstellen, maar definitief koppelen gebeurt altijd na menselijke bevestiging.
- Donaties crediteren met tegenboekingen (in plaats van direct bewerken) zodra er een koppeling met een boekhoudpakket is.
Open besluiten
- Definitieve rolverdeling en of donaties voor inactieve leden zijn toegestaan.
- Bewaartermijnen (o.a. van het logboek en de back-up, nu 7 dagen) en AVG-procedures. Back-up en herstel zijn ingericht met Litestream (zie Back-up en herstel).
- Keuze en kostenlimiet voor een externe AI-provider, en of verwerking in de EU vereist is.
- Boekhoudpakket en bankformaat voor de integraties.
Licentie
Ashab al-Jannah valt onder de PolyForm Noncommercial License 1.0.0. Kort samengevat (de Engelse licentietekst is bindend):
- Gratis voor goede doelen en andere organisaties zonder winstoogmerk, zoals een vakıf, dernek, stichting, vereniging, moskee, hafızlık-, Koran- of imam-hatipschool. Dat geldt ook als de organisatie inkomsten heeft uit donaties, contributie of kleine vergoedingen voor activiteiten (bijv. sadaka, zakat, waterputten, iftar, onderwijs).
- Gratis voor persoonlijk gebruik, studie, onderzoek en hobbyprojecten.
- Aanpassen en delen mag, mits de licentie en de copyrightregel (
Required Notice) meegaan. - Niet toegestaan: commercieel gebruik, zoals de software verkopen, als betaalde dienst (SaaS) aanbieden of inzetten in een bedrijf met winstoogmerk. Neem voor commercieel gebruik contact op via GitHub.
Online doneren (Mollie)
Elke stichting koppelt haar eigen Mollie-account via Instellingen → Online doneren: Live API-sleutel (live_...) plakken. Het geld gaat rechtstreeks naar de stichting; het platform raakt geen geld aan.
| Variabele | Betekenis |
|---|---|
SECRET_ENCRYPTION_KEY |
Fernet-sleutel waarmee API-sleutels versleuteld worden opgeslagen. Leeg = online doneren uit. |
MOLLIE_API_URL |
Standaard https://api.mollie.com/v2. |
PUBLIC_BASE_URL |
Moet publiek bereikbaar zijn (https) zodat Mollie de webhook /betalingen/webhook/{slug} kan aanroepen. Bij localhost wordt de status pas bijgewerkt als het lid terugkeert. |
Leden (rol lid, gekoppeld aan een lidrecord) zien op Mijn omgeving een doneerformulier. Na betaling (paid) wordt automatisch een donatie geboekt.
来源:GitHub · github.com