VraagHetZe is a public website for asking questions to politicians in the Tweede Kamer.
- SvelteKit and Svelte 5 in TypeScript
- Docker with SvelteKit Node adapter
- PostgreSQL with Drizzle ORM
- Better Auth for user management in local database
- SendGrid for outgoing and incoming email
- Tailwind CSS for styling
- Bits UI for unstyled components
- Vitest for testing
- Croner for the background jobs
- sharp for scaling politician images
Make sure you have Node, pnpm and Docker installed.
-
Copy
.env.exampleto.env. A development environment needs values forPOSTGRES_*,DATABASE_URL,ORIGINandBETTER_AUTH_SECRET. Keep the other keys in the file empty. -
Install the dependencies:
pnpm install
-
Start the database:
pnpm db:start
-
Push the schema to the database:
pnpm db:push
-
Activate the
pg_trgmextension:docker compose -f docker-compose.dev.yml exec db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm"'
-
Create
static/fonts/azurio.woff2. -
Start the development server:
pnpm dev
-
Open http://localhost:5173 and wait for the logs to show the politician sync is done.
-
Fill the database with test data:
pnpm db:seed
-
Run the tests once:
pnpm test:setup
-
To elevate a user to the
adminrole you can usebin/make_admin.sh
| Command | Function |
|---|---|
pnpm dev |
Start the development server. |
pnpm build |
Make a production build. |
pnpm check |
Do the TypeScript and Svelte checks. |
pnpm lint |
Do the Prettier and ESLint checks. |
pnpm format |
Correct the format of all files. |
pnpm test |
Do the tests. The database must run. |
pnpm test:setup |
Make the test database again. Necessary after a schema change. |
pnpm db:start |
Start the local database in Docker. |
pnpm db:push |
Write the schema to the local database. For local use only. |
pnpm db:seed |
Fill the local database with test data. For local use only. |
pnpm db:generate |
Make a migration file from a schema change. For production. |
pnpm db:studio |
Open Drizzle Studio on the database. For local use only. |
pnpm auth:schema |
Make auth.schema.ts again from the Better Auth configuration. |
- A person writes a question in the steps of
/vragen/stellen. The server makes a user account for the email address or finds the existing account. - The server sends a magic link. The person clicks the link. The server then sets
verifiedAton the question. Only a verified question goes to the moderators. - A moderator approves or rejects the question on
/modereren/vragen. - The server puts an email for the politician in the
outboxtable. The delivery job sends the email through SendGrid. - The politician answers by email. SendGrid sends the answer to
/api/sendgrid/inbound. The email is matched to a question based on the uniqueemailTokenin the address. The server writes the answer in theinboxtable, and makes ananswerrow with the statuspending. - A moderator approves or rejects the answer on
/modereren/antwoorden. - At approval the website publishes the answer. The server also puts an email in the outbox for the person who asked the question, and for each follower.
| Directory | Contents |
|---|---|
src/routes |
Page components, loaders and form actions. |
src/lib/components |
Re-used Svelte components. |
src/lib/server/db |
Database schema and connection. |
src/lib/server/email |
Inbox and outbox management and email templates. |
src/lib/server/sync |
Importing of politicians from Tweede Kamer. |
src/lib/server |
Other server logic per subject. |
drizzle |
Auto-generated migration files. |
Every person is a user, whether they ask a question, answer one as a politician, or moderate. Better Auth manages user, session, account and verification. A politician has a politician row next to their user row, which points at their fraction and, through commission_membership, at each commission they are a member of.
A question points at the user who wrote it and at the politician it is assigned to, and keeps assigneeFractionId as a snapshot of the fraction at that moment. An answer belongs to one question. question_follow holds the followers of a question. moderation_action records each approval and rejection of questions and answers. outbox and inbox hold the outgoing and incoming email.
Make a migration file with pnpm db:generate after each schema change and commit it. Edit manually if needed to make it backwards-compatible.
In development running pnpm db:migrate results in an error - use pnpm db:push instead.
There is no external search service. The question and answer tables have a generated searchVector column that holds the text of the row as searchable words. The search on /vragen joins the vectors of a question and its newest answer as one document. The related questions below a question match against the same joined vector. The vectors use the Dutch text search configuration of PostgreSQL. That configuration keeps Dutch compound words together, thus src/lib/server/search also matches parts of a word, such that "klimaat" or "beleid" finds "klimaatbeleid". The search on /politici uses no vectors, it matches the name of the politician directly.
src/lib/server/sync reads the politicians from the open data of the Tweede Kamer and stores them in the politician table. A politician without an email address is skipped. The sync also sets the fraction, commission and commission_membership tables.
The sync never deletes a politician. A politician who leaves the Tweede Kamer gets isActive = false, such that old questions keep their assignee. The sync fetches user images for a politician without one, and stores it as a 256px WebP data URL in user.image.
The server never sends an email directly without it going through the outbox table. This makes retries and a full history possible. Some emails, such as an answer notification, are enqueued for delivery by the background job, others, such as magic links, are enqueued and sent immediately. In development, the server writes each email to the console and sends nothing through SendGrid. Set DIVERSION_EMAIL in production to divert emails away from politicians, before launch.
Incoming email arrives via a webhook by Sendgrid to /api/sendgrid/inbound. If the endpoint is down, SendGrid retries for 72 hours. All emails are stored in the inbox table. The webhook has a token in the URL to secure the endpoint. SvelteKit's default CSRF check and the basic authentication skip this route.
src/hooks.server.ts starts two cron jobs at the start of the server:
- The politician sync, each day at 04:00.
- The outbox queue delivery, every 5 minutes.
Both jobs also run once when the server handles its first request.
docker-compose.yml defines three services: the database, a migration container and the application. The migration container automatically applies new database migrations and then stops. The application starts after that. Docker Compose reads .env.
To deploy, on the server:
-
git pull -
sudo docker compose build node -
sudo docker compose up -d
- Make
DIVERSION_EMAILempty. Real politicians then get the emails. - Make
BASIC_AUTH_USERandBASIC_AUTH_PASSWORDempty. - Remove
handleBasicAuthfromsrc/hooks.server.ts.