118 lines
8.5 KiB
Markdown
118 lines
8.5 KiB
Markdown
# Architecture
|
|
|
|
## Overview
|
|
|
|
```
|
|
┌──────────────────────────────┐ ┌──────────────────────────────────────────┐
|
|
│ React SPA (Vite, Tailwind) │─────▶│ kereru-forms (Go, Gin) │
|
|
│ - form filling │ HTTP │ ┌────────────┐ ┌────────────────────┐ │
|
|
│ - management │ │ │ OpenAPI │ │ Strict handlers │ │
|
|
│ - light/dark/system theme │ │ │ generated │─▶│ (validation, PDF, │ │
|
|
└──────────────────────────────┘ │ │ router │ │ access codes) │ │
|
|
│ └────────────┘ └─────────┬──────────┘ │
|
|
│ │ │
|
|
│ ┌────────────┐ ┌────────▼──────────┐ │
|
|
│ │ Telemetry │ │ Store interface │ │
|
|
│ │ (OTLP) │ │ SQLite adapter │ │
|
|
│ └────────────┘ └────────┬──────────┘ │
|
|
└───────────────────────────│──────────────┘
|
|
▼
|
|
SQLite database file
|
|
```
|
|
|
|
The API is mounted both at the root (for Kubernetes probes and direct clients) and under `/api` (used by the SPA). The backend binary can also serve the built SPA (`KERERU_STATIC_DIR`) with history fallback, so a single container is enough for most deployments.
|
|
|
|
## Backend layout
|
|
|
|
```
|
|
backend/
|
|
├── openapi/openapi.yaml Contract: paths, schemas, enums
|
|
├── oapi-codegen.yaml Code generation settings
|
|
├── cmd/kereru-forms/main.go Wiring, listeners, graceful shutdown
|
|
└── internal/
|
|
├── api/ Generated server + strict handlers + router
|
|
├── config/ YAML/JSON + .env + env + flags, precedence
|
|
├── database/ Store interface, SQLite implementation, migrations
|
|
├── logging/ zerolog setup + Gin access logging
|
|
├── pdf/ PDF rendering of a form definition
|
|
└── telemetry/ OpenTelemetry traces and metrics (OTLP/HTTP)
|
|
```
|
|
|
|
### Request flow
|
|
|
|
1. Gin middleware: telemetry span (`otelgin`), panic recovery, request id, request metadata (client IP/user agent), structured access log, CORS.
|
|
2. `api.RegisterHandlers` routes to the **generated strict handler**, which performs OpenAPI request decoding/validation.
|
|
3. `api.Server` methods validate business rules and call the `database.Store`.
|
|
4. Responses are mapped back to generated types; errors use the `Error` schema (`{error, code}`).
|
|
|
|
### Configuration
|
|
|
|
`internal/config` implements a deterministic precedence chain: defaults → YAML/JSON file (`--conf`) → `.env` (next to executable or cwd; never overrides existing env) → environment variables → CLI flags. The `--conf` flag is pre-scanned so the file is available as defaults for the remaining flags. Both IPv4 and IPv6 listeners are supported and started concurrently.
|
|
|
|
### Data model
|
|
|
|
`forms` represents one concrete version. `form_group_id` groups all versions of the same logical form; `(form_group_id, version)` is unique. Creating a new version clones the current definition, increments `version`, activates the new row and inactivates the previous one. Historical submissions keep `form_version` and denormalised `question_key` values, so they remain readable after questions change.
|
|
|
|
| Table | Purpose |
|
|
| --- | --- |
|
|
| `forms` | Form version metadata, contact details, state |
|
|
| `questions` | Ordered questions with type-specific configuration (JSON options, bounds) |
|
|
| `submissions` | Response header: user identifier, form version, server-captured tracking |
|
|
| `answers` | One row per answered question, value stored as JSON |
|
|
| `access_codes` | Single-use codes (`used`, `used_at`, owning submission) |
|
|
| `schema_migrations` | Applied schema versions |
|
|
|
|
All timestamps are stored as RFC 3339 UTC strings. IDs are UUIDs (TEXT).
|
|
|
|
### Validation
|
|
|
|
Form definitions: non-empty title, unique keys (`[A-Za-z][A-Za-z0-9_-]*`), supported question types, select questions require unique non-empty option values, numeric bounds ordered.
|
|
|
|
Submissions: identifier type must be `email|username|phone|guid`; email identifiers are parsed; answers must reference questions of that form; duplicates rejected; required questions enforced; type checks for boolean/number/select/text; numeric bounds; select values must exist; text length capped and single-line questions reject newlines.
|
|
|
|
Access codes are consumed in the same SQL transaction as the submission insert. The conditional `UPDATE ... WHERE used = 0` makes reuse safe under concurrency; failure rolls back the submission.
|
|
|
|
### Observability
|
|
|
|
- **Logs**: zerolog, JSON or console, level configurable. Access logs include method, path, status, latency, client IP, user agent and `X-Request-ID`.
|
|
- **Traces/metrics**: OpenTelemetry SDK with OTLP/HTTP exporters. Spans cover HTTP requests (`otelgin`) and individual use cases (`GetForm`, `SubmitForm`, `GetFormPdf`, …) with form/submission attributes. Metrics are exported every 15s. Telemetry is opt-in (`KERERU_TELEMETRY_ENABLED`).
|
|
- **Health**: `GET /health` returns status, version and uptime for liveness/readiness probes.
|
|
|
|
### PDF
|
|
|
|
`internal/pdf` renders the form definition with `go-pdf/fpdf`: title, description, introduction, contact block, numbered questions, type hints, options and answer lines. Built-in PDF fonts cover WinAnsi; macrons and common typographic characters are transliterated so the output is always valid. For full Unicode, embed a TTF font instead.
|
|
|
|
## Frontend layout
|
|
|
|
```
|
|
frontend/src/
|
|
├── api/… (reserved)
|
|
├── components/ Layout, ThemeToggle, QuestionField, StatusBadge
|
|
├── hooks/useTheme light/dark/system with localStorage + matchMedia
|
|
├── lib/api.ts Typed fetch client, ApiError, PDF download
|
|
├── lib/types.ts Mirrors the OpenAPI schemas
|
|
├── pages/ Home, FillForm, Manage, FormBuilder, FormDetail, SubmissionDetail
|
|
└── test/ Vitest + Testing Library
|
|
```
|
|
|
|
Theming uses Tailwind's `class` dark mode. An inline script in `index.html` applies the stored preference before paint to avoid a flash. The `useTheme` hook keeps `documentElement.dark`, `localStorage` and the OS preference in sync.
|
|
|
|
Forms may carry a company name, contact details and a logo URL; the UI renders the logo on the home, fill and detail views. Remote logos are not embedded in the generated PDF.
|
|
|
|
The fill flow asks for the respondent identifier and (optionally) an access code first, then renders the questions by type. On success the local answers are cleared and only the confirmation is shown — previously entered data is never re-presented. The management section lists active and inactive versions and supports activation, versioning, deletion, PDF download, access-code generation and submission review.
|
|
|
|
## Extending the database
|
|
|
|
`database.Store` is the boundary used by the API layer. To add PostgreSQL or MariaDB:
|
|
|
|
1. Add a new adapter implementing `Store` (or generalise the SQL adapter with a dialect for placeholders and upserts).
|
|
2. Extend `database.Open` with the driver case and adjust migrations (TEXT timestamps and JSON strings map cleanly to `timestamptz`/`jsonb` and `varchar`/`json`).
|
|
3. Keep transaction semantics for submission + access-code consumption.
|
|
|
|
## Known limitations / next steps
|
|
|
|
- Management endpoints have no authentication or authorisation yet; front them with an auth proxy or add an auth layer.
|
|
- SQLite is a single-writer store; for horizontal scaling use a network database adapter and remove the single-connection pool limit.
|
|
- Access codes are generated with 8 characters from a 32-symbol alphabet (~40 bits) — sufficient for single-use gating, not for secrets.
|
|
- PDF output uses built-in fonts and does not embed form logos (see above).
|