Initial commt for working copy generated
commit
5c9a7a0f64
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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:
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom'
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue