Initial commt for working copy generated

main
Tom Peltonen 2026-10-23 23:24:54 +11:00
commit 5c9a7a0f64
61 changed files with 18323 additions and 0 deletions

14
.dockerignore 100644
View File

@ -0,0 +1,14 @@
.git
.gitignore
README.md
docs
deploy
**/node_modules
**/dist
**/*.db
**/*.db-shm
**/*.db-wal
**/*.log
**/.env
backend/kereru-forms
backend/kereru-forms.exe

30
.gitignore vendored 100644
View File

@ -0,0 +1,30 @@
# Dependencies
node_modules/
frontend/dist/
backend/kereru-forms
backend/kereru-forms.exe
# Databases
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
data/
# Env files (keep examples)
.env
.env.local
!.env.example
# Logs / OS / editors
*.log
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
# Test / coverage
coverage/
*.out

114
README.md 100644
View File

@ -0,0 +1,114 @@
# Kererū Forms
Simple, versioned surveys with a Go + OpenAPI backend and a React frontend.
- **Backend**: Go 1.26, Gin, OpenAPI-generated server (`oapi-codegen`), SQLite via `modernc.org/sqlite` (pure Go), zerolog, OpenTelemetry.
- **Frontend**: React 18, Vite, TypeScript, Tailwind CSS, React Router, Vitest.
- **Deployment**: single container serving both the API and the built SPA, plus Docker Compose, Podman and Kubernetes manifests.
## Quick start
### 1. Backend
```bash
cd backend
go mod download
go run ./cmd/kereru-forms --listen-addr 0.0.0.0 --port 8080
```
The API listens on <http://localhost:8080>. The SQLite database (`kereru.db`) and its schema are created automatically.
### 2. Frontend
```bash
cd frontend
npm install
npm run dev
```
Open <http://localhost:5173>. Vite proxies `/api/*` to `http://localhost:8080/api/*`. The API is also available without the `/api` prefix for probes and direct clients.
## Configuration
Settings are resolved in this order (highest wins):
1. CLI flags
2. Environment variables
3. `.env` file next to the executable, then the working directory
4. YAML/JSON file passed with `--conf`
5. Built-in defaults
```bash
kereru-forms --conf ./config/config.example.yaml
```
| Flag | Environment variable | Default |
| --- | --- | --- |
| `--conf` | – | – |
| `--log-level` | `KERERU_LOG_LEVEL` | `info` |
| `--log-format` | `KERERU_LOG_FORMAT` | `console` |
| `--listen-addr` | `KERERU_LISTEN_ADDR` | `0.0.0.0` |
| `--listen-addr6` | `KERERU_LISTEN_ADDR6` | – |
| `--port` | `KERERU_PORT` | `8080` |
| `--db-driver` | `KERERU_DB_DRIVER` | `sqlite` |
| `--db-dsn` | `KERERU_DB_DSN` | `file:kereru.db?...` |
| `--static-dir` | `KERERU_STATIC_DIR` | – |
| – | `KERERU_TELEMETRY_ENABLED` | `false` |
| – | `OTEL_EXPORTER_OTLP_ENDPOINT` | `localhost:4318` |
| – | `KERERU_CORS_ORIGINS` | `http://localhost:5173` |
| – | `KERERU_ENVIRONMENT` | `development` |
Both an IPv4 and an IPv6 listener may be configured at the same time. Set `--listen-addr ""` to disable the IPv4 listener.
Frontend build-time setting: `VITE_API_BASE_URL` (defaults to `/api`; set to `http://localhost:8080` when the frontend is hosted separately).
Sample files live in [`config/`](config).
## API
The contract is defined in [`backend/openapi/openapi.yaml`](backend/openapi/openapi.yaml) and the server stubs are generated with:
```bash
cd backend
oapi-codegen -config oapi-codegen.yaml openapi/openapi.yaml
```
Interactive API documentation is available at <http://localhost:5173/docs>. Swagger UI and the OpenAPI specification are bundled with the frontend, so the docs work without an internet connection. Forms support optional linked PDFs, digital signatures, external reference identifiers and offline PDF uploads.
Key routes:
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/health` | Health and version |
| `GET`/`POST` | `/forms` | List / create forms |
| `GET`/`PUT`/`DELETE` | `/forms/{formId}` | Read / update / delete a form version |
| `POST` | `/forms/{formId}/activate` | Activate a form version |
| `POST` | `/forms/{formId}/inactivate` | Inactivate a form version |
| `POST` | `/forms/{formId}/versions` | Clone into a new version |
| `GET` | `/forms/{formId}/pdf` | Printable PDF of the definition |
| `GET`/`POST` | `/forms/{formId}/submissions` | List / submit responses |
| `GET` | `/submissions/{submissionId}` | Submission details |
| `GET` | `/submissions/{submissionId}/offline-pdf` | Download an uploaded offline PDF |
| `GET`/`POST` | `/forms/{formId}/access-codes` | List / generate single-use codes |
The specification is served at `/openapi.yaml` in development and production builds.
## Tests
```bash
cd backend && go test ./...
cd frontend && npm test
```
## Docker / Podman / Kubernetes
See [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) and [`deploy/k8s.yaml`](deploy/k8s.yaml).
## Documentation
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — components, data model, request flow, extension points.
- [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) — development, Docker, Podman and Kubernetes instructions.
## Security note
The management endpoints are intentionally unauthenticated to match the current scope. Deploy behind an authenticating reverse proxy or add an auth layer before exposing management to untrusted networks. Access codes protect individual form submissions, not administration.

View File

@ -0,0 +1,121 @@
// Command kereru-forms serves the Kererū Forms HTTP API.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"kereru.forms/internal/api"
"kereru.forms/internal/config"
"kereru.forms/internal/database"
"kereru.forms/internal/logging"
"kereru.forms/internal/telemetry"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "1.0.0"
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "kereru-forms: %v\n", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load(os.Args[1:])
if err != nil {
return err
}
logger := logging.New(cfg.LogLevel, cfg.LogFormat, cfg.Environment)
logger.Info().
Str("version", version).
Str("environment", cfg.Environment).
Strs("listen", cfg.Addresses()).
Str("database_driver", cfg.DBDriver).
Msg("starting kereru-forms")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
tel, err := telemetry.Setup(ctx, telemetry.Options{
Enabled: cfg.TelemetryEnabled,
Endpoint: cfg.OTLPEndpoint,
ServiceName: "kereru-forms",
Environment: cfg.Environment,
Version: version,
})
if err != nil {
return fmt.Errorf("setup telemetry: %w", err)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := tel.Shutdown(shutdownCtx); err != nil {
logger.Warn().Err(err).Msg("telemetry shutdown")
}
}()
store, err := database.Open(cfg.DBDriver, cfg.DBDSN)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer func() {
if err := store.Close(); err != nil {
logger.Warn().Err(err).Msg("database close")
}
}()
server := api.NewServer(store, logger, version)
router := api.NewRouter(server, logger, api.RouterOptions{
CORSOrigins: cfg.CORSOrigins,
ServiceName: "kereru-forms",
StaticDir: cfg.StaticDir,
})
addrs := cfg.Addresses()
servers := make([]*http.Server, 0, len(addrs))
errCh := make(chan error, len(addrs))
for _, addr := range addrs {
srv := &http.Server{
Addr: addr,
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
servers = append(servers, srv)
go func(srv *http.Server) {
logger.Info().Str("addr", srv.Addr).Msg("http server listening")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("listen on %s: %w", srv.Addr, err)
}
}(srv)
}
select {
case <-ctx.Done():
logger.Info().Msg("shutdown signal received")
case err := <-errCh:
logger.Error().Err(err).Msg("http server failed")
stop()
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
for _, srv := range servers {
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Warn().Err(err).Str("addr", srv.Addr).Msg("http server shutdown")
}
}
logger.Info().Msg("kereru-forms stopped")
return nil
}

78
backend/go.mod 100644
View File

@ -0,0 +1,78 @@
module kereru.forms
go 1.26.0
require (
github.com/getkin/kin-openapi v0.152.0
github.com/gin-gonic/gin v1.12.0
github.com/go-pdf/fpdf v0.9.0
github.com/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.8.0
github.com/rs/zerolog v1.35.1
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.72.0
go.opentelemetry.io/otel v1.47.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0
go.opentelemetry.io/otel/sdk v1.47.0
go.opentelemetry.io/otel/sdk/metric v1.47.0
go.opentelemetry.io/otel/trace v1.47.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.61.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.4 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.2 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-playground/locales v0.14.2 // indirect
github.com/go-playground/universal-translator v0.18.2 // indirect
github.com/go-playground/validator/v10 v10.30.5 // indirect
github.com/goccy/go-json v0.11.2 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.63.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.9.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect
go.opentelemetry.io/otel/log v1.47.0 // indirect
go.opentelemetry.io/otel/metric v1.47.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
golang.org/x/arch v0.31.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.12 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)

228
backend/go.sum 100644
View File

@ -0,0 +1,228 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.4 h1:FgtV/4aBHpla9AxuMpuuzVUpa/Cf3izufkxNmnEzdI8=
github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/getkin/kin-openapi v0.152.0 h1:XRVo77lMRyOGMHf1paMu9lxX3wzuQDZeprssZJnz/x8=
github.com/getkin/kin-openapi v0.152.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/runtime v1.8.0 h1:RJzEgQSJQADEPaG1cYuSCKYai/cCVRE2xnkrqhJs/3c=
github.com/oapi-codegen/runtime v1.8.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A=
github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ=
go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.72.0 h1:Vgm7vrC26vvecffpDcy2UINqBvASUg5qkfZle3Yrkug=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.72.0/go.mod h1:zgCChxie/dA3qG7IgZGswax6voxkOR4+33msLT/RgNg=
go.opentelemetry.io/contrib/propagators/b3 v1.47.0 h1:vlKDmtjhe1R0d7kdov/+KVTKWOKeGLyohB+iXoDPRMQ=
go.opentelemetry.io/contrib/propagators/b3 v1.47.0/go.mod h1:0zRiXIfXU30PKYMsdbqYbGK8iHF8KqyPi0qdXT2wAuE=
go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 h1:7kszRN4Ze5GeAkUnBjBSUIunVBMA+mZIG04auueqguM=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0/go.mod h1:CLzakYy8jOdkBfcveCWYkeKJxujcF4xPhh2Z7IfE98U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA=
go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM=
go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k=
go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I=
go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0=
go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw=
go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw=
go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk=
golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -0,0 +1,641 @@
package api
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog"
"kereru.forms/internal/database"
)
func newTestRouter(t *testing.T) (*gin.Engine, database.Store) {
t.Helper()
path := filepath.Join(t.TempDir(), "api-test.db")
store, err := database.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
server := NewServer(store, zerolog.Nop(), "test-version")
router := NewRouter(server, zerolog.Nop(), RouterOptions{CORSOrigins: []string{"http://localhost:5173"}})
return router, store
}
func doRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
reader = bytes.NewReader(payload)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", "kereru-test-agent")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %q: %v", w.Body.String(), err)
}
return out
}
func boolPtr(b bool) *bool { return &b }
func f32Ptr(f float32) *float32 { return &f }
func strPtr(s string) *string { return &s }
func createSampleForm(t *testing.T, router *gin.Engine) Form {
t.Helper()
options := []QuestionOption{
{Value: "red", Label: "Red"},
{Value: "blue", Label: "Blue"},
}
body := CreateFormRequest{
Title: "Customer feedback",
Description: strPtr("A short survey"),
Introduction: strPtr("Thanks for helping us improve."),
RequiresAccessCode: boolPtr(true),
LogoUrl: strPtr("https://example.com/logo.png"),
Questions: []QuestionInput{
{Key: "name", Label: "Your name", Type: Text, Required: true},
{Key: "happy", Label: "Are you happy?", Type: Boolean, Required: true},
{Key: "score", Label: "Score", Type: Number, MinValue: f32Ptr(1), MaxValue: f32Ptr(10)},
{Key: "colour", Label: "Favourite colour", Type: Select, Options: &options},
{Key: "when", Label: "Date", Type: Date},
},
}
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusCreated {
t.Fatalf("create form: status %d body %s", w.Code, w.Body.String())
}
return decode[Form](t, w)
}
func TestHealth(t *testing.T) {
router, _ := newTestRouter(t)
for _, path := range []string{"/health", "/api/health"} {
w := doRequest(t, router, http.MethodGet, path, nil)
if w.Code != http.StatusOK {
t.Fatalf("health %s: status %d", path, w.Code)
}
health := decode[Health](t, w)
if health.Status != "ok" || health.Version != "test-version" {
t.Fatalf("unexpected health: %+v", health)
}
if health.UptimeSeconds == nil {
t.Fatal("uptimeSeconds should be present")
}
}
}
func TestFormLifecycle(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
if form.Version != 1 || !form.IsActive || len(form.Questions) != 5 {
t.Fatalf("unexpected form: %+v", form)
}
w := doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get form: %d %s", w.Code, w.Body.String())
}
w = doRequest(t, router, http.MethodGet, "/forms", nil)
if w.Code != http.StatusOK {
t.Fatalf("list forms: %d", w.Code)
}
forms := decode[[]FormSummary](t, w)
if len(forms) != 1 || forms[0].QuestionCount != 5 {
t.Fatalf("unexpected forms: %+v", forms)
}
if forms[0].LogoUrl == nil || *forms[0].LogoUrl != "https://example.com/logo.png" {
t.Fatalf("logo url missing from summary: %+v", forms[0])
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/access-codes",
CreateAccessCodesRequest{Count: 2, Prefix: strPtr("demo")})
if w.Code != http.StatusCreated {
t.Fatalf("create codes: %d %s", w.Code, w.Body.String())
}
codes := decode[[]AccessCode](t, w)
if len(codes) != 2 || !strings.HasPrefix(codes[0].Code, "DEMO-") {
t.Fatalf("unexpected codes: %+v", codes)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/inactivate", nil)
if w.Code != http.StatusOK {
t.Fatalf("inactivate: %d %s", w.Code, w.Body.String())
}
if inactive := decode[Form](t, w); inactive.IsActive {
t.Fatal("form should be inactive")
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/activate", nil)
if w.Code != http.StatusOK {
t.Fatalf("activate: %d", w.Code)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/versions", nil)
if w.Code != http.StatusCreated {
t.Fatalf("create version: %d %s", w.Code, w.Body.String())
}
v2 := decode[Form](t, w)
if v2.Version != 2 || v2.FormGroupId != form.FormGroupId {
t.Fatalf("unexpected version: %+v", v2)
}
if v2.LogoUrl == nil || *v2.LogoUrl != "https://example.com/logo.png" {
t.Fatalf("new version should keep the logo: %+v", v2)
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil)
old := decode[Form](t, w)
if old.IsActive {
t.Fatal("previous version should be inactive")
}
}
func TestSubmitFlow(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
byKey := map[string]Question{}
for _, q := range form.Questions {
byKey[q.Key] = q
}
answers := []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["score"].Id, Value: 8},
{QuestionId: byKey["colour"].Id, Value: "blue"},
{QuestionId: byKey["when"].Id, Value: "2026-10-23"},
}
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/access-codes",
CreateAccessCodesRequest{Count: 1})
if w.Code != http.StatusCreated {
t.Fatalf("create code: %d", w.Code)
}
code := decode[[]AccessCode](t, w)[0].Code
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: answers,
Tracking: &ClientTracking{Locale: strPtr("en-NZ")},
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing access code should be 400, got %d %s", w.Code, w.Body.String())
}
submit.AccessCode = &code
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusCreated {
t.Fatalf("submit: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if submission.FormVersion != 1 || len(submission.Answers) != 5 {
t.Fatalf("unexpected submission: %+v", submission)
}
var storedDate string
for _, answer := range submission.Answers {
if answer.QuestionKey == "when" {
storedDate, _ = answer.Value.(string)
}
}
if storedDate != "2026-10-23" {
t.Fatalf("date answer not stored correctly: %+v", submission.Answers)
}
if submission.Tracking == nil || submission.Tracking.UserAgent == nil ||
*submission.Tracking.UserAgent != "kereru-test-agent" {
t.Fatalf("server-side tracking missing: %+v", submission.Tracking)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusConflict {
t.Fatalf("reused code should be 409, got %d %s", w.Code, w.Body.String())
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/submissions", nil)
if w.Code != http.StatusOK {
t.Fatalf("list submissions: %d", w.Code)
}
subs := decode[[]SubmissionSummary](t, w)
if len(subs) != 1 || subs[0].Id != submission.Id {
t.Fatalf("unexpected submissions: %+v", subs)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get submission: %d", w.Code)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/inactivate", nil)
if w.Code != http.StatusOK {
t.Fatalf("inactivate: %d", w.Code)
}
second := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: answers,
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", second)
if w.Code != http.StatusGone {
t.Fatalf("inactive form should be 410, got %d %s", w.Code, w.Body.String())
}
}
func TestSubmitValidation(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
byKey := map[string]Question{}
for _, q := range form.Questions {
byKey[q.Key] = q
}
cases := []struct {
name string
answers []Answer
}{
{
name: "missing required",
answers: []Answer{{QuestionId: byKey["score"].Id, Value: 5}},
},
{
name: "number out of range",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["score"].Id, Value: 42},
},
},
{
name: "bad select option",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["colour"].Id, Value: "green"},
},
},
{
name: "bad date",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["when"].Id, Value: "23/10/2026"},
},
},
{
name: "wrong type",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: "yes"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions",
SubmitFormRequest{UserId: "ada@example.com", UserIdType: Email, Answers: tc.answers})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d %s", w.Code, w.Body.String())
}
})
}
}
func TestCreateFormValidation(t *testing.T) {
router, _ := newTestRouter(t)
options := []QuestionOption{{Value: "a", Label: "A"}}
cases := []CreateFormRequest{
{Title: "", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "1bad", Label: "A", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "dup", Label: "A", Type: Text}, {Key: "dup", Label: "B", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: "datetime"}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Select}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Number, MinValue: f32Ptr(10), MaxValue: f32Ptr(1)}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Select, Options: &options}, {Key: "a", Label: "B", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}, LogoUrl: strPtr("javascript:alert(1)")},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}, LogoUrl: strPtr("not a url")},
}
for i, body := range cases {
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusBadRequest {
t.Fatalf("case %d: expected 400, got %d %s", i, w.Code, w.Body.String())
}
}
}
func TestPDFAndCORS(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
w := doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/pdf", nil)
if w.Code != http.StatusOK {
t.Fatalf("pdf: %d %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "application/pdf" {
t.Fatalf("content type = %q", ct)
}
if !bytes.HasPrefix(w.Body.Bytes(), []byte("%PDF")) {
t.Fatal("response is not a PDF")
}
req := httptest.NewRequest(http.MethodOptions, "/forms", nil)
req.Header.Set("Origin", "http://localhost:5173")
req.Header.Set("Access-Control-Request-Method", "POST")
corsW := httptest.NewRecorder()
router.ServeHTTP(corsW, req)
if corsW.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d", corsW.Code)
}
if got := corsW.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
t.Fatalf("allow origin = %q", got)
}
}
func TestNotFound(t *testing.T) {
router, _ := newTestRouter(t)
missing := uuid.NewString()
for _, tc := range []struct {
method string
path string
}{
{http.MethodGet, "/forms/" + missing},
{http.MethodDelete, "/forms/" + missing},
{http.MethodPost, "/forms/" + missing + "/inactivate"},
{http.MethodGet, "/forms/" + missing + "/submissions"},
{http.MethodGet, "/submissions/" + missing},
{http.MethodGet, "/submissions/" + missing + "/offline-pdf"},
} {
w := doRequest(t, router, tc.method, tc.path, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("%s %s: expected 404, got %d", tc.method, tc.path, w.Code)
}
}
}
func createSignatureForm(t *testing.T, router *gin.Engine) Form {
t.Helper()
body := CreateFormRequest{
Title: "Signed declaration",
SignatureRequired: boolPtr(true),
Questions: []QuestionInput{
{Key: "name", Label: "Your name", Type: Text, Required: true},
},
}
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusCreated {
t.Fatalf("create signature form: %d %s", w.Code, w.Body.String())
}
return decode[Form](t, w)
}
func pngDataURL() string {
data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data)
}
func pdfDataURL(data []byte) string {
return "data:application/pdf;base64," + base64.StdEncoding.EncodeToString(data)
}
func TestUpdateFormCreatesVersion(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
update := UpdateFormRequest{
Title: "Customer feedback v2",
PdfUrl: strPtr("https://example.com/prebuilt.pdf"),
ExternalReferenceRequired: boolPtr(true),
Questions: []QuestionInput{
{Key: "name", Label: "Your name", Type: Text, Required: true},
},
}
w := doRequest(t, router, http.MethodPut, "/forms/"+form.Id.String(), update)
if w.Code != http.StatusOK {
t.Fatalf("update form: %d %s", w.Code, w.Body.String())
}
updated := decode[Form](t, w)
if updated.Id == form.Id {
t.Fatal("editing should create a new form id")
}
if updated.Version != 2 || updated.FormGroupId != form.FormGroupId {
t.Fatalf("unexpected versioned form: %+v", updated)
}
if updated.PdfUrl == nil || *updated.PdfUrl != "https://example.com/prebuilt.pdf" {
t.Fatalf("pdf url not saved: %+v", updated.PdfUrl)
}
if updated.SignatureRequired == nil || *updated.SignatureRequired {
t.Fatalf("signature flag should preserve false: %+v", updated.SignatureRequired)
}
if updated.ExternalReferenceRequired == nil || !*updated.ExternalReferenceRequired ||
updated.ExternalReferenceLabel == nil || *updated.ExternalReferenceLabel != "External reference" {
t.Fatalf("external reference was not enabled with a default label: %+v", updated)
}
if len(updated.Questions) != 1 || updated.Questions[0].Id.String() == "" {
t.Fatalf("expected a fresh question: %+v", updated.Questions)
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get old form: %d", w.Code)
}
old := decode[Form](t, w)
if old.IsActive {
t.Fatal("previous version should be inactive")
}
w = doRequest(t, router, http.MethodGet, "/forms?includeInactive=true", nil)
if w.Code != http.StatusOK {
t.Fatalf("list forms: %d", w.Code)
}
forms := decode[[]FormSummary](t, w)
if len(forms) != 2 {
t.Fatalf("expected two versions, got %+v", forms)
}
}
func TestExternalReference(t *testing.T) {
router, _ := newTestRouter(t)
body := CreateFormRequest{
Title: "Invoice approval",
ExternalReferenceRequired: boolPtr(true),
ExternalReferenceLabel: strPtr("Invoice number"),
Questions: []QuestionInput{
{Key: "note", Label: "Note", Type: Text, Required: true},
},
}
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusCreated {
t.Fatalf("create form: %d %s", w.Code, w.Body.String())
}
form := decode[Form](t, w)
if form.ExternalReferenceRequired == nil || !*form.ExternalReferenceRequired ||
form.ExternalReferenceLabel == nil || *form.ExternalReferenceLabel != "Invoice number" {
t.Fatalf("unexpected external reference config: %+v", form)
}
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: []Answer{{
QuestionId: form.Questions[0].Id,
Value: "Approved",
}},
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing external reference should be 400, got %d %s", w.Code, w.Body.String())
}
submit.ExternalReference = strPtr("INV-42")
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusCreated {
t.Fatalf("submit with reference: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if submission.ExternalReference == nil || *submission.ExternalReference != "INV-42" {
t.Fatalf("external reference not returned: %+v", submission.ExternalReference)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get submission: %d %s", w.Code, w.Body.String())
}
stored := decode[Submission](t, w)
if stored.ExternalReference == nil || *stored.ExternalReference != "INV-42" {
t.Fatalf("external reference not persisted: %+v", stored.ExternalReference)
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/submissions", nil)
if w.Code != http.StatusOK {
t.Fatalf("list submissions: %d %s", w.Code, w.Body.String())
}
summaries := decode[[]SubmissionSummary](t, w)
if len(summaries) != 1 || summaries[0].ExternalReference == nil ||
*summaries[0].ExternalReference != "INV-42" {
t.Fatalf("reference missing from summary: %+v", summaries)
}
}
func TestSignatureSubmission(t *testing.T) {
router, _ := newTestRouter(t)
form := createSignatureForm(t, router)
questionID := form.Questions[0].Id
base := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: []Answer{{QuestionId: questionID, Value: "Ada"}},
}
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing signature should be 400, got %d %s", w.Code, w.Body.String())
}
base.Signature = &SignatureInput{
Type: Drawn,
DataUrl: pngDataURL(),
Name: "Ada Lovelace",
Title: strPtr("Analyst"),
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base)
if w.Code != http.StatusCreated {
t.Fatalf("signed submission: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if submission.Signature == nil || submission.Signature.Name != "Ada Lovelace" ||
submission.Signature.Title == nil || *submission.Signature.Title != "Analyst" ||
submission.Signature.SignedAt.IsZero() {
t.Fatalf("signature not returned: %+v", submission.Signature)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get submission: %d %s", w.Code, w.Body.String())
}
stored := decode[Submission](t, w)
if stored.Signature == nil || stored.Signature.DataUrl != pngDataURL() {
t.Fatalf("signature not persisted: %+v", stored.Signature)
}
base.Signature = &SignatureInput{Type: Drawn, DataUrl: pngDataURL(), Name: ""}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base)
if w.Code != http.StatusBadRequest {
t.Fatalf("empty signature name should be 400, got %d %s", w.Code, w.Body.String())
}
}
func TestOfflinePDFSubmission(t *testing.T) {
router, _ := newTestRouter(t)
form := createSignatureForm(t, router)
pdf := []byte("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n")
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
OfflinePdf: strPtr(pdfDataURL(pdf)),
OfflinePdfName: strPtr("signed-declaration.pdf"),
}
invalid := submit
invalid.OfflinePdf = strPtr(pdfDataURL([]byte("not a pdf")))
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", invalid)
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid pdf should be 400, got %d %s", w.Code, w.Body.String())
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusCreated {
t.Fatalf("offline submission: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if len(submission.Answers) != 0 || submission.OfflinePdfName == nil ||
*submission.OfflinePdfName != "signed-declaration.pdf" || submission.Signature != nil {
t.Fatalf("unexpected offline submission: %+v", submission)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String()+"/offline-pdf", nil)
if w.Code != http.StatusOK {
t.Fatalf("download offline pdf: %d %s", w.Code, w.Body.String())
}
if got := w.Header().Get("Content-Type"); got != "application/pdf" {
t.Fatalf("content type = %q", got)
}
if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "signed-declaration.pdf") {
t.Fatalf("content disposition = %q", got)
}
if !bytes.Equal(w.Body.Bytes(), pdf) {
t.Fatalf("downloaded pdf differs from upload")
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,202 @@
package api
import (
"encoding/json"
"github.com/google/uuid"
"kereru.forms/internal/database"
)
func toAPIForm(f *database.Form) Form {
questions := make([]Question, 0, len(f.Questions))
for _, q := range f.Questions {
questions = append(questions, toAPIQuestion(q))
}
return Form{
Id: uuid.MustParse(f.ID),
FormGroupId: uuid.MustParse(f.FormGroupID),
Version: f.Version,
Title: f.Title,
Description: f.Description,
Introduction: f.Introduction,
IsActive: f.IsActive,
RequiresAccessCode: f.RequiresAccessCode,
SignatureRequired: boolPtrIfSet(f.SignatureRequired, true),
ExternalReferenceRequired: boolPtrIfSet(f.ExternalReferenceRequired, true),
ExternalReferenceLabel: f.ExternalReferenceLabel,
LogoUrl: f.LogoURL,
PdfUrl: f.PDFURL,
CompanyName: f.CompanyName,
ContactEmail: f.ContactEmail,
ContactPhone: f.ContactPhone,
ContactUrl: f.ContactURL,
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
Questions: questions,
}
}
func toAPIQuestion(q database.Question) Question {
out := Question{
Id: uuid.MustParse(q.ID),
Key: q.Key,
Label: q.Label,
Description: q.Description,
Type: QuestionType(q.Type),
Required: q.Required,
Placeholder: q.Placeholder,
Multiline: boolPtrIfSet(q.Multiline, QuestionType(q.Type) == Text),
}
if q.MinValue != nil {
v := float32(*q.MinValue)
out.MinValue = &v
}
if q.MaxValue != nil {
v := float32(*q.MaxValue)
out.MaxValue = &v
}
if len(q.Options) > 0 {
opts := make([]QuestionOption, 0, len(q.Options))
for _, o := range q.Options {
opts = append(opts, QuestionOption{Value: o.Value, Label: o.Label})
}
out.Options = &opts
}
return out
}
func boolPtrIfSet(v bool, include bool) *bool {
if !include && !v {
return nil
}
b := v
return &b
}
func toAPIFormSummary(f database.FormSummary) FormSummary {
return FormSummary{
Id: uuid.MustParse(f.ID),
FormGroupId: uuid.MustParse(f.FormGroupID),
Version: f.Version,
Title: f.Title,
Description: f.Description,
LogoUrl: f.LogoURL,
PdfUrl: f.PDFURL,
ExternalReferenceRequired: boolPtrIfSet(f.ExternalReferenceRequired, true),
ExternalReferenceLabel: f.ExternalReferenceLabel,
IsActive: f.IsActive,
QuestionCount: f.QuestionCount,
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
}
}
func toAPIAnswerRecord(a database.Answer) AnswerRecord {
var value interface{}
_ = json.Unmarshal(a.Value, &value)
return AnswerRecord{
QuestionId: uuid.MustParse(a.QuestionID),
QuestionKey: a.QuestionKey,
Value: value,
}
}
func toAPISubmission(s *database.Submission) Submission {
answers := make([]AnswerRecord, 0, len(s.Answers))
for _, a := range s.Answers {
answers = append(answers, toAPIAnswerRecord(a))
}
out := Submission{
Id: uuid.MustParse(s.ID),
FormId: uuid.MustParse(s.FormID),
FormVersion: s.FormVersion,
UserId: s.UserID,
UserIdType: UserIdType(s.UserIDType),
SubmittedAt: s.SubmittedAt,
Answers: answers,
ExternalReference: s.ExternalReference,
OfflinePdfName: s.OfflinePDFName,
}
if s.Signature != nil {
out.Signature = &Signature{
Type: SignatureType(s.Signature.Type),
DataUrl: s.Signature.DataURL,
Name: s.Signature.Name,
Title: s.Signature.Title,
SignedAt: s.Signature.SignedAt,
}
}
if hasTracking(s) {
out.Tracking = &Tracking{
IpAddress: s.IPAddress,
UserAgent: s.UserAgent,
Locale: s.Locale,
Timezone: s.Timezone,
Place: s.Place,
Referer: s.Referer,
}
}
return out
}
func hasTracking(s *database.Submission) bool {
return s.IPAddress != nil || s.UserAgent != nil || s.Locale != nil ||
s.Timezone != nil || s.Place != nil || s.Referer != nil
}
func toAPISubmissionSummary(s database.SubmissionSummary) SubmissionSummary {
return SubmissionSummary{
Id: uuid.MustParse(s.ID),
FormId: uuid.MustParse(s.FormID),
FormVersion: s.FormVersion,
UserId: s.UserID,
UserIdType: UserIdType(s.UserIDType),
ExternalReference: s.ExternalReference,
SubmittedAt: s.SubmittedAt,
}
}
func toAPIAccessCode(c database.AccessCode) AccessCode {
return AccessCode{
Id: uuid.MustParse(c.ID),
FormId: uuid.MustParse(c.FormID),
Code: c.Code,
Used: c.Used,
UsedAt: c.UsedAt,
CreatedAt: c.CreatedAt,
}
}
func questionInputsToRecords(inputs []QuestionInput) []database.Question {
out := make([]database.Question, 0, len(inputs))
for i, q := range inputs {
rec := database.Question{
Position: i,
Key: q.Key,
Label: q.Label,
Description: q.Description,
Type: string(q.Type),
Required: q.Required,
Placeholder: q.Placeholder,
}
if q.MinValue != nil {
v := float64(*q.MinValue)
rec.MinValue = &v
}
if q.MaxValue != nil {
v := float64(*q.MaxValue)
rec.MaxValue = &v
}
if q.Multiline != nil {
rec.Multiline = *q.Multiline
}
if q.Options != nil {
for _, o := range *q.Options {
rec.Options = append(rec.Options, database.QuestionOption{Value: o.Value, Label: o.Label})
}
}
out = append(out, rec)
}
return out
}

View File

@ -0,0 +1,162 @@
package api
import (
"context"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"kereru.forms/internal/logging"
)
// RouterOptions configures cross-cutting HTTP behaviour.
type RouterOptions struct {
CORSOrigins []string
ServiceName string
// StaticDir, when set, serves a built frontend with SPA fallback.
StaticDir string
}
// NewRouter builds the Gin engine with all middleware and API routes.
func NewRouter(server *Server, logger zerolog.Logger, opts RouterOptions) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
serviceName := opts.ServiceName
if serviceName == "" {
serviceName = "kereru-forms"
}
r := gin.New()
r.RedirectTrailingSlash = false
// Ensure *gin.Context behaves as the request context for derived values.
r.ContextWithFallback = true
r.Use(otelgin.Middleware(serviceName))
r.Use(gin.Recovery())
r.Use(requestIDMiddleware())
r.Use(withRequestMetadata())
r.Use(logging.GinMiddleware(logger))
r.Use(corsMiddleware(opts.CORSOrigins))
strict := NewStrictHandler(server, nil)
// The API is exposed at the root (for probes and direct clients) and under
// /api so the SPA can use a stable same-origin prefix in every deployment.
RegisterHandlers(r, strict)
RegisterHandlers(r.Group("/api"), strict)
if opts.StaticDir != "" {
serveStatic(r, opts.StaticDir, logger)
}
return r
}
var apiPrefixes = []string{"/health", "/forms", "/submissions", "/api"}
func isAPIPath(path string) bool {
for _, prefix := range apiPrefixes {
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return true
}
}
return false
}
// serveStatic serves files from dir and falls back to index.html for client
// side routes so the SPA can be hosted by the backend binary.
func serveStatic(r *gin.Engine, dir string, logger zerolog.Logger) {
index := filepath.Join(dir, "index.html")
r.NoRoute(func(c *gin.Context) {
if isAPIPath(c.Request.URL.Path) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found", "code": "not_found"})
return
}
clean := filepath.Clean(strings.TrimPrefix(c.Request.URL.Path, "/"))
candidate := filepath.Join(dir, clean)
if clean != "." && !strings.HasPrefix(candidate, filepath.Clean(dir)) {
c.Status(http.StatusNotFound)
return
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
c.File(candidate)
return
}
if _, err := os.Stat(index); err != nil {
logger.Warn().Err(err).Str("dir", dir).Msg("static frontend not found")
c.Status(http.StatusNotFound)
return
}
c.File(index)
})
}
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
id := strings.TrimSpace(c.GetHeader("X-Request-ID"))
if id == "" {
id = uuid.NewString()
}
c.Header("X-Request-ID", id)
logging.SetRequestID(c, id)
c.Next()
}
}
func withRequestMetadata() gin.HandlerFunc {
return func(c *gin.Context) {
meta := requestMetadata{}
if ip := strings.TrimSpace(c.ClientIP()); ip != "" {
meta.ip = &ip
}
if ua := strings.TrimSpace(c.Request.UserAgent()); ua != "" {
meta.userAgent = &ua
}
c.Set(metaKey, meta)
ctx := context.WithValue(c.Request.Context(), metaKey, meta)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func corsMiddleware(origins []string) gin.HandlerFunc {
allowAll := false
allowed := make(map[string]struct{}, len(origins))
for _, o := range origins {
o = strings.TrimSpace(o)
if o == "*" {
allowAll = true
continue
}
if o != "" {
allowed[o] = struct{}{}
}
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
if allowAll {
c.Header("Access-Control-Allow-Origin", "*")
} else if _, ok := allowed[origin]; ok {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
c.Header("Access-Control-Max-Age", "600")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,279 @@
// Package config loads application configuration from defaults, a YAML/JSON
// document, environment variables (optionally via a .env file) and CLI flags.
//
// Precedence, highest first:
// 1. CLI flags (--conf, --log-level, --listen-addr, --listen-addr6, --port)
// 2. Environment variables
// 3. .env file located next to the executable or in the working directory
// 4. Configuration file supplied with --conf
// 5. Built-in defaults
package config
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Config holds every runtime setting supported by kereru-forms.
type Config struct {
// LogLevel is the minimum log level (trace, debug, info, warn, error).
LogLevel string `yaml:"logLevel" json:"logLevel"`
// LogFormat is "json" or "console".
LogFormat string `yaml:"logFormat" json:"logFormat"`
// Environment is a free-form deployment label (development, staging, production).
Environment string `yaml:"environment" json:"environment"`
// HTTPAddr is the IPv4 address to listen on. Empty disables that listener.
HTTPAddr string `yaml:"listenAddr" json:"listenAddr"`
// HTTPAddr6 is the IPv6 address to listen on. Empty disables that listener.
HTTPAddr6 string `yaml:"listenAddr6" json:"listenAddr6"`
// Port is the TCP port shared by both listeners.
Port string `yaml:"port" json:"port"`
// DBDriver selects the storage backend. Currently "sqlite" is implemented.
DBDriver string `yaml:"dbDriver" json:"dbDriver"`
// DBDSN is the driver-specific connection string.
DBDSN string `yaml:"dbDsn" json:"dbDsn"`
// TelemetryEnabled turns on OpenTelemetry tracing/metrics export.
TelemetryEnabled bool `yaml:"telemetryEnabled" json:"telemetryEnabled"`
// OTLPEndpoint is the OTLP/HTTP collector endpoint (host:port or URL).
OTLPEndpoint string `yaml:"otlpEndpoint" json:"otlpEndpoint"`
// CORSOrigins is the list of allowed browser origins ("*" allows all).
CORSOrigins []string `yaml:"corsOrigins" json:"corsOrigins"`
// StaticDir optionally serves a built frontend from this directory.
StaticDir string `yaml:"staticDir" json:"staticDir"`
}
// Default returns the built-in configuration.
func Default() *Config {
return &Config{
LogLevel: "info",
LogFormat: "console",
Environment: "development",
HTTPAddr: "0.0.0.0",
HTTPAddr6: "",
Port: "8080",
DBDriver: "sqlite",
DBDSN: "file:kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
TelemetryEnabled: false,
OTLPEndpoint: "localhost:4318",
CORSOrigins: []string{"http://localhost:5173"},
StaticDir: "",
}
}
// Load builds the configuration from args (usually os.Args[1:]).
func Load(args []string) (*Config, error) {
cfg := Default()
dotEnvPath := LoadDotEnv()
_ = dotEnvPath
confPath := scanConf(args)
if confPath != "" {
if err := loadFile(cfg, confPath); err != nil {
return nil, err
}
}
applyEnv(cfg)
fs := flag.NewFlagSet("kereru-forms", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var (
flagConf = fs.String("conf", confPath, "path to a YAML or JSON configuration file")
flagLevel = fs.String("log-level", cfg.LogLevel, "log level: trace, debug, info, warn, error")
flagFormat = fs.String("log-format", cfg.LogFormat, "log format: console or json")
flagAddr = fs.String("listen-addr", cfg.HTTPAddr, "IPv4 listen address (empty to disable)")
flagAddr6 = fs.String("listen-addr6", cfg.HTTPAddr6, "IPv6 listen address (empty to disable)")
flagPort = fs.String("port", cfg.Port, "TCP listen port")
flagDB = fs.String("db-dsn", cfg.DBDSN, "database connection string")
flagDriver = fs.String("db-driver", cfg.DBDriver, "database driver (sqlite)")
flagStatic = fs.String("static-dir", cfg.StaticDir, "directory containing a built frontend (optional)")
)
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: kereru-forms [flags]\n\nFlags:\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return nil, err
}
_ = flagConf
cfg.LogLevel = *flagLevel
cfg.LogFormat = *flagFormat
cfg.HTTPAddr = *flagAddr
cfg.HTTPAddr6 = *flagAddr6
cfg.Port = *flagPort
cfg.DBDSN = *flagDB
cfg.DBDriver = *flagDriver
cfg.StaticDir = *flagStatic
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// Validate checks that the configuration is usable.
func (c *Config) Validate() error {
if strings.TrimSpace(c.Port) == "" {
return errors.New("port must not be empty")
}
if _, err := strconv.Atoi(c.Port); err != nil {
return fmt.Errorf("port %q is not a number", c.Port)
}
if c.HTTPAddr == "" && c.HTTPAddr6 == "" {
return errors.New("at least one of listenAddr or listenAddr6 must be set")
}
if strings.TrimSpace(c.DBDriver) == "" {
return errors.New("dbDriver must not be empty")
}
if strings.TrimSpace(c.DBDSN) == "" {
return errors.New("dbDsn must not be empty")
}
return nil
}
// Addresses returns the listen addresses (host:port) for enabled listeners.
func (c *Config) Addresses() []string {
var addrs []string
if c.HTTPAddr != "" {
addrs = append(addrs, joinHostPort(c.HTTPAddr, c.Port))
}
if c.HTTPAddr6 != "" {
addrs = append(addrs, joinHostPort(c.HTTPAddr6, c.Port))
}
return addrs
}
func joinHostPort(host, port string) string {
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
return "[" + host + "]:" + port
}
return host + ":" + port
}
func scanConf(args []string) string {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--conf" || arg == "-conf" {
if i+1 < len(args) {
return args[i+1]
}
}
if strings.HasPrefix(arg, "--conf=") {
return strings.TrimPrefix(arg, "--conf=")
}
if strings.HasPrefix(arg, "-conf=") {
return strings.TrimPrefix(arg, "-conf=")
}
}
return ""
}
func loadFile(cfg *Config, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
if strings.EqualFold(filepath.Ext(path), ".json") {
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse JSON config %s: %w", path, err)
}
return nil
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse YAML config %s: %w", path, err)
}
return nil
}
func applyEnv(cfg *Config) {
setString(&cfg.LogLevel, "KERERU_LOG_LEVEL")
setString(&cfg.LogFormat, "KERERU_LOG_FORMAT")
setString(&cfg.Environment, "KERERU_ENVIRONMENT")
setString(&cfg.HTTPAddr, "KERERU_LISTEN_ADDR")
setString(&cfg.HTTPAddr6, "KERERU_LISTEN_ADDR6")
setString(&cfg.Port, "KERERU_PORT")
setString(&cfg.DBDriver, "KERERU_DB_DRIVER")
setString(&cfg.DBDSN, "KERERU_DB_DSN")
setString(&cfg.StaticDir, "KERERU_STATIC_DIR")
if v, ok := os.LookupEnv("KERERU_TELEMETRY_ENABLED"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.TelemetryEnabled = b
}
}
setString(&cfg.OTLPEndpoint, "OTEL_EXPORTER_OTLP_ENDPOINT")
if v, ok := os.LookupEnv("KERERU_CORS_ORIGINS"); ok {
var origins []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
origins = append(origins, p)
}
}
if len(origins) > 0 {
cfg.CORSOrigins = origins
}
}
}
func setString(dst *string, key string) {
if v, ok := os.LookupEnv(key); ok {
*dst = v
}
}
// LoadDotEnv reads a .env file from the directory containing the executable
// or, failing that, the working directory. Existing environment variables are never
// overwritten. It returns the path that was loaded, if any.
func LoadDotEnv() string {
candidateDirs := []string{}
if exe, err := os.Executable(); err == nil {
candidateDirs = append(candidateDirs, filepath.Dir(exe))
}
if wd, err := os.Getwd(); err == nil {
candidateDirs = append(candidateDirs, wd)
}
for _, dir := range candidateDirs {
path := filepath.Join(dir, ".env")
data, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.Trim(strings.TrimSpace(value), `"'`)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
return path
}
return ""
}

View File

@ -0,0 +1,96 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestDefaults(t *testing.T) {
cfg, err := Load(nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Port != "8080" {
t.Fatalf("Port = %q, want 8080", cfg.Port)
}
if cfg.DBDriver != "sqlite" {
t.Fatalf("DBDriver = %q, want sqlite", cfg.DBDriver)
}
if got := cfg.Addresses(); len(got) != 1 || got[0] != "0.0.0.0:8080" {
t.Fatalf("Addresses = %v, want [0.0.0.0:8080]", got)
}
}
func TestPrecedence(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := `
logLevel: debug
port: "9090"
listenAddr: "127.0.0.1"
dbDsn: "file:from-file.db"
corsOrigins:
- "https://forms.example.com"
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("KERERU_LOG_LEVEL", "warn")
t.Setenv("KERERU_PORT", "9091")
cfg, err := Load([]string{"--conf", path, "--port", "9092"})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "warn" {
t.Fatalf("LogLevel = %q, want env override warn", cfg.LogLevel)
}
if cfg.Port != "9092" {
t.Fatalf("Port = %q, want flag override 9092", cfg.Port)
}
if cfg.HTTPAddr != "127.0.0.1" {
t.Fatalf("HTTPAddr = %q, want file value", cfg.HTTPAddr)
}
if cfg.DBDSN != "file:from-file.db" {
t.Fatalf("DBDSN = %q, want file value", cfg.DBDSN)
}
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "https://forms.example.com" {
t.Fatalf("CORSOrigins = %v", cfg.CORSOrigins)
}
}
func TestJSONConfigAndIPv6(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
content := `{"listenAddr":"","listenAddr6":"::1","port":"8081"}`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load([]string{"--conf=" + path})
if err != nil {
t.Fatalf("Load: %v", err)
}
addrs := cfg.Addresses()
if len(addrs) != 1 || addrs[0] != "[::1]:8081" {
t.Fatalf("Addresses = %v, want [[::1]:8081]", addrs)
}
}
func TestValidateRequiresListener(t *testing.T) {
cfg := Default()
cfg.HTTPAddr = ""
cfg.HTTPAddr6 = ""
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error when no listener is configured")
}
}
func TestInvalidPort(t *testing.T) {
if _, err := Load([]string{"--port", "not-a-port"}); err == nil {
t.Fatal("expected validation error for invalid port")
}
}

View File

@ -0,0 +1,161 @@
package database
import (
"context"
"fmt"
"github.com/google/uuid"
)
func newID() string { return uuid.NewString() }
type migration struct {
version int
name string
statements string
}
var migrations = []migration{
{
version: 1,
name: "initial schema",
statements: `
CREATE TABLE IF NOT EXISTS forms (
id TEXT PRIMARY KEY,
form_group_id TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
title TEXT NOT NULL,
description TEXT,
introduction TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
requires_access_code INTEGER NOT NULL DEFAULT 0,
logo_url TEXT,
company_name TEXT,
contact_email TEXT,
contact_phone TEXT,
contact_url TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (form_group_id, version)
);
CREATE INDEX IF NOT EXISTS idx_forms_group ON forms (form_group_id);
CREATE INDEX IF NOT EXISTS idx_forms_active ON forms (is_active);
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
form_id TEXT NOT NULL REFERENCES forms (id) ON DELETE CASCADE,
position INTEGER NOT NULL,
question_key TEXT NOT NULL,
label TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 0,
options_json TEXT,
min_value REAL,
max_value REAL,
placeholder TEXT,
multiline INTEGER NOT NULL DEFAULT 0,
UNIQUE (form_id, question_key)
);
CREATE INDEX IF NOT EXISTS idx_questions_form ON questions (form_id);
CREATE TABLE IF NOT EXISTS submissions (
id TEXT PRIMARY KEY,
form_id TEXT NOT NULL REFERENCES forms (id) ON DELETE CASCADE,
form_version INTEGER NOT NULL,
user_id TEXT NOT NULL,
user_id_type TEXT NOT NULL,
submitted_at TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
locale TEXT,
timezone TEXT,
place TEXT,
referer TEXT
);
CREATE INDEX IF NOT EXISTS idx_submissions_form ON submissions (form_id);
CREATE INDEX IF NOT EXISTS idx_submissions_user ON submissions (user_id);
CREATE TABLE IF NOT EXISTS answers (
id TEXT PRIMARY KEY,
submission_id TEXT NOT NULL REFERENCES submissions (id) ON DELETE CASCADE,
question_id TEXT NOT NULL,
question_key TEXT NOT NULL,
value_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_answers_submission ON answers (submission_id);
CREATE TABLE IF NOT EXISTS access_codes (
id TEXT PRIMARY KEY,
form_id TEXT NOT NULL REFERENCES forms (id) ON DELETE CASCADE,
code TEXT NOT NULL UNIQUE,
used INTEGER NOT NULL DEFAULT 0,
used_at TEXT,
submission_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_codes_form ON access_codes (form_id);
`,
},
{
version: 2,
name: "signatures and offline pdf",
statements: `
ALTER TABLE forms ADD COLUMN pdf_url TEXT;
ALTER TABLE forms ADD COLUMN signature_required INTEGER NOT NULL DEFAULT 0;
ALTER TABLE submissions ADD COLUMN signature_json TEXT;
ALTER TABLE submissions ADD COLUMN offline_pdf_name TEXT;
ALTER TABLE submissions ADD COLUMN offline_pdf BLOB;
`,
},
{
version: 3,
name: "external reference",
statements: `
ALTER TABLE forms ADD COLUMN external_reference_required INTEGER NOT NULL DEFAULT 0;
ALTER TABLE forms ADD COLUMN external_reference_label TEXT;
ALTER TABLE submissions ADD COLUMN external_reference TEXT;
`,
},
}
func (s *sqliteStore) migrate(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
return err
}
for _, m := range migrations {
var exists int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, m.version).Scan(&exists)
if err != nil {
return err
}
if exists > 0 {
continue
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, m.statements); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %d (%s): %w", m.version, m.name, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version, name) VALUES (?, ?)`,
m.version, m.name); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,642 @@
package database
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
// Open creates a Store for the requested driver. SQLite is fully supported;
// the driver switch is the extension point for PostgreSQL and MariaDB.
func Open(driver, dsn string) (Store, error) {
switch strings.ToLower(strings.TrimSpace(driver)) {
case "", "sqlite", "sqlite3":
return openSQLite(dsn)
default:
return nil, fmt.Errorf("unsupported database driver %q (supported: sqlite)", driver)
}
}
type sqliteStore struct {
db *sql.DB
}
func openSQLite(dsn string) (*sqliteStore, error) {
if err := ensureSQLiteDir(dsn); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// SQLite allows a single writer; a small pool keeps behaviour predictable.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
store := &sqliteStore{db: db}
if err := store.migrate(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate sqlite: %w", err)
}
return store, nil
}
func ensureSQLiteDir(dsn string) error {
path := dsn
if idx := strings.Index(path, "?"); idx >= 0 {
path = path[:idx]
}
path = strings.TrimPrefix(path, "file:")
if path == "" || path == ":memory:" || strings.HasPrefix(path, ":memory:") {
return nil
}
dir := filepath.Dir(path)
if dir == "." || dir == "" {
return nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create database directory %s: %w", dir, err)
}
return nil
}
func (s *sqliteStore) Close() error { return s.db.Close() }
func (s *sqliteStore) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
func formatTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
func parseTime(v string) time.Time {
t, err := time.Parse(time.RFC3339Nano, v)
if err != nil {
return time.Time{}
}
return t.UTC()
}
func nullString(v *string) sql.NullString {
if v == nil || *v == "" {
return sql.NullString{}
}
return sql.NullString{String: *v, Valid: true}
}
func fromNullString(v sql.NullString) *string {
if !v.Valid {
return nil
}
s := v.String
return &s
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func (s *sqliteStore) CreateForm(ctx context.Context, form *Form) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := insertForm(ctx, tx, form); err != nil {
return err
}
for i := range form.Questions {
if err := insertQuestion(ctx, tx, &form.Questions[i]); err != nil {
return err
}
}
return tx.Commit()
}
func (s *sqliteStore) UpdateForm(ctx context.Context, form *Form) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx, `UPDATE forms SET
title = ?, description = ?, introduction = ?, is_active = ?,
requires_access_code = ?, signature_required = ?,
external_reference_required = ?, external_reference_label = ?,
logo_url = ?, pdf_url = ?, company_name = ?, contact_email = ?,
contact_phone = ?, contact_url = ?, updated_at = ?
WHERE id = ?`,
form.Title, nullString(form.Description), nullString(form.Introduction),
boolToInt(form.IsActive), boolToInt(form.RequiresAccessCode),
boolToInt(form.SignatureRequired), boolToInt(form.ExternalReferenceRequired),
nullString(form.ExternalReferenceLabel), nullString(form.LogoURL),
nullString(form.PDFURL), nullString(form.CompanyName),
nullString(form.ContactEmail), nullString(form.ContactPhone),
nullString(form.ContactURL), formatTime(form.UpdatedAt), form.ID,
)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `DELETE FROM questions WHERE form_id = ?`, form.ID); err != nil {
return err
}
for i := range form.Questions {
form.Questions[i].FormID = form.ID
if err := insertQuestion(ctx, tx, &form.Questions[i]); err != nil {
return err
}
}
return tx.Commit()
}
func insertForm(ctx context.Context, tx *sql.Tx, form *Form) error {
_, err := tx.ExecContext(ctx, `INSERT INTO forms (
id, form_group_id, version, title, description, introduction, is_active,
requires_access_code, signature_required, external_reference_required,
external_reference_label, logo_url, pdf_url, company_name, contact_email,
contact_phone, contact_url, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
form.ID, form.FormGroupID, form.Version, form.Title,
nullString(form.Description), nullString(form.Introduction),
boolToInt(form.IsActive), boolToInt(form.RequiresAccessCode),
boolToInt(form.SignatureRequired), boolToInt(form.ExternalReferenceRequired),
nullString(form.ExternalReferenceLabel), nullString(form.LogoURL),
nullString(form.PDFURL), nullString(form.CompanyName),
nullString(form.ContactEmail), nullString(form.ContactPhone),
nullString(form.ContactURL), formatTime(form.CreatedAt), formatTime(form.UpdatedAt),
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return ErrConflict
}
return err
}
return nil
}
func insertQuestion(ctx context.Context, tx *sql.Tx, q *Question) error {
options, err := json.Marshal(q.Options)
if err != nil {
return err
}
var minV, maxV sql.NullFloat64
if q.MinValue != nil {
minV = sql.NullFloat64{Float64: *q.MinValue, Valid: true}
}
if q.MaxValue != nil {
maxV = sql.NullFloat64{Float64: *q.MaxValue, Valid: true}
}
_, err = tx.ExecContext(ctx, `INSERT INTO questions (
id, form_id, position, question_key, label, description, type, required,
options_json, min_value, max_value, placeholder, multiline
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
q.ID, q.FormID, q.Position, q.Key, q.Label, nullString(q.Description),
q.Type, boolToInt(q.Required), string(options), minV, maxV,
nullString(q.Placeholder), boolToInt(q.Multiline),
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return ErrConflict
}
return err
}
return nil
}
const formColumns = `id, form_group_id, version, title, description, introduction,
is_active, requires_access_code, signature_required, external_reference_required,
external_reference_label, logo_url, pdf_url, company_name, contact_email,
contact_phone, contact_url, created_at, updated_at`
func (s *sqliteStore) GetForm(ctx context.Context, id string) (*Form, error) {
row := s.db.QueryRowContext(ctx, `SELECT `+formColumns+` FROM forms WHERE id = ?`, id)
form, err := scanForm(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
questions, err := s.listQuestions(ctx, id)
if err != nil {
return nil, err
}
form.Questions = questions
return form, nil
}
func scanForm(row interface{ Scan(...any) error }) (*Form, error) {
var (
form Form
description, introduction, logoURL sql.NullString
pdfURL, externalReferenceLabel sql.NullString
companyName, email, phone, contactURL sql.NullString
createdAt, updatedAt string
isActive, requiresAccessCode int
signatureRequired, externalRefReq int
)
err := row.Scan(
&form.ID, &form.FormGroupID, &form.Version, &form.Title,
&description, &introduction, &isActive, &requiresAccessCode,
&signatureRequired, &externalRefReq, &externalReferenceLabel,
&logoURL, &pdfURL, &companyName, &email, &phone, &contactURL,
&createdAt, &updatedAt,
)
if err != nil {
return nil, err
}
form.Description = fromNullString(description)
form.Introduction = fromNullString(introduction)
form.LogoURL = fromNullString(logoURL)
form.PDFURL = fromNullString(pdfURL)
form.ExternalReferenceLabel = fromNullString(externalReferenceLabel)
form.CompanyName = fromNullString(companyName)
form.ContactEmail = fromNullString(email)
form.ContactPhone = fromNullString(phone)
form.ContactURL = fromNullString(contactURL)
form.IsActive = isActive == 1
form.RequiresAccessCode = requiresAccessCode == 1
form.SignatureRequired = signatureRequired == 1
form.ExternalReferenceRequired = externalRefReq == 1
form.CreatedAt = parseTime(createdAt)
form.UpdatedAt = parseTime(updatedAt)
return &form, nil
}
func (s *sqliteStore) listQuestions(ctx context.Context, formID string) ([]Question, error) {
rows, err := s.db.QueryContext(ctx, `SELECT
id, form_id, position, question_key, label, description, type, required,
options_json, min_value, max_value, placeholder, multiline
FROM questions WHERE form_id = ? ORDER BY position`, formID)
if err != nil {
return nil, err
}
defer rows.Close()
var questions []Question
for rows.Next() {
var (
q Question
description, placeholder sql.NullString
optionsJSON string
minV, maxV sql.NullFloat64
required, multiline int
)
if err := rows.Scan(&q.ID, &q.FormID, &q.Position, &q.Key, &q.Label,
&description, &q.Type, &required, &optionsJSON, &minV, &maxV,
&placeholder, &multiline); err != nil {
return nil, err
}
q.Description = fromNullString(description)
q.Placeholder = fromNullString(placeholder)
q.Required = required == 1
q.Multiline = multiline == 1
if minV.Valid {
v := minV.Float64
q.MinValue = &v
}
if maxV.Valid {
v := maxV.Float64
q.MaxValue = &v
}
if optionsJSON != "" && optionsJSON != "null" {
if err := json.Unmarshal([]byte(optionsJSON), &q.Options); err != nil {
return nil, fmt.Errorf("decode options for question %s: %w", q.ID, err)
}
}
questions = append(questions, q)
}
return questions, rows.Err()
}
func (s *sqliteStore) ListForms(ctx context.Context, includeInactive bool) ([]FormSummary, error) {
query := `SELECT f.id, f.form_group_id, f.version, f.title, f.description, f.logo_url,
f.pdf_url, f.external_reference_required, f.external_reference_label,
f.is_active, f.created_at, f.updated_at,
(SELECT COUNT(*) FROM questions q WHERE q.form_id = f.id) AS question_count
FROM forms f`
if !includeInactive {
query += ` WHERE f.is_active = 1`
}
query += ` ORDER BY f.updated_at DESC`
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
summaries := make([]FormSummary, 0)
for rows.Next() {
var (
summary FormSummary
description sql.NullString
logoURL sql.NullString
pdfURL sql.NullString
externalRefLabel sql.NullString
createdAt string
updatedAt string
isActive int
externalRefRequired int
)
if err := rows.Scan(&summary.ID, &summary.FormGroupID, &summary.Version,
&summary.Title, &description, &logoURL, &pdfURL,
&externalRefRequired, &externalRefLabel, &isActive, &createdAt, &updatedAt,
&summary.QuestionCount); err != nil {
return nil, err
}
summary.Description = fromNullString(description)
summary.LogoURL = fromNullString(logoURL)
summary.PDFURL = fromNullString(pdfURL)
summary.ExternalReferenceLabel = fromNullString(externalRefLabel)
summary.ExternalReferenceRequired = externalRefRequired == 1
summary.IsActive = isActive == 1
summary.CreatedAt = parseTime(createdAt)
summary.UpdatedAt = parseTime(updatedAt)
summaries = append(summaries, summary)
}
return summaries, rows.Err()
}
func (s *sqliteStore) SetFormActive(ctx context.Context, id string, active bool) error {
res, err := s.db.ExecContext(ctx,
`UPDATE forms SET is_active = ?, updated_at = ? WHERE id = ?`,
boolToInt(active), formatTime(time.Now()), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *sqliteStore) DeleteForm(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM forms WHERE id = ?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *sqliteStore) CreateSubmission(ctx context.Context, sub *Submission, accessCode string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var signatureJSON sql.NullString
if sub.Signature != nil {
encoded, err := json.Marshal(sub.Signature)
if err != nil {
return err
}
signatureJSON = sql.NullString{String: string(encoded), Valid: true}
}
var offlinePDF any
if len(sub.OfflinePDF) > 0 {
offlinePDF = sub.OfflinePDF
}
_, err = tx.ExecContext(ctx, `INSERT INTO submissions (
id, form_id, form_version, user_id, user_id_type, submitted_at,
ip_address, user_agent, locale, timezone, place, referer,
external_reference, signature_json, offline_pdf_name, offline_pdf
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sub.ID, sub.FormID, sub.FormVersion, sub.UserID, sub.UserIDType,
formatTime(sub.SubmittedAt), nullString(sub.IPAddress),
nullString(sub.UserAgent), nullString(sub.Locale), nullString(sub.Timezone),
nullString(sub.Place), nullString(sub.Referer),
nullString(sub.ExternalReference), signatureJSON,
nullString(sub.OfflinePDFName), offlinePDF,
)
if err != nil {
return err
}
for i := range sub.Answers {
if _, err := tx.ExecContext(ctx, `INSERT INTO answers (
id, submission_id, question_id, question_key, value_json
) VALUES (?, ?, ?, ?, ?)`,
newID(), sub.ID, sub.Answers[i].QuestionID,
sub.Answers[i].QuestionKey, string(sub.Answers[i].Value),
); err != nil {
return err
}
}
if accessCode != "" {
res, err := tx.ExecContext(ctx, `UPDATE access_codes
SET used = 1, used_at = ?, submission_id = ?
WHERE form_id = ? AND code = ? AND used = 0`,
formatTime(time.Now()), sub.ID, sub.FormID, accessCode)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
var used int
err := tx.QueryRowContext(ctx,
`SELECT used FROM access_codes WHERE form_id = ? AND code = ?`,
sub.FormID, accessCode).Scan(&used)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccessCode
}
if err != nil {
return err
}
return ErrCodeAlreadyIn
}
}
return tx.Commit()
}
func (s *sqliteStore) GetSubmission(ctx context.Context, id string) (*Submission, error) {
var (
sub Submission
submittedAt string
ipAddress, userAgent, locale, timezone sql.NullString
place, referer sql.NullString
signatureJSON sql.NullString
offlinePDFName sql.NullString
externalReference sql.NullString
)
err := s.db.QueryRowContext(ctx, `SELECT id, form_id, form_version, user_id,
user_id_type, submitted_at, ip_address, user_agent, locale, timezone,
place, referer, external_reference, signature_json, offline_pdf_name
FROM submissions WHERE id = ?`, id).Scan(
&sub.ID, &sub.FormID, &sub.FormVersion, &sub.UserID, &sub.UserIDType,
&submittedAt, &ipAddress, &userAgent, &locale, &timezone, &place, &referer,
&externalReference, &signatureJSON, &offlinePDFName,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
sub.SubmittedAt = parseTime(submittedAt)
sub.IPAddress = fromNullString(ipAddress)
sub.UserAgent = fromNullString(userAgent)
sub.Locale = fromNullString(locale)
sub.Timezone = fromNullString(timezone)
sub.Place = fromNullString(place)
sub.Referer = fromNullString(referer)
sub.ExternalReference = fromNullString(externalReference)
sub.OfflinePDFName = fromNullString(offlinePDFName)
if signatureJSON.Valid && signatureJSON.String != "" {
var signature Signature
if err := json.Unmarshal([]byte(signatureJSON.String), &signature); err != nil {
return nil, fmt.Errorf("decode signature for submission %s: %w", id, err)
}
sub.Signature = &signature
}
rows, err := s.db.QueryContext(ctx, `SELECT question_id, question_key, value_json
FROM answers WHERE submission_id = ?`, id)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var (
a Answer
valueJSON string
)
if err := rows.Scan(&a.QuestionID, &a.QuestionKey, &valueJSON); err != nil {
return nil, err
}
a.Value = json.RawMessage(valueJSON)
sub.Answers = append(sub.Answers, a)
}
return &sub, rows.Err()
}
func (s *sqliteStore) GetSubmissionOfflinePDF(ctx context.Context, id string) ([]byte, string, error) {
var (
data []byte
filename sql.NullString
)
err := s.db.QueryRowContext(ctx,
`SELECT offline_pdf, offline_pdf_name FROM submissions WHERE id = ?`, id).
Scan(&data, &filename)
if errors.Is(err, sql.ErrNoRows) {
return nil, "", ErrNotFound
}
if err != nil {
return nil, "", err
}
if len(data) == 0 {
return nil, "", ErrNotFound
}
name := "submission.pdf"
if filename.Valid && strings.TrimSpace(filename.String) != "" {
name = filepath.Base(filename.String)
}
return data, name, nil
}
func (s *sqliteStore) ListSubmissions(ctx context.Context, formID string) ([]SubmissionSummary, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, form_id, form_version,
user_id, user_id_type, external_reference, submitted_at FROM submissions
WHERE form_id = ? ORDER BY submitted_at DESC`, formID)
if err != nil {
return nil, err
}
defer rows.Close()
summaries := make([]SubmissionSummary, 0)
for rows.Next() {
var (
summary SubmissionSummary
submittedAt string
externalReference sql.NullString
)
if err := rows.Scan(&summary.ID, &summary.FormID, &summary.FormVersion,
&summary.UserID, &summary.UserIDType, &externalReference, &submittedAt); err != nil {
return nil, err
}
summary.ExternalReference = fromNullString(externalReference)
summary.SubmittedAt = parseTime(submittedAt)
summaries = append(summaries, summary)
}
return summaries, rows.Err()
}
func (s *sqliteStore) CreateAccessCodes(ctx context.Context, codes []AccessCode) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, code := range codes {
_, err := tx.ExecContext(ctx, `INSERT INTO access_codes
(id, form_id, code, used, created_at) VALUES (?, ?, ?, ?, ?)`,
code.ID, code.FormID, code.Code, boolToInt(code.Used), formatTime(code.CreatedAt))
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return ErrConflict
}
return err
}
}
return tx.Commit()
}
func (s *sqliteStore) ListAccessCodes(ctx context.Context, formID string) ([]AccessCode, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, form_id, code, used,
used_at, created_at FROM access_codes WHERE form_id = ? ORDER BY created_at`, formID)
if err != nil {
return nil, err
}
defer rows.Close()
codes := make([]AccessCode, 0)
for rows.Next() {
var (
code AccessCode
usedAt sql.NullString
createdAt string
used int
)
if err := rows.Scan(&code.ID, &code.FormID, &code.Code, &used,
&usedAt, &createdAt); err != nil {
return nil, err
}
code.Used = used == 1
code.CreatedAt = parseTime(createdAt)
if usedAt.Valid {
t := parseTime(usedAt.String)
code.UsedAt = &t
}
codes = append(codes, code)
}
return codes, rows.Err()
}

View File

@ -0,0 +1,227 @@
package database
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
)
func testStore(t *testing.T) (*sqliteStore, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "kereru-test.db")
store, err := Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
return store.(*sqliteStore), path
}
func sampleForm() *Form {
now := time.Now().UTC()
return &Form{
ID: "11111111-1111-1111-1111-111111111111",
FormGroupID: "22222222-2222-2222-2222-222222222222",
Version: 1,
Title: "Customer feedback",
Description: strPtr("Tell us how we did"),
IsActive: true,
RequiresAccessCode: false,
CreatedAt: now,
UpdatedAt: now,
Questions: []Question{
{
ID: "33333333-3333-3333-3333-333333333333",
FormID: "11111111-1111-1111-1111-111111111111",
Position: 0,
Key: "happy",
Label: "Are you happy?",
Type: "boolean",
Required: true,
},
{
ID: "44444444-4444-4444-4444-444444444444",
FormID: "11111111-1111-1111-1111-111111111111",
Position: 1,
Key: "score",
Label: "Score",
Type: "number",
},
},
}
}
func strPtr(s string) *string { return &s }
func TestMigrateIsIdempotent(t *testing.T) {
_, path := testStore(t)
second, err := Open("sqlite", "file:"+path)
if err != nil {
t.Fatalf("re-open: %v", err)
}
_ = second.Close()
}
func TestFormCRUD(t *testing.T) {
store, _ := testStore(t)
ctx := context.Background()
form := sampleForm()
if err := store.CreateForm(ctx, form); err != nil {
t.Fatalf("CreateForm: %v", err)
}
got, err := store.GetForm(ctx, form.ID)
if err != nil {
t.Fatalf("GetForm: %v", err)
}
if got.Title != form.Title || len(got.Questions) != 2 {
t.Fatalf("unexpected form: %+v", got)
}
if got.Questions[0].Key != "happy" || got.Questions[1].Key != "score" {
t.Fatalf("questions out of order: %+v", got.Questions)
}
forms, err := store.ListForms(ctx, false)
if err != nil {
t.Fatalf("ListForms: %v", err)
}
if len(forms) != 1 || forms[0].QuestionCount != 2 {
t.Fatalf("ListForms = %+v", forms)
}
got.Title = "Updated title"
got.Questions = got.Questions[:1]
got.UpdatedAt = time.Now().UTC()
if err := store.UpdateForm(ctx, got); err != nil {
t.Fatalf("UpdateForm: %v", err)
}
updated, err := store.GetForm(ctx, form.ID)
if err != nil {
t.Fatalf("GetForm after update: %v", err)
}
if updated.Title != "Updated title" || len(updated.Questions) != 1 {
t.Fatalf("update not applied: %+v", updated)
}
if err := store.SetFormActive(ctx, form.ID, false); err != nil {
t.Fatalf("SetFormActive: %v", err)
}
active, err := store.ListForms(ctx, false)
if err != nil {
t.Fatalf("ListForms active: %v", err)
}
if len(active) != 0 {
t.Fatalf("expected no active forms, got %+v", active)
}
all, err := store.ListForms(ctx, true)
if err != nil {
t.Fatalf("ListForms all: %v", err)
}
if len(all) != 1 || all[0].IsActive {
t.Fatalf("expected one inactive form, got %+v", all)
}
if err := store.DeleteForm(ctx, form.ID); err != nil {
t.Fatalf("DeleteForm: %v", err)
}
if _, err := store.GetForm(ctx, form.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestSubmissionsAndAccessCodes(t *testing.T) {
store, _ := testStore(t)
ctx := context.Background()
form := sampleForm()
form.RequiresAccessCode = true
if err := store.CreateForm(ctx, form); err != nil {
t.Fatalf("CreateForm: %v", err)
}
now := time.Now().UTC()
code := AccessCode{
ID: "55555555-5555-5555-5555-555555555555",
FormID: form.ID,
Code: "TEST-ABCD-EFGH",
CreatedAt: now,
}
if err := store.CreateAccessCodes(ctx, []AccessCode{code}); err != nil {
t.Fatalf("CreateAccessCodes: %v", err)
}
codes, err := store.ListAccessCodes(ctx, form.ID)
if err != nil {
t.Fatalf("ListAccessCodes: %v", err)
}
if len(codes) != 1 || codes[0].Used {
t.Fatalf("unexpected codes: %+v", codes)
}
value, _ := json.Marshal(true)
sub := &Submission{
ID: "66666666-6666-6666-6666-666666666666",
FormID: form.ID,
FormVersion: form.Version,
UserID: "person@example.com",
UserIDType: "email",
SubmittedAt: now,
Answers: []Answer{
{QuestionID: form.Questions[0].ID, QuestionKey: "happy", Value: value},
},
}
if err := store.CreateSubmission(ctx, sub, "WRONG"); !errors.Is(err, ErrAccessCode) {
t.Fatalf("expected ErrAccessCode, got %v", err)
}
if err := store.CreateSubmission(ctx, sub, code.Code); err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
got, err := store.GetSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetSubmission: %v", err)
}
if len(got.Answers) != 1 || got.Answers[0].QuestionKey != "happy" {
t.Fatalf("unexpected submission: %+v", got)
}
codes, _ = store.ListAccessCodes(ctx, form.ID)
if !codes[0].Used || codes[0].UsedAt == nil {
t.Fatalf("access code should be marked used: %+v", codes[0])
}
reuse := *sub
reuse.ID = "77777777-7777-7777-7777-777777777777"
if err := store.CreateSubmission(ctx, &reuse, code.Code); !errors.Is(err, ErrCodeAlreadyIn) {
t.Fatalf("expected ErrCodeAlreadyIn, got %v", err)
}
subs, err := store.ListSubmissions(ctx, form.ID)
if err != nil {
t.Fatalf("ListSubmissions: %v", err)
}
if len(subs) != 1 {
t.Fatalf("expected 1 submission, got %d", len(subs))
}
if err := store.DeleteForm(ctx, form.ID); err != nil {
t.Fatalf("DeleteForm with submissions: %v", err)
}
if _, err := store.GetSubmission(ctx, sub.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("submission should cascade delete, got %v", err)
}
if codes, err := store.ListAccessCodes(ctx, form.ID); err != nil || len(codes) != 0 {
t.Fatalf("access codes should cascade delete, got %v err=%v", codes, err)
}
}
func TestUnsupportedDriver(t *testing.T) {
if _, err := Open("oracle", "dsn"); err == nil {
t.Fatal("expected error for unsupported driver")
}
}

View File

@ -0,0 +1,164 @@
// Package database provides the persistence layer for kereru-forms.
//
// The Store interface deliberately hides SQL details so additional providers
// (PostgreSQL, MariaDB) can be added later without changing the API layer.
package database
import (
"context"
"encoding/json"
"errors"
"time"
)
// Common persistence errors.
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrAccessCode = errors.New("access code is not valid")
ErrCodeAlreadyIn = errors.New("access code has already been used")
)
// QuestionOption is a selectable value for a select question.
type QuestionOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// Question is a single question belonging to a form version.
type Question struct {
ID string
FormID string
Position int
Key string
Label string
Description *string
Type string
Required bool
Options []QuestionOption
MinValue *float64
MaxValue *float64
Placeholder *string
Multiline bool
}
// Form is a concrete version of a logical form.
type Form struct {
ID string
FormGroupID string
Version int
Title string
Description *string
Introduction *string
IsActive bool
RequiresAccessCode bool
SignatureRequired bool
ExternalReferenceRequired bool
ExternalReferenceLabel *string
LogoURL *string
PDFURL *string
CompanyName *string
ContactEmail *string
ContactPhone *string
ContactURL *string
CreatedAt time.Time
UpdatedAt time.Time
Questions []Question
}
// FormSummary is a lightweight form listing entry.
type FormSummary struct {
ID string
FormGroupID string
Version int
Title string
Description *string
LogoURL *string
PDFURL *string
ExternalReferenceRequired bool
ExternalReferenceLabel *string
IsActive bool
QuestionCount int
CreatedAt time.Time
UpdatedAt time.Time
}
// Answer is a captured answer inside a submission.
type Answer struct {
QuestionID string
QuestionKey string
Value json.RawMessage
}
// Signature is a captured digital signature.
type Signature struct {
Type string `json:"type"`
DataURL string `json:"dataUrl"`
Name string `json:"name"`
Title *string `json:"title,omitempty"`
SignedAt time.Time `json:"signedAt"`
}
// Submission is a completed form response.
type Submission struct {
ID string
FormID string
FormVersion int
UserID string
UserIDType string
SubmittedAt time.Time
IPAddress *string
UserAgent *string
Locale *string
Timezone *string
Place *string
Referer *string
ExternalReference *string
Signature *Signature
OfflinePDFName *string
OfflinePDF []byte
Answers []Answer
}
// SubmissionSummary is a lightweight submission listing entry.
type SubmissionSummary struct {
ID string
FormID string
FormVersion int
UserID string
UserIDType string
ExternalReference *string
SubmittedAt time.Time
}
// AccessCode is a single-use code granting access to a form.
type AccessCode struct {
ID string
FormID string
Code string
Used bool
UsedAt *time.Time
CreatedAt time.Time
}
// Store is the persistence contract used by the API layer.
type Store interface {
Close() error
CreateForm(ctx context.Context, form *Form) error
UpdateForm(ctx context.Context, form *Form) error
GetForm(ctx context.Context, id string) (*Form, error)
ListForms(ctx context.Context, includeInactive bool) ([]FormSummary, error)
SetFormActive(ctx context.Context, id string, active bool) error
DeleteForm(ctx context.Context, id string) error
CreateSubmission(ctx context.Context, submission *Submission, accessCode string) error
GetSubmission(ctx context.Context, id string) (*Submission, error)
GetSubmissionOfflinePDF(ctx context.Context, id string) ([]byte, string, error)
ListSubmissions(ctx context.Context, formID string) ([]SubmissionSummary, error)
CreateAccessCodes(ctx context.Context, codes []AccessCode) error
ListAccessCodes(ctx context.Context, formID string) ([]AccessCode, error)
Ping(ctx context.Context) error
}

View File

@ -0,0 +1,89 @@
// Package logging configures the application's structured logger.
package logging
import (
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
// New builds a zerolog logger for the given level and format.
//
// level is one of: trace, debug, info, warn, error, fatal, panic.
// format is "json" for machine-readable output, anything else produces a
// human-friendly console output (useful for local development).
func New(level, format, environment string) zerolog.Logger {
lvl, err := zerolog.ParseLevel(strings.ToLower(strings.TrimSpace(level)))
if err != nil || lvl == zerolog.NoLevel {
lvl = zerolog.InfoLevel
}
var writer zerolog.LevelWriter
if strings.EqualFold(format, "json") {
writer = zerolog.MultiLevelWriter(os.Stdout)
} else {
console := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}
writer = zerolog.MultiLevelWriter(console)
}
logger := zerolog.New(writer).
Level(lvl).
With().
Timestamp().
Str("service", "kereru-forms").
Str("environment", environment).
Logger()
zerolog.SetGlobalLevel(lvl)
return logger
}
// GinMiddleware logs every HTTP request with useful observability fields.
func GinMiddleware(logger zerolog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
if raw := c.Request.URL.RawQuery; raw != "" {
path = path + "?" + raw
}
c.Next()
event := logger.Info()
if c.Writer.Status() >= 500 {
event = logger.Error()
} else if c.Writer.Status() >= 400 {
event = logger.Warn()
}
event.
Str("method", c.Request.Method).
Str("path", path).
Int("status", c.Writer.Status()).
Dur("latency", time.Since(start)).
Str("client_ip", c.ClientIP()).
Str("user_agent", c.Request.UserAgent()).
Str("request_id", RequestID(c)).
Msg("http request")
}
}
const requestIDKey = "request_id"
// RequestID returns the correlation id assigned to the current request.
func RequestID(c *gin.Context) string {
if v, ok := c.Get(requestIDKey); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// SetRequestID stores the correlation id on the context.
func SetRequestID(c *gin.Context, id string) {
c.Set(requestIDKey, id)
}

View File

@ -0,0 +1,475 @@
// Package pdf renders a printable representation of a form definition.
package pdf
import (
"bytes"
"fmt"
"strings"
"time"
"github.com/go-pdf/fpdf"
"github.com/google/uuid"
"kereru.forms/internal/database"
)
const (
pdfLeftMargin = 18.0
pdfPageWidth = 210.0
pdfPageHeight = 297.0
pdfBottomEdge = pdfPageHeight - 18
pdfContentWide = pdfPageWidth - pdfLeftMargin*2
pdfLabelWidth = 112.0
pdfAnswerX = pdfLeftMargin + pdfLabelWidth + 4
pdfAnswerWidth = pdfContentWide - pdfLabelWidth - 4
)
// Generate builds a PDF document for the supplied form. The document doubles
// as a printable offline form: it includes a submitter details area and space
// for handwritten answers.
func Generate(form *database.Form) ([]byte, error) {
if form == nil {
return nil, fmt.Errorf("form is nil")
}
doc := fpdf.New("P", "mm", "A4", "")
doc.SetMargins(pdfLeftMargin, pdfLeftMargin, pdfLeftMargin)
doc.SetAutoPageBreak(true, 18)
doc.SetTitle(sanitize(form.Title), false)
doc.SetAuthor(sanitize(deref(form.CompanyName, "Kereru Forms")), false)
doc.SetCreator("Kereru Forms", false)
doc.SetSubject(fmt.Sprintf("Form version %d", form.Version), false)
doc.SetFooterFunc(func() {
doc.SetY(-14)
doc.SetFont("Helvetica", "I", 8)
doc.SetTextColor(120, 120, 120)
doc.CellFormat(0, 6,
fmt.Sprintf("Kereru Forms - %s - page %d", sanitize(form.Title), doc.PageNo()),
"", 0, "C", false, 0, "")
})
doc.AddPage()
doc.SetTextColor(20, 20, 20)
if form.CompanyName != nil && *form.CompanyName != "" {
doc.SetFont("Helvetica", "", 11)
doc.SetTextColor(90, 90, 90)
doc.MultiCell(0, 6, sanitize(*form.CompanyName), "", "L", false)
}
doc.SetFont("Helvetica", "B", 20)
doc.SetTextColor(20, 20, 20)
doc.MultiCell(0, 9, sanitize(form.Title), "", "L", false)
doc.SetFont("Helvetica", "", 9)
doc.SetTextColor(110, 110, 110)
doc.MultiCell(0, 5, fmt.Sprintf("Version %d - form id %s", form.Version, form.ID), "", "L", false)
doc.Ln(2)
if form.Description != nil && strings.TrimSpace(*form.Description) != "" {
doc.SetDrawColor(220, 220, 220)
doc.SetFont("Helvetica", "", 10)
doc.SetTextColor(60, 60, 60)
doc.MultiCell(0, 5, sanitize(*form.Description), "", "L", false)
doc.Ln(2)
}
if form.Introduction != nil && strings.TrimSpace(*form.Introduction) != "" {
doc.SetFont("Helvetica", "B", 11)
doc.SetTextColor(20, 20, 20)
doc.CellFormat(0, 7, "Introduction", "", 1, "L", false, 0, "")
doc.SetFont("Helvetica", "", 10)
doc.SetTextColor(60, 60, 60)
doc.MultiCell(0, 5, sanitize(*form.Introduction), "", "L", false)
doc.Ln(2)
}
if hasContact(form) {
doc.SetFont("Helvetica", "B", 11)
doc.SetTextColor(20, 20, 20)
doc.CellFormat(0, 7, "Contact", "", 1, "L", false, 0, "")
doc.SetFont("Helvetica", "", 10)
doc.SetTextColor(60, 60, 60)
if form.ContactEmail != nil && *form.ContactEmail != "" {
doc.MultiCell(0, 5, sanitize("Email: "+*form.ContactEmail), "", "L", false)
}
if form.ContactPhone != nil && *form.ContactPhone != "" {
doc.MultiCell(0, 5, sanitize("Phone: "+*form.ContactPhone), "", "L", false)
}
if form.ContactURL != nil && *form.ContactURL != "" {
doc.MultiCell(0, 5, sanitize("Web: "+*form.ContactURL), "", "L", false)
}
doc.Ln(2)
}
doc.SetDrawColor(210, 210, 210)
doc.Line(pdfLeftMargin, doc.GetY(), pdfPageWidth-pdfLeftMargin, doc.GetY())
doc.Ln(4)
renderSubmitterDetails(doc, form)
doc.SetFont("Helvetica", "B", 12)
doc.SetTextColor(20, 20, 20)
doc.CellFormat(0, 8, "Questions", "", 1, "L", false, 0, "")
doc.Ln(1)
if len(form.Questions) == 0 {
doc.SetFont("Helvetica", "I", 10)
doc.SetTextColor(110, 110, 110)
doc.MultiCell(0, 5, "This form does not contain any questions yet.", "", "L", false)
}
for i, q := range form.Questions {
renderQuestion(doc, i+1, q)
}
if form.SignatureRequired {
renderSignatureSection(doc)
}
doc.Ln(4)
doc.SetFont("Helvetica", "I", 8)
doc.SetTextColor(130, 130, 130)
doc.MultiCell(0, 4, fmt.Sprintf(
"Generated %s - %s", time.Now().UTC().Format(time.RFC3339), uuid.NewString()),
"", "L", false)
var buf bytes.Buffer
if err := doc.Output(&buf); err != nil {
return nil, fmt.Errorf("render pdf: %w", err)
}
return buf.Bytes(), nil
}
func renderSignatureSection(doc *fpdf.Fpdf) {
ensureSpace(doc, 46)
doc.SetFont("Helvetica", "B", 12)
doc.SetTextColor(20, 20, 20)
doc.CellFormat(0, 8, "Digital signature", "", 1, "L", false, 0, "")
doc.SetFont("Helvetica", "I", 8)
doc.SetTextColor(120, 120, 120)
doc.MultiCell(0, 4, "Completed in the online form as a drawn or uploaded signature.", "", "L", false)
doc.Ln(2)
y := doc.GetY()
drawnField(doc, pdfLeftMargin, y, "Name:", 90)
drawnField(doc, pdfLeftMargin+95, y, "Title (optional):", 79)
doc.SetY(y + 11)
drawnField(doc, pdfLeftMargin, doc.GetY(), "Signature:", pdfContentWide)
doc.SetY(doc.GetY() + 11)
}
// renderSubmitterDetails draws the section a respondent completes by hand when
// the form is filled in offline.
func renderSubmitterDetails(doc *fpdf.Fpdf, form *database.Form) {
ensureSpace(doc, 62)
doc.SetFont("Helvetica", "B", 12)
doc.SetTextColor(20, 20, 20)
doc.CellFormat(0, 8, "Submitter details", "", 1, "L", false, 0, "")
doc.SetFont("Helvetica", "I", 8)
doc.SetTextColor(120, 120, 120)
doc.MultiCell(0, 4, "Complete this form offline, then return it using the contact details above.", "", "L", false)
doc.Ln(2)
y := doc.GetY()
drawnField(doc, pdfLeftMargin, y, "Name / identifier:", pdfContentWide)
y += 9
doc.SetFont("Helvetica", "", 9)
doc.SetTextColor(90, 90, 90)
doc.SetXY(pdfLeftMargin, y)
doc.CellFormat(26, 6, "Identifier type:", "", 0, "L", false, 0, "")
x := pdfLeftMargin + 28
for _, label := range []string{"Email", "Username", "Phone", "GUID"} {
drawCheckbox(doc, x, y+1)
doc.SetXY(x+5, y)
doc.CellFormat(0, 6, label, "", 0, "L", false, 0, "")
x += 5 + doc.GetStringWidth(label) + 8
}
doc.SetY(y + 9)
y = doc.GetY()
fieldWidth := 82.0
if form.RequiresAccessCode {
drawnField(doc, pdfLeftMargin, y, "Access code:", fieldWidth)
}
drawnField(doc, pdfLeftMargin+90, y, "Date completed:", fieldWidth)
doc.SetY(y + 9)
if form.ExternalReferenceRequired {
label := "External reference"
if form.ExternalReferenceLabel != nil && *form.ExternalReferenceLabel != "" {
label = *form.ExternalReferenceLabel
}
y = doc.GetY()
drawnField(doc, pdfLeftMargin, y, sanitize(label)+":", pdfContentWide)
doc.SetY(y + 9)
}
y = doc.GetY()
drawnField(doc, pdfLeftMargin, y, "Signature:", pdfContentWide)
doc.SetY(y + 10)
doc.SetDrawColor(210, 210, 210)
doc.Line(pdfLeftMargin, doc.GetY(), pdfPageWidth-pdfLeftMargin, doc.GetY())
doc.Ln(4)
}
func renderQuestion(doc *fpdf.Fpdf, number int, q database.Question) {
label := fmt.Sprintf("%d. %s", number, sanitize(q.Label))
if q.Required {
label += " *"
}
if q.Type == "text" {
renderTextQuestion(doc, label, q)
return
}
labelHeight := measureLabel(doc, label, q)
rowHeight := max(labelHeight, answerBlockHeight(q))
doc.SetFont("Helvetica", "", 10)
startY := doc.GetY()
if startY+rowHeight > pdfBottomEdge {
doc.AddPage()
startY = doc.GetY()
}
doc.SetXY(pdfLeftMargin, startY)
doc.SetFont("Helvetica", "B", 10)
doc.SetTextColor(25, 25, 25)
doc.MultiCell(pdfLabelWidth, 5, label, "", "L", false)
doc.SetFont("Helvetica", "", 8)
doc.SetTextColor(120, 120, 120)
doc.MultiCell(pdfLabelWidth, 4, "Key: "+sanitize(q.Key)+" - "+typeDescription(q), "", "L", false)
if q.Description != nil && *q.Description != "" {
doc.SetFont("Helvetica", "I", 9)
doc.SetTextColor(90, 90, 90)
doc.MultiCell(pdfLabelWidth, 4, sanitize(*q.Description), "", "L", false)
}
if q.Placeholder != nil && *q.Placeholder != "" {
doc.SetFont("Helvetica", "I", 9)
doc.SetTextColor(140, 140, 140)
doc.MultiCell(pdfLabelWidth, 4, "Hint: "+sanitize(*q.Placeholder), "", "L", false)
}
renderAnswerBlock(doc, startY, q)
doc.SetY(max(doc.GetY(), startY+rowHeight) + 3)
}
func renderTextQuestion(doc *fpdf.Fpdf, label string, q database.Question) {
lines := 1
if q.Multiline {
lines = 4
}
needed := 8.0 + float64(lines)*9 + 6
if q.Description != nil && *q.Description != "" {
needed += 5
}
ensureSpace(doc, needed)
doc.SetFont("Helvetica", "B", 10)
doc.SetTextColor(25, 25, 25)
doc.MultiCell(0, 5, label, "", "L", false)
doc.SetFont("Helvetica", "", 8)
doc.SetTextColor(120, 120, 120)
doc.MultiCell(0, 4, "Key: "+sanitize(q.Key)+" - "+typeDescription(q), "", "L", false)
if q.Description != nil && *q.Description != "" {
doc.SetFont("Helvetica", "I", 9)
doc.SetTextColor(90, 90, 90)
doc.MultiCell(0, 4, sanitize(*q.Description), "", "L", false)
}
if q.Placeholder != nil && *q.Placeholder != "" {
doc.SetFont("Helvetica", "I", 9)
doc.SetTextColor(140, 140, 140)
doc.MultiCell(0, 4, "Hint: "+sanitize(*q.Placeholder), "", "L", false)
}
doc.SetDrawColor(190, 190, 190)
doc.Ln(2)
for i := 0; i < lines; i++ {
doc.CellFormat(0, 7, "", "B", 1, "L", false, 0, "")
doc.Ln(2)
}
doc.Ln(2)
}
func renderAnswerBlock(doc *fpdf.Fpdf, startY float64, q database.Question) {
switch q.Type {
case "boolean":
drawCheckbox(doc, pdfAnswerX, startY+1)
doc.SetFont("Helvetica", "", 10)
doc.SetTextColor(50, 50, 50)
doc.SetXY(pdfAnswerX+6, startY)
doc.CellFormat(20, 6, "True", "", 0, "L", false, 0, "")
drawCheckbox(doc, pdfAnswerX+30, startY+1)
doc.SetXY(pdfAnswerX+36, startY)
doc.CellFormat(20, 6, "False", "", 0, "L", false, 0, "")
case "select":
y := startY
for _, opt := range q.Options {
drawCheckbox(doc, pdfAnswerX, y+1)
doc.SetXY(pdfAnswerX+6, y)
doc.SetFont("Helvetica", "", 9)
doc.SetTextColor(50, 50, 50)
doc.MultiCell(pdfAnswerWidth-6, 5, sanitize(opt.Label), "", "L", false)
y = doc.GetY()
}
case "number":
doc.SetDrawColor(150, 150, 150)
doc.Line(pdfAnswerX, startY+6, pdfAnswerX+48, startY+6)
case "date":
doc.SetFont("Helvetica", "", 8)
doc.SetTextColor(140, 140, 140)
doc.SetXY(pdfAnswerX+50, startY+1)
doc.CellFormat(0, 4, "YYYY-MM-DD", "", 0, "L", false, 0, "")
doc.SetDrawColor(150, 150, 150)
doc.Line(pdfAnswerX, startY+6, pdfAnswerX+48, startY+6)
}
}
func measureLabel(doc *fpdf.Fpdf, label string, q database.Question) float64 {
doc.SetFont("Helvetica", "B", 10)
height := float64(len(doc.SplitText(label, pdfLabelWidth))) * 5
doc.SetFont("Helvetica", "", 8)
hint := "Key: " + sanitize(q.Key) + " - " + typeDescription(q)
height += float64(len(doc.SplitText(hint, pdfLabelWidth))) * 4
if q.Description != nil && *q.Description != "" {
doc.SetFont("Helvetica", "I", 9)
height += float64(len(doc.SplitText(sanitize(*q.Description), pdfLabelWidth))) * 4
}
if q.Placeholder != nil && *q.Placeholder != "" {
doc.SetFont("Helvetica", "I", 9)
height += float64(len(doc.SplitText("Hint: "+sanitize(*q.Placeholder), pdfLabelWidth))) * 4
}
return height + 2
}
func answerBlockHeight(q database.Question) float64 {
switch q.Type {
case "boolean", "number", "date":
return 10
case "select":
return float64(max(1, len(q.Options))) * 6
default:
return 10
}
}
func ensureSpace(doc *fpdf.Fpdf, needed float64) {
if doc.GetY()+needed > pdfBottomEdge {
doc.AddPage()
}
}
func drawnField(doc *fpdf.Fpdf, x, y float64, label string, width float64) {
doc.SetFont("Helvetica", "", 9)
doc.SetTextColor(90, 90, 90)
runes := []rune(label)
for len(runes) > 0 && doc.GetStringWidth(string(runes))+2 > width-12 {
runes = runes[:len(runes)-1]
}
label = string(runes)
labelWidth := doc.GetStringWidth(label) + 2
doc.SetXY(x, y)
doc.CellFormat(labelWidth, 6, label, "", 0, "L", false, 0, "")
doc.SetDrawColor(150, 150, 150)
doc.CellFormat(width-labelWidth, 6, "", "B", 0, "L", false, 0, "")
}
func drawCheckbox(doc *fpdf.Fpdf, x, y float64) {
doc.SetDrawColor(120, 120, 120)
doc.Rect(x, y+1.2, 4, 4, "D")
}
func typeDescription(q database.Question) string {
switch q.Type {
case "boolean":
return "True / false"
case "number":
desc := "Number"
if q.MinValue != nil || q.MaxValue != nil {
desc += " ("
if q.MinValue != nil {
desc += fmt.Sprintf("min %g", *q.MinValue)
}
if q.MinValue != nil && q.MaxValue != nil {
desc += ", "
}
if q.MaxValue != nil {
desc += fmt.Sprintf("max %g", *q.MaxValue)
}
desc += ")"
}
return desc
case "select":
return "Select one option"
case "text":
if q.Multiline {
return "Text (multiline)"
}
return "Text (single line)"
case "date":
return "Date (YYYY-MM-DD)"
default:
return q.Type
}
}
func hasContact(form *database.Form) bool {
vals := []*string{form.ContactEmail, form.ContactPhone, form.ContactURL}
for _, v := range vals {
if v != nil && strings.TrimSpace(*v) != "" {
return true
}
}
return false
}
func deref(v *string, fallback string) string {
if v == nil || *v == "" {
return fallback
}
return *v
}
var replacements = map[rune]string{
'ā': "a", 'Ā': "A", 'ē': "e", 'Ē': "E", 'ī': "i", 'Ī': "I",
'ō': "o", 'Ō': "O", 'ū': "u", 'Ū': "U",
'‘': "'", '’': "'", '“': `"`, '”': `"`,
'–': "-", '—': "-", '…': "...", '\u00a0': " ",
}
// sanitize maps text to the WinAnsi/Latin-1 range supported by the built-in
// PDF fonts. Characters outside that range are transliterated or replaced.
func sanitize(s string) string {
return strings.Map(func(r rune) rune {
if r >= 32 && r <= 255 {
return r
}
return '?'
}, replaceRunes(s))
}
func replaceRunes(s string) string {
var b strings.Builder
for _, r := range s {
if rep, ok := replacements[r]; ok {
b.WriteString(rep)
continue
}
b.WriteRune(r)
}
return b.String()
}

View File

@ -0,0 +1,76 @@
package pdf
import (
"bytes"
"strings"
"testing"
"time"
"kereru.forms/internal/database"
)
func formFixture() *database.Form {
now := time.Now()
desc := "A short description"
intro := "Welcome to the survey"
email := "hello@example.com"
placeholder := "Type here"
multiline := true
return &database.Form{
ID: "11111111-1111-1111-1111-111111111111",
FormGroupID: "22222222-2222-2222-2222-222222222222",
Version: 3,
Title: "Kererū feedback",
Description: &desc,
Introduction: &intro,
IsActive: true,
ContactEmail: &email,
CreatedAt: now,
UpdatedAt: now,
Questions: []database.Question{
{ID: "1", Key: "name", Label: "Your name", Type: "text", Required: true, Placeholder: &placeholder},
{ID: "2", Key: "notes", Label: "Notes", Type: "text", Multiline: multiline},
{ID: "3", Key: "happy", Label: "Happy?", Type: "boolean"},
{ID: "4", Key: "score", Label: "Score", Type: "number", MinValue: floatPtr(1), MaxValue: floatPtr(5)},
{ID: "5", Key: "colour", Label: "Colour", Type: "select", Options: []database.QuestionOption{
{Value: "red", Label: "Red"},
{Value: "blue", Label: "Blue"},
}},
{ID: "6", Key: "when", Label: "When", Type: "date", Required: true},
},
}
}
func floatPtr(f float64) *float64 { return &f }
func TestGenerate(t *testing.T) {
data, err := Generate(formFixture())
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(data) < 500 {
t.Fatalf("pdf is suspiciously small: %d bytes", len(data))
}
if !bytes.HasPrefix(data, []byte("%PDF")) {
t.Fatal("output does not start with %PDF")
}
if !bytes.Contains(data, []byte("%%EOF")) {
t.Fatal("output does not contain the EOF marker")
}
}
func TestSanitize(t *testing.T) {
got := sanitize("Kererū — “quoted” …")
if strings.ContainsAny(got, "ū—") {
t.Fatalf("sanitize left unsupported characters: %q", got)
}
if got != `Kereru - "quoted" ...` {
t.Fatalf("sanitize = %q", got)
}
}
func TestGenerateNil(t *testing.T) {
if _, err := Generate(nil); err == nil {
t.Fatal("expected error for nil form")
}
}

View File

@ -0,0 +1,125 @@
// Package telemetry wires OpenTelemetry tracing and metrics.
package telemetry
import (
"context"
"strings"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
)
// Options configures the telemetry providers.
type Options struct {
Enabled bool
Endpoint string
ServiceName string
Environment string
Version string
}
// Provider owns the configured OpenTelemetry providers.
type Provider struct {
Enabled bool
shutdown []func(context.Context) error
}
// Setup installs global tracer and meter providers. When disabled, the
// OpenTelemetry no-op providers remain in place and Shutdown is a no-op.
func Setup(ctx context.Context, opts Options) (*Provider, error) {
p := &Provider{Enabled: opts.Enabled}
if !opts.Enabled {
return p, nil
}
serviceName := opts.ServiceName
if serviceName == "" {
serviceName = "kereru-forms"
}
endpoint := opts.Endpoint
if endpoint == "" {
endpoint = "localhost:4318"
}
res, err := resource.New(ctx,
resource.WithAttributes(
attribute.String("service.name", serviceName),
attribute.String("service.version", opts.Version),
attribute.String("deployment.environment", opts.Environment),
),
)
if err != nil {
return nil, err
}
traceExporter, err := otlptracehttp.New(ctx, traceOptions(endpoint)...)
if err != nil {
return nil, err
}
tp := trace.NewTracerProvider(
trace.WithBatcher(traceExporter),
trace.WithResource(res),
)
otel.SetTracerProvider(tp)
p.shutdown = append(p.shutdown, tp.Shutdown)
metricExporter, err := otlpmetrichttp.New(ctx, metricOptions(endpoint)...)
if err != nil {
_ = tp.Shutdown(ctx)
return nil, err
}
mp := metric.NewMeterProvider(
metric.WithReader(metric.NewPeriodicReader(metricExporter,
metric.WithInterval(15*time.Second))),
metric.WithResource(res),
)
otel.SetMeterProvider(mp)
p.shutdown = append(p.shutdown, mp.Shutdown)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{},
))
return p, nil
}
// Shutdown flushes telemetry. Safe to call when telemetry is disabled.
func (p *Provider) Shutdown(ctx context.Context) error {
if p == nil {
return nil
}
var firstErr error
for _, fn := range p.shutdown {
if err := fn(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func traceOptions(endpoint string) []otlptracehttp.Option {
if strings.Contains(endpoint, "://") {
return []otlptracehttp.Option{otlptracehttp.WithEndpointURL(endpoint)}
}
return []otlptracehttp.Option{
otlptracehttp.WithEndpoint(endpoint),
otlptracehttp.WithInsecure(),
}
}
func metricOptions(endpoint string) []otlpmetrichttp.Option {
if strings.Contains(endpoint, "://") {
return []otlpmetrichttp.Option{otlpmetrichttp.WithEndpointURL(endpoint)}
}
return []otlpmetrichttp.Option{
otlpmetrichttp.WithEndpoint(endpoint),
otlpmetrichttp.WithInsecure(),
}
}

View File

@ -0,0 +1,9 @@
package: api
generate:
models: true
gin-server: true
strict-server: true
embedded-spec: true
output: internal/api/generated.go
output-options:
skip-prune: false

View File

@ -0,0 +1,845 @@
openapi: 3.0.3
info:
title: Kererū Forms API
version: 1.0.0
description: |
API for **Kererū forms**, a simple survey/form capture system.
Forms are versioned and can be inactivated. A form contains an ordered set of
questions whose answers are one of: true/false, numeric, a drop-down selection,
or free-form text. Responses are identified by a user identifier captured at the
start of the form. A form may optionally require a single-use access code.
contact:
name: Kererū Forms
email: support@kereru.forms
license:
name: MIT
servers:
- url: http://localhost:8080
description: Local development
- url: http://localhost:8080/api/v1
description: Local development (v1 base)
tags:
- name: system
description: Health and meta endpoints
- name: forms
description: Form definition and management
- name: submissions
description: Form submissions and management of captured data
- name: access-codes
description: Single-use access codes
paths:
/health:
get:
tags: [system]
operationId: getHealth
summary: Service health
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
$ref: '#/components/schemas/Health'
/forms:
get:
tags: [forms]
operationId: listForms
summary: List forms
description: Returns form summaries. Use `includeInactive` to include inactivated forms (management view).
parameters:
- name: includeInactive
in: query
required: false
schema:
type: boolean
default: false
- name: activeOnly
in: query
required: false
deprecated: true
schema:
type: boolean
responses:
'200':
description: A list of forms
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/FormSummary'
'500':
$ref: '#/components/responses/Error'
post:
tags: [forms]
operationId: createForm
summary: Create a form
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateFormRequest'
responses:
'201':
description: Form created
content:
application/json:
schema:
$ref: '#/components/schemas/Form'
'400':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
/forms/{formId}:
parameters:
- $ref: '#/components/parameters/FormId'
get:
tags: [forms]
operationId: getForm
summary: Get a form by id
responses:
'200':
description: The form
content:
application/json:
schema:
$ref: '#/components/schemas/Form'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
put:
tags: [forms]
operationId: updateForm
summary: Update a form definition
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateFormRequest'
responses:
'200':
description: Updated form
content:
application/json:
schema:
$ref: '#/components/schemas/Form'
'400':
$ref: '#/components/responses/Error'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
delete:
tags: [forms]
operationId: deleteForm
summary: Delete a form definition
responses:
'204':
description: Deleted
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
/forms/{formId}/inactivate:
parameters:
- $ref: '#/components/parameters/FormId'
post:
tags: [forms]
operationId: inactivateForm
summary: Inactivate a form
description: Inactivated forms remain visible in management but cannot accept new submissions.
responses:
'200':
description: Inactivated form
content:
application/json:
schema:
$ref: '#/components/schemas/Form'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
/forms/{formId}/activate:
parameters:
- $ref: '#/components/parameters/FormId'
post:
tags: [forms]
operationId: activateForm
summary: Activate a form
responses:
'200':
description: Activated form
content:
application/json:
schema:
$ref: '#/components/schemas/Form'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
/forms/{formId}/versions:
parameters:
- $ref: '#/components/parameters/FormId'
post:
tags: [forms]
operationId: createFormVersion
summary: Create a new version of a form
description: Clones the form and its questions into a new version with the version number incremented. The new version is active; the previous version is inactivated.
responses:
'201':
description: New form version
content:
application/json:
schema:
$ref: '#/components/schemas/Form'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
/forms/{formId}/pdf:
parameters:
- $ref: '#/components/parameters/FormId'
get:
tags: [forms]
operationId: getFormPdf
summary: Download a PDF rendering of the form definition
responses:
'200':
description: PDF document generated from the form definition
content:
application/pdf:
schema:
type: string
format: binary
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
/forms/{formId}/submissions:
parameters:
- $ref: '#/components/parameters/FormId'
get:
tags: [submissions]
operationId: listFormSubmissions
summary: List submissions for a form (management)
responses:
'200':
description: Submissions
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/SubmissionSummary'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
post:
tags: [submissions]
operationId: submitForm
summary: Submit an answer set for a form
description: |
Captures a completed form. The `userId` (captured at the start of the form)
identifies the respondent. If the form requires an access code, a valid,
unused `accessCode` must be supplied and is consumed on success.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitFormRequest'
responses:
'201':
description: Submission accepted
content:
application/json:
schema:
$ref: '#/components/schemas/Submission'
'400':
$ref: '#/components/responses/Error'
'404':
$ref: '#/components/responses/Error'
'409':
$ref: '#/components/responses/Error'
'410':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
/submissions/{submissionId}:
parameters:
- name: submissionId
in: path
required: true
schema:
type: string
format: uuid
get:
tags: [submissions]
operationId: getSubmission
summary: Get a submission by id (management)
responses:
'200':
description: Submission
content:
application/json:
schema:
$ref: '#/components/schemas/Submission'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
/submissions/{submissionId}/offline-pdf:
parameters:
- name: submissionId
in: path
required: true
schema:
type: string
format: uuid
get:
tags: [submissions]
operationId: getSubmissionOfflinePdf
summary: Download the offline PDF uploaded with a submission
responses:
'200':
description: The uploaded PDF
content:
application/pdf:
schema:
type: string
format: binary
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
/forms/{formId}/access-codes:
parameters:
- $ref: '#/components/parameters/FormId'
get:
tags: [access-codes]
operationId: listAccessCodes
summary: List access codes for a form
responses:
'200':
description: Access codes
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/AccessCode'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
post:
tags: [access-codes]
operationId: createAccessCodes
summary: Create one or more access codes for a form
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAccessCodesRequest'
responses:
'201':
description: Created access codes
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/AccessCode'
'400':
$ref: '#/components/responses/Error'
'404':
$ref: '#/components/responses/Error'
'500':
$ref: '#/components/responses/Error'
components:
parameters:
FormId:
name: formId
in: path
required: true
schema:
type: string
format: uuid
responses:
Error:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
schemas:
Health:
type: object
required: [status, version]
properties:
status:
type: string
example: ok
version:
type: string
example: 1.0.0
uptimeSeconds:
type: integer
format: int64
QuestionType:
type: string
description: |
* `boolean` – true/false
* `number` – numeric answer
* `select` – one option from a configured list
* `text` – free-form text (single or multiline)
* `date` - calendar date in `YYYY-MM-DD` form
enum: [boolean, number, select, text, date]
QuestionOption:
type: object
required: [value, label]
properties:
value:
type: string
label:
type: string
Question:
type: object
required: [id, position, key, label, type, required]
properties:
id:
type: string
format: uuid
position:
type: integer
key:
type: string
description: Stable machine key for this question within the form
label:
type: string
description: The question text (may be multiline, no formatting directives)
description:
type: string
type:
$ref: '#/components/schemas/QuestionType'
required:
type: boolean
options:
type: array
description: Present for `select` questions
items:
$ref: '#/components/schemas/QuestionOption'
minValue:
type: number
description: Minimum allowed value for `number` questions
maxValue:
type: number
description: Maximum allowed value for `number` questions
placeholder:
type: string
multiline:
type: boolean
description: Whether a `text` answer may span multiple lines
FormSummary:
type: object
required: [id, formGroupId, version, title, isActive, questionCount, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
formGroupId:
type: string
format: uuid
description: Groups all versions of the same logical form
version:
type: integer
title:
type: string
description:
type: string
logoUrl:
type: string
pdfUrl:
type: string
description: Optional absolute URL of a prebuilt PDF form linked to this definition
externalReferenceRequired:
type: boolean
description: Whether a reference identifier is required before the form can be submitted
externalReferenceLabel:
type: string
description: Label shown for the external reference, for example Invoice number
isActive:
type: boolean
questionCount:
type: integer
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
Form:
type: object
required: [id, formGroupId, version, title, isActive, requiresAccessCode, questions, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
formGroupId:
type: string
format: uuid
version:
type: integer
title:
type: string
description:
type: string
introduction:
type: string
description: Introduction section explaining purpose, background and support details
isActive:
type: boolean
requiresAccessCode:
type: boolean
signatureRequired:
type: boolean
description: Whether a digital signature is captured at the end of the form
logoUrl:
type: string
pdfUrl:
type: string
description: Optional absolute URL of a prebuilt PDF form linked to this definition
externalReferenceRequired:
type: boolean
description: Whether a reference identifier is required before the form can be submitted
externalReferenceLabel:
type: string
description: Label shown for the external reference, for example Invoice number
companyName:
type: string
contactEmail:
type: string
contactPhone:
type: string
contactUrl:
type: string
questions:
type: array
items:
$ref: '#/components/schemas/Question'
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
QuestionInput:
type: object
required: [key, label, type, required]
properties:
key:
type: string
label:
type: string
description:
type: string
type:
$ref: '#/components/schemas/QuestionType'
required:
type: boolean
options:
type: array
items:
$ref: '#/components/schemas/QuestionOption'
minValue:
type: number
maxValue:
type: number
placeholder:
type: string
multiline:
type: boolean
CreateFormRequest:
type: object
required: [title, questions]
properties:
title:
type: string
description:
type: string
introduction:
type: string
requiresAccessCode:
type: boolean
signatureRequired:
type: boolean
logoUrl:
type: string
pdfUrl:
type: string
externalReferenceRequired:
type: boolean
externalReferenceLabel:
type: string
companyName:
type: string
contactEmail:
type: string
contactPhone:
type: string
contactUrl:
type: string
questions:
type: array
items:
$ref: '#/components/schemas/QuestionInput'
UpdateFormRequest:
type: object
required: [title, questions]
properties:
title:
type: string
description:
type: string
introduction:
type: string
requiresAccessCode:
type: boolean
signatureRequired:
type: boolean
logoUrl:
type: string
pdfUrl:
type: string
externalReferenceRequired:
type: boolean
externalReferenceLabel:
type: string
companyName:
type: string
contactEmail:
type: string
contactPhone:
type: string
contactUrl:
type: string
questions:
type: array
items:
$ref: '#/components/schemas/QuestionInput'
UserIdType:
type: string
enum: [email, username, phone, guid]
Answer:
type: object
required: [questionId, value]
properties:
questionId:
type: string
format: uuid
value:
description: Answer value. String, number, boolean or list of strings depending on the question type.
SubmitFormRequest:
type: object
required: [userId, userIdType, answers]
properties:
userId:
type: string
description: Respondent identifier captured at the start (email, username, phone or GUID)
userIdType:
$ref: '#/components/schemas/UserIdType'
accessCode:
type: string
description: Required when the form has `requiresAccessCode = true`
externalReference:
type: string
description: |-
External reference identifier used to link this response to a record in
another system, for example an invoice, account or policy number.
answers:
type: array
items:
$ref: '#/components/schemas/Answer'
signature:
$ref: '#/components/schemas/SignatureInput'
offlinePdf:
type: string
description: |
Base64 data URL of a PDF completed offline (for example
`data:application/pdf;base64,...`). Maximum decoded size is 10 MB.
offlinePdfName:
type: string
description: Original filename of the uploaded offline PDF
tracking:
$ref: '#/components/schemas/ClientTracking'
ClientTracking:
type: object
description: Client supplied tracking details. IP and user agent are captured server-side.
properties:
locale:
type: string
timezone:
type: string
place:
type: string
referer:
type: string
SignatureType:
type: string
description: |
* `drawn` - signature drawn in the browser
* `image` - uploaded image containing a signature
enum: [drawn, image]
SignatureInput:
type: object
required: [type, dataUrl, name]
properties:
type:
$ref: '#/components/schemas/SignatureType'
dataUrl:
type: string
description: PNG or JPEG image data URL (maximum decoded size 2 MB)
name:
type: string
description: Name of the person signing
title:
type: string
description: Optional title of the person signing
Signature:
type: object
required: [type, dataUrl, name, signedAt]
properties:
type:
$ref: '#/components/schemas/SignatureType'
dataUrl:
type: string
name:
type: string
title:
type: string
signedAt:
type: string
format: date-time
AnswerRecord:
type: object
required: [questionId, questionKey, value]
properties:
questionId:
type: string
format: uuid
questionKey:
type: string
value:
description: The captured value
Tracking:
type: object
properties:
ipAddress:
type: string
userAgent:
type: string
locale:
type: string
timezone:
type: string
place:
type: string
referer:
type: string
Submission:
type: object
required: [id, formId, formVersion, userId, userIdType, answers, submittedAt]
properties:
id:
type: string
format: uuid
formId:
type: string
format: uuid
formVersion:
type: integer
userId:
type: string
userIdType:
$ref: '#/components/schemas/UserIdType'
externalReference:
type: string
answers:
type: array
items:
$ref: '#/components/schemas/AnswerRecord'
signature:
$ref: '#/components/schemas/Signature'
offlinePdfName:
type: string
description: Present when the respondent uploaded a completed offline PDF
submittedAt:
type: string
format: date-time
tracking:
$ref: '#/components/schemas/Tracking'
SubmissionSummary:
type: object
required: [id, formId, formVersion, userId, userIdType, submittedAt]
properties:
id:
type: string
format: uuid
formId:
type: string
format: uuid
formVersion:
type: integer
userId:
type: string
userIdType:
$ref: '#/components/schemas/UserIdType'
externalReference:
type: string
submittedAt:
type: string
format: date-time
AccessCode:
type: object
required: [id, formId, code, used, createdAt]
properties:
id:
type: string
format: uuid
formId:
type: string
format: uuid
code:
type: string
used:
type: boolean
usedAt:
type: string
format: date-time
createdAt:
type: string
format: date-time
CreateAccessCodesRequest:
type: object
required: [count]
properties:
count:
type: integer
minimum: 1
maximum: 100
prefix:
type: string
Error:
type: object
required: [error]
properties:
error:
type: string
code:
type: string

View File

@ -0,0 +1,27 @@
# Kererū Forms environment sample
# Copy this file to .env next to the executable (or in the working directory).
# Values are never overwritten by existing environment variables.
# Logging
KERERU_LOG_LEVEL=info
KERERU_LOG_FORMAT=console
KERERU_ENVIRONMENT=development
# HTTP listeners (IPv4 and/or IPv6)
KERERU_LISTEN_ADDR=0.0.0.0
KERERU_LISTEN_ADDR6=
KERERU_PORT=8080
# Database (SQLite by default; swap to PostgreSQL/MariaDB later via dbDriver)
KERERU_DB_DRIVER=sqlite
KERERU_DB_DSN=file:kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)
# Optional: directory with the built SPA (served with history fallback)
KERERU_STATIC_DIR=
# Telemetry (OpenTelemetry OTLP)
KERERU_TELEMETRY_ENABLED=false
OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4318
# CORS allowed origins (comma separated). Use "*" to allow all.
KERERU_CORS_ORIGINS=http://localhost:5173

View File

@ -0,0 +1,16 @@
{
"logLevel": "info",
"logFormat": "console",
"environment": "development",
"listenAddr": "0.0.0.0",
"listenAddr6": "",
"port": "8080",
"dbDriver": "sqlite",
"dbDsn": "file:kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
"staticDir": "",
"telemetryEnabled": false,
"otlpEndpoint": "localhost:4318",
"corsOrigins": [
"http://localhost:5173"
]
}

View File

@ -0,0 +1,22 @@
# Kererū Forms configuration (YAML)
# Pass with: kereru-forms --conf ./config.yaml
logLevel: info
logFormat: console
environment: development
listenAddr: 0.0.0.0
listenAddr6: ""
port: "8080"
dbDriver: sqlite
dbDsn: "file:kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
# Optional: directory with the built SPA (served with history fallback)
staticDir: ""
telemetryEnabled: false
otlpEndpoint: "localhost:4318"
corsOrigins:
- "http://localhost:5173"

138
deploy/k8s.yaml 100644
View File

@ -0,0 +1,138 @@
apiVersion: v1
kind: Namespace
metadata:
name: kereru-forms
---
apiVersion: v1
kind: ConfigMap
metadata:
name: kereru-forms-config
namespace: kereru-forms
data:
KERERU_LOG_LEVEL: info
KERERU_LOG_FORMAT: json
KERERU_ENVIRONMENT: production
KERERU_LISTEN_ADDR: 0.0.0.0
KERERU_PORT: "8080"
KERERU_DB_DRIVER: sqlite
KERERU_DB_DSN: "file:/data/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
KERERU_STATIC_DIR: /app/static
KERERU_CORS_ORIGINS: "*"
KERERU_TELEMETRY_ENABLED: "false"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability.svc.cluster.local:4318"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: kereru-forms-data
namespace: kereru-forms
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kereru-forms
namespace: kereru-forms
labels:
app: kereru-forms
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: kereru-forms
template:
metadata:
labels:
app: kereru-forms
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
containers:
- name: kereru-forms
image: kereru-forms:1.0.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: kereru-forms-config
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: data
persistentVolumeClaim:
claimName: kereru-forms-data
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: kereru-forms
namespace: kereru-forms
spec:
selector:
app: kereru-forms
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kereru-forms
namespace: kereru-forms
annotations:
kubernetes.io/ingress.class: nginx
spec:
rules:
- host: forms.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kereru-forms
port:
name: http

43
docker/Dockerfile 100644
View File

@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.7
# --- Backend build ---
FROM golang:1.26 AS backend-builder
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/. ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=1.0.0" -o /kereru-forms ./cmd/kereru-forms
# --- Frontend build ---
FROM node:22 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/. ./
COPY backend/openapi /app/backend/openapi
RUN npm run build
# --- Runtime ---
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -g 10001 -S kereru \
&& adduser -u 10001 -S -G kereru kereru \
&& mkdir -p /data /app/static \
&& chown -R kereru:kereru /data /app
WORKDIR /app
ENV KERERU_LOG_LEVEL=info \
KERERU_LOG_FORMAT=json \
KERERU_ENVIRONMENT=production \
KERERU_LISTEN_ADDR=0.0.0.0 \
KERERU_PORT=8080 \
KERERU_DB_DRIVER=sqlite \
KERERU_DB_DSN="file:/data/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" \
KERERU_STATIC_DIR=/app/static \
KERERU_CORS_ORIGINS="*"
COPY --from=backend-builder /kereru-forms /app/kereru-forms
COPY --from=frontend-builder /app/frontend/dist /app/static
VOLUME ["/data"]
EXPOSE 8080
USER kereru:kereru
ENTRYPOINT ["/app/kereru-forms"]
CMD ["--listen-addr", "0.0.0.0", "--port", "8080"]

View File

@ -0,0 +1,33 @@
name: kereru-forms
services:
kereru-forms:
build:
context: ..
dockerfile: docker/Dockerfile
image: kereru-forms:1.0.0
container_name: kereru-forms
restart: unless-stopped
ports:
- "8080:8080"
environment:
KERERU_LOG_LEVEL: info
KERERU_LOG_FORMAT: json
KERERU_ENVIRONMENT: production
KERERU_LISTEN_ADDR: 0.0.0.0
KERERU_PORT: 8080
KERERU_DB_DRIVER: sqlite
KERERU_DB_DSN: "file:/data/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
KERERU_CORS_ORIGINS: "http://localhost:8080"
KERERU_TELEMETRY_ENABLED: "false"
volumes:
- kereru-data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
kereru-data:

158
docs/AGENTS.md 100644
View File

@ -0,0 +1,158 @@
# Design
Note **AI AGENT NOT TO UPDATE THIS FILE**
This is the design for a simple forms site to be used to capture a number of survey type questions
fom a person. The forms will be like Adove PDF forms and allow for configuration of questions to
ask and an answer. The questions can be multiline text and do not contain formatting commands
within the question.
The answer can be of types:
1. True or false
2. Numeric answer
3. Drop down options
4. Free form text
5. Date field
Project name is ``Kererū forms`` and its internal identifier is ``kereru.forms``.
## Technology stack
The design is based around a modern web UI using React + Vite + Tailwind CSS as the frontend and a Golang backend.
The database is any standard SQL DB and the initial verion is using SQLite. A SQL provider
will allow future flexibility for other database SQL variants such as:
* Postgres
* MariaDB
## Backend
The backend executable server name is to be ``kereru-forms``.
The backend is to expose APIs that are defined in an OpenAPI file
which is used to generate the golang code. A health service is
to be included.
Include telemetry, logging and observability.
## Frontend
Allow for themeing the UI for light, dark and system.
Past data captured is not presented again to the user inputing the data
A management section exists for viewing past data forms completions, and
managing forms. Forms can be inactivated and can have versions.
A form can contain an introduction section explaining purpose, background
and further contact or support details.
There is the ability by the user supplying the form to request a
PDF version that is generated from the form definition.
The PDF form can be generated by the person submitting the form
so that the PDF form can be completed offline and even printed.
The PDF form is to have an area for including the submitters details.
Alternatively the PDF form can be a prebuilt Adbove PDF document
linked to the survey definition.
The form can be configured to request a unique user code for filling in
the form and once used the code cannot be used again.
The questions are to be presented on one line with question at left and answer options on right,
except for free form text which is input below the question.
A form can have a digital signature defined at the end which can capture
a drawn sigature, a picture file with signature, name of person and
optional title.
Include an option at the start of the survey to upload a PDF form that
has been completed offline by the user. This upload will still need to
capture the identity of the person uploading the PDF document.
Include UI for displaying the OpenAPI definition as a web page under path /docs.
The UI for OpenAPI definition is not to be linked to an external service
providing the rendering and must be able to be run locally for
offline dvelopment.
For management section when viewing past submissions and answers, show
the question along with the answwer.
## Database Schema
The schema is to allow for multiple forms and capture the user input
using an user identifier such as email, username, international phine number or GUID.
The identifier is captured at the start.
Forms can be versioned and include a logo and company name plus contact details that is
linked to the form. Existing forms can be edited which will generate a new version number
for the form definition.
A form can be configured to require an external reference identifier such as
invoice number, account number, policy number. This identifier will be used
to verfiy and link the form to an external consumer of the data.
Each form capture is to gather details about the time, place, IP, browser as tracking detais.
## Authentication
Include OAuth authentication for the management services including viewing past submissions.
The OAuth is to be configurable to use external service such as Okta, KeyCloak. If no serviec provided
then a simple password is to be used so that the application can be tested locally.
Accessing the form submission can optionally be configured to require authentication against the
OAuth identity provider. Access for submission does not require a role claim just authentication.
Access to manage forms for CRUD purposes requires an admin role claim in the JWT. Access to view
forms that have been submitted requires a view role claim in the JWT. Uusers with admin role claim
only cannot view submissions.
## Configuration
The solution is to include configuration. Configuration is supplied via a JSON or YAML
file.
The configuration file is loaded first and can be specified as argument --conf to
the backend program. Once the configuration file is loaded the values are overridden
by envionment variables that can also be supplied as a .env file in the path of
the executable.
The following arguments can be supplied to executable as arguments, overriding
the configuration and environment inputs.
* configuration file location
* logging level
* listening address either IPv4 and/or IPv6. Both can be supplied.
* listening port
## Testing
Create test cases for code in both frontend and backend.
## Containerisation
The solution is to be containerised and has
* Dockerfile
* Docker compose file
Instructions for Docker, Podman and Kubernetes setup are to be provided.
## Documentation
The design documentation and design decisions are to be captured in
document ``ARCHITECTURE.md`` in folder ``docs``.
Create a sample environment and configuration file.
## Development
Create developer instructions for local execution and producton deployment.
The frontend is to be executable separately from the backend
using ``npm run dev`` for local development
The backend is to be executed in local development with standard
Golang command to run.

View File

@ -0,0 +1,117 @@
# 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).

156
docs/DEPLOYMENT.md 100644
View File

@ -0,0 +1,156 @@
# Deployment
## Local development
### Backend
```bash
cd backend
go mod download
go run ./cmd/kereru-forms \
--listen-addr 127.0.0.1 \
--port 8080 \
--log-format console \
--db-dsn "file:./kereru-dev.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
```
Useful flags:
```bash
kereru-forms --help
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Vite serves on <http://localhost:5173> and proxies `/api/*` to `http://localhost:8080`. To point at another API host, set `VITE_API_BASE_URL` at build/dev time:
```bash
VITE_API_BASE_URL=http://localhost:8080 npm run dev
```
## Production build without containers
```bash
# Backend binary
cd backend
CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=1.0.0" -o kereru-forms ./cmd/kereru-forms
# Frontend assets
cd ../frontend
npm ci
npm run build
```
Run the binary with the SPA directory and a persistent database:
```bash
KERERU_STATIC_DIR=../frontend/dist \
KERERU_DB_DSN="file:/var/lib/kereru/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" \
KERERU_LOG_FORMAT=json \
KERERU_ENVIRONMENT=production \
./kereru-forms --listen-addr 0.0.0.0 --port 8080
```
The binary serves the API at `/health`, `/forms`, `/submissions` and the SPA for every other path.
## Docker
Build from the repository root so both `backend/` and `frontend/` are in context:
```bash
docker build -f docker/Dockerfile -t kereru-forms:1.0.0 .
docker run -d --name kereru-forms \
-p 8080:8080 \
-v kereru-data:/data \
-e KERERU_CORS_ORIGINS="https://forms.example.com" \
kereru-forms:1.0.0
```
Or with Compose:
```bash
docker compose -f docker/docker-compose.yml up -d --build
```
The image runs as an unprivileged user, stores SQLite data in `/data`, and serves the built frontend from `/app/static`.
## Podman
The image is OCI-compatible. Use rootless Podman:
```bash
podman build -f docker/Dockerfile -t kereru-forms:1.0.0 .
podman run -d --name kereru-forms \
-p 8080:8080 \
-v kereru-data:/data:Z \
-e KERERU_CORS_ORIGINS="*" \
kereru-forms:1.0.0
```
With Compose (either `podman-compose` or `docker compose` using the Podman socket):
```bash
podman-compose -f docker/docker-compose.yml up -d --build
```
`podman generate systemd --new --name kereru-forms` (or a Quadlet unit) can turn the container into a system service.
## Kubernetes
1. Build, tag and push the image to a registry reachable by the cluster:
```bash
docker build -f docker/Dockerfile -t registry.example.com/kereru-forms:1.0.0 .
docker push registry.example.com/kereru-forms:1.0.0
```
2. Update `image:` and the Ingress host in [`deploy/k8s.yaml`](../deploy/k8s.yaml).
3. Apply:
```bash
kubectl apply -f deploy/k8s.yaml
kubectl -n kereru-forms rollout status deployment/kereru-forms
```
The manifest includes a namespace, config map, 1 GiB `ReadWriteOnce` PVC mounted at `/data`, a `Recreate` deployment (SQLite is single-writer), liveness/readiness probes on `/health`, a service on port 80 and an Ingress. A writable `/tmp` emptyDir is mounted because the container root filesystem is read-only.
### Scaling notes
SQLite requires a single writer. Keep `replicas: 1` and use the `Recreate` strategy. To scale horizontally, implement a network database adapter (PostgreSQL/MariaDB) behind `database.Store`, then switch `dbDriver`/`dbDsn` and lift the Deployment restrictions.
Use a `ReadWriteMany` volume only if you replace SQLite; do not share a SQLite file across pods.
## Configuration in production
Prefer environment variables (twelve-factor) or a mounted YAML/JSON file:
```bash
kereru-forms --conf /etc/kereru/config.yaml
```
Typical production settings:
- `KERERU_LOG_FORMAT=json`
- `KERERU_ENVIRONMENT=production`
- `KERERU_CORS_ORIGINS=https://forms.example.com`
- `KERERU_TELEMETRY_ENABLED=true` and `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318`
- `KERERU_STATIC_DIR=/app/static`
- SQLite DSN on a persistent volume: `file:/data/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)`
### Backups
For SQLite, back up the database file while the service is stopped, or use the SQLite online backup API/`.backup` command. Keep the volume and file permissions owned by the runtime user (UID used in the container image).
### Security
- Terminate TLS at the ingress/load balancer.
- Management routes (`/forms`, `/submissions`, `/forms/{id}/pdf`) are not authenticated; restrict them with an auth proxy or network policy.
- Set `KERERU_CORS_ORIGINS` to the exact frontend origin in production.
- Do not expose the SQLite file over a network share.

View File

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Kererū Forms — simple, versioned surveys." />
<title>Kererū Forms</title>
<script>
;(function () {
try {
var stored = localStorage.getItem('kereru-theme') || 'system'
var dark =
stored === 'dark' ||
(stored === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
if (dark) document.documentElement.classList.add('dark')
} catch (e) {}
})()
</script>
</head>
<body class="h-full">
<div id="root" class="min-h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6308
frontend/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
{
"name": "kereru-forms-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"swagger-ui-react": "^5.34.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^26.6.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#129c5b"/>
<path d="M18 16h20a10 10 0 0 1 0 20H26v12h-8V16Zm8 7v6h12a3 3 0 0 0 0-6H26Z" fill="#fff"/>
<circle cx="47" cy="47" r="5" fill="#b5f5d3"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@ -0,0 +1,39 @@
import { Suspense, lazy } from 'react'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { Layout } from './components/Layout'
import { FillFormPage } from './pages/FillFormPage'
import { FormBuilderPage } from './pages/FormBuilderPage'
import { FormDetailPage } from './pages/FormDetailPage'
import { HomePage } from './pages/HomePage'
import { ManagePage } from './pages/ManagePage'
import { SubmissionDetailPage } from './pages/SubmissionDetailPage'
const DocsPage = lazy(() =>
import('./pages/DocsPage').then((module) => ({ default: module.DocsPage })),
)
export function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="forms/:formId" element={<FillFormPage />} />
<Route path="manage" element={<ManagePage />} />
<Route path="manage/new" element={<FormBuilderPage />} />
<Route path="manage/forms/:formId" element={<FormDetailPage />} />
<Route path="manage/forms/:formId/edit" element={<FormBuilderPage />} />
<Route path="submissions/:submissionId" element={<SubmissionDetailPage />} />
<Route
path="docs"
element={
<Suspense fallback={<p className="text-slate-500 dark:text-slate-400">Loading API docs…</p>}>
<DocsPage />
</Suspense>
}
/>
</Route>
</Routes>
</BrowserRouter>
)
}

View File

@ -0,0 +1,55 @@
import { Link, NavLink, Outlet } from 'react-router-dom'
import { ThemeToggle } from './ThemeToggle'
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`rounded-lg px-3 py-2 text-sm font-medium transition ${
isActive
? 'bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-300'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`
export function Layout() {
return (
<div className="flex min-h-full flex-col">
<header className="border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-3 px-4 py-3 sm:px-6">
<Link to="/" className="flex items-center gap-2 text-lg font-bold tracking-tight">
<span
aria-hidden="true"
className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-600 text-sm font-black text-white"
>
K
</span>
<span>
Kererū <span className="text-brand-600 dark:text-brand-400">Forms</span>
</span>
</Link>
<nav className="order-3 flex w-full items-center gap-1 sm:order-2 sm:ml-4 sm:w-auto" aria-label="Main">
<NavLink to="/" end className={navLinkClass}>
Forms
</NavLink>
<NavLink to="/manage" className={navLinkClass}>
Management
</NavLink>
<NavLink to="/docs" className={navLinkClass}>
API docs
</NavLink>
</nav>
<div className="order-2 ml-auto sm:order-3">
<ThemeToggle />
</div>
</div>
</header>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6">
<Outlet />
</main>
<footer className="border-t border-slate-200 py-6 text-center text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
Kererū Forms · simple, versioned surveys
</footer>
</div>
)
}

View File

@ -0,0 +1,164 @@
import type { ReactNode } from 'react'
import type { Question } from '../lib/types'
interface QuestionFieldProps {
question: Question
value: unknown
onChange: (value: unknown) => void
}
export function QuestionField({ question, value, onChange }: QuestionFieldProps) {
const id = `q-${question.id}`
const descriptionId = question.description ? `${id}-description` : undefined
const isText = question.type === 'text'
const label = (
<label htmlFor={id} className="block text-sm font-semibold text-slate-900 dark:text-slate-100">
{question.label}
{question.required && (
<span className="ml-1 text-red-600 dark:text-red-400" aria-label="required">
*
</span>
)}
</label>
)
const description = question.description ? (
<p id={descriptionId} className="text-sm text-slate-500 dark:text-slate-400">
{question.description}
</p>
) : null
let control: ReactNode = null
if (question.type === 'boolean') {
control = (
<div id={id} className="flex gap-6 pt-1" role="radiogroup" aria-label={question.label}>
{[
{ label: 'True', checked: value === true },
{ label: 'False', checked: value === false },
].map((option) => (
<label key={option.label} className="flex items-center gap-2 text-sm">
<input
type="radio"
name={`radio-${question.id}`}
checked={option.checked}
onChange={() => onChange(option.label === 'True')}
className="h-4 w-4 accent-brand-600"
/>
{option.label}
</label>
))}
</div>
)
}
if (question.type === 'number') {
control = (
<div className="space-y-1">
<input
id={id}
type="number"
className="input max-w-xs"
value={value === undefined || value === null ? '' : String(value)}
min={question.minValue}
max={question.maxValue}
step="any"
placeholder={question.placeholder}
aria-describedby={descriptionId}
onChange={(event) => {
const raw = event.target.value
onChange(raw === '' ? undefined : Number(raw))
}}
/>
{(question.minValue !== undefined || question.maxValue !== undefined) && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{question.minValue !== undefined && `Minimum ${question.minValue}`}
{question.minValue !== undefined && question.maxValue !== undefined && ' · '}
{question.maxValue !== undefined && `Maximum ${question.maxValue}`}
</p>
)}
</div>
)
}
if (question.type === 'date') {
control = (
<input
id={id}
type="date"
className="input max-w-xs"
value={typeof value === 'string' ? value : ''}
aria-describedby={descriptionId}
onChange={(event) => onChange(event.target.value || undefined)}
/>
)
}
if (question.type === 'select') {
control = (
<select
id={id}
className="input max-w-sm"
value={typeof value === 'string' ? value : ''}
aria-describedby={descriptionId}
onChange={(event) => onChange(event.target.value || undefined)}
>
<option value="">Select an option…</option>
{(question.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
}
if (question.type === 'text' && question.multiline) {
control = (
<textarea
id={id}
className="input min-h-[6rem]"
rows={4}
value={typeof value === 'string' ? value : ''}
placeholder={question.placeholder}
aria-describedby={descriptionId}
onChange={(event) => onChange(event.target.value)}
/>
)
}
if (question.type === 'text' && !question.multiline) {
control = (
<input
id={id}
type="text"
className="input"
value={typeof value === 'string' ? value : ''}
placeholder={question.placeholder}
aria-describedby={descriptionId}
onChange={(event) => onChange(event.target.value)}
/>
)
}
if (isText) {
return (
<div className="space-y-2">
{label}
{description}
{control}
</div>
)
}
return (
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-start">
<div className="space-y-1">
{label}
{description}
</div>
<div>{control}</div>
</div>
)
}

View File

@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from 'react'
import type { PointerEvent as ReactPointerEvent } from 'react'
import type { SignatureType } from '../lib/types'
export interface SignatureDraft {
type: SignatureType
dataUrl: string
}
interface SignaturePadProps {
value: SignatureDraft | null
onChange: (value: SignatureDraft | null) => void
}
const CANVAS_WIDTH = 640
const CANVAS_HEIGHT = 200
const MAX_IMAGE_BYTES = 2 * 1024 * 1024
export function SignaturePad({ value, onChange }: SignaturePadProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const drawing = useRef(false)
const [mode, setMode] = useState<SignatureType>(value?.type ?? 'drawn')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || mode !== 'drawn') return
canvas.width = CANVAS_WIDTH
canvas.height = CANVAS_HEIGHT
const context = canvas.getContext('2d')
if (!context) return
context.lineWidth = 2.5
context.lineCap = 'round'
context.lineJoin = 'round'
context.strokeStyle = '#0f172a'
}, [mode])
function position(event: ReactPointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
return {
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
}
}
function startDrawing(event: ReactPointerEvent<HTMLCanvasElement>) {
if (mode !== 'drawn') return
const canvas = canvasRef.current
const context = canvas?.getContext('2d')
if (!canvas || !context) return
event.preventDefault()
canvas.setPointerCapture(event.pointerId)
drawing.current = true
const point = position(event)
context.beginPath()
context.moveTo(point.x, point.y)
}
function draw(event: ReactPointerEvent<HTMLCanvasElement>) {
if (!drawing.current || mode !== 'drawn') return
const canvas = canvasRef.current
const context = canvas?.getContext('2d')
if (!canvas || !context) return
event.preventDefault()
const point = position(event)
context.lineTo(point.x, point.y)
context.stroke()
onChange({ type: 'drawn', dataUrl: canvas.toDataURL('image/png') })
}
function stopDrawing(event: ReactPointerEvent<HTMLCanvasElement>) {
if (!drawing.current) return
drawing.current = false
if (canvasRef.current?.hasPointerCapture(event.pointerId)) {
canvasRef.current.releasePointerCapture(event.pointerId)
}
}
function clear() {
const canvas = canvasRef.current
const context = canvas?.getContext('2d')
if (canvas && context) {
context.clearRect(0, 0, canvas.width, canvas.height)
}
setError(null)
onChange(null)
}
function switchMode(next: SignatureType) {
setMode(next)
setError(null)
if (next === 'drawn') {
onChange(null)
}
}
function uploadImage(file: File | undefined) {
if (!file) return
setError(null)
if (file.type !== 'image/png' && file.type !== 'image/jpeg') {
setError('Choose a PNG or JPEG image.')
return
}
if (file.size > MAX_IMAGE_BYTES) {
setError('The signature image must be 2 MB or smaller.')
return
}
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
setMode('image')
onChange({ type: 'image', dataUrl: reader.result })
}
}
reader.onerror = () => setError('Could not read that image.')
reader.readAsDataURL(file)
}
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
className={mode === 'drawn' ? 'btn-primary py-1 text-xs' : 'btn-secondary py-1 text-xs'}
onClick={() => switchMode('drawn')}
>
Draw signature
</button>
<label className={mode === 'image' ? 'btn-primary py-1 text-xs' : 'btn-secondary py-1 text-xs'}>
Upload image
<input
type="file"
accept="image/png,image/jpeg"
className="sr-only"
onChange={(event) => uploadImage(event.target.files?.[0])}
/>
</label>
{value && (
<button type="button" className="btn-secondary py-1 text-xs" onClick={clear}>
Clear
</button>
)}
</div>
{mode === 'drawn' ? (
<canvas
ref={canvasRef}
width={CANVAS_WIDTH}
height={CANVAS_HEIGHT}
className="h-40 w-full touch-none rounded-lg border border-dashed border-slate-300 bg-white dark:border-slate-700"
aria-label="Draw your signature"
onPointerDown={startDrawing}
onPointerMove={draw}
onPointerUp={stopDrawing}
onPointerCancel={stopDrawing}
onPointerLeave={stopDrawing}
/>
) : (
<div className="flex h-40 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-white p-2 dark:border-slate-700">
{value?.type === 'image' ? (
<img src={value.dataUrl} alt="Uploaded signature" className="max-h-full max-w-full object-contain" />
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">Upload an image containing your signature.</p>
)}
</div>
)}
{mode === 'drawn' && !value && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Sign inside the box using a mouse, finger or stylus.
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
)
}

View File

@ -0,0 +1,17 @@
interface StatusBadgeProps {
active: boolean
}
export function StatusBadge({ active }: StatusBadgeProps) {
return (
<span
className={`badge ${
active
? 'bg-brand-100 text-brand-800 dark:bg-brand-900/60 dark:text-brand-200'
: 'bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
}`}
>
{active ? 'Active' : 'Inactive'}
</span>
)
}

View File

@ -0,0 +1,42 @@
import { useTheme, type ThemePreference } from '../hooks/useTheme'
const options: Array<{ value: ThemePreference; label: string; icon: string }> = [
{ value: 'light', label: 'Light', icon: '☀' },
{ value: 'dark', label: 'Dark', icon: '☾' },
{ value: 'system', label: 'System', icon: '⌥' },
]
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<div
role="group"
aria-label="Colour theme"
className="inline-flex rounded-lg border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800"
>
{options.map((option) => {
const active = theme === option.value
return (
<button
key={option.value}
type="button"
onClick={() => setTheme(option.value)}
aria-pressed={active}
title={`${option.label} theme`}
className={`rounded-md px-2.5 py-1 text-xs font-semibold transition ${
active
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white'
: 'text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100'
}`}
>
<span aria-hidden="true" className="mr-1">
{option.icon}
</span>
{option.label}
</button>
)
})}
</div>
)
}

View File

@ -0,0 +1,69 @@
import { useCallback, useEffect, useState } from 'react'
export type ThemePreference = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
export const THEME_STORAGE_KEY = 'kereru-theme'
function readStoredTheme(): ThemePreference {
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY)
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
} catch {
// localStorage is unavailable; fall through to the default.
}
return 'system'
}
function prefersDark(): boolean {
return typeof window !== 'undefined' && !!window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
}
function applyTheme(preference: ThemePreference): ResolvedTheme {
const dark = preference === 'dark' || (preference === 'system' && prefersDark())
document.documentElement.classList.toggle('dark', dark)
return dark ? 'dark' : 'light'
}
export function useTheme() {
const [theme, setThemeState] = useState<ThemePreference>(readStoredTheme)
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
theme === 'dark' || (theme === 'system' && prefersDark()) ? 'dark' : 'light',
)
useEffect(() => {
const next = applyTheme(theme)
setResolved(next)
try {
localStorage.setItem(THEME_STORAGE_KEY, theme)
} catch {
// Persisting the preference is best-effort.
}
}, [theme])
useEffect(() => {
if (theme !== 'system' || !window.matchMedia) return
const query = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => setResolved(applyTheme('system'))
if (query.addEventListener) {
query.addEventListener('change', handler)
return () => query.removeEventListener('change', handler)
}
query.addListener(handler)
return () => query.removeListener(handler)
}, [theme])
const setTheme = useCallback((next: ThemePreference) => setThemeState(next), [])
const toggle = useCallback(() => {
setThemeState((current) => {
const isDark =
current === 'dark' || (current === 'system' && prefersDark())
return isDark ? 'light' : 'dark'
})
}, [])
return { theme, resolved, setTheme, toggle }
}

View File

@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
-webkit-text-size-adjust: 100%;
}
body {
@apply bg-slate-50 text-slate-900 antialiased dark:bg-slate-950 dark:text-slate-100;
}
:focus-visible {
@apply outline-none ring-2 ring-brand-500 ring-offset-2 ring-offset-white dark:ring-offset-slate-950;
}
}
@layer components {
.card {
@apply rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60;
}
.btn-primary {
@apply btn bg-brand-600 text-white hover:bg-brand-700;
}
.btn-secondary {
@apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800;
}
.btn-danger {
@apply btn bg-red-600 text-white hover:bg-red-700;
}
.label {
@apply mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300;
}
.input {
@apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500;
}
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
}
.table-wrap {
@apply overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-800;
}
.table {
@apply min-w-full divide-y divide-slate-200 text-left text-sm dark:divide-slate-800;
}
.table thead {
@apply bg-slate-100 text-xs uppercase tracking-wide text-slate-600 dark:bg-slate-800/60 dark:text-slate-400;
}
.table th {
@apply px-4 py-3 font-semibold;
}
.table td {
@apply px-4 py-3 text-slate-700 dark:text-slate-300;
}
.table tbody tr {
@apply border-b border-slate-100 last:border-0 dark:border-slate-800/60;
}
}

View File

@ -0,0 +1,152 @@
import type {
AccessCode,
ApiErrorBody,
CreateAccessCodesRequest,
CreateFormRequest,
Form,
FormSummary,
Health,
Submission,
SubmissionSummary,
SubmitFormRequest,
UpdateFormRequest,
} from './types'
export const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api'
export class ApiError extends Error {
readonly status: number
readonly code?: string
constructor(status: number, message: string, code?: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
})
if (!response.ok) {
let message = `Request failed with status ${response.status}`
let code: string | undefined
try {
const body = (await response.json()) as ApiErrorBody
if (body?.error) message = body.error
code = body?.code
} catch {
// Response was not JSON; keep the generic message.
}
throw new ApiError(response.status, message, code)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
export const api = {
health: () => request<Health>('/health'),
listForms: (includeInactive = false) =>
request<FormSummary[]>(`/forms${includeInactive ? '?includeInactive=true' : ''}`),
getForm: (id: string) => request<Form>(`/forms/${encodeURIComponent(id)}`),
createForm: (body: CreateFormRequest) =>
request<Form>('/forms', { method: 'POST', body: JSON.stringify(body) }),
updateForm: (id: string, body: UpdateFormRequest) =>
request<Form>(`/forms/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteForm: (id: string) =>
request<void>(`/forms/${encodeURIComponent(id)}`, { method: 'DELETE' }),
activateForm: (id: string) =>
request<Form>(`/forms/${encodeURIComponent(id)}/activate`, { method: 'POST' }),
inactivateForm: (id: string) =>
request<Form>(`/forms/${encodeURIComponent(id)}/inactivate`, { method: 'POST' }),
createVersion: (id: string) =>
request<Form>(`/forms/${encodeURIComponent(id)}/versions`, { method: 'POST' }),
formPdfUrl: (id: string) => `${BASE_URL}/forms/${encodeURIComponent(id)}/pdf`,
downloadPdf: async (id: string, filename?: string, linkedUrl?: string) => {
if (linkedUrl) {
const link = document.createElement('a')
link.href = linkedUrl
link.target = '_blank'
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
return
}
const response = await fetch(api.formPdfUrl(id))
if (!response.ok) {
throw new ApiError(response.status, 'Could not download the PDF')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename ?? `form-${id}.pdf`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
},
downloadSubmissionOfflinePdf: async (id: string, filename?: string) => {
const response = await fetch(
`${BASE_URL}/submissions/${encodeURIComponent(id)}/offline-pdf`,
)
if (!response.ok) {
throw new ApiError(response.status, 'Could not download the offline PDF')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename ?? `submission-${id}.pdf`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
},
listSubmissions: (formId: string) =>
request<SubmissionSummary[]>(`/forms/${encodeURIComponent(formId)}/submissions`),
getSubmission: (id: string) =>
request<Submission>(`/submissions/${encodeURIComponent(id)}`),
submitForm: (formId: string, body: SubmitFormRequest) =>
request<Submission>(`/forms/${encodeURIComponent(formId)}/submissions`, {
method: 'POST',
body: JSON.stringify(body),
}),
listAccessCodes: (formId: string) =>
request<AccessCode[]>(`/forms/${encodeURIComponent(formId)}/access-codes`),
createAccessCodes: (formId: string, body: CreateAccessCodesRequest) =>
request<AccessCode[]>(`/forms/${encodeURIComponent(formId)}/access-codes`, {
method: 'POST',
body: JSON.stringify(body),
}),
}

View File

@ -0,0 +1,194 @@
export type QuestionType = 'boolean' | 'date' | 'number' | 'select' | 'text'
export type UserIdType = 'email' | 'username' | 'phone' | 'guid'
export type SignatureType = 'drawn' | 'image'
export interface SignatureInput {
type: SignatureType
dataUrl: string
name: string
title?: string
}
export interface Signature extends SignatureInput {
signedAt: string
}
export interface QuestionOption {
value: string
label: string
}
export interface Question {
id: string
key: string
label: string
description?: string
type: QuestionType
required: boolean
options?: QuestionOption[]
minValue?: number
maxValue?: number
placeholder?: string
multiline?: boolean
}
export interface QuestionInput {
key: string
label: string
description?: string
type: QuestionType
required: boolean
options?: QuestionOption[]
minValue?: number
maxValue?: number
placeholder?: string
multiline?: boolean
}
export interface FormSummary {
id: string
formGroupId: string
version: number
title: string
description?: string
logoUrl?: string
pdfUrl?: string
externalReferenceRequired?: boolean
externalReferenceLabel?: string
isActive: boolean
questionCount: number
createdAt: string
updatedAt: string
}
export interface Form {
id: string
formGroupId: string
version: number
title: string
description?: string
introduction?: string
isActive: boolean
requiresAccessCode: boolean
signatureRequired?: boolean
externalReferenceRequired?: boolean
externalReferenceLabel?: string
logoUrl?: string
pdfUrl?: string
companyName?: string
contactEmail?: string
contactPhone?: string
contactUrl?: string
createdAt: string
updatedAt: string
questions: Question[]
}
export interface CreateFormRequest {
title: string
description?: string
introduction?: string
requiresAccessCode?: boolean
signatureRequired?: boolean
externalReferenceRequired?: boolean
externalReferenceLabel?: string
logoUrl?: string
pdfUrl?: string
companyName?: string
contactEmail?: string
contactPhone?: string
contactUrl?: string
questions: QuestionInput[]
}
export type UpdateFormRequest = CreateFormRequest
export interface Answer {
questionId: string
value: unknown
}
export interface ClientTracking {
locale?: string
timezone?: string
place?: string
referer?: string
}
export interface SubmitFormRequest {
userId: string
userIdType: UserIdType
accessCode?: string
tracking?: ClientTracking
externalReference?: string
answers: Answer[]
signature?: SignatureInput
offlinePdf?: string
offlinePdfName?: string
}
export interface AnswerRecord {
questionId: string
questionKey: string
value: unknown
}
export interface Tracking {
ipAddress?: string
userAgent?: string
locale?: string
timezone?: string
place?: string
referer?: string
}
export interface Submission {
id: string
formId: string
formVersion: number
userId: string
userIdType: UserIdType
submittedAt: string
externalReference?: string
tracking?: Tracking
answers: AnswerRecord[]
signature?: Signature
offlinePdfName?: string
}
export interface SubmissionSummary {
id: string
formId: string
formVersion: number
userId: string
userIdType: UserIdType
externalReference?: string
submittedAt: string
}
export interface AccessCode {
id: string
formId: string
code: string
used: boolean
usedAt?: string
createdAt: string
}
export interface CreateAccessCodesRequest {
count: number
prefix?: string
}
export interface Health {
status: string
version: string
uptimeSeconds?: number
}
export interface ApiErrorBody {
error: string
code?: string
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,20 @@
import SwaggerUI from 'swagger-ui-react'
import 'swagger-ui-react/swagger-ui.css'
export function DocsPage() {
const specUrl = `${import.meta.env.BASE_URL}openapi.yaml`
return (
<div className="space-y-4">
<header className="space-y-1">
<h1 className="text-2xl font-bold">API documentation</h1>
<p className="text-slate-500 dark:text-slate-400">
Interactive reference for the Kereru Forms API, served from the local OpenAPI specification.
</p>
</header>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-800">
<SwaggerUI url={specUrl} docExpansion="list" tryItOutEnabled />
</div>
</div>
)
}

View File

@ -0,0 +1,578 @@
import { useEffect, useMemo, useState } from 'react'
import type { FormEvent } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api, ApiError } from '../lib/api'
import type { Answer, Form, Submission, UserIdType } from '../lib/types'
import { QuestionField } from '../components/QuestionField'
import { SignaturePad } from '../components/SignaturePad'
import type { SignatureDraft } from '../components/SignaturePad'
type Step = 'intro' | 'questions' | 'done'
type CompletionMode = 'online' | 'offline'
const MAX_OFFLINE_PDF_BYTES = 10 * 1024 * 1024
function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') resolve(reader.result)
else reject(new Error('Could not read the file'))
}
reader.onerror = () => reject(new Error('Could not read the file'))
reader.readAsDataURL(file)
})
}
const userIdTypes: Array<{ value: UserIdType; label: string }> = [
{ value: 'email', label: 'Email address' },
{ value: 'username', label: 'Username' },
{ value: 'phone', label: 'Phone number' },
{ value: 'guid', label: 'GUID' },
]
function isBlank(value: unknown): boolean {
if (value === undefined || value === null) return true
if (typeof value === 'string') return value.trim() === ''
return false
}
export function FillFormPage() {
const { formId = '' } = useParams<{ formId: string }>()
const [form, setForm] = useState<Form | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [step, setStep] = useState<Step>('intro')
const [completionMode, setCompletionMode] = useState<CompletionMode>('online')
const [offlineFile, setOfflineFile] = useState<{ name: string; dataUrl: string } | null>(null)
const [offlineError, setOfflineError] = useState<string | null>(null)
const [signature, setSignature] = useState<SignatureDraft | null>(null)
const [signatureName, setSignatureName] = useState('')
const [signatureTitle, setSignatureTitle] = useState('')
const [userIdType, setUserIdType] = useState<UserIdType>('email')
const [userId, setUserId] = useState('')
const [accessCode, setAccessCode] = useState('')
const [externalReference, setExternalReference] = useState('')
const [answers, setAnswers] = useState<Record<string, unknown>>({})
const [formError, setFormError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [submission, setSubmission] = useState<Submission | null>(null)
const [downloadingPdf, setDownloadingPdf] = useState(false)
const [pdfError, setPdfError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
api
.getForm(formId)
.then((result) => {
if (!cancelled) setForm(result)
})
.catch((err: unknown) => {
if (!cancelled) {
setLoadError(err instanceof ApiError ? err.message : 'Could not load this form')
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [formId])
const answeredCount = useMemo(
() => Object.values(answers).filter((value) => !isBlank(value)).length,
[answers],
)
async function downloadPrintablePdf() {
if (!form) return
setPdfError(null)
setDownloadingPdf(true)
try {
const filename = form.title.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase()
await api.downloadPdf(form.id, `${filename || 'form'}.pdf`, form.pdfUrl)
} catch (err) {
setPdfError(err instanceof ApiError ? err.message : 'Could not download the PDF')
} finally {
setDownloadingPdf(false)
}
}
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading form…</p>
}
if (loadError || !form) {
return (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
{loadError ?? 'Form not found'}
</div>
)
}
if (!form.isActive && step !== 'done') {
return (
<div className="card space-y-2">
<h1 className="text-xl font-bold">This form is closed</h1>
<p className="text-slate-500 dark:text-slate-400">
“{form.title}” is no longer accepting responses.
</p>
<Link to="/" className="btn-secondary mt-2 w-fit">
Back to forms
</Link>
</div>
)
}
function validateIdentity(): boolean {
if (userId.trim() === '') {
setFormError('Please enter your identifier before continuing.')
return false
}
if (form!.requiresAccessCode && accessCode.trim() === '') {
setFormError('An access code is required for this form.')
return false
}
if (form!.externalReferenceRequired && externalReference.trim() === '') {
setFormError(`${form!.externalReferenceLabel ?? 'External reference'} is required.`)
return false
}
return true
}
function completionTracking() {
return {
locale: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
referer: document.referrer || undefined,
}
}
async function completeSubmission(extra: {
answers: Answer[]
signature?: { type: 'drawn' | 'image'; dataUrl: string; name: string; title?: string }
offlinePdf?: string
offlinePdfName?: string
}) {
if (!form) return
setFormError(null)
setSubmitting(true)
try {
const result = await api.submitForm(form.id, {
userId: userId.trim(),
userIdType,
accessCode: form.requiresAccessCode ? accessCode.trim() : undefined,
externalReference: externalReference.trim() || undefined,
tracking: completionTracking(),
...extra,
})
setSubmission(result)
setAnswers({})
setAccessCode('')
setUserId('')
setExternalReference('')
setSignature(null)
setSignatureName('')
setSignatureTitle('')
setOfflineFile(null)
setStep('done')
} catch (err) {
setFormError(err instanceof ApiError ? err.message : 'Could not submit the form')
} finally {
setSubmitting(false)
}
}
async function chooseOfflineFile(file: File | undefined) {
setOfflineError(null)
setFormError(null)
if (!file) {
setOfflineFile(null)
return
}
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
setOfflineError('Choose a PDF file.')
return
}
if (file.size > MAX_OFFLINE_PDF_BYTES) {
setOfflineError('The PDF must be 10 MB or smaller.')
return
}
try {
const dataUrl = await readFileAsDataURL(file)
setOfflineFile({ name: file.name, dataUrl })
} catch {
setOfflineError('Could not read that PDF.')
}
}
async function startQuestions(event: FormEvent) {
event.preventDefault()
setFormError(null)
if (!validateIdentity()) return
if (completionMode === 'offline') {
if (!offlineFile) {
setOfflineError('Upload the completed PDF before submitting.')
return
}
await completeSubmission({
answers: [],
offlinePdf: offlineFile.dataUrl,
offlinePdfName: offlineFile.name,
})
return
}
setStep('questions')
}
async function submit(event: FormEvent) {
event.preventDefault()
setFormError(null)
if (!form) return
const missing = form.questions.filter((q) => q.required && isBlank(answers[q.id]))
if (missing.length > 0) {
setFormError(`Please answer: ${missing.map((q) => q.label).join(', ')}`)
return
}
const payloadAnswers: Answer[] = form.questions
.filter((q) => !isBlank(answers[q.id]))
.map((q) => ({ questionId: q.id, value: answers[q.id] }))
let signaturePayload:
| { type: 'drawn' | 'image'; dataUrl: string; name: string; title?: string }
| undefined
if (form.signatureRequired) {
if (!signature) {
setFormError('Please provide your signature before submitting.')
return
}
if (signatureName.trim() === '') {
setFormError('Please enter the name of the person signing.')
return
}
signaturePayload = {
type: signature.type,
dataUrl: signature.dataUrl,
name: signatureName.trim(),
title: signatureTitle.trim() || undefined,
}
}
await completeSubmission({ answers: payloadAnswers, signature: signaturePayload })
}
if (step === 'done') {
return (
<div className="card mx-auto max-w-xl space-y-3 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-100 text-2xl text-brand-700 dark:bg-brand-900 dark:text-brand-200">
✓
</div>
<h1 className="text-2xl font-bold">Thank you</h1>
<p className="text-slate-500 dark:text-slate-400">
Your response to “{form.title}” has been recorded.
</p>
{submission && (
<p className="text-xs text-slate-400 dark:text-slate-500">
Reference {submission.id}
</p>
)}
{pdfError && <p className="text-sm text-red-600 dark:text-red-400">{pdfError}</p>}
<div className="flex flex-wrap justify-center gap-3 pt-2">
<button
type="button"
className="btn-secondary"
onClick={downloadPrintablePdf}
disabled={downloadingPdf}
>
{downloadingPdf ? 'Preparing PDF…' : 'Download printable PDF'}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => {
setSubmission(null)
setStep('intro')
}}
>
Submit another response
</button>
<Link to="/" className="btn-primary">
Back to forms
</Link>
</div>
</div>
)
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="space-y-2">
{form.logoUrl && (
<img
src={form.logoUrl}
alt={form.companyName ?? form.title}
className="h-14 w-auto"
referrerPolicy="no-referrer"
/>
)}
{form.companyName && (
<p className="text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">
{form.companyName}
</p>
)}
<h1 className="text-3xl font-bold">{form.title}</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">Version {form.version}</p>
{form.description && (
<p className="text-slate-600 dark:text-slate-300">{form.description}</p>
)}
{form.introduction && (
<div className="card bg-slate-100/70 dark:bg-slate-900/60">
<p className="whitespace-pre-line text-sm text-slate-700 dark:text-slate-300">
{form.introduction}
</p>
</div>
)}
{(form.contactEmail || form.contactPhone || form.contactUrl) && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Questions?{' '}
{[form.contactEmail, form.contactPhone, form.contactUrl].filter(Boolean).join(' · ')}
</p>
)}
<div className="pt-1">
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={downloadPrintablePdf}
disabled={downloadingPdf}
>
{downloadingPdf ? 'Preparing PDF…' : 'Download printable PDF'}
</button>
</div>
{pdfError && <p className="text-sm text-red-600 dark:text-red-400">{pdfError}</p>}
</header>
{step === 'intro' && (
<form onSubmit={startQuestions} className="card space-y-4">
<h2 className="text-lg font-semibold">Before you begin</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
We use your identifier to link this response to you. It is not shown again after you
submit.
</p>
<fieldset className="space-y-2">
<legend className="label">How would you like to respond?</legend>
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
name="completionMode"
checked={completionMode === 'online'}
onChange={() => setCompletionMode('online')}
className="mt-0.5 h-4 w-4 accent-brand-600"
/>
<span>
<span className="font-medium">Complete the form online</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">
Answer the questions here and submit your response.
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
name="completionMode"
checked={completionMode === 'offline'}
onChange={() => setCompletionMode('offline')}
className="mt-0.5 h-4 w-4 accent-brand-600"
/>
<span>
<span className="font-medium">Upload a completed PDF</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">
Download the PDF, complete it offline, then upload it with your identity details.
</span>
</span>
</label>
</fieldset>
{completionMode === 'offline' && (
<div>
<label htmlFor="offlinePdf" className="label">
Completed PDF *
</label>
<input
id="offlinePdf"
type="file"
accept="application/pdf,.pdf"
className="input"
onChange={(event) => void chooseOfflineFile(event.target.files?.[0])}
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Maximum 10 MB. {offlineFile ? `Selected: ${offlineFile.name}` : 'No file selected.'}
</p>
{offlineError && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{offlineError}</p>}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="userIdType" className="label">
Identifier type
</label>
<select
id="userIdType"
className="input"
value={userIdType}
onChange={(event) => setUserIdType(event.target.value as UserIdType)}
>
{userIdTypes.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="userId" className="label">
Your identifier
</label>
<input
id="userId"
className="input"
value={userId}
onChange={(event) => setUserId(event.target.value)}
placeholder={userIdType === 'email' ? 'you@example.com' : 'Enter your identifier'}
required
/>
</div>
</div>
{form.requiresAccessCode && (
<div>
<label htmlFor="accessCode" className="label">
Access code
</label>
<input
id="accessCode"
className="input uppercase"
value={accessCode}
onChange={(event) => setAccessCode(event.target.value.toUpperCase())}
placeholder="XXXX-XXXX"
required
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Each access code can be used once.
</p>
</div>
)}
{form.externalReferenceRequired && (
<div>
<label htmlFor="externalReference" className="label">
{form.externalReferenceLabel ?? 'External reference'}
</label>
<input
id="externalReference"
className="input"
value={externalReference}
onChange={(event) => setExternalReference(event.target.value)}
maxLength={200}
required
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Links this response to a record in another system.
</p>
</div>
)}
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting
? 'Submitting…'
: completionMode === 'offline'
? 'Upload and submit response'
: 'Start form'}
</button>
</form>
)}
{step === 'questions' && (
<form onSubmit={submit} className="space-y-4">
<div className="flex items-center justify-between text-sm text-slate-500 dark:text-slate-400">
<span>
{answeredCount} of {form.questions.length} answered
</span>
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={() => setStep('intro')}
>
Back
</button>
</div>
{form.questions.map((question) => (
<div key={question.id} className="card">
<QuestionField
question={question}
value={answers[question.id]}
onChange={(value) =>
setAnswers((current) => ({ ...current, [question.id]: value }))
}
/>
</div>
))}
{form.signatureRequired && (
<section className="card space-y-4">
<div>
<h2 className="text-lg font-semibold">Signature</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Draw your signature or upload an image of it. Your name is required; a title is
optional.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="signatureName" className="label">
Name of person signing *
</label>
<input
id="signatureName"
className="input"
value={signatureName}
onChange={(event) => setSignatureName(event.target.value)}
required
/>
</div>
<div>
<label htmlFor="signatureTitle" className="label">
Title (optional)
</label>
<input
id="signatureTitle"
className="input"
value={signatureTitle}
onChange={(event) => setSignatureTitle(event.target.value)}
/>
</div>
</div>
<SignaturePad value={signature} onChange={setSignature} />
</section>
)}
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Submitting…' : 'Submit response'}
</button>
</form>
)}
</div>
)
}

View File

@ -0,0 +1,574 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, ApiError } from '../lib/api'
import type { Form, QuestionInput, QuestionOption, QuestionType } from '../lib/types'
const questionTypes: Array<{ value: QuestionType; label: string; hint: string }> = [
{ value: 'text', label: 'Text', hint: 'Free-form text (single or multiline)' },
{ value: 'boolean', label: 'True / false', hint: 'Yes/no style answer' },
{ value: 'number', label: 'Number', hint: 'Numeric input with optional bounds' },
{ value: 'select', label: 'Select', hint: 'Choose one from a list' },
{ value: 'date', label: 'Date', hint: 'Calendar date in YYYY-MM-DD format' },
]
function emptyQuestion(): QuestionInput {
return {
key: `q${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
label: '',
type: 'text',
required: false,
multiline: false,
}
}
function emptyOption(): QuestionOption {
return { value: '', label: '' }
}
function slugKey(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64) || 'question'
)
}
export function FormBuilderPage() {
const { formId } = useParams<{ formId?: string }>()
const navigate = useNavigate()
const isEdit = Boolean(formId)
const [loading, setLoading] = useState(isEdit)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [introduction, setIntroduction] = useState('')
const [requiresAccessCode, setRequiresAccessCode] = useState(false)
const [signatureRequired, setSignatureRequired] = useState(false)
const [externalReferenceRequired, setExternalReferenceRequired] = useState(false)
const [externalReferenceLabel, setExternalReferenceLabel] = useState('')
const [logoUrl, setLogoUrl] = useState('')
const [pdfUrl, setPdfUrl] = useState('')
const [companyName, setCompanyName] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('')
const [contactUrl, setContactUrl] = useState('')
const [questions, setQuestions] = useState<QuestionInput[]>([emptyQuestion()])
useEffect(() => {
if (!formId) return
let cancelled = false
api
.getForm(formId)
.then((form: Form) => {
if (cancelled) return
setTitle(form.title)
setDescription(form.description ?? '')
setIntroduction(form.introduction ?? '')
setRequiresAccessCode(form.requiresAccessCode)
setSignatureRequired(Boolean(form.signatureRequired))
setExternalReferenceRequired(Boolean(form.externalReferenceRequired))
setExternalReferenceLabel(form.externalReferenceLabel ?? '')
setLogoUrl(form.logoUrl ?? '')
setPdfUrl(form.pdfUrl ?? '')
setCompanyName(form.companyName ?? '')
setContactEmail(form.contactEmail ?? '')
setContactPhone(form.contactPhone ?? '')
setContactUrl(form.contactUrl ?? '')
setQuestions(
form.questions.map((q) => ({
key: q.key,
label: q.label,
description: q.description,
type: q.type,
required: q.required,
options: q.options ? q.options.map((o) => ({ ...o })) : undefined,
minValue: q.minValue,
maxValue: q.maxValue,
placeholder: q.placeholder,
multiline: q.multiline,
})),
)
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : 'Could not load the form')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [formId])
function addQuestion() {
setQuestions((current) => [...current, emptyQuestion()])
}
function updateQuestion(index: number, patch: Partial<QuestionInput>) {
setQuestions((current) => {
const next = [...current]
next[index] = { ...next[index], ...patch }
return next
})
}
function suggestKey(index: number) {
setQuestions((current) => {
const next = [...current]
next[index] = { ...next[index], key: slugKey(next[index].label) }
return next
})
}
function removeQuestion(index: number) {
setQuestions((current) => current.filter((_, i) => i !== index))
}
function addOption(index: number) {
setQuestions((current) => {
const next = [...current]
const options = next[index].options ? [...next[index].options] : []
options.push(emptyOption())
next[index] = { ...next[index], options }
return next
})
}
function updateOption(qIndex: number, oIndex: number, patch: Partial<QuestionOption>) {
setQuestions((current) => {
const next = [...current]
const options = next[qIndex].options ? [...next[qIndex].options] : []
options[oIndex] = { ...options[oIndex], ...patch }
next[qIndex] = { ...next[qIndex], options }
return next
})
}
function removeOption(qIndex: number, oIndex: number) {
setQuestions((current) => {
const next = [...current]
const options = (next[qIndex].options ?? []).filter((_, i) => i !== oIndex)
next[qIndex] = { ...next[qIndex], options }
return next
})
}
async function save(event: React.FormEvent) {
event.preventDefault()
setError(null)
if (title.trim() === '') {
setError('Title is required.')
return
}
if (questions.length === 0) {
setError('Add at least one question.')
return
}
const payload = {
title: title.trim(),
description: description.trim() || undefined,
introduction: introduction.trim() || undefined,
requiresAccessCode,
signatureRequired,
externalReferenceRequired,
externalReferenceLabel: externalReferenceLabel.trim() || undefined,
logoUrl: logoUrl.trim() || undefined,
pdfUrl: pdfUrl.trim() || undefined,
companyName: companyName.trim() || undefined,
contactEmail: contactEmail.trim() || undefined,
contactPhone: contactPhone.trim() || undefined,
contactUrl: contactUrl.trim() || undefined,
questions: questions.map((q) => {
const out: QuestionInput = {
key: q.key.trim(),
label: q.label.trim(),
type: q.type,
required: q.required,
description: q.description?.trim() || undefined,
placeholder: q.placeholder?.trim() || undefined,
}
if (q.type === 'number') {
out.minValue = q.minValue
out.maxValue = q.maxValue
}
if (q.type === 'text') {
out.multiline = q.multiline
}
if (q.type === 'select') {
out.options = (q.options ?? [])
.filter((o) => o.value.trim() && o.label.trim())
.map((o) => ({ value: o.value.trim(), label: o.label.trim() }))
}
return out
}),
}
setSaving(true)
try {
if (isEdit && formId) {
const updated = await api.updateForm(formId, payload)
navigate(`/manage/forms/${updated.id}`)
} else {
const created = await api.createForm(payload)
navigate(`/manage/forms/${created.id}`)
}
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not save the form')
} finally {
setSaving(false)
}
}
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading form…</p>
}
return (
<form onSubmit={save} className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<Link to="/manage" className="hover:text-brand-600 dark:hover:text-brand-400">
Management
</Link>
<span>/</span>
<span>{isEdit ? 'Edit form' : 'Create form'}</span>
</div>
<h1 className="mt-1 text-2xl font-bold">{isEdit ? 'Edit form' : 'Create form'}</h1>
<p className="text-slate-500 dark:text-slate-400">
{isEdit
? 'Saving creates a new version; earlier submissions keep their original definition.'
: 'Build a new versioned form.'}
</p>
</div>
<div className="flex gap-2">
<Link to="/manage" className="btn-secondary">
Cancel
</Link>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save form'}
</button>
</div>
</header>
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error}</div>
)}
<section className="card space-y-4">
<h2 className="text-lg font-semibold">Form details</h2>
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<label htmlFor="title" className="label">
Title *
</label>
<input id="title" className="input" value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div className="md:col-span-2">
<label htmlFor="description" className="label">
Description
</label>
<textarea
id="description"
className="input min-h-[4rem]"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="md:col-span-2">
<label htmlFor="introduction" className="label">
Introduction (optional section)
</label>
<textarea
id="introduction"
className="input min-h-[6rem]"
value={introduction}
onChange={(e) => setIntroduction(e.target.value)}
placeholder="Explain purpose, background and support details."
/>
</div>
<div>
<label htmlFor="company" className="label">
Company name
</label>
<input id="company" className="input" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div>
<label htmlFor="logo" className="label">
Logo URL
</label>
<input
id="logo"
className="input"
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
placeholder="https://example.com/logo.png"
/>
</div>
<div>
<label htmlFor="pdf" className="label">
Linked PDF URL
</label>
<input
id="pdf"
className="input"
type="url"
value={pdfUrl}
onChange={(e) => setPdfUrl(e.target.value)}
placeholder="https://example.com/prebuilt-form.pdf"
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Optional prebuilt PDF used for printable and offline copies.
</p>
</div>
<div>
<label htmlFor="email" className="label">
Contact email
</label>
<input id="email" className="input" type="email" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} />
</div>
<div>
<label htmlFor="phone" className="label">
Contact phone
</label>
<input id="phone" className="input" value={contactPhone} onChange={(e) => setContactPhone(e.target.value)} />
</div>
<div>
<label htmlFor="url" className="label">
Contact URL
</label>
<input id="url" className="input" value={contactUrl} onChange={(e) => setContactUrl(e.target.value)} />
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={requiresAccessCode}
onChange={(e) => setRequiresAccessCode(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
Require single-use access codes
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={signatureRequired}
onChange={(e) => setSignatureRequired(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
Require a digital signature at the end of the form
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={externalReferenceRequired}
onChange={(e) => setExternalReferenceRequired(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
Require an external reference from respondents
</label>
{externalReferenceRequired && (
<div>
<label htmlFor="externalReferenceLabel" className="label">
External reference label
</label>
<input
id="externalReferenceLabel"
className="input"
value={externalReferenceLabel}
onChange={(e) => setExternalReferenceLabel(e.target.value)}
placeholder="External reference"
maxLength={100}
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Shown to respondents and stored with each response, for example “Invoice number”.
</p>
</div>
)}
</section>
<section className="card space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Questions</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">Questions may not have formatting directives.</p>
</div>
<button type="button" className="btn-secondary" onClick={addQuestion}>
Add question
</button>
</div>
<div className="space-y-4">
{questions.map((question, index) => (
<article key={index} className="rounded-xl border border-slate-200 p-4 dark:border-slate-700">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold">Question {index + 1}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Key used to store answers</p>
</div>
<div className="flex gap-2">
<button type="button" className="btn-secondary py-1 text-xs" onClick={() => suggestKey(index)}>
Suggest key
</button>
{questions.length > 1 && (
<button type="button" className="btn-danger py-1 text-xs" onClick={() => removeQuestion(index)}>
Remove
</button>
)}
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className="label">Label *</label>
<input
className="input"
value={question.label}
onChange={(e) => updateQuestion(index, { label: e.target.value })}
required
/>
</div>
<div>
<label className="label">Key *</label>
<input
className="input font-mono text-sm"
value={question.key}
onChange={(e) => updateQuestion(index, { key: e.target.value })}
required
/>
</div>
<div className="md:col-span-2">
<label className="label">Description</label>
<input
className="input"
value={question.description ?? ''}
onChange={(e) => updateQuestion(index, { description: e.target.value })}
/>
</div>
<div>
<label className="label">Type</label>
<select
className="input"
value={question.type}
onChange={(e) => updateQuestion(index, { type: e.target.value as QuestionType, options: undefined })}
>
{questionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{questionTypes.find((t) => t.value === question.type)?.hint}
</p>
</div>
<div>
<label className="label">Placeholder</label>
<input
className="input"
value={question.placeholder ?? ''}
onChange={(e) => updateQuestion(index, { placeholder: e.target.value })}
/>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={question.required}
onChange={(e) => updateQuestion(index, { required: e.target.checked })}
className="h-4 w-4 accent-brand-600"
/>
Required
</label>
{question.type === 'text' && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(question.multiline)}
onChange={(e) => updateQuestion(index, { multiline: e.target.checked })}
className="h-4 w-4 accent-brand-600"
/>
Multiline
</label>
)}
</div>
{question.type === 'number' && (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label className="label">Min value</label>
<input
type="number"
className="input"
value={question.minValue ?? ''}
onChange={(e) =>
updateQuestion(index, { minValue: e.target.value === '' ? undefined : Number(e.target.value) })
}
/>
</div>
<div>
<label className="label">Max value</label>
<input
type="number"
className="input"
value={question.maxValue ?? ''}
onChange={(e) =>
updateQuestion(index, { maxValue: e.target.value === '' ? undefined : Number(e.target.value) })
}
/>
</div>
</div>
)}
{question.type === 'select' && (
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Options</p>
<button type="button" className="btn-secondary py-1 text-xs" onClick={() => addOption(index)}>
Add option
</button>
</div>
{(question.options ?? []).map((option, oIndex) => (
<div key={oIndex} className="grid gap-2 sm:grid-cols-[1fr,1fr,auto]">
<input
className="input"
placeholder="Label"
value={option.label}
onChange={(e) => updateOption(index, oIndex, { label: e.target.value })}
/>
<input
className="input font-mono text-sm"
placeholder="Value"
value={option.value}
onChange={(e) => updateOption(index, oIndex, { value: e.target.value })}
/>
<button
type="button"
className="btn-danger py-1 text-xs"
onClick={() => removeOption(index, oIndex)}
>
Remove
</button>
</div>
))}
{(question.options ?? []).length === 0 && (
<p className="text-sm text-slate-500 dark:text-slate-400">Add at least one option.</p>
)}
</div>
)}
</article>
))}
</div>
</section>
</form>
)
}

View File

@ -0,0 +1,352 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, ApiError } from '../lib/api'
import type { AccessCode, Form, SubmissionSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge'
export function FormDetailPage() {
const { formId = '' } = useParams<{ formId: string }>()
const navigate = useNavigate()
const [form, setForm] = useState<Form | null>(null)
const [submissions, setSubmissions] = useState<SubmissionSummary[]>([])
const [codes, setCodes] = useState<AccessCode[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [codeCount, setCodeCount] = useState(5)
const [codePrefix, setCodePrefix] = useState('')
async function load() {
setLoading(true)
setError(null)
try {
const [f, subs, accessCodes] = await Promise.all([
api.getForm(formId),
api.listSubmissions(formId).catch(() => [] as SubmissionSummary[]),
api.listAccessCodes(formId).catch(() => [] as AccessCode[]),
])
setForm(f)
setSubmissions(subs)
setCodes(accessCodes)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not load form details')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [formId])
async function toggleActive() {
if (!form) return
setBusy(true)
setError(null)
try {
if (form.isActive) {
await api.inactivateForm(form.id)
} else {
await api.activateForm(form.id)
}
await load()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not update the form')
} finally {
setBusy(false)
}
}
async function createVersion() {
if (!form) return
setBusy(true)
setError(null)
try {
const created = await api.createVersion(form.id)
navigate(`/manage/forms/${created.id}`)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not create a new version')
} finally {
setBusy(false)
}
}
async function generateCodes(event: React.FormEvent) {
event.preventDefault()
if (!form) return
setBusy(true)
setError(null)
try {
await api.createAccessCodes(form.id, {
count: codeCount,
prefix: codePrefix.trim() || undefined,
})
setCodePrefix('')
await load()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not generate access codes')
} finally {
setBusy(false)
}
}
async function downloadPdf() {
if (!form) return
try {
await api.downloadPdf(
form.id,
`kereru-form-${form.title.replace(/[^a-z0-9]+/gi, '-')}-v${form.version}.pdf`,
form.pdfUrl,
)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not download the PDF')
}
}
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading form details…</p>
}
if (error && !form) {
return <div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error}</div>
}
if (!form) return null
const externalReferenceLabel = form.externalReferenceLabel ?? 'External reference'
const showExternalReference =
form.externalReferenceRequired || submissions.some((submission) => Boolean(submission.externalReference))
return (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<Link to="/manage" className="hover:text-brand-600 dark:hover:text-brand-400">
Management
</Link>
<span>/</span>
<span>{form.title}</span>
</div>
<div className="flex items-center gap-3">
{form.logoUrl && (
<img
src={form.logoUrl}
alt={form.companyName ?? form.title}
className="h-10 w-auto"
referrerPolicy="no-referrer"
/>
)}
<h1 className="text-2xl font-bold">{form.title}</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge active={form.isActive} />
<span className="text-sm text-slate-500 dark:text-slate-400">Version {form.version}</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{form.requiresAccessCode ? 'Access code required' : 'Open to anyone'}
</span>
{form.signatureRequired && (
<span className="text-sm text-slate-500 dark:text-slate-400">Signature required</span>
)}
{form.externalReferenceRequired && (
<span className="text-sm text-slate-500 dark:text-slate-400">
{form.externalReferenceLabel ?? 'External reference'} required
</span>
)}
{form.pdfUrl && (
<span className="text-sm text-slate-500 dark:text-slate-400">Linked PDF</span>
)}
</div>
{form.description && <p className="text-slate-600 dark:text-slate-300">{form.description}</p>}
</div>
<div className="flex flex-wrap gap-2">
<button type="button" className="btn-secondary" onClick={() => navigate(`/manage/forms/${form.id}/edit`)}>
Edit
</button>
<button type="button" className="btn-secondary" onClick={downloadPdf}>
Download PDF
</button>
<button type="button" className="btn-secondary" disabled={busy} onClick={createVersion}>
New version
</button>
<button type="button" className="btn-secondary" disabled={busy} onClick={toggleActive}>
{form.isActive ? 'Inactivate' : 'Activate'}
</button>
<Link to={`/forms/${form.id}`} className="btn-primary">
Preview form
</Link>
</div>
</header>
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error}</div>
)}
<div className="grid gap-6 lg:grid-cols-2">
<section className="card space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Form details</h2>
<span className="text-xs text-slate-500 dark:text-slate-400">{form.questions.length} questions</span>
</div>
{form.introduction && (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Introduction</p>
<p className="mt-1 whitespace-pre-line text-sm text-slate-600 dark:text-slate-400">{form.introduction}</p>
</div>
)}
<div className="space-y-2">
{form.questions.map((question, index) => (
<div key={question.id} className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="flex items-start justify-between gap-2">
<p className="font-medium">
{index + 1}. {question.label}
{question.required && <span className="ml-1 text-red-500">*</span>}
</p>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-400">
{question.type}
</span>
</div>
{question.description && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{question.description}</p>}
{question.type === 'select' && question.options && (
<ul className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
{question.options.map((option) => (
<li key={option.value} className="rounded-full border border-slate-200 px-2 py-0.5 dark:border-slate-700">
{option.label} ({option.value})
</li>
))}
</ul>
)}
</div>
))}
</div>
</section>
<div className="space-y-6">
{form.requiresAccessCode && (
<section className="card space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Access codes</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">{codes.length} total</span>
</div>
<form onSubmit={generateCodes} className="flex flex-wrap items-end gap-2">
<div>
<label htmlFor="code-count" className="label">
Count
</label>
<input
id="code-count"
type="number"
min={1}
max={1000}
className="input w-24"
value={codeCount}
onChange={(event) => setCodeCount(Number(event.target.value))}
/>
</div>
<div className="flex-1 min-w-[160px]">
<label htmlFor="code-prefix" className="label">
Prefix (optional)
</label>
<input
id="code-prefix"
className="input uppercase"
value={codePrefix}
onChange={(event) => setCodePrefix(event.target.value.toUpperCase())}
placeholder="DEMO"
/>
</div>
<button type="submit" className="btn-primary" disabled={busy}>
Generate
</button>
</form>
{codes.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">No access codes yet.</p>
) : (
<div className="max-h-64 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700">
<table className="table">
<thead>
<tr>
<th>Code</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{codes.map((code) => (
<tr key={code.id}>
<td className="font-mono text-xs">{code.code}</td>
<td>
<span
className={`badge ${
code.used
? 'bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
: 'bg-brand-100 text-brand-700 dark:bg-brand-900/60 dark:text-brand-200'
}`}
>
{code.used ? 'Used' : 'Unused'}
</span>
</td>
<td className="text-xs text-slate-500 dark:text-slate-400">
{new Date(code.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
<section className="card space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Submissions</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">{submissions.length} total</span>
</div>
{submissions.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">No submissions yet.</p>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Identifier</th>
<th>Type</th>
{showExternalReference && (
<th>{externalReferenceLabel}</th>
)}
<th>Submitted</th>
<th className="text-right">Details</th>
</tr>
</thead>
<tbody>
{submissions.map((submission) => (
<tr key={submission.id}>
<td className="font-medium">{submission.userId}</td>
<td className="uppercase text-xs text-slate-500 dark:text-slate-400">{submission.userIdType}</td>
{showExternalReference && (
<td className="text-xs text-slate-500 dark:text-slate-400">
{submission.externalReference ?? '—'}
</td>
)}
<td className="text-xs text-slate-500 dark:text-slate-400">
{new Date(submission.submittedAt).toLocaleString()}
</td>
<td className="text-right">
<Link to={`/submissions/${submission.id}`} className="btn-secondary py-1 text-xs">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, ApiError } from '../lib/api'
import type { FormSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge'
export function HomePage() {
const [forms, setForms] = useState<FormSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
api
.listForms(false)
.then((result) => {
if (!cancelled) setForms(result)
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : 'Could not load forms')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
return (
<div className="space-y-8">
<section className="card bg-gradient-to-br from-brand-600 to-brand-800 text-white dark:from-brand-800 dark:to-brand-950">
<p className="text-sm font-semibold uppercase tracking-widest text-brand-100">Kererū Forms</p>
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Simple, versioned surveys</h1>
<p className="mt-2 max-w-2xl text-brand-50">
Pick a form below to share your feedback. Forms are versioned, so your previous answers are
never shown again.
</p>
<div className="mt-5 flex flex-wrap gap-3">
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
Manage forms
</Link>
<Link
to="/manage/new"
className="btn border border-white/40 text-white hover:bg-white/10"
>
Create a form
</Link>
</div>
</section>
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold">Available forms</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{forms.length} active {forms.length === 1 ? 'form' : 'forms'}
</span>
</div>
{loading && <p className="text-slate-500 dark:text-slate-400">Loading forms…</p>}
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
{error}
</div>
)}
{!loading && !error && forms.length === 0 && (
<div className="card text-center">
<p className="text-lg font-semibold">No active forms yet</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Create one from the management section to get started.
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{forms.map((form) => (
<article key={form.id} className="card flex flex-col justify-between">
<div>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
{form.logoUrl && (
<img
src={form.logoUrl}
alt=""
className="h-8 w-auto"
referrerPolicy="no-referrer"
/>
)}
<h3 className="text-lg font-semibold">{form.title}</h3>
</div>
<StatusBadge active={form.isActive} />
</div>
{form.description && (
<p className="mt-2 line-clamp-3 text-sm text-slate-500 dark:text-slate-400">
{form.description}
</p>
)}
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
Version {form.version} · {form.questionCount} questions
</p>
</div>
<Link to={`/forms/${form.id}`} className="btn-primary mt-5 w-full">
Open form
</Link>
</article>
))}
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,207 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { api, ApiError } from '../lib/api'
import type { FormSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge'
export function ManagePage() {
const navigate = useNavigate()
const [forms, setForms] = useState<FormSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
async function load() {
setLoading(true)
setError(null)
try {
const result = await api.listForms(true)
setForms(result)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not load forms')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
async function toggleActive(form: FormSummary) {
setBusy(form.id)
setError(null)
try {
if (form.isActive) {
await api.inactivateForm(form.id)
} else {
await api.activateForm(form.id)
}
await load()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not update the form')
} finally {
setBusy(null)
}
}
async function createVersion(form: FormSummary) {
setBusy(form.id)
setError(null)
try {
const created = await api.createVersion(form.id)
navigate(`/manage/forms/${created.id}`)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not create a new version')
} finally {
setBusy(null)
}
}
async function remove(form: FormSummary) {
if (!window.confirm(`Delete "${form.title}" (version ${form.version})? This also removes its submissions and access codes.`)) return
setBusy(form.id)
setError(null)
try {
await api.deleteForm(form.id)
await load()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not delete the form')
} finally {
setBusy(null)
}
}
async function downloadPdf(form: FormSummary) {
try {
await api.downloadPdf(
form.id,
`kereru-form-${form.title.replace(/[^a-z0-9]+/gi, '-')}-v${form.version}.pdf`,
form.pdfUrl,
)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not download the PDF')
}
}
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">Management</h1>
<p className="text-slate-500 dark:text-slate-400">
Create, version, activate and review forms and submissions.
</p>
</div>
<Link to="/manage/new" className="btn-primary">
Create form
</Link>
</header>
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
{error}
</div>
)}
{loading ? (
<p className="text-slate-500 dark:text-slate-400">Loading forms…</p>
) : forms.length === 0 ? (
<div className="card text-center">
<p className="text-lg font-semibold">No forms yet</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Create your first form to start collecting responses.
</p>
<Link to="/manage/new" className="btn-primary mt-4">
Create form
</Link>
</div>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Form</th>
<th>Version</th>
<th>Status</th>
<th>Questions</th>
<th>Updated</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{forms.map((form) => (
<tr key={form.id}>
<td>
<div>
<p className="font-semibold text-slate-900 dark:text-white">{form.title}</p>
{form.description && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{form.description}
</p>
)}
{form.externalReferenceRequired && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{form.externalReferenceLabel ?? 'External reference'} required
</p>
)}
</div>
</td>
<td>v{form.version}</td>
<td>
<StatusBadge active={form.isActive} />
</td>
<td>{form.questionCount}</td>
<td className="text-xs text-slate-500 dark:text-slate-400">
{new Date(form.updatedAt).toLocaleString()}
</td>
<td>
<div className="flex flex-wrap justify-end gap-2">
<Link to={`/forms/${form.id}`} className="btn-secondary py-1 text-xs">
Preview
</Link>
<Link to={`/manage/forms/${form.id}`} className="btn-secondary py-1 text-xs">
View / submissions
</Link>
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={() => downloadPdf(form)}
>
PDF
</button>
<button
type="button"
className="btn-secondary py-1 text-xs"
disabled={busy === form.id}
onClick={() => createVersion(form)}
>
New version
</button>
<button
type="button"
className="btn-secondary py-1 text-xs"
disabled={busy === form.id}
onClick={() => toggleActive(form)}
>
{form.isActive ? 'Inactivate' : 'Activate'}
</button>
<button
type="button"
className="btn-danger py-1 text-xs"
disabled={busy === form.id}
onClick={() => remove(form)}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,201 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api, ApiError } from '../lib/api'
import type { Form, Submission } from '../lib/types'
export function SubmissionDetailPage() {
const { submissionId = '' } = useParams<{ submissionId: string }>()
const [submission, setSubmission] = useState<Submission | null>(null)
const [form, setForm] = useState<Form | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [downloadingOffline, setDownloadingOffline] = useState(false)
useEffect(() => {
let cancelled = false
api
.getSubmission(submissionId)
.then(async (result) => {
if (cancelled) return
setSubmission(result)
try {
const resultForm = await api.getForm(result.formId)
if (!cancelled) setForm(resultForm)
} catch {
if (!cancelled) setForm(null)
}
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : 'Could not load submission')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [submissionId])
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading submission…</p>
}
if (error || !submission) {
return <div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error ?? 'Submission not found'}</div>
}
function formatValue(value: unknown): string {
if (value === true) return 'True'
if (value === false) return 'False'
if (Array.isArray(value)) return value.join(', ')
if (typeof value === 'object' && value !== null) return JSON.stringify(value)
return String(value)
}
async function downloadOfflinePdf() {
if (!submission) return
setDownloadingOffline(true)
setError(null)
try {
await api.downloadSubmissionOfflinePdf(submission.id, submission.offlinePdfName)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not download the offline PDF')
} finally {
setDownloadingOffline(false)
}
}
const questionLabels = new Map(form?.questions.map((question) => [question.id, question.label]) ?? [])
const externalReferenceLabel = form?.externalReferenceLabel ?? 'External reference'
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<Link to="/manage" className="hover:text-brand-600 dark:hover:text-brand-400">
Management
</Link>
<span>/</span>
<Link to={`/manage/forms/${submission.formId}`} className="hover:text-brand-600 dark:hover:text-brand-400">
Form
</Link>
<span>/</span>
<span>Submission</span>
</div>
<h1 className="text-2xl font-bold">Submission details</h1>
<div className="flex flex-wrap gap-3 text-sm text-slate-500 dark:text-slate-400">
<span>
Identifier: <span className="text-slate-900 dark:text-white">{submission.userId}</span> ({submission.userIdType})
</span>
<span>Form version {submission.formVersion}</span>
<span>{new Date(submission.submittedAt).toLocaleString()}</span>
</div>
</header>
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error}</div>
)}
{submission.externalReference && (
<section className="card space-y-2">
<h2 className="text-lg font-semibold">{externalReferenceLabel}</h2>
<p className="text-slate-700 dark:text-slate-300">{submission.externalReference}</p>
</section>
)}
{(submission.signature || submission.offlinePdfName) && (
<section className="card space-y-4">
<h2 className="text-lg font-semibold">Signature and attachments</h2>
{submission.signature && (
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{submission.signature.name}
{submission.signature.title ? `, ${submission.signature.title}` : ''}
</p>
<img
src={submission.signature.dataUrl}
alt={`Signature of ${submission.signature.name}`}
className="max-h-40 max-w-full rounded-lg border border-slate-200 bg-white p-2 dark:border-slate-700"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
{submission.signature.type === 'drawn' ? 'Drawn signature' : 'Uploaded signature'} ·{' '}
{new Date(submission.signature.signedAt).toLocaleString()}
</p>
</div>
)}
{submission.offlinePdfName && (
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm">
Offline PDF: <span className="font-medium">{submission.offlinePdfName}</span>
</p>
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={downloadOfflinePdf}
disabled={downloadingOffline}
>
{downloadingOffline ? 'Preparing…' : 'Download PDF'}
</button>
</div>
)}
</section>
)}
<section className="card space-y-4">
<h2 className="text-lg font-semibold">Answers</h2>
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{submission.answers.map((answer) => (
<div key={answer.questionId} className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0">
<p className="font-medium">{questionLabels.get(answer.questionId) ?? answer.questionKey}</p>
<p className="text-slate-700 dark:text-slate-300">{formatValue(answer.value)}</p>
</div>
))}
</div>
</section>
{submission.tracking && (
<section className="card space-y-3">
<h2 className="text-lg font-semibold">Tracking (server captured)</h2>
<dl className="grid gap-2 text-sm sm:grid-cols-2">
{submission.tracking.ipAddress && (
<>
<dt className="text-slate-500 dark:text-slate-400">IP address</dt>
<dd>{submission.tracking.ipAddress}</dd>
</>
)}
{submission.tracking.userAgent && (
<>
<dt className="text-slate-500 dark:text-slate-400">User agent</dt>
<dd className="break-all">{submission.tracking.userAgent}</dd>
</>
)}
{submission.tracking.locale && (
<>
<dt className="text-slate-500 dark:text-slate-400">Locale</dt>
<dd>{submission.tracking.locale}</dd>
</>
)}
{submission.tracking.timezone && (
<>
<dt className="text-slate-500 dark:text-slate-400">Timezone</dt>
<dd>{submission.tracking.timezone}</dd>
</>
)}
{submission.tracking.place && (
<>
<dt className="text-slate-500 dark:text-slate-400">Place</dt>
<dd>{submission.tracking.place}</dd>
</>
)}
{submission.tracking.referer && (
<>
<dt className="text-slate-500 dark:text-slate-400">Referer</dt>
<dd className="break-all">{submission.tracking.referer}</dd>
</>
)}
</dl>
</section>
)}
</div>
)
}

View File

@ -0,0 +1,123 @@
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import { ThemeToggle } from '../components/ThemeToggle'
import { StatusBadge } from '../components/StatusBadge'
import { QuestionField } from '../components/QuestionField'
import { SignaturePad } from '../components/SignaturePad'
describe('ThemeToggle', () => {
it('renders all theme options', () => {
const { getByText } = render(
<MemoryRouter>
<ThemeToggle />
</MemoryRouter>,
)
expect(getByText('Light')).toBeInTheDocument()
expect(getByText('Dark')).toBeInTheDocument()
expect(getByText('System')).toBeInTheDocument()
})
})
describe('StatusBadge', () => {
it('shows active and inactive states', () => {
const { getByText, rerender } = render(<StatusBadge active />)
expect(getByText('Active')).toBeInTheDocument()
rerender(<StatusBadge active={false} />)
expect(getByText('Inactive')).toBeInTheDocument()
})
})
describe('QuestionField', () => {
it('renders boolean radios', () => {
const onChange = vi.fn()
const { getByLabelText } = render(
<QuestionField
question={{
id: 'q1',
key: 'happy',
label: 'Are you happy?',
type: 'boolean',
required: true,
}}
value={undefined}
onChange={onChange}
/>,
)
expect(getByLabelText(/Are you happy\?/)).toBeInTheDocument()
expect(getByLabelText('True')).toBeInTheDocument()
expect(getByLabelText('False')).toBeInTheDocument()
})
it('renders select options', () => {
const onChange = vi.fn()
const { getByRole } = render(
<QuestionField
question={{
id: 'q2',
key: 'colour',
label: 'Colour',
type: 'select',
required: false,
options: [
{ value: 'red', label: 'Red' },
{ value: 'blue', label: 'Blue' },
],
}}
value=""
onChange={onChange}
/>,
)
expect(getByRole('combobox')).toBeInTheDocument()
})
it('renders multiline text area', () => {
const onChange = vi.fn()
const { getByRole } = render(
<QuestionField
question={{
id: 'q3',
key: 'notes',
label: 'Notes',
type: 'text',
required: false,
multiline: true,
}}
value=""
onChange={onChange}
/>,
)
expect(getByRole('textbox')).toBeInTheDocument()
})
it('renders a date input', () => {
const onChange = vi.fn()
const { getByLabelText } = render(
<QuestionField
question={{
id: 'q4',
key: 'when',
label: 'When',
type: 'date',
required: true,
}}
value="2026-10-23"
onChange={onChange}
/>,
)
expect(getByLabelText(/When/)).toHaveValue('2026-10-23')
})
})
describe('SignaturePad', () => {
it('shows upload controls and previews an uploaded signature', () => {
const onChange = vi.fn()
const dataUrl = 'data:image/png;base64,aGVsbG8='
const { getByText, getByAltText } = render(
<SignaturePad value={{ type: 'image', dataUrl }} onChange={onChange} />,
)
expect(getByText('Draw signature')).toBeInTheDocument()
expect(getByText('Upload image')).toBeInTheDocument()
expect(getByAltText('Uploaded signature')).toHaveAttribute('src', dataUrl)
})
})

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { Layout } from '../components/Layout'
describe('Layout', () => {
it('renders navigation and theme toggle', () => {
render(
<MemoryRouter>
<Layout />
</MemoryRouter>,
)
expect(screen.getAllByText(/Kererū/).length).toBeGreaterThan(0)
expect(screen.getByRole('navigation', { name: /main/i })).toBeInTheDocument()
expect(screen.getByRole('group', { name: /colour theme/i })).toBeInTheDocument()
})
})

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

View File

@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#effdf6',
100: '#d9fbe8',
200: '#b5f5d3',
300: '#82ebb5',
400: '#47d990',
500: '#1fbf72',
600: '#129c5b',
700: '#117b4b',
800: '#12613e',
900: '#115036',
950: '#052d1d',
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "vite.config.ts"]
}

View File

@ -0,0 +1,49 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react'
const openapiSpecPath = fileURLToPath(new URL('../backend/openapi/openapi.yaml', import.meta.url))
function openapiSpec(): Plugin {
return {
name: 'kereru-openapi-spec',
configureServer(server) {
server.middlewares.use('/openapi.yaml', (_req, res, next) => {
readFile(openapiSpecPath)
.then((data) => {
res.setHeader('Content-Type', 'application/yaml')
res.end(data)
})
.catch(next)
})
},
async generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'openapi.yaml',
source: await readFile(openapiSpecPath, 'utf8'),
})
},
}
}
export default defineConfig({
plugins: [react(), openapiSpec()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
css: false,
},
})