A full-stack LLM writing assistance tool for the procurement team at the Municipality of Amsterdam. Built with Next.js, FastAPI, PostgreSQL, and Azure OpenAI, using the Amsterdam Design System.
This version is made specifically for Inkoop (procurement), but can be extended to other teams and use cases.
- Node.js 24+ and npm 11+
- Python 3.10+
- Docker (for PostgreSQL database)
cd backend/database
docker build -t inkoop-db:latest .
docker run -d \
--name inkoop-db \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=inkoopsstrategie \
-v inkoop_db_data:/var/lib/postgresql/data \
-p 5432:5432 \
inkoop-db:latestThis creates a PostgreSQL image with pgvector, the base schema, admin infrastructure, and two pre-seeded admin accounts.
cd backend/api
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -r requirements.txt
# Configure environment (see Environment Variables below)
cp .env.example .env
# Edit .env with your database and Azure OpenAI credentials
uvicorn main:app --reloadAPI available at http://localhost:8000 — Swagger docs at http://localhost:8000/docs
Note: Remember to activate the virtual environment each time:
cd backend/api && source .venv/bin/activate# From project root
npm install
npm run devThe app will be available at http://localhost:3000
The application starts empty with generic placeholders. To configure it:
-
Log in with a pre-configured admin account:
Email Password Display Name j.baas@example.com secret123!J. Baas k.bouwens@example.com secret123!K. Bouwens -
Navigate to the admin panel at http://localhost:3000/admin
-
Click "Reset naar standaardwaarden inkoop" to load:
- App title and branding
- System prompt for AI-generated content
- Collection templates (e.g., "Inkoopstrategie")
- Flow templates with prompts for chapters (e.g., "Hoofdstuk 5 Marktanalyse", "Hoofdstuk 9 Aanbestedingsprocedure")
After loading defaults, you can customize settings, templates, and add users through the admin interface.
Important: Change the default admin passwords after first login. The seed button overwrites existing settings and templates — use it for initial setup or to reset to defaults.
See .env.example:
NEXT_PUBLIC_BASE_PATH=''
API_BASE_URL=http://localhost:8000You can optionally add NEXT_PUBLIC_API_BASE_URL if the public-facing API URL differs from API_BASE_URL.
See backend/api/.env.example:
DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/inkoopsstrategie
JWT_SECRET=change_me_in_production
JWT_EXPIRES_MIN=60
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_API_KEY=your-api-key
AZURE_OPENAI_DEPLOYMENT=your-deployment-nameFrontend:
- Next.js 15 with App Router and React Server Components
- Amsterdam Design System React components
- SWR for client-side caching and optimistic updates
- React Hook Form + Zod schemas
- MDX Editor for rich text editing
Backend:
- FastAPI (Python 3.10+)
- PostgreSQL 16 + pgvector
- JWT authentication (bcrypt for passwords)
- Azure OpenAI for text generation
- Selenium WebDriver for web search
The app uses cookie-based authentication with httpOnly cookies:
- User logs in via
/loginpage → calls/api/loginroute /api/loginproxies to backend/auth/loginendpoint- Backend returns access token, which is stored in httpOnly cookie
- All authenticated requests include the cookie automatically
- Session validation via
/api/meendpoint on app load
├── src/ # Frontend (Next.js)
│ ├── app/
│ │ ├── (inkoop)/ # Authenticated routes
│ │ │ ├── page.tsx # Home/dashboard (collections list)
│ │ │ ├── project/ # Project pages
│ │ │ │ ├── page.tsx # Project detail (flows list)
│ │ │ │ ├── instructie/ # Flow instruction page
│ │ │ │ ├── bronnen/ # Sources upload page
│ │ │ │ └── concepttekst/ # Generated text preview
│ │ │ └── admin/ # Admin panel
│ │ ├── api/ # Next.js API routes (proxy to backend)
│ │ └── login/ # Login page (public)
│ ├── components/ # Reusable React components
│ ├── contexts/ # React contexts (AuthContext)
│ ├── hooks/ # SWR data fetching hooks
│ ├── lib/ # API helpers, validation schemas, seed data
│ └── utils/ # General utilities
│
├── backend/
│ ├── api/
│ │ ├── main.py # FastAPI entry point
│ │ ├── endpoints/ # Route handlers (auth, collections, flows, admin)
│ │ ├── models/ # SQLAlchemy ORM models
│ │ ├── schemas/ # Pydantic request/response models
│ │ ├── services/ # Business logic
│ │ ├── middleware/ # OpenAI and websearch integrations
│ │ ├── prompts/ # LLM prompt templates
│ │ └── utils/ # Helper functions
│ └── database/
│ ├── Dockerfile # PostgreSQL + pgvector image
│ └── init.sql # Full schema + seed data
│
├── package.json # Frontend dependencies
├── next.config.mjs # Next.js configuration
└── tsconfig.json # TypeScript configuration
The app uses SWR hooks for efficient data fetching with automatic caching:
useCollections()— Fetch all collections for current useruseCollection(id)— Fetch single collection with metadatauseFlows(collectionId)— Fetch flows for a collectionuseFlow(flowId)— Fetch single flow with template content
All hooks are in src/hooks/useInkoopData.ts and provide automatic deduplication, optimistic UI updates, error handling, and cache revalidation.
All API routes in src/app/api/ proxy to the backend API, adding authentication from cookies:
| Frontend Route | Backend Endpoint |
|---|---|
POST /api/login |
POST /auth/login |
POST /api/logout |
POST /auth/logout |
GET /api/me |
GET /users/me |
GET /api/collections |
GET /collections |
GET /api/collections/:id |
GET /collections/:id |
PATCH /api/collections/:id |
PATCH /collections/:id |
GET /api/flows/:id |
GET /flows/:id |
PATCH /api/flows/:id |
PATCH /flows/:id |
GET /api/collections/:id/flows |
GET /collections/:id/flows |
POST /api/flows/:id/websearch |
POST /flows/:id/websearch |
- API Utilities (
src/lib/api/utils.ts):ensureBaseUrl(),getAuthToken(),createAuthHeaders() - Validation (
src/lib/validation/schemas.ts): Zod schemas for URLs, files, collections + constants (MAX_FILE_SIZE,MAX_FILE_COUNT,MAX_URL_COUNT)
Base URL: http://localhost:8000
Authentication:
POST /auth/register— Set password for pre-created usersPOST /auth/login— Login (returns JWT)POST /auth/change-password— Change own password
Collections & Flows:
POST /collections— Create collectionDELETE /collections/{id}— Delete collectionPOST /flows/{collection_id}— Create flowDELETE /flows/{flow_id}— Delete flowPOST /flows/{flow_id}/documents— Upload document
Web Search:
POST /flows/{flow_id}/websearch— Trigger searchPOST /flows/{flow_id}/websearch/get_sources— Get results
Admin (requires admin token):
GET/PATCH /admin/settings— App settingsGET/POST /admin/collection-templates— Collection templatesGET/POST /admin/users— User managementPATCH /admin/users/{id}— Update user (including password reset)
Core Tables:
users— Accounts (email, password hash, admin flag)collections— User's document collectionsflows— Workflows within collectionsdocuments— Uploaded files linked to flowsweb_sources— Search results from internet search
Admin Tables:
app_settings— Key-value configuration (system prompt, branding)collection_templates— Collection type definitionsflow_templates— Flow configurations linked to collection templates
Access the database via psql:
docker exec -it inkoop-db psql -U postgres -d inkoopsstrategieFull database reset (deletes ALL data):
docker stop inkoop-db || true
docker rm inkoop-db || true
docker volume rm inkoop_db_data || true
cd backend/database
docker build -t inkoop-db:latest .
docker run -d \
--name inkoop-db \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=inkoopsstrategie \
-v inkoop_db_data:/var/lib/postgresql/data \
-p 5432:5432 \
inkoop-db:latestAdmins can create users via the admin panel at http://localhost:3000/admin:
- Expand "Gebruikers" section
- Click "Nieuwe gebruiker"
- Fill in email, display name, password
- Check "Admin" if needed
Alternative (API):
curl -X POST http://localhost:8000/admin/users \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"display_name": "New User",
"password": "temp_password",
"is_admin": false
}'New users (created by admin without password) can set their password at http://localhost:3000/account → "Registreren".
Note: This flow will be replaced by IntraID/SSO in production.
Change own password (API):
curl -X POST http://localhost:8000/auth/change-password \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password": "old_password", "new_password": "new_password"}'Reset user password (admin): Via admin panel → "Gebruikers" → "Wachtwoord resetten", or:
curl -X PATCH http://localhost:8000/admin/users/USER_ID \
-H "Authorization: Bearer ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"password": "new_password"}'docker ps | rg inkoop-db # Check if container is running
docker logs inkoop-db # Check logsdocker exec -it inkoop-db psql -U postgres -d inkoopsstrategie \
-c "SELECT email, is_admin, hashed_password IS NOT NULL AS has_password FROM users WHERE is_admin = TRUE;"docker exec -it inkoop-db psql -U postgres -d inkoopsstrategie \
-c "SELECT tablename FROM pg_tables WHERE schemaname = 'public';"If tables are missing, the init script didn't run. Delete the volume and rebuild the database image.
This project uses the Amsterdam Design System for all UI components and styling.
- IntraID/SSO integration (replace password login for internal users)
- Row-level security (RLS) for multi-tenant data isolation
- Audit logging for admin actions
- Email notifications for user creation
- API rate limiting