diff --git a/src/program-generator/.devcontainer.json b/src/program-generator/.devcontainer.json new file mode 100644 index 00000000..a75608b8 --- /dev/null +++ b/src/program-generator/.devcontainer.json @@ -0,0 +1,14 @@ +{ + "name": "Program Generator", + "dockerComposeFile": "docker-compose.yaml", + "service": "app", + "shutdownAction": "none", + "workspaceFolder": "/workspace", + "postCreateCommand": [ + "./startupscript/post-startup.sh" + ], + "postStartCommand": [ + "./startupscript/remount-on-restart.sh" + ], + "remoteUser": "root" +} diff --git a/src/program-generator/app/Dockerfile b/src/program-generator/app/Dockerfile new file mode 100644 index 00000000..fcd52abc --- /dev/null +++ b/src/program-generator/app/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.23-alpine AS builder + +RUN apk add --no-cache git ca-certificates + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /program-generator . + +# --- + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates + +COPY --from=builder /program-generator /usr/local/bin/program-generator + +EXPOSE 8080 +CMD ["program-generator"] diff --git a/src/program-generator/app/envs/dev-stable.env b/src/program-generator/app/envs/dev-stable.env new file mode 100644 index 00000000..94f03d3e --- /dev/null +++ b/src/program-generator/app/envs/dev-stable.env @@ -0,0 +1,35 @@ +# ═══════════════════════════════════════════════════════════════════════════════ +# dev-stable.env — Environment-specific values for the dev-stable cluster +# ═══════════════════════════════════════════════════════════════════════════════ +# +# Sourced by config.env when TARGET_ENV=dev-stable (the default). +# All values use ${VAR:-default} so command-line overrides still work. +# +# GCP Project: prj-d-1v-ucd +# Cluster: gke-cluster / us-west1 +# Auto-deploy: main branch +# ═══════════════════════════════════════════════════════════════════════════════ + +# ─── GCP / GKE ──────────────────────────────────────────────────────────────── +GCP_PROJECT="${GCP_PROJECT:-prj-d-1v-ucd}" +GKE_CLUSTER="${GKE_CLUSTER:-gke-cluster}" +GKE_REGION="${GKE_REGION:-us-west1}" + +# ─── FHIR Store ─────────────────────────────────────────────────────────────── +FHIR_STORE="${FHIR_STORE:-projects/prj-d-1v-ucd/locations/us-west1/datasets/operational-healthcare-dataset/fhirStores/operational-fhir-store}" +FHIR_STORE_BASE="${FHIR_STORE_BASE:-https://healthcare.googleapis.com/v1/${FHIR_STORE}/fhir}" + +# ─── GCS (consent PDFs) ────────────────────────────────────────────────────── +GCS_BUCKET="${GCS_BUCKET:-econsent-pdf-pilot-dev-oneverily-prj-d-1v-ucd}" + +# ─── Organization ───────────────────────────────────────────────────────────── +PLATFORM_ORG_ID="${PLATFORM_ORG_ID:-c4d08196-b1ab-4453-a7e4-d0057dd7287a}" +ENV_SHORT_NAME="${ENV_SHORT_NAME:-dev-stable}" +ENV_BASE_URL="${ENV_BASE_URL:-https://dev-stable.one.verily.com}" + +# ─── Auth0 / CIAM ──────────────────────────────────────────────────────────── +AUTH0_TENANT_DOMAIN="${AUTH0_TENANT_DOMAIN:-verily-us-dev-participant.us.auth0.com}" + +# ─── API routing (local mode) ──────────────────────────────────────────────── +# Host header sent to grpc-web-envoy — must match VirtualService config. +API_HOST_HEADER="${API_HOST_HEADER:-dev.app.verilyme.com}" diff --git a/src/program-generator/app/go.mod b/src/program-generator/app/go.mod new file mode 100644 index 00000000..301cb827 --- /dev/null +++ b/src/program-generator/app/go.mod @@ -0,0 +1,64 @@ +module github.com/verily-src/workbench-app-devcontainers/src/program-generator/app + +go 1.25.0 + +require ( + cloud.google.com/go/storage v1.56.0 + cloud.google.com/go/vertexai v0.17.0 + github.com/google/uuid v1.6.0 + github.com/lib/pq v1.10.9 + golang.org/x/oauth2 v0.36.0 + google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/aiplatform v1.120.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/longrunning v0.8.0 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.18.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/api v0.272.0 // indirect + google.golang.org/genai v1.50.0 // indirect + google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect + google.golang.org/grpc v1.79.2 // indirect +) diff --git a/src/program-generator/app/go.sum b/src/program-generator/app/go.sum new file mode 100644 index 00000000..5198aa25 --- /dev/null +++ b/src/program-generator/app/go.sum @@ -0,0 +1,144 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/aiplatform v1.120.0 h1:jKWTpEs+xoUhDa1FMdSuhMcEQYyUiMdufGyX3zvtLVQ= +cloud.google.com/go/aiplatform v1.120.0/go.mod h1:6mDthfmy0oS1EQhVFdijoxkVdI2+HIZkpuGTBpedeCg= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI= +cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +cloud.google.com/go/vertexai v0.17.0 h1:jnMsFEgi4cWAECvuP0YhHnvEnhHl8B3dIQh5qgaHQBY= +cloud.google.com/go/vertexai v0.17.0/go.mod h1:WSmxmxzYvzWEMB8vxQ7S5huioo0UwtrG9d//Jm8Npxo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= +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/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/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/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/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +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/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= +github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/genai v1.50.0 h1:yHKV/vjoeN9PJ3iF0ur4cBZco4N3Kl7j09rMq7XSoWk= +google.golang.org/genai v1.50.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/program-generator/app/internal/ai/client.go b/src/program-generator/app/internal/ai/client.go new file mode 100644 index 00000000..1da916c6 --- /dev/null +++ b/src/program-generator/app/internal/ai/client.go @@ -0,0 +1,81 @@ +package ai + +import ( + "context" + _ "embed" + "fmt" + "strings" + + "cloud.google.com/go/vertexai/genai" +) + +//go:embed prompts/system.md +var systemPrompt string + +//go:embed prompts/reference-template.yaml +var referenceTemplate string + +type Client struct { + client *genai.Client + model string +} + +func NewClient(ctx context.Context, projectID, region, model string) (*Client, error) { + client, err := genai.NewClient(ctx, projectID, region) + if err != nil { + return nil, fmt.Errorf("creating Vertex AI client: %w", err) + } + if model == "" { + model = "gemini-2.5-pro" + } + return &Client{client: client, model: model}, nil +} + +func (c *Client) GenerateProgram(ctx context.Context, userRequest string) (string, error) { + model := c.client.GenerativeModel(c.model) + + // Build system instruction with embedded reference template + fullSystemPrompt := systemPrompt + "\n\n## Reference Template\n\n```yaml\n" + referenceTemplate + "\n```" + + model.SystemInstruction = &genai.Content{ + Parts: []genai.Part{genai.Text(fullSystemPrompt)}, + } + model.SetTemperature(0.3) + + resp, err := model.GenerateContent(ctx, genai.Text(userRequest)) + if err != nil { + return "", fmt.Errorf("generating content: %w", err) + } + + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + return "", fmt.Errorf("no content returned from model") + } + + text, ok := resp.Candidates[0].Content.Parts[0].(genai.Text) + if !ok { + return "", fmt.Errorf("unexpected response type from model") + } + + yaml := string(text) + yaml = stripCodeFences(yaml) + return yaml, nil +} + +func (c *Client) Close() { + c.client.Close() +} + +// stripCodeFences removes markdown code fences if the model wraps the YAML in them. +func stripCodeFences(s string) string { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "```yaml") { + s = strings.TrimPrefix(s, "```yaml") + s = strings.TrimSuffix(s, "```") + s = strings.TrimSpace(s) + } else if strings.HasPrefix(s, "```") { + s = strings.TrimPrefix(s, "```") + s = strings.TrimSuffix(s, "```") + s = strings.TrimSpace(s) + } + return s +} diff --git a/src/program-generator/app/internal/ai/prompts/reference-template.yaml b/src/program-generator/app/internal/ai/prompts/reference-template.yaml new file mode 100644 index 00000000..239913db --- /dev/null +++ b/src/program-generator/app/internal/ai/prompts/reference-template.yaml @@ -0,0 +1,481 @@ +# simple-program.yaml — A minimal VerilyMe program for testing. +# +# This template creates a single-bundle program with: +# - One info step (welcome screen using component nodes) +# - One consent step (regulated consent: sign flow + review flow) +# - One survey step (a short health check-in with choice and boolean questions) +# - One info step (thank-you screen using component nodes) +# +# Node-tree format: +# The entire file is a single node tree. Every element uses the same schema, +# with field names matching the proto Node message (components_common.proto): +# node_type — SCREAMING_SNAKE_CASE node type (proto: NodeType node_type) +# value_string — optional string payload (proto: optional string value_string) +# html — optional HTML content (proto: optional string html) +# nodes — nested child nodes (proto: repeated Node nodes) +# uri — optional URL (proto: optional string uri) +# id — optional identifier (proto: optional string id) +# +# Four prefix categories: +# ADMIN_ — administrative structure (builder-processed, not rendered) +# CMPT_ — renderable UI components +# PROP_ — metadata properties +# ACTN_ — behavioral actions +# +# New types proposed (not yet in the proto): +# +# Admin: ADMIN_PROGRAM, ADMIN_BUNDLE, ADMIN_CARD, ADMIN_INFO_STEP, +# ADMIN_CONSENT_STEP, ADMIN_SURVEY_STEP, ADMIN_CONSENT_SIGN, +# ADMIN_CONSENT_REVIEW +# Components: CMPT_BUNDLE_LAYOUT, CMPT_HEADER, CMPT_FOOTER, +# CMPT_EXIT_BUTTON, CMPT_SURVEY_CONTEXT, CMPT_PAGE, +# CMPT_QUESTION_GROUP, CMPT_CHOICE_QUESTION, +# CMPT_FREE_TEXT_QUESTION, CMPT_TITLE, CMPT_PDF_VIEWER, +# CMPT_DIALOG, CMPT_CTA_BUTTON +# Properties: PROP_ORG_ID, PROP_VERSION, PROP_ENV_BASE_URL, +# PROP_TITLE, PROP_DESCRIPTION, PROP_LINK_ID, PROP_LABEL, +# PROP_REQUIRED, PROP_OPTION, PROP_BOOLEAN, PROP_SIGNATURE, +# PROP_CONSTRAINTS, PROP_NUMERIC, PROP_MIN_VALUE, +# PROP_MAX_VALUE, PROP_ALLOW_DECIMAL, PROP_MAX_DECIMAL_PLACES, +# PROP_UNITS, PROP_UNIT, PROP_UNIT_DISPLAY, PROP_UNIT_SYSTEM, +# PROP_UNIT_CODE, PROP_BODY +# Actions: ACTN_ON_CLICK +# +# Valueless markers (presence = true): +# PROP_REQUIRED, PROP_BOOLEAN, PROP_SIGNATURE, PROP_ALLOW_DECIMAL +# +# Numeric type convention: +# PROP_NUMERIC defaults to integer. Add PROP_ALLOW_DECIMAL +# (valueless marker) to opt into decimal. Optionally nest +# PROP_MAX_DECIMAL_PLACES under PROP_ALLOW_DECIMAL. +# +# Dialog trigger convention: +# CMPT_CTA_BUTTON with ACTN_ON_CLICK opens the CMPT_DIALOG whose +# value_string matches the action value (e.g. ACTN_ON_CLICK +# value_string="disagree" opens CMPT_DIALOG value_string="disagree"). +# +# To create a new program from this template: +# go run ./cmd/seed-program \ +# --template templates/simple-program.yaml \ +# --fhir-store "projects/prj-d-1v-ucd/locations/us-west1/datasets/operational-healthcare-dataset/fhirStores/operational-fhir-store" \ +# --gcs-bucket "econsent-pdf-pilot-dev-oneverily-prj-d-1v-ucd" \ +# --output /tmp/program-config.json +# +# The --gcs-bucket flag is required because this template has a consent step. +# The consent backend fetches the generated PDF from GCS at runtime. +# +# To preview the FHIR bundle without posting: +# go run ./cmd/seed-program \ +# --template templates/simple-program.yaml \ +# --dry-run + +node_type: ADMIN_PROGRAM +value_string: "simple-test-program" +nodes: + - node_type: PROP_ORG_ID + value_string: "264770f4-6a7b-496c-90e7-e895e3fe36d7" + - node_type: PROP_VERSION + value_string: "v1" + - node_type: PROP_ENV_BASE_URL + value_string: "https://dev-stable.one.verily.com" + + - node_type: ADMIN_BUNDLE + value_string: "health-check-in" + nodes: + - node_type: ADMIN_CARD + nodes: + - node_type: PROP_TITLE + value_string: "Test Health Check-In" + - node_type: PROP_DESCRIPTION + value_string: "Complete a quick health check-in to help us understand your wellness" + + # ── Info step (welcome) ───────────────────────────────────────── + - node_type: ADMIN_INFO_STEP + value_string: "Welcome to Your Health Check-In" + nodes: + - node_type: CMPT_BUNDLE_LAYOUT + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_VERTICAL_CONTAINER + nodes: + - node_type: CMPT_RICH_TEXT + html: "

About This Check-In

" + value_string: "## About This Check-In" + - node_type: CMPT_RICH_TEXT + html: "

This short health check-in will help us understand how you're doing. It should take about 2 minutes to complete.

" + value_string: "This short health check-in will help us understand how you're doing. It should take about **2 minutes** to complete." + - node_type: CMPT_SECTION_DIVIDER + - node_type: CMPT_RICH_TEXT + html: "

Your responses are confidential and will be used to personalize your experience in the program.

" + value_string: "Your responses are confidential and will be used to personalize your experience in the program." + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Continue" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "next" + + # ── Consent step ──────────────────────────────────────────────── + - node_type: ADMIN_CONSENT_STEP + value_string: "Research Participation Agreement" + nodes: + - node_type: ADMIN_CONSENT_SIGN + nodes: + - node_type: CMPT_BUNDLE_LAYOUT + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Decline" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "disagree" + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_PDF_VIEWER + + - node_type: CMPT_CHOICE_QUESTION + nodes: + - node_type: PROP_LABEL + value_string: "I agree to participate in this health check-in program" + - node_type: PROP_BOOLEAN + - node_type: PROP_REQUIRED + + - node_type: CMPT_CHOICE_QUESTION + nodes: + - node_type: PROP_LABEL + value_string: "I understand my responses will be used to personalize my experience" + - node_type: PROP_BOOLEAN + - node_type: PROP_REQUIRED + + - node_type: CMPT_FREE_TEXT_QUESTION + nodes: + - node_type: PROP_SIGNATURE + - node_type: PROP_REQUIRED + + - node_type: CMPT_DIALOG + value_string: "disagree" + nodes: + - node_type: PROP_LABEL + value_string: "Are you sure?" + - node_type: CMPT_CTA_BUTTON + value_string: "Yes, decline" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "confirm" + - node_type: CMPT_CTA_BUTTON + value_string: "Go back" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "cancel" + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "I Agree" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "submit" + + - node_type: ADMIN_CONSENT_REVIEW + nodes: + - node_type: CMPT_BUNDLE_LAYOUT + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_PDF_VIEWER + + - node_type: CMPT_DIALOG + value_string: "withdraw" + nodes: + - node_type: PROP_LABEL + value_string: "Withdraw Consent?" + - node_type: PROP_BODY + value_string: "If you withdraw, your previous responses will no longer be used." + - node_type: CMPT_CTA_BUTTON + value_string: "Withdraw" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "confirm" + - node_type: CMPT_CTA_BUTTON + value_string: "Cancel" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "cancel" + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Withdraw" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "withdraw" + + # ── Survey step ───────────────────────────────────────────────── + # + # Structure: CMPT_PAGE = one screen (navigation boundary), + # CMPT_QUESTION_GROUP = related questions within that screen. + # + # Identifier architecture: + # - Survey OID: auto-derived by the builder. Resource-level. + # - PROP_LINK_ID: per-question Questionnaire.item[].linkId. + # Optional — auto-generated from tree position if omitted. + # + # NOTE: The node tree declares richer structure than the flat FHIR + # Questionnaire converter currently supports. Specifically: + # - CMPT_PAGE grouping (page boundaries) + # - CMPT_QUESTION_GROUP semantics + # - CMPT_HORIZONTAL_CONTAINER (compound numeric layout) + # - PROP_CONSTRAINTS / PROP_NUMERIC (min/max validation) + # - PROP_UNITS (unit metadata) + # - CMPT_HEADER / CMPT_FOOTER / CMPT_CTA_BUTTON chrome + # These are faithfully declared in the DSL but the converter + # currently flattens all questions into a flat Questionnaire.item[]. + # A future converter could leverage these for richer FHIR output. + - node_type: ADMIN_SURVEY_STEP + value_string: "Health Check-In Survey" + nodes: + - node_type: CMPT_SURVEY_CONTEXT + nodes: + - node_type: CMPT_BUNDLE_LAYOUT + nodes: + # ── Page 1: Overall Health ─────────────────────── + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_QUESTION_GROUP + nodes: + - node_type: CMPT_CHOICE_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q1" + - node_type: PROP_LABEL + value_string: "How would you rate your overall health today?" + - node_type: PROP_OPTION + value_string: "Excellent" + - node_type: PROP_OPTION + value_string: "Very Good" + - node_type: PROP_OPTION + value_string: "Good" + - node_type: PROP_OPTION + value_string: "Fair" + - node_type: PROP_OPTION + value_string: "Poor" + - node_type: PROP_REQUIRED + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Next" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "next" + + # ── Page 2: Exercise ───────────────────────────── + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_QUESTION_GROUP + nodes: + - node_type: CMPT_CHOICE_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q2" + - node_type: PROP_LABEL + value_string: "Have you exercised in the past week?" + - node_type: PROP_BOOLEAN + - node_type: PROP_REQUIRED + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Next" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "next" + + # ── Page 3: Sleep ──────────────────────────────── + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_QUESTION_GROUP + nodes: + - node_type: CMPT_FREE_TEXT_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q3" + - node_type: PROP_LABEL + value_string: "How many hours of sleep did you get last night?" + - node_type: PROP_CONSTRAINTS + nodes: + - node_type: PROP_NUMERIC + nodes: + - node_type: PROP_MIN_VALUE + value_string: "0" + - node_type: PROP_MAX_VALUE + value_string: "24" + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Next" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "next" + + # ── Page 4: Blood Pressure (compound numeric) ──── + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_QUESTION_GROUP + nodes: + - node_type: CMPT_TITLE + nodes: + - node_type: CMPT_RICH_TEXT + html: "

What is your blood pressure?

" + value_string: "What is your blood pressure?" + - node_type: CMPT_HORIZONTAL_CONTAINER + nodes: + - node_type: CMPT_FREE_TEXT_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q4-systolic" + - node_type: PROP_LABEL + value_string: "Systolic" + - node_type: PROP_CONSTRAINTS + nodes: + - node_type: PROP_NUMERIC + nodes: + - node_type: PROP_MIN_VALUE + value_string: "50" + - node_type: PROP_MAX_VALUE + value_string: "250" + - node_type: PROP_UNITS + nodes: + - node_type: PROP_UNIT + nodes: + - node_type: PROP_UNIT_DISPLAY + value_string: "mmHg" + - node_type: PROP_UNIT_SYSTEM + value_string: "http://unitsofmeasure.org" + - node_type: PROP_UNIT_CODE + value_string: "mm[Hg]" + - node_type: PROP_REQUIRED + - node_type: CMPT_FREE_TEXT_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q4-diastolic" + - node_type: PROP_LABEL + value_string: "Diastolic" + - node_type: PROP_CONSTRAINTS + nodes: + - node_type: PROP_NUMERIC + nodes: + - node_type: PROP_MIN_VALUE + value_string: "30" + - node_type: PROP_MAX_VALUE + value_string: "150" + - node_type: PROP_UNITS + nodes: + - node_type: PROP_UNIT + nodes: + - node_type: PROP_UNIT_DISPLAY + value_string: "mmHg" + - node_type: PROP_UNIT_SYSTEM + value_string: "http://unitsofmeasure.org" + - node_type: PROP_UNIT_CODE + value_string: "mm[Hg]" + - node_type: PROP_REQUIRED + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Next" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "next" + + # ── Page 5: Open-ended ─────────────────────────── + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_QUESTION_GROUP + nodes: + - node_type: CMPT_FREE_TEXT_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q5" + - node_type: PROP_LABEL + value_string: "Is there anything else you'd like to share about your health?" + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Submit" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "submit" + + # ── Info step (thank you) ─────────────────────────────────────── + - node_type: ADMIN_INFO_STEP + value_string: "Thank You!" + nodes: + - node_type: CMPT_BUNDLE_LAYOUT + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_VERTICAL_CONTAINER + nodes: + - node_type: CMPT_RICH_TEXT + html: "

Check-In Complete

" + value_string: "## Check-In Complete" + - node_type: CMPT_SECTION_DIVIDER + - node_type: CMPT_RICH_TEXT + html: "

Thank you for completing your health check-in! Your responses have been recorded.

" + value_string: "Thank you for completing your health check-in! Your responses have been recorded." + - node_type: CMPT_HIGHLIGHT_CARD + nodes: + - node_type: CMPT_RICH_TEXT + html: "

What's next? Check back regularly for new missions and updates to your personalized health plan.

" + value_string: "**What's next?** Check back regularly for new missions and updates to your personalized health plan." + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Done" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "complete" diff --git a/src/program-generator/app/internal/ai/prompts/system.md b/src/program-generator/app/internal/ai/prompts/system.md new file mode 100644 index 00000000..2ff68b02 --- /dev/null +++ b/src/program-generator/app/internal/ai/prompts/system.md @@ -0,0 +1,85 @@ +You are a VerilyMe program template generator. + +## User's Request + +{{USER_REQUEST}} + +## Your Task + +Generate a valid program template YAML in the node-tree DSL format based on the user's request. + +## Source of Truth + +**Read the reference template before generating anything.** It contains the DSL format, node types, +conventions, and a complete working example. Read and internalize the reference template's header +comments — they document all available node types, prefix categories (ADMIN*, CMPT*, PROP*, ACTN*), +and conventions. + +## Generation Rules + +### Structure + +- Root node must be `ADMIN_PROGRAM` with a slugified `value_string` derived from the user's + description +- Always include `PROP_ORG_ID`, `PROP_VERSION`, and `PROP_ENV_BASE_URL` as direct children of the + root +- Use the same default values as the reference template: + - org_id: `264770f4-6a7b-496c-90e7-e895e3fe36d7` + - version: `v1` + - env_base_url: `https://dev-stable.one.verily.com` +- Each program must have at least one `ADMIN_BUNDLE` with an `ADMIN_CARD` + +### Step Types + +- **ADMIN_INFO_STEP**: For informational screens (welcome, thank-you, educational content). Must + contain `CMPT_BUNDLE_LAYOUT` > `CMPT_VERTICAL_CONTAINER` with `CMPT_RICH_TEXT` nodes. Include + `CMPT_HEADER` with `CMPT_EXIT_BUTTON` and `CMPT_FOOTER` with `CMPT_CTA_BUTTON`. +- **ADMIN_CONSENT_STEP**: For regulated consent. Must contain both `ADMIN_CONSENT_SIGN` and + `ADMIN_CONSENT_REVIEW` sub-nodes. Include `CMPT_PDF_VIEWER`, boolean `CMPT_CHOICE_QUESTION` + checkboxes with `PROP_REQUIRED`, a `CMPT_FREE_TEXT_QUESTION` with `PROP_SIGNATURE`, and + decline/withdraw `CMPT_DIALOG` nodes. Follow the exact structure from the reference template. +- **ADMIN_SURVEY_STEP**: For surveys. Must contain `CMPT_SURVEY_CONTEXT` > `CMPT_BUNDLE_LAYOUT` with + one or more `CMPT_PAGE` nodes, each containing `CMPT_QUESTION_GROUP` with questions. + +### Question Types + +- **CMPT_CHOICE_QUESTION** with `PROP_OPTION` children: multiple-choice +- **CMPT_CHOICE_QUESTION** with `PROP_BOOLEAN`: yes/no +- **CMPT_FREE_TEXT_QUESTION**: open-ended text +- **CMPT_FREE_TEXT_QUESTION** with `PROP_CONSTRAINTS` > `PROP_NUMERIC`: numeric input (add + `PROP_MIN_VALUE` / `PROP_MAX_VALUE` as appropriate) +- Add `PROP_LINK_ID` to each question (e.g., `q1`, `q2`, etc.) +- Add `PROP_REQUIRED` to questions that should be mandatory + +### Navigation + +- Every page/step needs `CMPT_HEADER` with `CMPT_EXIT_BUTTON` (action: "exit") +- Every page/step needs `CMPT_FOOTER` with `CMPT_CTA_BUTTON` (action: "next", "submit", or + "complete") +- Last survey page should use "submit" action; last info step should use "complete" + +### Content Guidelines + +- Generate realistic, domain-appropriate content based on the user's description +- Use proper medical/health terminology when relevant +- Write clear, concise question text +- Provide reasonable answer options for choice questions +- Include a welcome info step and a thank-you info step unless the user says otherwise +- Include a consent step unless the user explicitly says to skip it +- Rich text uses both `html` and `value_string` (markdown) fields + +## Output + +Return only the generated YAML. Do not include any other text, explanation, or markdown code fences. + +## Important Notes + +- **Always read the reference template first** — node types and conventions may have evolved +- **Use the node-tree format** (ADMIN_PROGRAM root with node_type/value_string), NOT the legacy flat + format +- **Do not invent new node types** — only use types documented in the reference template's header + comments +- **Consent steps are structurally complex** — copy the structure from the reference template + closely and adapt the text content +- **Field names must be proto-native**: `node_type`, `value_string`, `html`, `uri`, `nodes` (not + `type`/`value`) diff --git a/src/program-generator/app/internal/contentpb/components_common.pb.go b/src/program-generator/app/internal/contentpb/components_common.pb.go new file mode 100644 index 00000000..e2a3b938 --- /dev/null +++ b/src/program-generator/app/internal/contentpb/components_common.pb.go @@ -0,0 +1,421 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc v4.23.2 +// source: common/content/api/v1/components_common.proto + +// (-- api-linter: core::0191::proto-package=disabled +// aip.dev/not-precedent: We need to do this because linter +// still throws warning after following package naming convention --) + +package contentpb + +import ( + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Enum representing the type of Content Node. +// This enum is intended to be extended in the future to include additional node types. +// Enum Items with the CMPT_ prefix represent renderable components. +// Enum Items with the PROP_ prefix represent properties that modify the components they are nested under. +type NodeType int32 + +const ( + // Represents the default value + NodeType_NODE_TYPE_UNSPECIFIED NodeType = 0 + // Represents a vertical container node. + NodeType_CMPT_VERTICAL_CONTAINER NodeType = 1 + // Represents a horizontal container node. + NodeType_CMPT_HORIZONTAL_CONTAINER NodeType = 2 + // Represents a rich text node. + NodeType_CMPT_RICH_TEXT NodeType = 3 + // Represents an image node. + NodeType_CMPT_IMAGE NodeType = 4 + // Represents a highlight card node. + NodeType_CMPT_HIGHLIGHT_CARD NodeType = 5 + // Represents an icon list node. + NodeType_CMPT_ICON_LIST NodeType = 6 + // Represents alternative text for accessibility. + NodeType_PROP_ALT_TEXT NodeType = 7 + // Represents alternative content for dark mode. + NodeType_PROP_DARK_MODE NodeType = 8 + // Represents an icon component. + NodeType_CMPT_ICON NodeType = 9 + // Represents a content color property. + NodeType_PROP_COLOR NodeType = 10 + // Represents a content type property. + NodeType_PROP_MIME_TYPE NodeType = 11 + // Represents a utility field for App in image GETs. + NodeType_PROP_BLOB_KEY NodeType = 12 + // Represents an accordion group container component. + NodeType_CMPT_ACCORDION_GROUP NodeType = 13 + // Represents an individual accordion row (item). + NodeType_CMPT_ACCORDION_ROW NodeType = 14 + // Represents the header/title content of an accordion row. + NodeType_CMPT_ACCORDION_SUMMARY NodeType = 15 + // Represents the expandable body content of an accordion row. + NodeType_CMPT_ACCORDION_DETAILS NodeType = 16 + // Represents a section divider component. + // Displays as a horizontal divider line for visual separation between content sections. + NodeType_CMPT_SECTION_DIVIDER NodeType = 17 + // Represents a list component. + NodeType_CMPT_LIST NodeType = 18 + // Represents an individual list item. + NodeType_CMPT_LIST_ITEM NodeType = 19 + // Represents a template argument property. + // Contains prefetch query, path, enable-when expression, and default text. + NodeType_PROP_TEMPLATE_ARGUMENT NodeType = 20 + // Represents the FHIR query used to prefetch the resource. + NodeType_PROP_FHIR_PREFETCH_QUERY NodeType = 21 + // Represents the FHIRPath expression to extract a value from the prefetched resource. + NodeType_PROP_FHIR_PATH NodeType = 22 + // Represents the fallback text when the FHIR query does not resolve. + NodeType_PROP_DEFAULT_TEXT NodeType = 23 +) + +// Enum value maps for NodeType. +var ( + NodeType_name = map[int32]string{ + 0: "NODE_TYPE_UNSPECIFIED", + 1: "CMPT_VERTICAL_CONTAINER", + 2: "CMPT_HORIZONTAL_CONTAINER", + 3: "CMPT_RICH_TEXT", + 4: "CMPT_IMAGE", + 5: "CMPT_HIGHLIGHT_CARD", + 6: "CMPT_ICON_LIST", + 7: "PROP_ALT_TEXT", + 8: "PROP_DARK_MODE", + 9: "CMPT_ICON", + 10: "PROP_COLOR", + 11: "PROP_MIME_TYPE", + 12: "PROP_BLOB_KEY", + 13: "CMPT_ACCORDION_GROUP", + 14: "CMPT_ACCORDION_ROW", + 15: "CMPT_ACCORDION_SUMMARY", + 16: "CMPT_ACCORDION_DETAILS", + 17: "CMPT_SECTION_DIVIDER", + 18: "CMPT_LIST", + 19: "CMPT_LIST_ITEM", + 20: "PROP_TEMPLATE_ARGUMENT", + 21: "PROP_FHIR_PREFETCH_QUERY", + 22: "PROP_FHIR_PATH", + 23: "PROP_DEFAULT_TEXT", + } + NodeType_value = map[string]int32{ + "NODE_TYPE_UNSPECIFIED": 0, + "CMPT_VERTICAL_CONTAINER": 1, + "CMPT_HORIZONTAL_CONTAINER": 2, + "CMPT_RICH_TEXT": 3, + "CMPT_IMAGE": 4, + "CMPT_HIGHLIGHT_CARD": 5, + "CMPT_ICON_LIST": 6, + "PROP_ALT_TEXT": 7, + "PROP_DARK_MODE": 8, + "CMPT_ICON": 9, + "PROP_COLOR": 10, + "PROP_MIME_TYPE": 11, + "PROP_BLOB_KEY": 12, + "CMPT_ACCORDION_GROUP": 13, + "CMPT_ACCORDION_ROW": 14, + "CMPT_ACCORDION_SUMMARY": 15, + "CMPT_ACCORDION_DETAILS": 16, + "CMPT_SECTION_DIVIDER": 17, + "CMPT_LIST": 18, + "CMPT_LIST_ITEM": 19, + "PROP_TEMPLATE_ARGUMENT": 20, + "PROP_FHIR_PREFETCH_QUERY": 21, + "PROP_FHIR_PATH": 22, + "PROP_DEFAULT_TEXT": 23, + } +) + +func (x NodeType) Enum() *NodeType { + p := new(NodeType) + *p = x + return p +} + +func (x NodeType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NodeType) Descriptor() protoreflect.EnumDescriptor { + return file_common_content_api_v1_components_common_proto_enumTypes[0].Descriptor() +} + +func (NodeType) Type() protoreflect.EnumType { + return &file_common_content_api_v1_components_common_proto_enumTypes[0] +} + +func (x NodeType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NodeType.Descriptor instead. +func (NodeType) EnumDescriptor() ([]byte, []int) { + return file_common_content_api_v1_components_common_proto_rawDescGZIP(), []int{0} +} + +// Message representing a Content Node. +// This message is used to represent both renderable components and properties on those components. +// It can represent top-level nodes (such as layouts), nested content components (such as Images, +// Highlight Cards, Rich Text, etc.), and properties (such as alt text, color, etc.). +type Node struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The type of the node. + NodeType NodeType `protobuf:"varint,1,opt,name=node_type,json=nodeType,proto3,enum=common.content.api.v1.components_common.NodeType" json:"node_type,omitempty"` + // The value string content, if applicable (e.g. for text content, color values, icon identifiers). + ValueString *string `protobuf:"bytes,2,opt,name=value_string,json=valueString,proto3,oneof" json:"value_string,omitempty"` + // The raw bytes of the value, if applicable (e.g. for images/videos or binary data). + ValueBytes []byte `protobuf:"bytes,3,opt,name=value_bytes,json=valueBytes,proto3,oneof" json:"value_bytes,omitempty"` + // The URL pointing to the content, if applicable. + Uri *string `protobuf:"bytes,4,opt,name=uri,proto3,oneof" json:"uri,omitempty"` + // The HTML content, if applicable (e.g. for rich text components). + Html *string `protobuf:"bytes,6,opt,name=html,proto3,oneof" json:"html,omitempty"` + // Nested content nodes, allowing for hierarchical structures. + Nodes []*Node `protobuf:"bytes,5,rep,name=nodes,proto3" json:"nodes,omitempty"` + // Unique identifier for the Node. + Id *string `protobuf:"bytes,7,opt,name=id,proto3,oneof" json:"id,omitempty"` +} + +func (x *Node) Reset() { + *x = Node{} + if protoimpl.UnsafeEnabled { + mi := &file_common_content_api_v1_components_common_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Node) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Node) ProtoMessage() {} + +func (x *Node) ProtoReflect() protoreflect.Message { + mi := &file_common_content_api_v1_components_common_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Node.ProtoReflect.Descriptor instead. +func (*Node) Descriptor() ([]byte, []int) { + return file_common_content_api_v1_components_common_proto_rawDescGZIP(), []int{0} +} + +func (x *Node) GetNodeType() NodeType { + if x != nil { + return x.NodeType + } + return NodeType_NODE_TYPE_UNSPECIFIED +} + +func (x *Node) GetValueString() string { + if x != nil && x.ValueString != nil { + return *x.ValueString + } + return "" +} + +func (x *Node) GetValueBytes() []byte { + if x != nil { + return x.ValueBytes + } + return nil +} + +func (x *Node) GetUri() string { + if x != nil && x.Uri != nil { + return *x.Uri + } + return "" +} + +func (x *Node) GetHtml() string { + if x != nil && x.Html != nil { + return *x.Html + } + return "" +} + +func (x *Node) GetNodes() []*Node { + if x != nil { + return x.Nodes + } + return nil +} + +func (x *Node) GetId() string { + if x != nil && x.Id != nil { + return *x.Id + } + return "" +} + +var File_common_content_api_v1_components_common_proto protoreflect.FileDescriptor + +var file_common_content_api_v1_components_common_proto_rawDesc = []byte{ + 0x0a, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, + 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, + 0x74, 0x73, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, + 0x27, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x2e, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, + 0x73, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x1a, 0x17, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, + 0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe7, 0x02, + 0x0a, 0x04, 0x4e, 0x6f, 0x64, 0x65, 0x12, 0x4e, 0x0a, 0x09, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x74, + 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x31, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, + 0x6f, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, + 0x31, 0x2e, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x63, 0x6f, 0x6d, + 0x6d, 0x6f, 0x6e, 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x54, 0x79, 0x70, 0x65, 0x52, 0x08, 0x6e, 0x6f, + 0x64, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x26, 0x0a, 0x0c, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, + 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x24, + 0x0a, 0x0b, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0c, 0x48, 0x01, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x79, 0x74, 0x65, + 0x73, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x48, 0x02, 0x52, 0x03, 0x75, 0x72, 0x69, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x68, + 0x74, 0x6d, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x04, 0x68, 0x74, 0x6d, + 0x6c, 0x88, 0x01, 0x01, 0x12, 0x43, 0x0a, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x05, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x6e, 0x74, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x2e, 0x63, 0x6f, 0x6d, 0x70, + 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x4e, 0x6f, + 0x64, 0x65, 0x52, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x02, 0x69, 0x64, 0x88, 0x01, 0x01, 0x42, 0x0f, + 0x0a, 0x0d, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x42, + 0x0e, 0x0a, 0x0c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x42, + 0x06, 0x0a, 0x04, 0x5f, 0x75, 0x72, 0x69, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x68, 0x74, 0x6d, 0x6c, + 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x64, 0x2a, 0xab, 0x04, 0x0a, 0x08, 0x4e, 0x6f, 0x64, 0x65, + 0x54, 0x79, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x15, 0x4e, 0x4f, 0x44, 0x45, 0x5f, 0x54, 0x59, 0x50, + 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, + 0x1b, 0x0a, 0x17, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x56, 0x45, 0x52, 0x54, 0x49, 0x43, 0x41, 0x4c, + 0x5f, 0x43, 0x4f, 0x4e, 0x54, 0x41, 0x49, 0x4e, 0x45, 0x52, 0x10, 0x01, 0x12, 0x1d, 0x0a, 0x19, + 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x48, 0x4f, 0x52, 0x49, 0x5a, 0x4f, 0x4e, 0x54, 0x41, 0x4c, 0x5f, + 0x43, 0x4f, 0x4e, 0x54, 0x41, 0x49, 0x4e, 0x45, 0x52, 0x10, 0x02, 0x12, 0x12, 0x0a, 0x0e, 0x43, + 0x4d, 0x50, 0x54, 0x5f, 0x52, 0x49, 0x43, 0x48, 0x5f, 0x54, 0x45, 0x58, 0x54, 0x10, 0x03, 0x12, + 0x0e, 0x0a, 0x0a, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x49, 0x4d, 0x41, 0x47, 0x45, 0x10, 0x04, 0x12, + 0x17, 0x0a, 0x13, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x48, 0x49, 0x47, 0x48, 0x4c, 0x49, 0x47, 0x48, + 0x54, 0x5f, 0x43, 0x41, 0x52, 0x44, 0x10, 0x05, 0x12, 0x12, 0x0a, 0x0e, 0x43, 0x4d, 0x50, 0x54, + 0x5f, 0x49, 0x43, 0x4f, 0x4e, 0x5f, 0x4c, 0x49, 0x53, 0x54, 0x10, 0x06, 0x12, 0x11, 0x0a, 0x0d, + 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x41, 0x4c, 0x54, 0x5f, 0x54, 0x45, 0x58, 0x54, 0x10, 0x07, 0x12, + 0x12, 0x0a, 0x0e, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x44, 0x41, 0x52, 0x4b, 0x5f, 0x4d, 0x4f, 0x44, + 0x45, 0x10, 0x08, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x49, 0x43, 0x4f, 0x4e, + 0x10, 0x09, 0x12, 0x0e, 0x0a, 0x0a, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x43, 0x4f, 0x4c, 0x4f, 0x52, + 0x10, 0x0a, 0x12, 0x12, 0x0a, 0x0e, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x4d, 0x49, 0x4d, 0x45, 0x5f, + 0x54, 0x59, 0x50, 0x45, 0x10, 0x0b, 0x12, 0x11, 0x0a, 0x0d, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x42, + 0x4c, 0x4f, 0x42, 0x5f, 0x4b, 0x45, 0x59, 0x10, 0x0c, 0x12, 0x18, 0x0a, 0x14, 0x43, 0x4d, 0x50, + 0x54, 0x5f, 0x41, 0x43, 0x43, 0x4f, 0x52, 0x44, 0x49, 0x4f, 0x4e, 0x5f, 0x47, 0x52, 0x4f, 0x55, + 0x50, 0x10, 0x0d, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x41, 0x43, 0x43, 0x4f, + 0x52, 0x44, 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x4f, 0x57, 0x10, 0x0e, 0x12, 0x1a, 0x0a, 0x16, 0x43, + 0x4d, 0x50, 0x54, 0x5f, 0x41, 0x43, 0x43, 0x4f, 0x52, 0x44, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x55, + 0x4d, 0x4d, 0x41, 0x52, 0x59, 0x10, 0x0f, 0x12, 0x1a, 0x0a, 0x16, 0x43, 0x4d, 0x50, 0x54, 0x5f, + 0x41, 0x43, 0x43, 0x4f, 0x52, 0x44, 0x49, 0x4f, 0x4e, 0x5f, 0x44, 0x45, 0x54, 0x41, 0x49, 0x4c, + 0x53, 0x10, 0x10, 0x12, 0x18, 0x0a, 0x14, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x53, 0x45, 0x43, 0x54, + 0x49, 0x4f, 0x4e, 0x5f, 0x44, 0x49, 0x56, 0x49, 0x44, 0x45, 0x52, 0x10, 0x11, 0x12, 0x0d, 0x0a, + 0x09, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x4c, 0x49, 0x53, 0x54, 0x10, 0x12, 0x12, 0x12, 0x0a, 0x0e, + 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x4c, 0x49, 0x53, 0x54, 0x5f, 0x49, 0x54, 0x45, 0x4d, 0x10, 0x13, + 0x12, 0x1a, 0x0a, 0x16, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, + 0x45, 0x5f, 0x41, 0x52, 0x47, 0x55, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x14, 0x12, 0x1c, 0x0a, 0x18, + 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x46, 0x48, 0x49, 0x52, 0x5f, 0x50, 0x52, 0x45, 0x46, 0x45, 0x54, + 0x43, 0x48, 0x5f, 0x51, 0x55, 0x45, 0x52, 0x59, 0x10, 0x15, 0x12, 0x12, 0x0a, 0x0e, 0x50, 0x52, + 0x4f, 0x50, 0x5f, 0x46, 0x48, 0x49, 0x52, 0x5f, 0x50, 0x41, 0x54, 0x48, 0x10, 0x16, 0x12, 0x15, + 0x0a, 0x11, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x5f, 0x54, + 0x45, 0x58, 0x54, 0x10, 0x17, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x76, 0x65, 0x72, 0x69, 0x6c, 0x79, 0x2d, 0x73, 0x72, 0x63, 0x2f, 0x76, + 0x65, 0x72, 0x69, 0x6c, 0x79, 0x31, 0x2f, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, + 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_common_content_api_v1_components_common_proto_rawDescOnce sync.Once + file_common_content_api_v1_components_common_proto_rawDescData = file_common_content_api_v1_components_common_proto_rawDesc +) + +func file_common_content_api_v1_components_common_proto_rawDescGZIP() []byte { + file_common_content_api_v1_components_common_proto_rawDescOnce.Do(func() { + file_common_content_api_v1_components_common_proto_rawDescData = protoimpl.X.CompressGZIP(file_common_content_api_v1_components_common_proto_rawDescData) + }) + return file_common_content_api_v1_components_common_proto_rawDescData +} + +var file_common_content_api_v1_components_common_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_common_content_api_v1_components_common_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_common_content_api_v1_components_common_proto_goTypes = []interface{}{ + (NodeType)(0), // 0: common.content.api.v1.components_common.NodeType + (*Node)(nil), // 1: common.content.api.v1.components_common.Node +} +var file_common_content_api_v1_components_common_proto_depIdxs = []int32{ + 0, // 0: common.content.api.v1.components_common.Node.node_type:type_name -> common.content.api.v1.components_common.NodeType + 1, // 1: common.content.api.v1.components_common.Node.nodes:type_name -> common.content.api.v1.components_common.Node + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_common_content_api_v1_components_common_proto_init() } +func file_common_content_api_v1_components_common_proto_init() { + if File_common_content_api_v1_components_common_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_common_content_api_v1_components_common_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Node); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_common_content_api_v1_components_common_proto_msgTypes[0].OneofWrappers = []interface{}{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_common_content_api_v1_components_common_proto_rawDesc, + NumEnums: 1, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_common_content_api_v1_components_common_proto_goTypes, + DependencyIndexes: file_common_content_api_v1_components_common_proto_depIdxs, + EnumInfos: file_common_content_api_v1_components_common_proto_enumTypes, + MessageInfos: file_common_content_api_v1_components_common_proto_msgTypes, + }.Build() + File_common_content_api_v1_components_common_proto = out.File + file_common_content_api_v1_components_common_proto_rawDesc = nil + file_common_content_api_v1_components_common_proto_goTypes = nil + file_common_content_api_v1_components_common_proto_depIdxs = nil +} diff --git a/src/program-generator/app/internal/db/client.go b/src/program-generator/app/internal/db/client.go new file mode 100644 index 00000000..4ddf495c --- /dev/null +++ b/src/program-generator/app/internal/db/client.go @@ -0,0 +1,97 @@ +package db + +import ( + "database/sql" + "fmt" + "time" + + _ "github.com/lib/pq" +) + +type Client struct { + db *sql.DB +} + +type Template struct { + ID int `json:"id"` + Name string `json:"name"` + Yaml string `json:"yaml"` + CreatedAt time.Time `json:"created_at"` +} + +func NewClient(connStr string) (*Client, error) { + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(3) + db.SetConnMaxLifetime(5 * time.Minute) + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("pinging database: %w", err) + } + return &Client{db: db}, nil +} + +func (c *Client) InitSchema() error { + _, err := c.db.Exec(` + CREATE TABLE IF NOT EXISTS templates ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + yaml TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_templates_name ON templates(name); + `) + return err +} + +func (c *Client) SaveTemplate(name, yaml string) (*Template, error) { + var t Template + err := c.db.QueryRow( + `INSERT INTO templates (name, yaml) VALUES ($1, $2) RETURNING id, name, yaml, created_at`, + name, yaml, + ).Scan(&t.ID, &t.Name, &t.Yaml, &t.CreatedAt) + if err != nil { + return nil, err + } + return &t, nil +} + +func (c *Client) ListTemplates() ([]Template, error) { + rows, err := c.db.Query(`SELECT id, name, yaml, created_at FROM templates ORDER BY created_at DESC LIMIT 50`) + if err != nil { + return nil, err + } + defer rows.Close() + + var templates []Template + for rows.Next() { + var t Template + if err := rows.Scan(&t.ID, &t.Name, &t.Yaml, &t.CreatedAt); err != nil { + return nil, err + } + templates = append(templates, t) + } + return templates, nil +} + +func (c *Client) GetTemplate(id int) (*Template, error) { + var t Template + err := c.db.QueryRow( + `SELECT id, name, yaml, created_at FROM templates WHERE id = $1`, id, + ).Scan(&t.ID, &t.Name, &t.Yaml, &t.CreatedAt) + if err != nil { + return nil, err + } + return &t, nil +} + +func (c *Client) Ping() error { + return c.db.Ping() +} + +func (c *Client) Close() { + c.db.Close() +} diff --git a/src/program-generator/app/internal/seeder/builder.go b/src/program-generator/app/internal/seeder/builder.go new file mode 100644 index 00000000..52db0dd1 --- /dev/null +++ b/src/program-generator/app/internal/seeder/builder.go @@ -0,0 +1,1031 @@ +package seeder + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// --------------------------------------------------------------------------- +// Builder — the main orchestrator +// --------------------------------------------------------------------------- + +// Builder creates VerilyMe programs from templates. +type Builder struct { + fhirClient *FHIRClient + gcsClient *GCSClient + gcsBucket string +} + +// NewBuilder creates a Builder with the given FHIR client and optional GCS client. +// The GCS client and bucket are required for templates with consent steps (regulated +// consents need a PDF uploaded to GCS). Pass nil/empty for templates without consent. +func NewBuilder(fhirClient *FHIRClient, gcsClient *GCSClient, gcsBucket string) *Builder { + return &Builder{ + fhirClient: fhirClient, + gcsClient: gcsClient, + gcsBucket: gcsBucket, + } +} + +// LoadTemplate reads and parses a YAML template file. +// It detects the format automatically: +// - Node-tree format: root has node_type (e.g. ADMIN_PROGRAM). Converted to Template. +// - Legacy format: root has name/org_id/bundles. Parsed directly into Template. +func LoadTemplate(path string) (*Template, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading template %s: %w", path, err) + } + + // Detect format: node-tree format has `node_type` at the root level. + var probe struct { + NodeType string `yaml:"node_type"` + } + _ = yaml.Unmarshal(data, &probe) // best-effort; errors handled below + + if probe.NodeType != "" { + // Node-tree format — unmarshal as a single ContentNode tree, + // then convert to the internal Template representation. + var root ContentNode + if err := yaml.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("parsing node-tree template %s: %w", path, err) + } + tmpl, err := convertNodeTreeToTemplate(root) + if err != nil { + return nil, fmt.Errorf("converting node-tree template: %w", err) + } + if err := validateTemplate(tmpl); err != nil { + return nil, fmt.Errorf("validating template: %w", err) + } + return tmpl, nil + } + + // Legacy format — parse directly into Template. + var tmpl Template + if err := yaml.Unmarshal(data, &tmpl); err != nil { + return nil, fmt.Errorf("parsing template %s: %w", path, err) + } + if err := validateTemplate(&tmpl); err != nil { + return nil, fmt.Errorf("validating template: %w", err) + } + return &tmpl, nil +} + +// LoadTemplateFromBytes parses a YAML template from raw bytes. +// Like LoadTemplate, it auto-detects the format (node-tree vs legacy). +func LoadTemplateFromBytes(data []byte) (*Template, error) { + // Detect format: node-tree format has `node_type` at the root level. + var probe struct { + NodeType string `yaml:"node_type"` + } + _ = yaml.Unmarshal(data, &probe) + + if probe.NodeType != "" { + var root ContentNode + if err := yaml.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("parsing node-tree template: %w", err) + } + tmpl, err := convertNodeTreeToTemplate(root) + if err != nil { + return nil, fmt.Errorf("converting node-tree template: %w", err) + } + if err := validateTemplate(tmpl); err != nil { + return nil, fmt.Errorf("validating template: %w", err) + } + return tmpl, nil + } + + var tmpl Template + if err := yaml.Unmarshal(data, &tmpl); err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + if err := validateTemplate(&tmpl); err != nil { + return nil, fmt.Errorf("validating template: %w", err) + } + return &tmpl, nil +} + +// validateTemplate performs basic validation on a template. +func validateTemplate(tmpl *Template) error { + if tmpl.Name == "" { + return fmt.Errorf("name is required") + } + if tmpl.OrgID == "" { + return fmt.Errorf("org_id is required") + } + if tmpl.Version == "" { + tmpl.Version = "v1" + } + if tmpl.EnvBaseURL == "" { + tmpl.EnvBaseURL = "https://dev-stable.one.verily.com" + } + if len(tmpl.Bundles) == 0 { + return fmt.Errorf("at least one bundle is required") + } + for i, b := range tmpl.Bundles { + if b.Name == "" { + return fmt.Errorf("bundle %d: name is required", i) + } + if len(b.Steps) == 0 { + return fmt.Errorf("bundle %q: at least one step is required", b.Name) + } + for j, s := range b.Steps { + switch s.Type { + case "info", "survey", "consent": + // valid + default: + return fmt.Errorf("bundle %q step %d: unsupported type %q (supported: info, survey, consent)", b.Name, j, s.Type) + } + if s.Type == "info" && s.BodyHTML != "" && len(s.Nodes) > 0 { + return fmt.Errorf("bundle %q step %d: body_html and nodes are mutually exclusive — use one or the other", b.Name, j) + } + if s.Type == "consent" && len(s.Checkboxes) == 0 { + return fmt.Errorf("bundle %q step %d (consent): at least one checkbox is required", b.Name, j) + } + } + } + return nil +} + +// TemplateHasConsentSteps returns true if any bundle in the template has a consent step. +func TemplateHasConsentSteps(tmpl *Template) bool { + for _, b := range tmpl.Bundles { + for _, s := range b.Steps { + if s.Type == "consent" { + return true + } + } + } + return false +} + +// Build creates all FHIR resources for the template and returns the program output. +// Each run generates a unique suffix appended to the program name, making the +// canonical URLs unique so the tool can be run multiple times with the same template. +func (b *Builder) Build(ctx context.Context, tmpl *Template) (*ProgramOutput, error) { + // Append a unique suffix so each run creates distinct FHIR resources. + // Format: name-YYYYMMDD-HHMMSS (human-readable, sortable, unique per second) + suffix := time.Now().Format("20060102-150405") + tmplCopy := *tmpl + tmplCopy.Name = tmpl.Name + "-" + suffix + fmt.Printf("Program name (with run suffix): %s\n", tmplCopy.Name) + + bc := newBuildContext(tmplCopy) + + // Phase 1: Build all content resources (DocumentReferences, Questionnaires, etc.) + childPlanDefs, err := b.buildBundles(ctx, bc) + if err != nil { + return nil, fmt.Errorf("building bundles: %w", err) + } + + // Phase 1.5: Check if the Organization already exists in the FHIR store. + // If it does, we must NOT include it in the transaction — a PUT would replace + // the entire resource, destroying its verily-part-of-organization hierarchy + // that other teams depend on. We only create it in fresh stores (dev-hermetic). + // + // Default to true (safe): if the check fails we assume the org exists so we + // never accidentally overwrite a shared org's hierarchy. The worst case of a + // false-positive is that the transaction fails with an org-compartment + // reference error in a truly fresh store, which is easy to diagnose and retry. + orgExists := true + if b.fhirClient != nil { + exists, err := b.fhirClient.ResourceExists(ctx, "Organization", bc.tmpl.OrgID) + if err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Could not check if Organization %s exists (assuming exists to avoid destructive PUT): %v\n", bc.tmpl.OrgID, err) + } else { + orgExists = exists + } + } + + // Phase 2: Build workflow structure + b.buildWorkflowStructure(bc, childPlanDefs, orgExists) + + // Phase 3: Post the transaction bundle to FHIR + fmt.Printf("Posting FHIR transaction bundle with %d entries...\n", len(bc.entries)) + resp, err := b.fhirClient.PostTransaction(ctx, bc.entries) + if err != nil { + return nil, fmt.Errorf("posting FHIR transaction: %w", err) + } + + // Phase 4: Extract IDs from response and build output + return b.buildOutput(bc, resp) +} + +// childPlanDefInfo tracks a child PlanDefinition's canonical URL and temp ID. +type childPlanDefInfo struct { + canonicalURL string + tempID string +} + +// buildBundles processes all bundles in the template and returns child PlanDef info. +func (b *Builder) buildBundles(ctx context.Context, bc *buildContext) ([]childPlanDefInfo, error) { + var childPlanDefs []childPlanDefInfo + + for bundleIdx, bundle := range bc.tmpl.Bundles { + info, err := b.buildBundle(ctx, bc, bundle, bundleIdx) + if err != nil { + return nil, fmt.Errorf("bundle %q: %w", bundle.Name, err) + } + childPlanDefs = append(childPlanDefs, *info) + } + + return childPlanDefs, nil +} + +// buildBundle processes a single bundle and returns its child PlanDef info. +func (b *Builder) buildBundle(ctx context.Context, bc *buildContext, bundle Bundle, bundleIdx int) (*childPlanDefInfo, error) { + // 1. Create the card DocumentReference + cardTempID, cardRes := buildCardDocumentReference(bc, bundle, bundleIdx) + bc.addEntry(cardTempID, "DocumentReference", cardRes) + bc.outputResources = append(bc.outputResources, ResourceRef{ + Type: "DocumentReference", + Name: fmt.Sprintf("%s-card", bundle.Name), + }) + + // 1b. Create the companion CodeSystem for the card (provides localized title/description) + cardCSTempID, cardCSRes := buildCardCodeSystem(bc, bundle, bundleIdx) + bc.addEntry(cardCSTempID, "CodeSystem", cardCSRes) + bc.outputResources = append(bc.outputResources, ResourceRef{ + Type: "CodeSystem", + Name: fmt.Sprintf("%s-card-translations", bundle.Name), + }) + + // 2. Process each step + var stepActions []bundleStepAction + for stepIdx, step := range bundle.Steps { + action, err := b.buildStep(ctx, bc, step, stepIdx, bundle.Name) + if err != nil { + return nil, fmt.Errorf("step %d (%s): %w", stepIdx, step.Type, err) + } + stepActions = append(stepActions, *action) + } + + // 3. Create the child PlanDefinition + childTempID, childRes := buildChildPlanDefinition(bc, bundle, bundleIdx, cardTempID, stepActions) + bc.addEntry(childTempID, "PlanDefinition", childRes) + + childCanonical := canonicalURL("standalone-seeding", "PlanDefinition", fmt.Sprintf("%s-%s", bc.tmpl.Name, bundle.Name)) + bc.outputResources = append(bc.outputResources, ResourceRef{ + Type: "PlanDefinition", + Name: fmt.Sprintf("%s (bundle)", bundle.Name), + }) + + return &childPlanDefInfo{ + canonicalURL: childCanonical + "|" + bc.tmpl.Version, + tempID: childTempID, + }, nil +} + +// buildStep processes a single step and returns the action info for the PlanDefinition. +func (b *Builder) buildStep(ctx context.Context, bc *buildContext, step Step, stepIdx int, bundleName string) (*bundleStepAction, error) { + switch step.Type { + case "info": + return b.buildInfoStep(bc, step, stepIdx, bundleName) + case "survey": + return b.buildSurveyStep(bc, step, stepIdx, bundleName) + case "consent": + return b.buildConsentStep(ctx, bc, step, stepIdx, bundleName) + default: + return nil, fmt.Errorf("unsupported step type: %s", step.Type) + } +} + +// buildInfoStep creates all resources for an info step. +func (b *Builder) buildInfoStep(bc *buildContext, step Step, stepIdx int, bundleName string) (*bundleStepAction, error) { + // Create DocumentReference with proto-encoded content + docRefTempID, docRefRes, err := buildInfoDocumentReference(bc, step, stepIdx, bundleName) + if err != nil { + return nil, fmt.Errorf("building DocumentReference: %w", err) + } + bc.addEntry(docRefTempID, "DocumentReference", docRefRes) + + actionID := fmt.Sprintf("%s-%s-info-%d", bc.tmpl.Name, bundleName, stepIdx) + + // Create ActivityDefinition referencing the DocumentReference + adTempID, adRes := buildActivityDefinition(bc, actionID, docRefTempID) + bc.addEntry(adTempID, "ActivityDefinition", adRes) + + adCanonical := canonicalURL("standalone-seeding", "ActivityDefinition", actionID) + + bc.outputResources = append(bc.outputResources, ResourceRef{ + Type: "DocumentReference", + Name: fmt.Sprintf("%s-info-%d", bundleName, stepIdx), + }) + bc.outputResources = append(bc.outputResources, ResourceRef{ + Type: "ActivityDefinition", + Name: fmt.Sprintf("%s-info-%d", bundleName, stepIdx), + }) + + contentOID := fmt.Sprintf("%s-%s-info-%d", bc.tmpl.Name, bundleName, stepIdx) + + return &bundleStepAction{ + StepType: "info", + ActionID: actionID, + DefinitionCanonical: adCanonical + "|" + bc.tmpl.Version, + ContentOID: contentOID, + }, nil +} + +// buildSurveyStep creates all resources for a survey step. +func (b *Builder) buildSurveyStep(bc *buildContext, step Step, stepIdx int, bundleName string) (*bundleStepAction, error) { + // Build survey FHIR resources + sr, err := buildSurveyResources(bc, step, stepIdx, bundleName) + if err != nil { + return nil, fmt.Errorf("building survey resources: %w", err) + } + + // Add Questionnaire + qTempID := newTempID() + bc.addEntry(qTempID, "Questionnaire", sr.Questionnaire) + + // Add CodeSystem + csTempID := newTempID() + bc.addEntry(csTempID, "CodeSystem", sr.CodeSystem) + + // Add ValueSets + for _, vs := range sr.ValueSets { + vsTempID := newTempID() + bc.addEntry(vsTempID, "ValueSet", vs) + } + + actionID := fmt.Sprintf("%s-%s-survey-%d", bc.tmpl.Name, bundleName, stepIdx) + contentOID := actionID + + bc.outputResources = append(bc.outputResources, ResourceRef{ + Type: "Questionnaire", + Name: fmt.Sprintf("%s-survey-%d", bundleName, stepIdx), + }) + + return &bundleStepAction{ + StepType: "survey", + ActionID: actionID, + DefinitionCanonical: sr.QuestionnaireURL + "|" + bc.tmpl.Version, + ContentOID: contentOID, + }, nil +} + +// buildConsentStep creates all resources for a consent step: +// Contract, Questionnaire, Content CodeSystem, Metadata CodeSystem, and ActivityDefinition. +// If a GCS client is configured, it also generates and uploads a consent PDF. +func (b *Builder) buildConsentStep(ctx context.Context, bc *buildContext, step Step, stepIdx int, bundleName string) (*bundleStepAction, error) { + // Generate and upload consent PDF (if GCS is configured) + var pdfGCSURL string + if b.gcsClient != nil && b.gcsBucket != "" { + // Collect checkbox texts for the PDF + checkboxTexts := make([]string, len(step.Checkboxes)) + for i, cb := range step.Checkboxes { + checkboxTexts[i] = cb.Text + } + + // Generate PDF document + pdfBytes := generateConsentPDF(step.Title, checkboxTexts) + fmt.Fprintf(os.Stderr, " Generated consent PDF (%d bytes)\n", len(pdfBytes)) + + // Upload to GCS + objectPath := fmt.Sprintf("standalone-seeding/%s/%s-consent-%d.pdf", bc.tmpl.Name, bundleName, stepIdx) + var err error + pdfGCSURL, err = b.gcsClient.UploadPDF(ctx, b.gcsBucket, objectPath, pdfBytes) + if err != nil { + return nil, fmt.Errorf("uploading consent PDF: %w", err) + } + fmt.Fprintf(os.Stderr, " Uploaded consent PDF to %s\n", pdfGCSURL) + } else { + // Dry-run or no GCS: use a placeholder URL so the FHIR structure is complete. + // The consent-be will fail to fetch this at runtime, but the structure is valid. + pdfGCSURL = "https://storage.googleapis.com/PLACEHOLDER_BUCKET/consent-placeholder.pdf" + fmt.Fprintf(os.Stderr, " Using placeholder PDF URL (no GCS bucket configured)\n") + } + + cr, err := buildConsentResources(bc, step, stepIdx, bundleName, pdfGCSURL) + if err != nil { + return nil, fmt.Errorf("building consent resources: %w", err) + } + + // Add Questionnaire (referenced by Contract.topicReference) + bc.addEntry(cr.QuestionnaireTempID, "Questionnaire", cr.Questionnaire) + + // Add Content CodeSystem + bc.addEntry(newTempID(), "CodeSystem", cr.ContentCodeSystem) + + // Add Metadata CodeSystem + bc.addEntry(newTempID(), "CodeSystem", cr.MetadataCodeSystem) + + // Add Contract (references Questionnaire via topicReference temp ID) + bc.addEntry(newTempID(), "Contract", cr.Contract) + + actionID := fmt.Sprintf("%s-%s-consent-%d", bc.tmpl.Name, bundleName, stepIdx) + + // Create ActivityDefinition with Contract canonical + contractCanonical := cr.ContractURL + "|" + consentVersion + adTempID, adRes := buildConsentActivityDefinition(bc, actionID, contractCanonical) + bc.addEntry(adTempID, "ActivityDefinition", adRes) + + adCanonical := canonicalURL("standalone-seeding", "ActivityDefinition", actionID) + + bc.outputResources = append(bc.outputResources, + ResourceRef{Type: "Questionnaire", Name: fmt.Sprintf("%s-consent-%d-questionnaire", bundleName, stepIdx)}, + ResourceRef{Type: "CodeSystem", Name: fmt.Sprintf("%s-consent-%d-content-cs", bundleName, stepIdx)}, + ResourceRef{Type: "CodeSystem", Name: fmt.Sprintf("%s-consent-%d-metadata-cs", bundleName, stepIdx)}, + ResourceRef{Type: "Contract", Name: fmt.Sprintf("%s-consent-%d-contract", bundleName, stepIdx)}, + ResourceRef{Type: "ActivityDefinition", Name: fmt.Sprintf("%s-consent-%d-ad", bundleName, stepIdx)}, + ) + + return &bundleStepAction{ + StepType: "consent", + ActionID: actionID, + DefinitionCanonical: adCanonical + "|" + bc.tmpl.Version, + ContentOID: cr.ContentID, + }, nil +} + +// buildWorkflowStructure creates the Organization, Group, root PlanDefinition, and HealthcareService. +// orgExists indicates whether the Organization already exists in the FHIR store (checked +// by the caller via a GET). When true the Organization entry is skipped to avoid +// overwriting its verily-part-of-organization hierarchy. +func (b *Builder) buildWorkflowStructure(bc *buildContext, childPlanDefs []childPlanDefInfo, orgExists bool) { + // Organization — only include if it doesn't already exist in the FHIR store. + // Uses PUT Organization/ so the org is created at an exact, known ID + // (required for org-compartment references in other resources). + // In shared environments (dev-stable) the org already exists with a real + // verily-part-of-organization hierarchy — a PUT would destroy it. + if orgExists { + fmt.Printf(" Organization %s already exists — skipping (preserving existing hierarchy)\n", bc.tmpl.OrgID) + } else { + orgRes := buildOrganization(bc) + orgTempID := newTempID() + bc.addUpsertEntry(orgTempID, "Organization/"+bc.tmpl.OrgID, orgRes) + bc.outputResources = append(bc.outputResources, ResourceRef{ + Type: "Organization", + Name: bc.tmpl.OrgID, + }) + fmt.Printf(" Organization %s (create via PUT)\n", bc.tmpl.OrgID) + } + + // Group + groupRes := buildGroup(bc) + bc.addEntry(bc.groupTempID, "Group", groupRes) + bc.outputResources = append(bc.outputResources, ResourceRef{ + Type: "Group", + Name: "applicability-group", + }) + + // Root PlanDefinition + childCanonicals := make([]string, len(childPlanDefs)) + for i, cp := range childPlanDefs { + childCanonicals[i] = cp.canonicalURL + } + rootPDRes := buildRootPlanDefinition(bc, childCanonicals) + bc.addEntry(bc.rootPlanDefTempID, "PlanDefinition", rootPDRes) + bc.outputResources = append(bc.outputResources, ResourceRef{ + Type: "PlanDefinition", + Name: "root-care-pathway", + }) + + // HealthcareService + rootCanonical := canonicalURL("standalone-seeding", "PlanDefinition", bc.tmpl.Name) + "|" + bc.tmpl.Version + hcsRes := buildHealthcareService(bc, rootCanonical) + bc.addEntry(bc.hcsTempID, "HealthcareService", hcsRes) + bc.outputResources = append(bc.outputResources, ResourceRef{ + Type: "HealthcareService", + Name: bc.tmpl.Name, + }) +} + +// buildOutput extracts resource IDs from the transaction response. +func (b *Builder) buildOutput(bc *buildContext, resp *TransactionResponse) (*ProgramOutput, error) { + output := &ProgramOutput{ + Name: bc.tmpl.Name, + OrgID: bc.tmpl.OrgID, + Version: bc.tmpl.Version, + } + + // Map temp IDs to actual IDs using entry order (entries and response are in same order) + tempToActual := make(map[string]string) + for i, entry := range bc.entries { + if i < len(resp.Entries) { + respEntry := resp.Entries[i] + if entry.FullURL != "" { + tempToActual[entry.FullURL] = respEntry.ID + } + // Update output resources with actual IDs + if i < len(bc.outputResources) { + bc.outputResources[i].ID = respEntry.ID + } + } + } + + // The output resources were collected in order, but we added them during build + // while entries were also added. We need to match them correctly. + // Actually, outputResources were appended in non-1:1 correspondence with entries. + // Let's just use the known temp IDs for the important ones. + output.PlanDefinitionID = tempToActual[bc.rootPlanDefTempID] + output.GroupID = tempToActual[bc.groupTempID] + output.HealthcareServiceID = tempToActual[bc.hcsTempID] + + // Collect all resources from the response + output.Resources = []ResourceRef{} + for i, entry := range resp.Entries { + ref := ResourceRef{ + Type: entry.ResourceType, + ID: entry.ID, + } + // Try to find the corresponding name from our output resources + // We need to match by position in the entries list + for _, or := range bc.outputResources { + if or.Type == entry.ResourceType && or.ID == "" { + ref.Name = or.Name + or.ID = entry.ID // mark as used + break + } + } + // Fallback: use entry index + if ref.Name == "" && i < len(bc.entries) { + ref.Name = fmt.Sprintf("entry-%d", i) + } + output.Resources = append(output.Resources, ref) + } + + return output, nil +} + +// LoadPreviousOutput reads a previously saved program config JSON and returns +// the output (if any). Returns nil if the file doesn't exist or can't be parsed. +func LoadPreviousOutput(path string) *ProgramOutput { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var prev ProgramOutput + if err := json.Unmarshal(data, &prev); err != nil { + return nil + } + return &prev +} + +// RetireOldPlanDefinitions patches all PlanDefinitions from a previous run to +// "retired" status via the FHIR API. This is critical because the workflow engine +// applies ALL active PlanDefinitions whose Group applicability matches the patient, +// not just registered ones. Without retirement, every old program run would +// keep applying to new patients. +func (b *Builder) RetireOldPlanDefinitions(ctx context.Context, prev *ProgramOutput) error { + if b.fhirClient == nil || prev == nil { + return nil + } + + planDefIDs := []string{} + for _, r := range prev.Resources { + if r.Type == "PlanDefinition" { + planDefIDs = append(planDefIDs, r.ID) + } + } + + if len(planDefIDs) == 0 { + return nil + } + + fmt.Fprintf(os.Stderr, "Retiring %d PlanDefinitions from previous run (best-effort, 404s are normal on fresh stores)...\n", len(planDefIDs)) + for _, id := range planDefIDs { + if err := b.fhirClient.PatchResourceStatus(ctx, "PlanDefinition", id, "retired"); err != nil { + fmt.Fprintf(os.Stderr, " ℹ️ PlanDefinition/%s not found (already gone or different store) — skipping\n", id) + // Non-fatal — resource may not exist on a fresh ephemeral store + } else { + fmt.Fprintf(os.Stderr, " ✅ Retired PlanDefinition/%s\n", id) + } + } + return nil +} + +// DryRun builds the FHIR transaction bundle without posting it. +// Returns the bundle as a map suitable for JSON marshaling. +func (b *Builder) DryRun(ctx context.Context, tmpl *Template) (map[string]interface{}, error) { + bc := newBuildContext(*tmpl) + + // Phase 1: Build all content resources + childPlanDefs, err := b.buildBundles(ctx, bc) + if err != nil { + return nil, fmt.Errorf("building bundles: %w", err) + } + + // Phase 2: Build workflow structure (dry-run: always include Organization) + b.buildWorkflowStructure(bc, childPlanDefs, false) + + // Return as transaction bundle + return map[string]interface{}{ + "resourceType": "Bundle", + "type": "transaction", + "entry": bc.entries, + }, nil +} + +// SaveOutput writes the program output as JSON to a file. +func SaveOutput(output *ProgramOutput, path string) error { + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("marshaling output: %w", err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing output to %s: %w", path, err) + } + return nil +} + +// --------------------------------------------------------------------------- +// Node-tree format → Template conversion +// --------------------------------------------------------------------------- +// +// When the YAML root is an ADMIN_PROGRAM node, we convert the entire node tree +// into the internal Template/Bundle/Step/Question/ConsentCheckbox structs so +// that the downstream FHIR builders work unchanged. This is the "facade" +// approach: the DSL declares richer structure than the converters consume, +// and the conversion layer bridges the gap. +// +// What IS extracted: +// - Program metadata (name, org_id, version, env_base_url) +// - Bundle name and card (title, description) +// - Info step content nodes (from CMPT_VERTICAL_CONTAINER inside CMPT_BUNDLE_LAYOUT) +// - Survey questions (CMPT_CHOICE_QUESTION, CMPT_FREE_TEXT_QUESTION with basic props) +// - Compound numeric questions (CMPT_QUESTION_GROUP > CMPT_HORIZONTAL_CONTAINER) +// - Numeric constraints (PROP_CONSTRAINTS > PROP_NUMERIC > PROP_MIN_VALUE / PROP_MAX_VALUE) +// - Unit info (PROP_UNITS > PROP_UNIT > PROP_UNIT_DISPLAY / PROP_UNIT_SYSTEM / PROP_UNIT_CODE) +// - Consent checkboxes (CMPT_CHOICE_QUESTION with PROP_BOOLEAN in ADMIN_CONSENT_SIGN) +// +// What is DECLARED in the DSL but NOT yet leveraged by the converter: +// - CMPT_BUNDLE_LAYOUT / CMPT_HEADER / CMPT_FOOTER / CMPT_CTA_BUTTON chrome +// - CMPT_EXIT_BUTTON / ACTN_ON_CLICK behavior +// - CMPT_PAGE grouping (page boundaries for navigation) +// - CMPT_DIALOG content (dialog text is currently hardcoded in consent.go) +// - ADMIN_CONSENT_REVIEW (the review flow's nodes are declared but not consumed) +// - CMPT_PDF_VIEWER, CMPT_FREE_TEXT_QUESTION with PROP_SIGNATURE (consent modules) +// - PROP_ALLOW_DECIMAL / PROP_MAX_DECIMAL_PLACES (decimal/quantity distinction) +// +// These are all faithfully declared in the YAML for when a richer converter is built. + +// convertNodeTreeToTemplate converts an ADMIN_PROGRAM root node into a Template. +func convertNodeTreeToTemplate(root ContentNode) (*Template, error) { + if root.NodeType != "ADMIN_PROGRAM" { + return nil, fmt.Errorf("root node must be ADMIN_PROGRAM, got %q", root.NodeType) + } + tmpl := &Template{ + Name: root.ValueString, + } + for _, child := range root.Nodes { + switch child.NodeType { + case "PROP_ORG_ID": + tmpl.OrgID = child.ValueString + case "PROP_VERSION": + tmpl.Version = child.ValueString + case "PROP_ENV_BASE_URL": + tmpl.EnvBaseURL = child.ValueString + case "ADMIN_BUNDLE": + bundle, err := extractBundle(child) + if err != nil { + return nil, fmt.Errorf("bundle %q: %w", child.ValueString, err) + } + tmpl.Bundles = append(tmpl.Bundles, *bundle) + } + } + return tmpl, nil +} + +// extractBundle converts an ADMIN_BUNDLE node into a Bundle. +func extractBundle(node ContentNode) (*Bundle, error) { + bundle := &Bundle{Name: node.ValueString} + for _, child := range node.Nodes { + switch child.NodeType { + case "ADMIN_CARD": + bundle.Card = extractCard(child) + case "ADMIN_INFO_STEP": + step := extractInfoStep(child) + bundle.Steps = append(bundle.Steps, *step) + case "ADMIN_SURVEY_STEP": + step, err := extractSurveyStep(child) + if err != nil { + return nil, err + } + bundle.Steps = append(bundle.Steps, *step) + case "ADMIN_CONSENT_STEP": + step, err := extractConsentStep(child) + if err != nil { + return nil, err + } + bundle.Steps = append(bundle.Steps, *step) + } + } + return bundle, nil +} + +// extractCard converts an ADMIN_CARD node into a Card. +func extractCard(node ContentNode) Card { + var card Card + for _, child := range node.Nodes { + switch child.NodeType { + case "PROP_TITLE": + card.Title = child.ValueString + case "PROP_DESCRIPTION": + card.Description = child.ValueString + } + } + return card +} + +// extractInfoStep converts an ADMIN_INFO_STEP node into an info Step. +// It finds the CMPT_VERTICAL_CONTAINER inside CMPT_BUNDLE_LAYOUT and +// extracts its children as the content nodes for the DocumentReference. +// +// NOTE: CMPT_BUNDLE_LAYOUT, CMPT_HEADER, CMPT_FOOTER, CMPT_EXIT_BUTTON, +// CMPT_CTA_BUTTON, and ACTN_ON_CLICK are declared in the DSL but not +// stored in the DocumentReference. The MFE handles these at runtime via +// the template/route system described in the design doc. +func extractInfoStep(node ContentNode) *Step { + step := &Step{ + Type: "info", + Title: node.ValueString, + } + // Find CMPT_BUNDLE_LAYOUT > CMPT_VERTICAL_CONTAINER and extract its children. + bl := findChild(node, "CMPT_BUNDLE_LAYOUT") + if bl != nil { + vc := findChild(*bl, "CMPT_VERTICAL_CONTAINER") + if vc != nil { + step.Nodes = vc.Nodes + } + } + return step +} + +// extractSurveyStep converts an ADMIN_SURVEY_STEP node into a survey Step. +// It recursively walks the tree and collects all CMPT_CHOICE_QUESTION and +// CMPT_FREE_TEXT_QUESTION nodes into flat Question structs. +// +// NOTE: The tree structure (CMPT_SURVEY_CONTEXT, CMPT_BUNDLE_LAYOUT, +// CMPT_PAGE, CMPT_QUESTION_GROUP, CMPT_HORIZONTAL_CONTAINER, CMPT_TITLE, +// CMPT_HEADER, CMPT_FOOTER, CMPT_CTA_BUTTON, ACTN_ON_CLICK) is declared +// in the DSL but the converter flattens all questions into a linear +// Questionnaire.item[]. A richer converter could use page/group structure +// for nested items and PROP_CONSTRAINTS for FHIR extensions. +func extractSurveyStep(node ContentNode) (*Step, error) { + step := &Step{ + Type: "survey", + Title: node.ValueString, + } + step.Questions = collectQuestions(node) + if len(step.Questions) == 0 { + return nil, fmt.Errorf("survey step %q has no questions in node tree", node.ValueString) + } + return step, nil +} + +// extractConsentStep converts an ADMIN_CONSENT_STEP node into a consent Step. +// It finds ADMIN_CONSENT_SIGN and collects CMPT_CHOICE_QUESTION nodes with +// PROP_BOOLEAN children as consent checkboxes. +// +// NOTE: The DSL also declares ADMIN_CONSENT_REVIEW (withdraw flow), +// CMPT_DIALOG (dialog text/buttons), CMPT_PDF_VIEWER, CMPT_FREE_TEXT_QUESTION +// with PROP_SIGNATURE (signature module), and CMPT_HEADER/CMPT_FOOTER chrome. +// These are not yet consumed — the consent builder uses hardcoded defaults for +// dialog text, and the signature/PDF modules are generated automatically. +// A richer converter could read dialog text from CMPT_DIALOG nodes to customize +// the FHIR CodeSystem concepts. +func extractConsentStep(node ContentNode) (*Step, error) { + step := &Step{ + Type: "consent", + Title: node.ValueString, + } + // Find ADMIN_CONSENT_SIGN and collect boolean choice questions as checkboxes. + sign := findChild(node, "ADMIN_CONSENT_SIGN") + if sign != nil { + step.Checkboxes = collectCheckboxes(*sign) + } + if len(step.Checkboxes) == 0 { + return nil, fmt.Errorf("consent step %q: no checkboxes found in ADMIN_CONSENT_SIGN", node.ValueString) + } + return step, nil +} + +// --------------------------------------------------------------------------- +// Node-tree traversal helpers +// --------------------------------------------------------------------------- + +// collectQuestions recursively collects all question nodes from a tree, +// converting each CMPT_CHOICE_QUESTION or CMPT_FREE_TEXT_QUESTION into +// a Question struct. CMPT_QUESTION_GROUP nodes are inspected to detect +// compound questions (multiple sub-questions inside a CMPT_HORIZONTAL_CONTAINER). +// +// Compound numeric detection: when a CMPT_QUESTION_GROUP contains a +// CMPT_HORIZONTAL_CONTAINER with exactly 2 CMPT_FREE_TEXT_QUESTION children +// that have numeric constraints, the group is emitted as a single Question +// with Type "compound_numeric" and SubQuestions. This matches the survey-be's +// QuestionnaireItemTypeCode_QUESTION structure with /field1 and /field2 linkIds. +func collectQuestions(node ContentNode) []Question { + var questions []Question + for _, child := range node.Nodes { + switch child.NodeType { + case "CMPT_QUESTION_GROUP": + // Check for compound question: a group containing a CMPT_HORIZONTAL_CONTAINER + // with multiple question children. + if compound := tryExtractCompoundNumeric(child); compound != nil { + questions = append(questions, *compound) + } else { + // Single-question group or unknown layout — recurse. + questions = append(questions, collectQuestions(child)...) + } + case "CMPT_CHOICE_QUESTION": + questions = append(questions, extractChoiceQuestion(child)) + case "CMPT_FREE_TEXT_QUESTION": + // Skip signature questions (those are consent, not survey). + if !hasChild(child, "PROP_SIGNATURE") { + questions = append(questions, extractFreeTextQuestion(child)) + } + default: + // Recurse into structural nodes: CMPT_SURVEY_CONTEXT, + // CMPT_BUNDLE_LAYOUT, CMPT_PAGE, CMPT_HEADER, CMPT_FOOTER, etc. + questions = append(questions, collectQuestions(child)...) + } + } + return questions +} + +// tryExtractCompoundNumeric checks if a CMPT_QUESTION_GROUP represents a +// compound numeric question (e.g., blood pressure with systolic/diastolic). +// +// It returns a compound Question if the group has: +// - A CMPT_HORIZONTAL_CONTAINER with 2+ CMPT_FREE_TEXT_QUESTION children +// - At least one sub-question has numeric constraints (PROP_CONSTRAINTS > PROP_NUMERIC) +// +// Returns nil if this is a regular single-question group. +func tryExtractCompoundNumeric(group ContentNode) *Question { + // Find CMPT_HORIZONTAL_CONTAINER in the group. + hc := findChild(group, "CMPT_HORIZONTAL_CONTAINER") + if hc == nil { + return nil + } + + // Collect free-text questions from the horizontal container. + var subs []Question + for _, child := range hc.Nodes { + if child.NodeType == "CMPT_FREE_TEXT_QUESTION" && !hasChild(child, "PROP_SIGNATURE") { + subs = append(subs, extractFreeTextQuestion(child)) + } + } + if len(subs) < 2 { + return nil + } + + // Extract group title from CMPT_TITLE > CMPT_RICH_TEXT. + var groupTitle string + title := findChild(group, "CMPT_TITLE") + if title != nil { + rt := findChild(*title, "CMPT_RICH_TEXT") + if rt != nil { + groupTitle = rt.ValueString + } + } + + // Derive parent linkId from the first sub-question's linkId. + // Convention: if sub has "q4-systolic", parent is "q4". + parentLinkID := "" + if subs[0].LinkID != "" { + if idx := strings.LastIndex(subs[0].LinkID, "-"); idx != -1 { + parentLinkID = subs[0].LinkID[:idx] + } + } + + // Determine if any sub-question is required — the parent inherits it. + anyRequired := false + for _, s := range subs { + if s.Required { + anyRequired = true + break + } + } + + return &Question{ + Text: groupTitle, + Type: "compound_numeric", + LinkID: parentLinkID, + Required: anyRequired, + SubQuestions: subs, + } +} + +// extractChoiceQuestion converts a CMPT_CHOICE_QUESTION node into a Question. +func extractChoiceQuestion(node ContentNode) Question { + q := Question{Type: "choice"} + for _, child := range node.Nodes { + switch child.NodeType { + case "PROP_LINK_ID": + q.LinkID = child.ValueString + case "PROP_LABEL": + q.Text = child.ValueString + case "PROP_BOOLEAN": + q.Type = "boolean" + case "PROP_OPTION": + q.Options = append(q.Options, child.ValueString) + q.OptionsMarkdown = append(q.OptionsMarkdown, child.HTML) + case "PROP_REQUIRED": + q.Required = true + } + } + return q +} + +// extractFreeTextQuestion converts a CMPT_FREE_TEXT_QUESTION node into a Question. +// It examines PROP_CONSTRAINTS for numeric type/min/max and PROP_UNITS for unit info. +func extractFreeTextQuestion(node ContentNode) Question { + q := Question{Type: "text"} + for _, child := range node.Nodes { + switch child.NodeType { + case "PROP_LINK_ID": + q.LinkID = child.ValueString + case "PROP_LABEL": + q.Text = child.ValueString + case "PROP_REQUIRED": + q.Required = true + case "PROP_CONSTRAINTS": + numNode := findChild(child, "PROP_NUMERIC") + if numNode != nil { + q.Type = "integer" + if hasChild(*numNode, "PROP_ALLOW_DECIMAL") { + q.Type = "decimal" + } + // Extract min/max values for FHIR extensions. + minNode := findChild(*numNode, "PROP_MIN_VALUE") + if minNode != nil { + q.MinValue = minNode.ValueString + } + maxNode := findChild(*numNode, "PROP_MAX_VALUE") + if maxNode != nil { + q.MaxValue = maxNode.ValueString + } + } + case "PROP_UNITS": + // Extract unit options for FHIR questionnaire-unit extensions. + // When units are present, the FHIR type becomes "quantity". + for _, unitChild := range child.Nodes { + if unitChild.NodeType == "PROP_UNIT" { + unit := QuestionUnit{} + for _, prop := range unitChild.Nodes { + switch prop.NodeType { + case "PROP_UNIT_DISPLAY": + unit.Display = prop.ValueString + case "PROP_UNIT_SYSTEM": + unit.System = prop.ValueString + case "PROP_UNIT_CODE": + unit.Code = prop.ValueString + } + } + q.Units = append(q.Units, unit) + } + } + } + } + // NOTE: We intentionally keep q.Type as "integer" or "decimal" even when + // units are present. The FHIR type override to "quantity" happens at output + // time in mapQuestionType (survey.go), so that buildNumericExtensions can + // still use the underlying integer/decimal type for valueInteger vs valueDecimal. + return q +} + +// collectCheckboxes recursively collects consent checkboxes from a node tree. +// A checkbox is a CMPT_CHOICE_QUESTION with a PROP_BOOLEAN child. +func collectCheckboxes(node ContentNode) []ConsentCheckbox { + var checkboxes []ConsentCheckbox + for _, child := range node.Nodes { + if child.NodeType == "CMPT_CHOICE_QUESTION" && hasChild(child, "PROP_BOOLEAN") { + cb := ConsentCheckbox{} + for _, prop := range child.Nodes { + switch prop.NodeType { + case "PROP_LABEL": + cb.Text = prop.ValueString + case "PROP_REQUIRED": + cb.Required = true + } + } + checkboxes = append(checkboxes, cb) + } else { + // Recurse into structural nodes. + checkboxes = append(checkboxes, collectCheckboxes(child)...) + } + } + return checkboxes +} + +// findChild returns a pointer to the first child with the given node type, or nil. +func findChild(node ContentNode, nodeType string) *ContentNode { + for i := range node.Nodes { + if node.Nodes[i].NodeType == nodeType { + return &node.Nodes[i] + } + } + return nil +} + +// hasChild returns true if any direct child has the given node type. +func hasChild(node ContentNode, nodeType string) bool { + return findChild(node, nodeType) != nil +} diff --git a/src/program-generator/app/internal/seeder/consent.go b/src/program-generator/app/internal/seeder/consent.go new file mode 100644 index 00000000..fb69170d --- /dev/null +++ b/src/program-generator/app/internal/seeder/consent.go @@ -0,0 +1,432 @@ +package seeder + +import "fmt" + +// --------------------------------------------------------------------------- +// Consent resource builders (Contract + Questionnaire + 2 CodeSystems) +// +// A **regulated** consent in the VerilyMe system is stored as 4 FHIR resources: +// +// Contract — the legal agreement (type="consent", references Questionnaire) +// Questionnaire — the UI layout (PDF + checkbox + signature + dialog modules) +// Content CodeSystem — localized text for all UI elements + PDF GCS URL +// Metadata CodeSystem — minimal metadata (title, supported languages) +// +// The consent-be dispatches to the regulated converter when +// Contract.type.coding[0].code == "consent" +// and validates the Questionnaire profile is +// verily-questionnaire-regulated-contract. +// +// The regulated consent renders (via SignView in the consent MFE): +// 1. PDF document (scrollable; gates viewConsent lifecycle RPC via pdfLoaded) +// 2. Checkbox reasons (agreement items the participant must check) +// 3. Full legal name text field + handwritten signature pad +// 4. Submit / Decline buttons +// 5. Disagree dialog — shown when user taps "Decline" +// 6. Withdraw dialog — shown when user taps "Withdraw" post-signing +// --------------------------------------------------------------------------- + +const ( + consentVersion = "1" + + // FHIR profiles + contractProfile = "http://fhir.verily.com/StructureDefinition/verily-contract-definition" + regulatedQuestionnaireProfile = "http://fhir.verily.com/StructureDefinition/verily-questionnaire-regulated-contract" + consentContentCSProfile = "http://fhir.verily.com/StructureDefinition/verily-consent-content-code-system" + consentMetadataCSProfile = "http://fhir.verily.com/StructureDefinition/verily-consent-metadata-code-system" + + // URL prefixes — consent-be searches by these + version + contractURLPrefix = "http://fhir.verily.com/Contract" + consentQuestionnaireURLPfx = "http://fhir.verily.com/Questionnaire" + consentContentCSURLPrefix = "http://fhir.verily.com/CodeSystem/ConsentContent" + consentMetadataCSURLPrefix = "http://fhir.verily.com/CodeSystem/ConsentMetadata" + + // NamingSystem identifiers + contractIDSystem = "http://fhir.verily.com/NamingSystem/consent-contract-id" + consentQIDSystem = "http://fhir.verily.com/NamingSystem/consent-questionnaire-id" + consentContentCSIDSystem = "http://fhir.verily.com/NamingSystem/consent-content-code-system-id" + consentMetadataCSIDSystem = "http://fhir.verily.com/NamingSystem/consent-metadata-code-system-id" + + // Code systems used by the consent backend to identify module types + consentRenderingTypeSystem = "http://fhir.verily.com/CodeSystem/consent-item-rendering-type" + dialogRenderingTypeSystem = "http://fhir.verily.com/CodeSystem/consent-dialog-rendering-type" + contractTypeSystem = "http://terminology.hl7.org/CodeSystem/contract-type" + contractTermTypeSystem = "http://fhir.verily.com/CodeSystem/verily-contract-term-type" + contractActionTypeSystem = "http://terminology.hl7.org/CodeSystem/contractaction" + contractActionStatusSystem = "http://terminology.hl7.org/CodeSystem/contract-actionstatus" + purposeOfUseSystem = "http://terminology.hl7.org/ValueSet/v3-GeneralPurposeOfUse" + + // Fixed concept code for consent title + consentTitleCode = "consent-title" +) + +// consentResources holds all FHIR resources for a single consent step. +type consentResources struct { + Contract map[string]interface{} + Questionnaire map[string]interface{} + ContentCodeSystem map[string]interface{} + MetadataCodeSystem map[string]interface{} + + ContentID string // e.g. "my-program-20260227-welcome-consent-0" + ContractURL string // e.g. "http://fhir.verily.com/Contract/{ContentID}" + QuestionnaireTempID string // urn:uuid:... (used for Contract.topicReference) +} + +// buildConsentResources creates the 4 FHIR resources for a regulated consent step. +// +// pdfGCSURL is the GCS URL of the uploaded consent PDF document. When non-empty, +// a pdf-module is added to the Questionnaire (required for the consent MFE to +// render the consent and trigger the viewConsent lifecycle RPC). +// +// Questionnaire layout (with PDF): +// +// cg0 group pdf-module — consent document PDF +// cg0-pa attach └── PDF attachment (GCS URL in CodeSystem) +// cg1 group checkbox-module — agreement checkboxes +// cg1-r1 bool ├── checkbox 1 +// cg1-r2 bool └── checkbox 2 +// cg2 group signature-module — handwritten signature +// cg2-hw attach └── signature pad (required=true) +// dlg-disagree group dialog-disagree-module — decline confirmation +// title / confirm-button / cancel-button +// dlg-withdraw group dialog-withdraw-module — post-sign withdrawal +// title / body / confirm-button / cancel-button +func buildConsentResources(bc *buildContext, step Step, stepIdx int, bundleName string, pdfGCSURL string) (*consentResources, error) { + contentID := fmt.Sprintf("%s-%s-consent-%d", bc.tmpl.Name, bundleName, stepIdx) + contentCSURL := fmt.Sprintf("%s/%s", consentContentCSURLPrefix, contentID) + idValue := fmt.Sprintf("%s:%s", contentID, consentVersion) + + // --------------- Content CodeSystem concepts --------------- + concepts := []map[string]interface{}{ + designationConcept(consentTitleCode, step.Title), + } + + // --------------- Questionnaire items --------------- + items := []map[string]interface{}{} + moduleIdx := 0 + + // ── PDF module (cg0) ── only when a PDF URL is provided + if pdfGCSURL != "" { + pdfGroupLinkID := fmt.Sprintf("cg%d", moduleIdx) + pdfAttLinkID := fmt.Sprintf("%s-pa", pdfGroupLinkID) + + // The consent-be looks up the GCS URL via CodeSystem designation + concepts = append(concepts, designationConcept(pdfAttLinkID, pdfGCSURL)) + + items = append(items, map[string]interface{}{ + "type": "group", + "linkId": pdfGroupLinkID, + "code": []map[string]interface{}{ + {"system": consentRenderingTypeSystem, "code": "pdf-module"}, + }, + "item": []map[string]interface{}{ + { + "type": "attachment", + "linkId": pdfAttLinkID, + "code": []map[string]interface{}{ + {"system": contentCSURL, "code": pdfAttLinkID, "version": consentVersion}, + }, + }, + }, + }) + moduleIdx++ + } + + // ── Checkbox module (cg1 when PDF present, cg0 otherwise) ── + cbGroupLinkID := fmt.Sprintf("cg%d", moduleIdx) + cbItems := []map[string]interface{}{} + for i, cb := range step.Checkboxes { + reasonLinkID := fmt.Sprintf("%s-r%d", cbGroupLinkID, i+1) + concepts = append(concepts, designationConcept(reasonLinkID, cb.Text)) + cbItems = append(cbItems, map[string]interface{}{ + "type": "boolean", + "linkId": reasonLinkID, + "required": cb.Required, + "code": []map[string]interface{}{ + {"system": contentCSURL, "code": reasonLinkID, "version": consentVersion}, + }, + }) + } + items = append(items, map[string]interface{}{ + "type": "group", + "linkId": cbGroupLinkID, + "code": []map[string]interface{}{ + {"system": consentRenderingTypeSystem, "code": "checkbox-module"}, + }, + "item": cbItems, + }) + moduleIdx++ + + // ── Signature module (cg2 when PDF present, cg1 otherwise) ── + sigGroupLinkID := fmt.Sprintf("cg%d", moduleIdx) + sigHWLinkID := fmt.Sprintf("%s-hw", sigGroupLinkID) + items = append(items, map[string]interface{}{ + "type": "group", + "linkId": sigGroupLinkID, + "code": []map[string]interface{}{ + {"system": consentRenderingTypeSystem, "code": "signature-module"}, + }, + "item": []map[string]interface{}{ + { + "type": "attachment", + "linkId": sigHWLinkID, + "required": true, // enables handwritten signature in the renderer + }, + }, + }) + moduleIdx++ + + // ── Disagree dialog module ── + concepts = append(concepts, + designationConcept("dlg-disagree-title", "Are you sure?"), + designationConcept("dlg-disagree-cfmbtn", "Yes, decline"), + designationConcept("dlg-disagree-cxlbtn", "Go back"), + ) + items = append(items, dialogModuleItem( + "dlg-disagree", + "dialog-disagree-module", + contentCSURL, + []dialogNestedItem{ + {linkID: "dlg-disagree-title", dialogType: "title"}, + {linkID: "dlg-disagree-cfmbtn", dialogType: "confirm-button"}, + {linkID: "dlg-disagree-cxlbtn", dialogType: "cancel-button"}, + }, + )) + + // ── Withdraw dialog module ── + concepts = append(concepts, + designationConcept("dlg-withdraw-title", "Withdraw Consent?"), + designationConcept("dlg-withdraw-body", "If you withdraw, your previous responses will no longer be used."), + designationConcept("dlg-withdraw-cfmbtn", "Withdraw"), + designationConcept("dlg-withdraw-cxlbtn", "Cancel"), + ) + items = append(items, dialogModuleItem( + "dlg-withdraw", + "dialog-withdraw-module", + contentCSURL, + []dialogNestedItem{ + {linkID: "dlg-withdraw-title", dialogType: "title"}, + {linkID: "dlg-withdraw-body", dialogType: "body"}, + {linkID: "dlg-withdraw-cfmbtn", dialogType: "confirm-button"}, + {linkID: "dlg-withdraw-cxlbtn", dialogType: "cancel-button"}, + }, + )) + + // --------------- Questionnaire --------------- + qTempID := newTempID() + questionnaire := map[string]interface{}{ + "resourceType": "Questionnaire", + "meta": buildOrgCompartmentMeta(bc, regulatedQuestionnaireProfile), + "identifier": []map[string]interface{}{ + {"system": consentQIDSystem, "value": idValue}, + }, + "url": fmt.Sprintf("%s/%s", consentQuestionnaireURLPfx, contentID), + "version": consentVersion, + "status": "active", + "code": []map[string]interface{}{ + {"system": contentCSURL, "code": consentTitleCode, "version": consentVersion}, + }, + "item": items, + } + + // --------------- Content CodeSystem --------------- + contentCS := map[string]interface{}{ + "resourceType": "CodeSystem", + "meta": buildOrgCompartmentMeta(bc, consentContentCSProfile), + "identifier": []map[string]interface{}{ + {"system": consentContentCSIDSystem, "value": idValue}, + }, + "url": contentCSURL, + "version": consentVersion, + "status": "active", + "content": "complete", + "caseSensitive": true, + "concept": concepts, + } + + // --------------- Metadata CodeSystem --------------- + metadataCS := map[string]interface{}{ + "resourceType": "CodeSystem", + "meta": buildOrgCompartmentMeta(bc, consentMetadataCSProfile), + "identifier": []map[string]interface{}{ + {"system": consentMetadataCSIDSystem, "value": idValue}, + }, + "url": fmt.Sprintf("%s/%s", consentMetadataCSURLPrefix, contentID), + "version": consentVersion, + "status": "active", + "content": "complete", + "caseSensitive": true, + "concept": []map[string]interface{}{ + // The consent-be requires a "supported-languages" concept whose + // designations enumerate the supported locales. Without this, + // getSupportedLanguagesFromConceptList returns an error and + // ListConsentMetadata fails at runtime. + supportedLanguagesConcept(), + designationConcept(consentTitleCode, step.Title), + }, + } + + // --------------- Contract --------------- + terms := buildConsentTerms(step.Checkboxes, cbGroupLinkID) + + contract := map[string]interface{}{ + "resourceType": "Contract", + "meta": buildContractMeta(bc, contentID), + "identifier": []map[string]interface{}{ + {"system": contractIDSystem, "value": idValue}, + }, + "url": fmt.Sprintf("%s/%s", contractURLPrefix, contentID), + "version": consentVersion, + "name": contentID, + "type": map[string]interface{}{ + "coding": []map[string]interface{}{ + // "consent" routes to the regulated converter in consent-be. + // ("privacy" would route to the agreement converter.) + {"system": contractTypeSystem, "code": "consent"}, + }, + }, + "topicReference": map[string]interface{}{ + "reference": qTempID, + }, + "term": terms, + } + + return &consentResources{ + Contract: contract, + Questionnaire: questionnaire, + ContentCodeSystem: contentCS, + MetadataCodeSystem: metadataCS, + ContentID: contentID, + ContractURL: fmt.Sprintf("%s/%s", contractURLPrefix, contentID), + QuestionnaireTempID: qTempID, + }, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// buildContractMeta creates the meta block for a Contract. +// It adds both the org compartment extension AND the metadata identifier +// extension that links the Contract to its metadata CodeSystem. +func buildContractMeta(bc *buildContext, contentID string) map[string]interface{} { + return map[string]interface{}{ + "profile": []string{contractProfile}, + "extension": []map[string]interface{}{ + { + "url": orgCompartmentExtURL, + "valueReference": map[string]interface{}{ + "reference": bc.orgCompartmentRef(), + }, + }, + { + "url": consentMetadataCSIDSystem, + "valueString": fmt.Sprintf("%s:%s", contentID, consentVersion), + }, + }, + } +} + +// dialogNestedItem defines a single nested item in a dialog module. +type dialogNestedItem struct { + linkID string // e.g. "dlg-disagree-title" + dialogType string // e.g. "title", "body", "confirm-button", "cancel-button" +} + +// dialogModuleItem creates a Questionnaire group item for a dialog module. +// The consent-be identifies dialog modules by the itemrenderingtype.System code, +// and looks up localized text for each nested item via its dialogrenderingtype.System +// code + Content CodeSystem code. +func dialogModuleItem(groupLinkID, renderingCode, contentCSURL string, nested []dialogNestedItem) map[string]interface{} { + nestedItems := []map[string]interface{}{} + for _, n := range nested { + nestedItems = append(nestedItems, map[string]interface{}{ + "type": "string", + "linkId": n.linkID, + "code": []map[string]interface{}{ + // First code: identifies the dialog rendering type (title, body, etc.) + {"system": dialogRenderingTypeSystem, "code": n.dialogType}, + // Second code: references the Content CodeSystem for localized text lookup + {"system": contentCSURL, "code": n.linkID, "version": consentVersion}, + }, + }) + } + return map[string]interface{}{ + "type": "group", + "linkId": groupLinkID, + "code": []map[string]interface{}{ + {"system": consentRenderingTypeSystem, "code": renderingCode}, + }, + "item": nestedItems, + } +} + +// supportedLanguagesConcept creates the "supported-languages" concept required +// by the consent-be's getLocaleMetadataFromConcept / getSupportedLanguagesFromConceptList. +// Each designation's language field declares a supported locale. The value is +// the human-readable name (not used by code, but useful for debugging). +func supportedLanguagesConcept() map[string]interface{} { + return map[string]interface{}{ + "code": "supported-languages", + "designation": []map[string]interface{}{ + {"language": "en", "value": "English"}, + {"language": "en-US", "value": "English (US)"}, + }, + } +} + +// designationConcept creates a CodeSystem concept with an English designation. +// The consent-be looks up designations by exact locale match (e.g. "en-US"), +// so we include both "en" and "en-US" to cover all lookup paths. +func designationConcept(code, text string) map[string]interface{} { + return map[string]interface{}{ + "code": code, + "designation": []map[string]interface{}{ + {"language": "en", "value": text}, + {"language": "en-US", "value": text}, + }, + } +} + +// buildConsentTerms creates Contract.term entries from checkboxes. +// Each term maps 1:1 with a checkbox and uses the same linkId as the +// Questionnaire item so the consent-be can correlate them. +func buildConsentTerms(checkboxes []ConsentCheckbox, cbGroupLinkID string) []map[string]interface{} { + terms := []map[string]interface{}{} + for i, cb := range checkboxes { + reasonLinkID := fmt.Sprintf("%s-r%d", cbGroupLinkID, i+1) + terms = append(terms, map[string]interface{}{ + "identifier": map[string]interface{}{ + "value": reasonLinkID, + }, + "text": cb.Text, + // offer is required (1..1) by FHIR R4 Contract.term — empty object suffices. + "offer": map[string]interface{}{}, + "type": map[string]interface{}{ + "coding": []map[string]interface{}{ + {"system": contractTermTypeSystem, "code": "participation"}, + }, + }, + "action": []map[string]interface{}{ + { + "intent": map[string]interface{}{ + "coding": []map[string]interface{}{ + {"system": purposeOfUseSystem, "code": "TREAT"}, + }, + }, + "status": map[string]interface{}{ + "coding": []map[string]interface{}{ + {"system": contractActionStatusSystem, "code": "complete"}, + }, + }, + "type": map[string]interface{}{ + "coding": []map[string]interface{}{ + {"system": contractActionTypeSystem, "code": "action-a"}, + }, + }, + }, + }, + }) + } + return terms +} diff --git a/src/program-generator/app/internal/seeder/consent_pdf.go b/src/program-generator/app/internal/seeder/consent_pdf.go new file mode 100644 index 00000000..cd8c5afc --- /dev/null +++ b/src/program-generator/app/internal/seeder/consent_pdf.go @@ -0,0 +1,145 @@ +package seeder + +import ( + "bytes" + "fmt" + "strings" +) + +// generateConsentPDF creates a minimal single-page PDF with the consent title +// and agreement checkbox texts. The PDF uses built-in Helvetica fonts and +// requires no external Go dependencies. +// +// The generated PDF is a well-formed PDF-1.4 document that react-pdf (pdf.js) +// can parse and render successfully — this is required because the consent MFE's +// SignView gates the viewConsent() lifecycle RPC on pdfLoaded, which only fires +// after the PDF finishes rendering. +func generateConsentPDF(title string, checkboxTexts []string) []byte { + // Build the content stream (PDF text-drawing operators). + var cs bytes.Buffer + cs.WriteString("BT\n") + + // Title in Helvetica-Bold 18pt + cs.WriteString("/F2 18 Tf\n") + cs.WriteString("72 720 Td\n") + cs.WriteString(fmt.Sprintf("(%s) Tj\n", escapePDF(title))) + + // Horizontal rule (draw a line in the graphics state) + cs.WriteString("ET\n") + cs.WriteString("0.8 0.8 0.8 RG\n") // light grey + cs.WriteString("72 706 468 0.5 re f\n") + cs.WriteString("BT\n") + + // Subtitle in Helvetica 11pt + cs.WriteString("/F1 11 Tf\n") + cs.WriteString("72 686 Td\n") + cs.WriteString("(By signing this document, you agree to the following:) Tj\n") + + // Each checkbox text, indented with a bullet marker + for _, text := range checkboxTexts { + cs.WriteString("0 -28 Td\n") + lines := wrapPDFText(text, 80) + for i, line := range lines { + if i > 0 { + cs.WriteString("0 -14 Td\n") + } + prefix := "\\225 " // bullet character (•) + if i > 0 { + prefix = " " // indent continuation lines + } + cs.WriteString(fmt.Sprintf("(%s%s) Tj\n", prefix, escapePDF(line))) + } + } + + // Footer + cs.WriteString("0 -40 Td\n") + cs.WriteString("/F1 9 Tf\n") + cs.WriteString("(This document was generated by the standalone seeding tool.) Tj\n") + cs.WriteString("ET\n") + + contentBytes := cs.Bytes() + + // Assemble the PDF objects. We track byte offsets for the xref table. + var pdf bytes.Buffer + offsets := make([]int, 0, 6) + + pdf.WriteString("%PDF-1.4\n") + // Binary comment to signal this is a binary file (PDF spec recommendation) + pdf.WriteString("%\xe2\xe3\xcf\xd3\n") + + // Object 1: Catalog + offsets = append(offsets, pdf.Len()) + pdf.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") + + // Object 2: Pages + offsets = append(offsets, pdf.Len()) + pdf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") + + // Object 3: Page (US Letter, 612x792 points) + offsets = append(offsets, pdf.Len()) + pdf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]") + pdf.WriteString(" /Contents 4 0 R") + pdf.WriteString(" /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >>") + pdf.WriteString(" >>\nendobj\n") + + // Object 4: Content stream + offsets = append(offsets, pdf.Len()) + pdf.WriteString(fmt.Sprintf("4 0 obj\n<< /Length %d >>\nstream\n", len(contentBytes))) + pdf.Write(contentBytes) + pdf.WriteString("\nendstream\nendobj\n") + + // Object 5: Font — Helvetica (regular) + offsets = append(offsets, pdf.Len()) + pdf.WriteString("5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n") + + // Object 6: Font — Helvetica-Bold + offsets = append(offsets, pdf.Len()) + pdf.WriteString("6 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n") + + // Cross-reference table + xrefOffset := pdf.Len() + numObjs := len(offsets) + 1 // +1 for the free entry (object 0) + pdf.WriteString("xref\n") + pdf.WriteString(fmt.Sprintf("0 %d\n", numObjs)) + pdf.WriteString("0000000000 65535 f \n") // object 0 is always free + for _, off := range offsets { + pdf.WriteString(fmt.Sprintf("%010d 00000 n \n", off)) + } + + // Trailer + pdf.WriteString("trailer\n") + pdf.WriteString(fmt.Sprintf("<< /Size %d /Root 1 0 R >>\n", numObjs)) + pdf.WriteString("startxref\n") + pdf.WriteString(fmt.Sprintf("%d\n", xrefOffset)) + pdf.WriteString("%%EOF\n") + + return pdf.Bytes() +} + +// escapePDF escapes special characters for a PDF string literal. +func escapePDF(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "(", "\\(") + s = strings.ReplaceAll(s, ")", "\\)") + return s +} + +// wrapPDFText performs simple word-wrapping at maxChars characters. +func wrapPDFText(s string, maxChars int) []string { + words := strings.Fields(s) + if len(words) == 0 { + return []string{""} + } + var lines []string + current := words[0] + for _, word := range words[1:] { + if len(current)+1+len(word) > maxChars { + lines = append(lines, current) + current = word + } else { + current += " " + word + } + } + lines = append(lines, current) + return lines +} diff --git a/src/program-generator/app/internal/seeder/content.go b/src/program-generator/app/internal/seeder/content.go new file mode 100644 index 00000000..eaea8ab1 --- /dev/null +++ b/src/program-generator/app/internal/seeder/content.go @@ -0,0 +1,442 @@ +package seeder + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/google/uuid" + componentspb "github.com/verily-src/workbench-app-devcontainers/src/program-generator/app/internal/contentpb" + "google.golang.org/protobuf/proto" +) + +// --------------------------------------------------------------------------- +// Content / DocumentReference builder +// --------------------------------------------------------------------------- + +const ( + docRefProfile = "http://fhir.verily.com/StructureDefinition/verily-document-reference-content" + cardCodeSystemProfile = "http://fhir.verily.com/StructureDefinition/verily-code-system-content" + orgCompartmentExtURL = "http://fhir.verily.com/StructureDefinition/verily-organization-compartment" + vcmsMetadataExtURL = "http://fhir.verily.com/StructureDefinition/vcms-content-metadata" + vcmsContentTemplateURL = "http://fhir.verily.com/StructureDefinition/vcms-content-templates" + contentOIDSystem = "http://fhir.verily.com/NamingSystem/vcms-content-object-identifier" + contentVersionIDSystem = "http://fhir.verily.com/NamingSystem/vcms-version-specific-content-id" +) + +// buildInfoDocumentReference creates a FHIR DocumentReference for an info step. +// It encodes the step's body_html as a proto Node tree and embeds it as base64. +func buildInfoDocumentReference(bc *buildContext, step Step, stepIdx int, bundleName string) (tempID string, resource map[string]interface{}, error error) { + contentUID := fmt.Sprintf("%s-%s-info-%d", bc.tmpl.Name, bundleName, stepIdx) + oid := contentUID // Use contentUID as the OID for simplicity + versionedID := fmt.Sprintf("%s-%s", contentUID, bc.tmpl.Version) + + // Build the proto Node tree for the content + node, err := buildInfoNode(step) + if err != nil { + return "", nil, fmt.Errorf("building node tree: %w", err) + } + + // Encode to base64 + encodedData, err := encodeNodeToBase64(node) + if err != nil { + return "", nil, fmt.Errorf("encoding content node: %w", err) + } + + tempID = "urn:uuid:" + uuid.New().String() + resource = map[string]interface{}{ + "resourceType": "DocumentReference", + "meta": buildDocRefMeta(bc), + "status": "current", + "type": map[string]interface{}{"text": "ActivityPage"}, + "description": step.Title, + "identifier": []map[string]interface{}{ + {"system": contentOIDSystem, "value": oid}, + {"system": contentVersionIDSystem, "value": versionedID}, + }, + "content": []map[string]interface{}{ + {"attachment": map[string]interface{}{}}, + }, + "extension": []interface{}{ + buildVCMSMetadataExtension(contentUID, oid, "activity-page"), + buildContentTemplateExtension(encodedData), + }, + } + return tempID, resource, nil +} + +// buildCardDocumentReference creates a FHIR DocumentReference for a bundle card. +func buildCardDocumentReference(bc *buildContext, bundle Bundle, bundleIdx int) (tempID string, resource map[string]interface{}) { + contentUID := fmt.Sprintf("%s-%s-card", bc.tmpl.Name, bundle.Name) + oid := contentUID + versionedID := fmt.Sprintf("%s-%s", contentUID, bc.tmpl.Version) + + // Build a simple card node with title + description + node := buildCardNode(bundle.Card) + encodedData, err := encodeNodeToBase64(node) + if err != nil { + // Cards are non-critical; use empty data if encoding fails + encodedData = "" + } + + tempID = "urn:uuid:" + uuid.New().String() + resource = map[string]interface{}{ + "resourceType": "DocumentReference", + "meta": buildDocRefMeta(bc), + "status": "current", + "type": map[string]interface{}{"text": "ActivityCard"}, + "description": bundle.Card.Title, + "identifier": []map[string]interface{}{ + {"system": contentOIDSystem, "value": oid}, + {"system": contentVersionIDSystem, "value": versionedID}, + }, + "content": []map[string]interface{}{ + {"attachment": map[string]interface{}{}}, + }, + "extension": []interface{}{ + buildVCMSMetadataExtension(contentUID, oid, "activity-card"), + buildContentTemplateExtension(encodedData), + }, + } + return tempID, resource +} + +// buildCardCodeSystem creates a companion CodeSystem for a card DocumentReference. +// The Content BE uses this CodeSystem to get localized title/description for ActivityCard +// type DocumentReferences. It links them via the vcms-version-specific-content-id identifier. +func buildCardCodeSystem(bc *buildContext, bundle Bundle, bundleIdx int) (tempID string, resource map[string]interface{}) { + contentUID := fmt.Sprintf("%s-%s-card", bc.tmpl.Name, bundle.Name) + versionedID := fmt.Sprintf("%s-%s", contentUID, bc.tmpl.Version) + + concepts := []map[string]interface{}{ + { + "code": "title", + "designation": []map[string]interface{}{ + { + "language": "en-US", + "value": bundle.Card.Title, + }, + }, + }, + { + "code": "description", + "designation": []map[string]interface{}{ + { + "language": "en-US", + "value": bundle.Card.Description, + "extension": []map[string]interface{}{ + { + "url": "http://hl7.org/fhir/extensions/StructureDefinition/rendering-markdown", + "valueMarkdown": bundle.Card.Description, + }, + }, + }, + }, + }, + } + + tempID = "urn:uuid:" + uuid.New().String() + resource = map[string]interface{}{ + "resourceType": "CodeSystem", + "meta": buildOrgCompartmentMeta(bc, cardCodeSystemProfile), + "url": fmt.Sprintf("%s/cortex-fhir-proxy/operational/fhir/CodeSystem/%s", bc.tmpl.EnvBaseURL, contentUID), + "version": bc.tmpl.Version, + "name": contentUID, + "title": fmt.Sprintf("%s Card Translations", bundle.Card.Title), + "status": "active", + "content": "complete", + "identifier": []map[string]interface{}{ + {"system": contentVersionIDSystem, "value": versionedID}, + }, + "concept": concepts, + } + return tempID, resource +} + +// --------------------------------------------------------------------------- +// Node proto tree builders +// --------------------------------------------------------------------------- + +// buildInfoNode constructs a Node proto tree for an info step. +// +// Two modes: +// - body_html (sugar): VerticalContainer > [RichText(title), RichText(body)] +// - nodes (full power): VerticalContainer > [...converted nodes] +// In full-power mode the content tree is self-contained (title is already +// a CMPT_RICH_TEXT node), so we do NOT prepend step.Title as a heading. +func buildInfoNode(step Step) (*componentspb.Node, error) { + // Full node tree mode — convert each ContentNode to proto. + if len(step.Nodes) > 0 { + children := make([]*componentspb.Node, 0, len(step.Nodes)) + + for i, cn := range step.Nodes { + converted, err := convertContentNode(cn) + if err != nil { + return nil, fmt.Errorf("nodes[%d]: %w", i, err) + } + children = append(children, converted) + } + + return &componentspb.Node{ + NodeType: componentspb.NodeType_CMPT_VERTICAL_CONTAINER, + Nodes: children, + }, nil + } + + // Sugar mode — simple body_html. + children := []*componentspb.Node{} + + if step.Title != "" { + titleHTML := fmt.Sprintf("

%s

", step.Title) + titleMD := "# " + step.Title + children = append(children, &componentspb.Node{ + NodeType: componentspb.NodeType_CMPT_RICH_TEXT, + ValueString: &titleMD, + Html: &titleHTML, + }) + } + + if step.BodyHTML != "" { + bodyMD := step.BodyHTML // Simplification; real impl would convert HTML→markdown + children = append(children, &componentspb.Node{ + NodeType: componentspb.NodeType_CMPT_RICH_TEXT, + ValueString: &bodyMD, + Html: &step.BodyHTML, + }) + } + + return &componentspb.Node{ + NodeType: componentspb.NodeType_CMPT_VERTICAL_CONTAINER, + Nodes: children, + }, nil +} + +// --------------------------------------------------------------------------- +// ContentNode → proto Node conversion +// --------------------------------------------------------------------------- + +// nodeTypeMap maps YAML node type names to proto NodeType values. +// It accepts both SCREAMING_SNAKE_CASE proto enum names (new node-tree format) +// and lowercase snake_case names (legacy format) for backward compatibility. +var nodeTypeMap = map[string]componentspb.NodeType{ + // SCREAMING_SNAKE_CASE — proto enum names (node-tree format) + "CMPT_VERTICAL_CONTAINER": componentspb.NodeType_CMPT_VERTICAL_CONTAINER, + "CMPT_HORIZONTAL_CONTAINER": componentspb.NodeType_CMPT_HORIZONTAL_CONTAINER, + "CMPT_RICH_TEXT": componentspb.NodeType_CMPT_RICH_TEXT, + "CMPT_IMAGE": componentspb.NodeType_CMPT_IMAGE, + "CMPT_HIGHLIGHT_CARD": componentspb.NodeType_CMPT_HIGHLIGHT_CARD, + "CMPT_ICON_LIST": componentspb.NodeType_CMPT_ICON_LIST, + "CMPT_ICON": componentspb.NodeType_CMPT_ICON, + "CMPT_ACCORDION_GROUP": componentspb.NodeType_CMPT_ACCORDION_GROUP, + "CMPT_ACCORDION_ROW": componentspb.NodeType_CMPT_ACCORDION_ROW, + "CMPT_ACCORDION_SUMMARY": componentspb.NodeType_CMPT_ACCORDION_SUMMARY, + "CMPT_ACCORDION_DETAILS": componentspb.NodeType_CMPT_ACCORDION_DETAILS, + "CMPT_SECTION_DIVIDER": componentspb.NodeType_CMPT_SECTION_DIVIDER, + "PROP_ALT_TEXT": componentspb.NodeType_PROP_ALT_TEXT, + "PROP_DARK_MODE": componentspb.NodeType_PROP_DARK_MODE, + "PROP_COLOR": componentspb.NodeType_PROP_COLOR, + "PROP_MIME_TYPE": componentspb.NodeType_PROP_MIME_TYPE, + "PROP_BLOB_KEY": componentspb.NodeType_PROP_BLOB_KEY, + + // Lowercase — legacy YAML format (snake_case without CMPT_/PROP_ prefix) + "vertical_container": componentspb.NodeType_CMPT_VERTICAL_CONTAINER, + "horizontal_container": componentspb.NodeType_CMPT_HORIZONTAL_CONTAINER, + "rich_text": componentspb.NodeType_CMPT_RICH_TEXT, + "image": componentspb.NodeType_CMPT_IMAGE, + "highlight_card": componentspb.NodeType_CMPT_HIGHLIGHT_CARD, + "icon_list": componentspb.NodeType_CMPT_ICON_LIST, + "icon": componentspb.NodeType_CMPT_ICON, + "accordion_group": componentspb.NodeType_CMPT_ACCORDION_GROUP, + "accordion_row": componentspb.NodeType_CMPT_ACCORDION_ROW, + "accordion_summary": componentspb.NodeType_CMPT_ACCORDION_SUMMARY, + "accordion_details": componentspb.NodeType_CMPT_ACCORDION_DETAILS, + "section_divider": componentspb.NodeType_CMPT_SECTION_DIVIDER, + "alt_text": componentspb.NodeType_PROP_ALT_TEXT, + "dark_mode": componentspb.NodeType_PROP_DARK_MODE, + "color": componentspb.NodeType_PROP_COLOR, + "mime_type": componentspb.NodeType_PROP_MIME_TYPE, + "blob_key": componentspb.NodeType_PROP_BLOB_KEY, +} + +// convertContentNode recursively converts a YAML ContentNode to a proto Node. +func convertContentNode(cn ContentNode) (*componentspb.Node, error) { + nt, ok := nodeTypeMap[cn.NodeType] + if !ok { + return nil, fmt.Errorf("unknown node type %q (valid types: %s)", cn.NodeType, validNodeTypes()) + } + + node := &componentspb.Node{ + NodeType: nt, + } + + if cn.ValueString != "" { + node.ValueString = &cn.ValueString + } + if cn.HTML != "" { + node.Html = &cn.HTML + } + if cn.URI != "" { + node.Uri = &cn.URI + } + if cn.Data != "" { + node.ValueBytes = []byte(cn.Data) + } + if cn.ID != "" { + node.Id = &cn.ID + } + + for i, child := range cn.Nodes { + converted, err := convertContentNode(child) + if err != nil { + return nil, fmt.Errorf("nodes[%d]: %w", i, err) + } + node.Nodes = append(node.Nodes, converted) + } + + return node, nil +} + +// validNodeTypes returns a comma-separated list of valid YAML node type names. +func validNodeTypes() string { + types := make([]string, 0, len(nodeTypeMap)) + for k := range nodeTypeMap { + types = append(types, k) + } + return strings.Join(types, ", ") +} + +// buildCardNode constructs a Node proto tree for a bundle card. +func buildCardNode(card Card) *componentspb.Node { + children := []*componentspb.Node{} + + if card.Title != "" { + titleHTML := fmt.Sprintf("%s", card.Title) + titleMD := "**" + card.Title + "**" + children = append(children, &componentspb.Node{ + NodeType: componentspb.NodeType_CMPT_RICH_TEXT, + ValueString: &titleMD, + Html: &titleHTML, + }) + } + + if card.Description != "" { + descHTML := fmt.Sprintf("

%s

", card.Description) + children = append(children, &componentspb.Node{ + NodeType: componentspb.NodeType_CMPT_RICH_TEXT, + ValueString: &card.Description, + Html: &descHTML, + }) + } + + return &componentspb.Node{ + NodeType: componentspb.NodeType_CMPT_VERTICAL_CONTAINER, + Nodes: children, + } +} + +// --------------------------------------------------------------------------- +// Proto encoding +// --------------------------------------------------------------------------- + +// encodeNodeToBase64 marshals a Node proto and returns a double-base64-encoded string. +// +// Why double-encode? The FHIR `data` field is type base64Binary, so the FHIR +// client automatically decodes the outer layer when reading. VCMS stores data +// as proto → base64 → base64 so that after the automatic outer decode, the +// content-be still receives a base64 string it can decode to get the raw proto. +func encodeNodeToBase64(node *componentspb.Node) (string, error) { + data, err := proto.Marshal(node) + if err != nil { + return "", fmt.Errorf("proto marshal: %w", err) + } + inner := base64.StdEncoding.EncodeToString(data) + return base64.StdEncoding.EncodeToString([]byte(inner)), nil +} + +// --------------------------------------------------------------------------- +// FHIR extension helpers +// --------------------------------------------------------------------------- + +// buildDocRefMeta creates the meta block for a DocumentReference with org compartment. +func buildDocRefMeta(bc *buildContext) map[string]interface{} { + return map[string]interface{}{ + "profile": []string{docRefProfile}, + "extension": []map[string]interface{}{ + { + "url": orgCompartmentExtURL, + "valueReference": map[string]interface{}{ + "reference": bc.orgCompartmentRef(), + }, + }, + }, + } +} + +// buildOrgCompartmentMeta creates a meta block with org compartment for any resource. +func buildOrgCompartmentMeta(bc *buildContext, profiles ...string) map[string]interface{} { + meta := map[string]interface{}{ + "extension": []map[string]interface{}{ + { + "url": orgCompartmentExtURL, + "valueReference": map[string]interface{}{ + "reference": bc.orgCompartmentRef(), + }, + }, + }, + } + if len(profiles) > 0 { + meta["profile"] = profiles + } + return meta +} + +// buildVCMSMetadataExtension creates the vcms-content-metadata extension. +// The semanticVersion and version fields are hardcoded: semver is decorative +// vCMS metadata that nothing resolves by, and the integer version is always "1" +// for freshly-seeded content. +func buildVCMSMetadataExtension(contentUID, oid, contentType string) map[string]interface{} { + return map[string]interface{}{ + "url": vcmsMetadataExtURL, + "extension": []map[string]interface{}{ + {"url": "semanticVersion", "valueString": "1.0.0"}, + {"url": "contentUID", "valueString": contentUID}, + {"url": "objectIdentifier", "valueString": oid}, + {"url": "version", "valueString": "1"}, + { + "url": "locale", + "valueCoding": map[string]interface{}{ + "system": "urn:ietf:bcp:47", + "code": "en-US", + "display": "English (United States)", + }, + }, + {"url": "contentId", "valueString": "1"}, + { + "url": "contentType", + "valueCoding": map[string]interface{}{ + "system": "http://fhir.verily.com/CodeSystem/vcms-content-type", + "code": contentType, + }, + }, + {"url": "publishedBy", "valueString": "Standalone Seeding Tool"}, + }, + } +} + +// buildContentTemplateExtension creates the vcms-content-templates extension +// with the base64-encoded proto Node data. +func buildContentTemplateExtension(encodedData string) map[string]interface{} { + return map[string]interface{}{ + "url": vcmsContentTemplateURL, + "extension": []map[string]interface{}{ + { + "url": "componentData", + "valueAttachment": map[string]interface{}{ + "contentType": "application/x-protobuf", + "language": "en-US", + "data": encodedData, + }, + }, + }, + } +} diff --git a/src/program-generator/app/internal/seeder/envconfig.go b/src/program-generator/app/internal/seeder/envconfig.go new file mode 100644 index 00000000..9d832b7b --- /dev/null +++ b/src/program-generator/app/internal/seeder/envconfig.go @@ -0,0 +1,138 @@ +package seeder + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// EnvConfig holds environment-specific values loaded from an env profile file. +// These can override template defaults for FHIR store, GCS bucket, and base URL. +type EnvConfig struct { + FHIRStore string // e.g. "projects//locations//datasets//fhirStores/" + GCSBucket string // e.g. "econsent-pdf-pilot-dev-oneverily-" + EnvBaseURL string // e.g. "https://dev-stable.one.verily.com" + EnvName string // e.g. "dev-stable" +} + +// LoadEnvConfig reads an environment profile from the envs/ directory relative +// to the given base directory (typically the standalone/ directory). Returns nil +// if envName is empty (no override requested). +func LoadEnvConfig(baseDir, envName string) (*EnvConfig, error) { + if envName == "" { + return nil, nil + } + + envFile := filepath.Join(baseDir, "envs", envName+".env") + if _, err := os.Stat(envFile); os.IsNotExist(err) { + return nil, fmt.Errorf("environment profile not found: %s (available: dev-stable, dev-hermetic)", envFile) + } + + vars, err := parseEnvFile(envFile) + if err != nil { + return nil, fmt.Errorf("parsing env profile %s: %w", envFile, err) + } + + // Load .local.env overlay (written by hermetic-create.sh, git-ignored). + // Values in the local file take precedence over the base env file. + localEnvFile := filepath.Join(baseDir, "envs", envName+".local.env") + if _, err := os.Stat(localEnvFile); err == nil { + localVars, err := parseEnvFile(localEnvFile) + if err != nil { + return nil, fmt.Errorf("parsing local env overlay %s: %w", localEnvFile, err) + } + for k, v := range localVars { + vars[k] = v + } + } + + cfg := &EnvConfig{ + EnvName: envName, + } + + // Extract values we care about. + if v, ok := vars["FHIR_STORE"]; ok && v != "" { + cfg.FHIRStore = v + } + if v, ok := vars["GCS_BUCKET"]; ok && v != "" { + cfg.GCSBucket = v + } + if v, ok := vars["ENV_BASE_URL"]; ok && v != "" { + cfg.EnvBaseURL = v + } + + return cfg, nil +} + +// parseEnvFile reads a shell-style env file and returns a map of KEY=VALUE pairs. +// It handles: +// - Comments (lines starting with #) +// - Empty lines +// - ${VAR:-default} patterns (extracts the default value) +// - Quoted values ("value" or 'value') +// +// It does NOT handle complex shell expansions or ${VAR} references to other vars. +func parseEnvFile(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + vars := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Find KEY=VALUE + eqIdx := strings.Index(line, "=") + if eqIdx < 0 { + continue + } + key := strings.TrimSpace(line[:eqIdx]) + val := strings.TrimSpace(line[eqIdx+1:]) + + // Strip quotes + val = stripQuotes(val) + + // Handle ${VAR:-default} pattern — extract the default value + if strings.HasPrefix(val, "${") && strings.HasSuffix(val, "}") { + inner := val[2 : len(val)-1] + if dashIdx := strings.Index(inner, ":-"); dashIdx >= 0 { + val = inner[dashIdx+2:] + } else { + // ${VAR} without default — skip (value comes from environment) + continue + } + } + + // Strip quotes from extracted default + val = stripQuotes(val) + + // Handle ${FHIR_STORE} reference in FHIR_STORE_BASE — skip these, + // we'll compose the URL from FHIR_STORE ourselves. + if strings.Contains(val, "${") { + continue + } + + vars[key] = val + } + + return vars, scanner.Err() +} + +// stripQuotes removes surrounding double or single quotes from a string. +func stripQuotes(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/src/program-generator/app/internal/seeder/fhirclient.go b/src/program-generator/app/internal/seeder/fhirclient.go new file mode 100644 index 00000000..40859d4c --- /dev/null +++ b/src/program-generator/app/internal/seeder/fhirclient.go @@ -0,0 +1,183 @@ +package seeder + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "golang.org/x/oauth2/google" +) + +const ( + healthcareScope = "https://www.googleapis.com/auth/cloud-healthcare" + fhirAPIBase = "https://healthcare.googleapis.com/v1" +) + +// FHIRClient communicates with the Google Healthcare FHIR API. +type FHIRClient struct { + httpClient *http.Client + storePath string // e.g. "projects//locations//datasets//fhirStores/" +} + +// NewFHIRClient creates an authenticated FHIR client using application-default credentials. +func NewFHIRClient(ctx context.Context, fhirStorePath string) (*FHIRClient, error) { + client, err := google.DefaultClient(ctx, healthcareScope) + if err != nil { + return nil, fmt.Errorf("creating authenticated client: %w", err) + } + return &FHIRClient{ + httpClient: client, + storePath: fhirStorePath, + }, nil +} + +// fhirURL constructs the full FHIR endpoint URL. +func (c *FHIRClient) fhirURL(path string) string { + return fmt.Sprintf("%s/%s/fhir/%s", fhirAPIBase, c.storePath, path) +} + +// TransactionResponse holds the parsed response from a FHIR transaction bundle. +type TransactionResponse struct { + Entries []TransactionResponseEntry +} + +// TransactionResponseEntry holds one entry from the transaction response. +type TransactionResponseEntry struct { + ResourceType string + ID string + Location string +} + +// PostTransaction posts a FHIR transaction bundle and returns the created resource IDs. +func (c *FHIRClient) PostTransaction(ctx context.Context, entries []bundleEntry) (*TransactionResponse, error) { + bundle := map[string]interface{}{ + "resourceType": "Bundle", + "type": "transaction", + "entry": entries, + } + + body, err := json.Marshal(bundle) + if err != nil { + return nil, fmt.Errorf("marshaling transaction bundle: %w", err) + } + + url := fmt.Sprintf("%s/%s/fhir", fhirAPIBase, c.storePath) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/fhir+json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("FHIR transaction failed (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + + return parseTransactionResponse(respBody) +} + +// parseTransactionResponse extracts resource IDs from a FHIR transaction response. +func parseTransactionResponse(body []byte) (*TransactionResponse, error) { + var raw struct { + Entry []struct { + Response struct { + Location string `json:"location"` + Status string `json:"status"` + } `json:"response"` + } `json:"entry"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parsing transaction response: %w", err) + } + + result := &TransactionResponse{} + for _, e := range raw.Entry { + entry := TransactionResponseEntry{Location: e.Response.Location} + // Location is like "ResourceType/id/_history/version" + if entry.Location != "" { + entry.ResourceType, entry.ID = parseLocation(entry.Location) + } + result.Entries = append(result.Entries, entry) + } + return result, nil +} + +// PatchResourceStatus patches a FHIR resource's status field via JSON Patch. +func (c *FHIRClient) PatchResourceStatus(ctx context.Context, resourceType, id, newStatus string) error { + patch := []map[string]interface{}{ + {"op": "replace", "path": "/status", "value": newStatus}, + } + body, _ := json.Marshal(patch) + + url := c.fhirURL(fmt.Sprintf("%s/%s", resourceType, id)) + req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("creating PATCH request: %w", err) + } + req.Header.Set("Content-Type", "application/json-patch+json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("executing PATCH: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("PATCH failed (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// ResourceExists checks whether a FHIR resource exists by ID. +// Returns true if the resource exists (HTTP 200), false if not found (HTTP 404). +func (c *FHIRClient) ResourceExists(ctx context.Context, resourceType, id string) (bool, error) { + url := c.fhirURL(fmt.Sprintf("%s/%s", resourceType, id)) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return false, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Accept", "application/fhir+json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return false, fmt.Errorf("checking resource existence: %w", err) + } + defer resp.Body.Close() + io.ReadAll(resp.Body) // drain body + + return resp.StatusCode == http.StatusOK, nil +} + +// parseLocation extracts ResourceType and ID from a FHIR location URL. +// Location can be: +// - "PlanDefinition/abc-123/_history/1" (relative) +// - "https://healthcare.googleapis.com/v1/.../fhir/PlanDefinition/abc-123/_history/1" (absolute) +func parseLocation(location string) (resourceType, id string) { + // Find the "/fhir/" marker to handle absolute URLs + fhirIdx := strings.Index(location, "/fhir/") + if fhirIdx >= 0 { + location = location[fhirIdx+len("/fhir/"):] + } + + // Now split "ResourceType/id/..." on "/" + parts := strings.SplitN(location, "/", 3) + if len(parts) >= 2 { + return parts[0], parts[1] + } + return "", "" +} diff --git a/src/program-generator/app/internal/seeder/gcs.go b/src/program-generator/app/internal/seeder/gcs.go new file mode 100644 index 00000000..f44efb31 --- /dev/null +++ b/src/program-generator/app/internal/seeder/gcs.go @@ -0,0 +1,49 @@ +package seeder + +import ( + "context" + "fmt" + + "cloud.google.com/go/storage" +) + +// GCSClient uploads objects to Google Cloud Storage. +type GCSClient struct { + client *storage.Client +} + +// NewGCSClient creates a GCS client using application-default credentials. +func NewGCSClient(ctx context.Context) (*GCSClient, error) { + client, err := storage.NewClient(ctx) + if err != nil { + return nil, fmt.Errorf("creating GCS client: %w", err) + } + return &GCSClient{client: client}, nil +} + +// UploadPDF writes a PDF document to the specified GCS bucket and returns +// the public URL in the format expected by the consent backend: +// +// https://storage.googleapis.com/{bucket}/{objectPath} +func (g *GCSClient) UploadPDF(ctx context.Context, bucket, objectPath string, pdfBytes []byte) (string, error) { + obj := g.client.Bucket(bucket).Object(objectPath) + writer := obj.NewWriter(ctx) + writer.ContentType = "application/pdf" + writer.CacheControl = "public, max-age=86400" // 1 day + + if _, err := writer.Write(pdfBytes); err != nil { + writer.Close() + return "", fmt.Errorf("writing PDF to GCS gs://%s/%s: %w", bucket, objectPath, err) + } + if err := writer.Close(); err != nil { + return "", fmt.Errorf("finalizing GCS upload gs://%s/%s: %w", bucket, objectPath, err) + } + + gcsURL := fmt.Sprintf("https://storage.googleapis.com/%s/%s", bucket, objectPath) + return gcsURL, nil +} + +// Close releases the GCS client's resources. +func (g *GCSClient) Close() { + g.client.Close() +} diff --git a/src/program-generator/app/internal/seeder/survey.go b/src/program-generator/app/internal/seeder/survey.go new file mode 100644 index 00000000..c444efdb --- /dev/null +++ b/src/program-generator/app/internal/seeder/survey.go @@ -0,0 +1,418 @@ +package seeder + +import ( + "fmt" + "strconv" + + "github.com/google/uuid" +) + +// --------------------------------------------------------------------------- +// Survey resource builders (Questionnaire + CodeSystem + ValueSet) +// --------------------------------------------------------------------------- + +const ( + questionnaireProfile = "http://fhir.verily.com/StructureDefinition/verily-questionnaire" + codeSystemProfile = "http://fhir.verily.com/StructureDefinition/verily-code-system" + valueSetProfile = "http://fhir.verily.com/StructureDefinition/verily-value-set" + surveyOIDNamingSystem = "http://fhir.verily.com/NamingSystem/survey-content-object-identifier" +) + +// surveyResources holds the FHIR resources generated for a single survey step. +type surveyResources struct { + // Questionnaire is the main survey resource (referenced by definitionCanonical). + Questionnaire map[string]interface{} + QuestionnaireURL string // Canonical URL for the Questionnaire + + // CodeSystem defines the question/answer codes. + CodeSystem map[string]interface{} + + // ValueSets define the answer options for choice questions. + ValueSets []map[string]interface{} +} + +// buildSurveyResources creates all FHIR resources for a survey step. +func buildSurveyResources(bc *buildContext, step Step, stepIdx int, bundleName string) (*surveyResources, error) { + if len(step.Questions) == 0 { + return nil, fmt.Errorf("survey step %d in bundle %q has no questions", stepIdx, bundleName) + } + + contentUID := fmt.Sprintf("%s-%s-survey-%d", bc.tmpl.Name, bundleName, stepIdx) + oid := contentUID + qURL := fmt.Sprintf("http://fhir.verily.com/Questionnaire/%s", contentUID) + csURL := fmt.Sprintf("http://fhir.verily.com/CodeSystem/%s", contentUID) + + // Build CodeSystem concepts and Questionnaire items + csConcepts := []map[string]interface{}{} + qItems := []map[string]interface{}{} + valueSets := []map[string]interface{}{} + + // Add title concept to CodeSystem + csConcepts = append(csConcepts, map[string]interface{}{ + "code": "title", + "display": step.Title, + }) + + for qIdx, question := range step.Questions { + // Use the explicit LinkID from the node tree (PROP_LINK_ID) if provided; + // fall back to the auto-generated code (q1, q2, ...) for legacy templates. + questionCode := fmt.Sprintf("q%d", qIdx+1) + if question.LinkID != "" { + questionCode = question.LinkID + } + + if question.Type == "compound_numeric" { + // Compound numeric: emit a parent item with type "question" (FHIR code 3) + // and two sub-items with linkIds "{parent}/field1" and "{parent}/field2". + // This is the exact structure the survey-be expects for QUESTION_TYPE_COMPOUND_NUMERIC. + item, subConcepts := buildCompoundNumericItem(bc, question, questionCode, contentUID) + qItems = append(qItems, item) + csConcepts = append(csConcepts, map[string]interface{}{ + "code": questionCode, + "display": question.Text, + }) + csConcepts = append(csConcepts, subConcepts...) + continue + } + + // The survey-be does not support the FHIR "boolean" question type. + // Convert boolean questions to choice questions with Yes/No options, + // which is how VCMS handles them. + q := question + if q.Type == "boolean" { + q.Type = "choice" + if len(q.Options) == 0 { + q.Options = []string{"Yes", "No"} + } + } + + // Add question concept to CodeSystem + csConcepts = append(csConcepts, map[string]interface{}{ + "code": questionCode, + "display": q.Text, + }) + + // Build Questionnaire item + item := buildQuestionnaireItem(bc, q, questionCode, contentUID, qIdx) + qItems = append(qItems, item) + + // Build ValueSet for choice questions + if q.Type == "choice" && len(q.Options) > 0 { + vs := buildValueSet(bc, q, questionCode, contentUID, oid) + valueSets = append(valueSets, vs) + + // Add answer option concepts to CodeSystem + for optIdx, opt := range q.Options { + optCode := fmt.Sprintf("%s-a%d", questionCode, optIdx+1) + concept := map[string]interface{}{ + "code": optCode, + "display": opt, + } + // Add rendering-markdown extension if markdown formatting exists. + if optIdx < len(q.OptionsMarkdown) && q.OptionsMarkdown[optIdx] != "" { + concept["extension"] = []map[string]interface{}{ + { + "url": "http://hl7.org/fhir/extensions/StructureDefinition/rendering-markdown", + "valueMarkdown": q.OptionsMarkdown[optIdx], + }, + } + } + csConcepts = append(csConcepts, concept) + } + } + } + + // Build the Questionnaire + // The survey-be requires a `code` field on the Questionnaire root that maps + // to a concept in the CodeSystem (used for title/description lookup in basicView). + questionnaire := map[string]interface{}{ + "resourceType": "Questionnaire", + "meta": buildOrgCompartmentMeta(bc, questionnaireProfile), + "url": qURL, + "version": bc.tmpl.Version, + "title": step.Title, + "status": "active", + "code": []map[string]interface{}{ + { + "system": csURL, + "code": "title", + }, + }, + "identifier": []map[string]interface{}{ + {"system": surveyOIDNamingSystem, "value": oid}, + }, + "item": qItems, + } + + // Build the CodeSystem + codeSystem := map[string]interface{}{ + "resourceType": "CodeSystem", + "meta": buildOrgCompartmentMeta(bc, codeSystemProfile), + "url": csURL, + "version": bc.tmpl.Version, + "title": step.Title, + "status": "active", + "content": "complete", + "caseSensitive": true, + "concept": csConcepts, + "identifier": []map[string]interface{}{ + {"system": contentOIDSystem, "value": oid}, + }, + } + + return &surveyResources{ + Questionnaire: questionnaire, + QuestionnaireURL: qURL, + CodeSystem: codeSystem, + ValueSets: valueSets, + }, nil +} + +// buildQuestionnaireItem creates a single Questionnaire.item for a question. +// Each item needs a `code` field that references the question concept in the +// CodeSystem — the survey-be uses item.Code[0] for question type mapping. +// For choice questions, the survey-be expects `answerValueSet` (a reference to +// an external ValueSet), NOT inline `answerOption`. +func buildQuestionnaireItem(bc *buildContext, q Question, questionCode, contentUID string, qIdx int) map[string]interface{} { + csURL := fmt.Sprintf("http://fhir.verily.com/CodeSystem/%s", contentUID) + item := map[string]interface{}{ + "linkId": questionCode, + "text": q.Text, + "type": mapQuestionType(q), + "required": q.Required, + "code": []map[string]interface{}{ + { + "system": csURL, + "code": questionCode, + }, + }, + } + + // For choice questions: + // 1. Reference the ValueSet via answerValueSet (the survey-be's optionsFromItem + // requires this, not inline answerOption). + // 2. Add the questionnaire-itemControl extension so the survey-be sets the + // ChoiceConfig.Type field. Without it, the frontend's ChoiceQuestion component + // renders null (the default case returns nothing). + // VCMS uses "radio-button" or "drop-down"; we default to "radio-button". + if q.Type == "choice" && len(q.Options) > 0 { + vsCanonical := fmt.Sprintf("http://fhir.verily.com/ValueSet/%s/%s|%s", contentUID, questionCode, bc.tmpl.Version) + item["answerValueSet"] = vsCanonical + item["extension"] = []map[string]interface{}{ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": map[string]interface{}{ + "text": "radio-button", + }, + }, + } + } + + return item +} + +// buildValueSet creates a ValueSet for a choice question's answer options. +func buildValueSet(bc *buildContext, q Question, questionCode, contentUID, surveyOID string) map[string]interface{} { + csURL := fmt.Sprintf("http://fhir.verily.com/CodeSystem/%s", contentUID) + + concepts := []map[string]interface{}{} + for optIdx, opt := range q.Options { + optCode := fmt.Sprintf("%s-a%d", questionCode, optIdx+1) + concepts = append(concepts, map[string]interface{}{ + "code": optCode, + "display": opt, + }) + } + + return map[string]interface{}{ + "resourceType": "ValueSet", + "meta": buildOrgCompartmentMeta(bc, valueSetProfile), + "url": fmt.Sprintf("http://fhir.verily.com/ValueSet/%s/%s", contentUID, questionCode), + "version": bc.tmpl.Version, + "status": "active", + "identifier": []map[string]interface{}{ + {"system": surveyOIDNamingSystem, "value": fmt.Sprintf("%s/%s", surveyOID, questionCode)}, + }, + "compose": map[string]interface{}{ + "include": []map[string]interface{}{ + { + "system": csURL, + "version": bc.tmpl.Version, + "concept": concepts, + }, + }, + }, + } +} + +// buildCompoundNumericItem creates a FHIR Questionnaire.item for a compound numeric +// question. The structure matches exactly what the survey-be expects: +// +// Parent item: +// linkId: "{questionCode}" +// type: "question" (QuestionnaireItemTypeCode_QUESTION = 3) +// repeats: true +// item[]: +// [0] linkId: "{questionCode}/field1", type: integer/decimal/quantity +// [1] linkId: "{questionCode}/field2", type: integer/decimal/quantity +// +// The survey-be's mapToQuestionType dispatches "question" → COMPOUND_NUMERIC, +// and compoundNumericConfigFromItem reads item.Item[0] and item.Item[1]. +// Sub-item linkIds MUST use the "/{fieldN}" suffix because the response +// serializer hardcodes "field1" / "field2" as lookup keys. +func buildCompoundNumericItem(bc *buildContext, q Question, questionCode, contentUID string) (map[string]interface{}, []map[string]interface{}) { + csURL := fmt.Sprintf("http://fhir.verily.com/CodeSystem/%s", contentUID) + + var subItems []map[string]interface{} + var subConcepts []map[string]interface{} + + for i, sub := range q.SubQuestions { + fieldKey := fmt.Sprintf("field%d", i+1) + subLinkID := fmt.Sprintf("%s/%s", questionCode, fieldKey) + subCode := subLinkID // CodeSystem code matches linkId + + subItem := map[string]interface{}{ + "linkId": subLinkID, + "text": sub.Text, + "type": mapQuestionType(sub), + "required": sub.Required, + "code": []map[string]interface{}{ + { + "system": csURL, + "version": bc.tmpl.Version, + "code": subCode, + }, + }, + } + + // Add numeric constraint extensions (minValue, maxValue, maxDecimalPlaces). + exts := buildNumericExtensions(sub) + if len(exts) > 0 { + subItem["extension"] = exts + } + + subItems = append(subItems, subItem) + subConcepts = append(subConcepts, map[string]interface{}{ + "code": subCode, + "display": sub.Text, + }) + } + + parentItem := map[string]interface{}{ + "linkId": questionCode, + "text": q.Text, + "type": "question", // FHIR QuestionnaireItemTypeCode_QUESTION + "repeats": true, // Required by the survey-be for compound numerics + "required": q.Required, + "code": []map[string]interface{}{ + { + "system": csURL, + "code": questionCode, + }, + }, + "item": subItems, + } + + return parentItem, subConcepts +} + +// buildNumericExtensions creates FHIR extensions for numeric constraints on a question. +// The extensions follow the same structure that the survey-be's extension parser expects: +// - minValue: http://hl7.org/fhir/StructureDefinition/minValue +// - maxValue: http://hl7.org/fhir/StructureDefinition/maxValue +// - maxDecimalPlaces: http://hl7.org/fhir/StructureDefinition/maxDecimalPlaces +// - questionnaire-unit: http://hl7.org/fhir/StructureDefinition/questionnaire-unit +func buildNumericExtensions(q Question) []map[string]interface{} { + var exts []map[string]interface{} + + isInteger := q.Type == "integer" + // isDecimalOrQuantity := q.Type == "decimal" || q.Type == "quantity" + + // For integer questions, force maxDecimalPlaces to 0 (the survey-be extension + // parser expects this to distinguish integers from decimals). + if isInteger { + exts = append(exts, map[string]interface{}{ + "url": "http://hl7.org/fhir/StructureDefinition/maxDecimalPlaces", + "valueInteger": 0, + }) + } + + if q.MinValue != "" { + if isInteger { + if v, err := strconv.Atoi(q.MinValue); err == nil { + exts = append(exts, map[string]interface{}{ + "url": "http://hl7.org/fhir/StructureDefinition/minValue", + "valueInteger": v, + }) + } + } else { + if v, err := strconv.ParseFloat(q.MinValue, 64); err == nil { + exts = append(exts, map[string]interface{}{ + "url": "http://hl7.org/fhir/StructureDefinition/minValue", + "valueDecimal": v, + }) + } + } + } + + if q.MaxValue != "" { + if isInteger { + if v, err := strconv.Atoi(q.MaxValue); err == nil { + exts = append(exts, map[string]interface{}{ + "url": "http://hl7.org/fhir/StructureDefinition/maxValue", + "valueInteger": v, + }) + } + } else { + if v, err := strconv.ParseFloat(q.MaxValue, 64); err == nil { + exts = append(exts, map[string]interface{}{ + "url": "http://hl7.org/fhir/StructureDefinition/maxValue", + "valueDecimal": v, + }) + } + } + } + + for _, unit := range q.Units { + exts = append(exts, map[string]interface{}{ + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unit", + "valueCoding": map[string]interface{}{ + "system": unit.System, + "code": unit.Code, + "display": unit.Display, + }, + }) + } + + return exts +} + +// mapQuestionType maps our simple type names to FHIR Questionnaire item types. +// Note: "boolean" is NOT a supported FHIR type in the survey-be. Boolean +// questions should be converted to "choice" with Yes/No options before +// reaching this function (see buildSurveyResources). +func mapQuestionType(q Question) string { + // When units are present, the FHIR type is "quantity" regardless of + // the underlying integer/decimal distinction. The survey-be dispatches + // QUANTITY items to the numeric renderer with unit support. + if len(q.Units) > 0 && (q.Type == "integer" || q.Type == "decimal") { + return "quantity" + } + switch q.Type { + case "choice": + return "choice" + case "text": + return "string" + case "integer": + return "integer" + case "decimal": + return "decimal" + default: + return "string" + } +} + +// newTempID generates a new urn:uuid temporary ID for transaction bundle references. +func newTempID() string { + return "urn:uuid:" + uuid.New().String() +} diff --git a/src/program-generator/app/internal/seeder/types.go b/src/program-generator/app/internal/seeder/types.go new file mode 100644 index 00000000..fb776567 --- /dev/null +++ b/src/program-generator/app/internal/seeder/types.go @@ -0,0 +1,332 @@ +// Package seeder provides the core logic for creating VerilyMe programs +// from YAML templates. It constructs all necessary FHIR resources (content, +// surveys, workflow definitions) and posts them to the Healthcare API. +package seeder + +import ( + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +// --------------------------------------------------------------------------- +// Template types — read from YAML +// --------------------------------------------------------------------------- + +// Template is the top-level YAML structure for a program definition. +// In the node-tree format, this is derived from an ADMIN_PROGRAM root node +// via convertNodeTreeToTemplate (see builder.go). +type Template struct { + // Name is a human-readable name for the program (e.g. "my-test-program"). + // Used to generate FHIR resource names/URLs. + Name string `yaml:"name"` + + // OrgID is the FHIR Organization ID that owns all resources. + // Must match the enrollment profile's organization. + OrgID string `yaml:"org_id"` + + // Version is the semantic version for all FHIR resources (e.g. "1.0.0"). + Version string `yaml:"version"` + + // EnvBaseURL is the environment base URL (e.g. "https://dev-stable.one.verily.com"). + // Used to construct org compartment references. + EnvBaseURL string `yaml:"env_base_url"` + + // Bundles defines the missions/bundles in the program. + // Each bundle appears as a card on the VerilyMe home screen. + Bundles []Bundle `yaml:"bundles"` +} + +// Bundle represents a mission bundle — a collection of steps shown as a single +// card on the VerilyMe home screen. +type Bundle struct { + // Name is the internal name for the bundle (e.g. "welcome-mission"). + Name string `yaml:"name"` + + // Card defines the home-screen card content. + Card Card `yaml:"card"` + + // Steps defines the ordered steps within the bundle. + // Supported step types: "info", "survey". + Steps []Step `yaml:"steps"` +} + +// Card defines the content shown on the VerilyMe home-screen card for a bundle. +type Card struct { + Title string `yaml:"title"` + Description string `yaml:"description"` +} + +// Step is a single step within a bundle. The Type field determines which +// sub-fields are relevant. +type Step struct { + // Type is the step type: "info", "survey", or "consent". + Type string `yaml:"type"` + + // Title is displayed at the top of the step. + Title string `yaml:"title"` + + // BodyHTML is the rich-text HTML content for "info" steps. + // Sugar for a simple vertical container with title + body rich text. + // Mutually exclusive with Nodes — use one or the other. + // Note: not used by "consent" steps (regulated consent has no HTML module). + BodyHTML string `yaml:"body_html,omitempty"` + + // Nodes is the full component node tree for "info" steps. + // When set, this gives full control over the content layout (images, + // accordions, highlight cards, etc.). The nodes are wrapped in an implicit + // CMPT_VERTICAL_CONTAINER root. + // Mutually exclusive with BodyHTML — use one or the other. + Nodes []ContentNode `yaml:"nodes,omitempty"` + + // Questions are the survey questions for "survey" steps. + Questions []Question `yaml:"questions,omitempty"` + + // Checkboxes are the agreement checkboxes for "consent" steps. + // At least one is required. + Checkboxes []ConsentCheckbox `yaml:"checkboxes,omitempty"` +} + +// ContentNode represents a single node in the component tree. +// It mirrors the proto Node message (components_common.proto) with +// proto-native field names for zero-translation YAML↔proto mapping. +// +// The custom UnmarshalYAML accepts both proto-native field names (new format) +// and legacy snake_case names (old format) for backward compatibility: +// +// New format: node_type, value_string (proto-native) +// Old format: type, value (legacy) +// +// Node types use SCREAMING_SNAKE_CASE matching the proto NodeType enum: +// +// CMPT_RICH_TEXT, CMPT_IMAGE, CMPT_VERTICAL_CONTAINER, etc. +// +// The legacy format's lowercase names (rich_text, image, etc.) are also accepted. +type ContentNode struct { + // NodeType is the node type (e.g. "CMPT_RICH_TEXT", "CMPT_IMAGE"). + // Maps to proto Node.node_type. Legacy: "type" field with lowercase names. + NodeType string + + // ValueString maps to proto Node.value_string (text content, color values, icon IDs). + // Legacy: "value" field. + ValueString string + + // HTML maps to proto Node.html (rich text HTML content). + HTML string + + // URI maps to proto Node.uri (URL for images or linked content). + URI string + + // Data maps to proto Node.value_bytes as base64 (binary data, data URIs). + Data string + + // ID maps to proto Node.id (unique identifier for the node). + ID string + + // Nodes are nested child nodes. Maps to proto Node.nodes. + Nodes []ContentNode +} + +// UnmarshalYAML implements yaml.Unmarshaler for ContentNode. +// It accepts both proto-native field names (node_type, value_string) and +// legacy YAML field names (type, value), preferring the proto-native names. +func (cn *ContentNode) UnmarshalYAML(value *yaml.Node) error { + // Use an auxiliary struct to avoid infinite recursion while still + // triggering ContentNode.UnmarshalYAML for nested children. + type raw struct { + // Proto-native names (new format) + NodeType string `yaml:"node_type"` + ValueString string `yaml:"value_string"` + // Legacy names (old format) + Type string `yaml:"type"` + Value string `yaml:"value"` + // Common fields (same name in both formats) + HTML string `yaml:"html"` + URI string `yaml:"uri"` + Data string `yaml:"data"` + ID string `yaml:"id"` + Nodes []ContentNode `yaml:"nodes"` + } + var r raw + if err := value.Decode(&r); err != nil { + return err + } + // Prefer proto-native names; fall back to legacy. + cn.NodeType = r.NodeType + if cn.NodeType == "" { + cn.NodeType = r.Type + } + cn.ValueString = r.ValueString + if cn.ValueString == "" { + cn.ValueString = r.Value + } + cn.HTML = r.HTML + cn.URI = r.URI + cn.Data = r.Data + cn.ID = r.ID + cn.Nodes = r.Nodes + return nil +} + +// Question defines a single survey question. +// It can also represent a compound question (e.g., blood pressure with systolic/diastolic) +// when Type is "compound_numeric" and SubQuestions holds the individual fields. +type Question struct { + // Text is the question prompt (or group title for compound questions). + Text string `yaml:"text"` + + // Type is the answer type: "choice", "boolean", "text", "integer", "decimal", + // "quantity", or "compound_numeric" (for compound questions with sub-fields). + Type string `yaml:"type"` + + // LinkID is the optional Questionnaire.item[].linkId for this question. + // If empty, the builder auto-generates one from the question index. + LinkID string `yaml:"link_id,omitempty"` + + // Options lists the answer choices for "choice" type questions. + Options []string `yaml:"options,omitempty"` + + // OptionsMarkdown holds optional markdown-formatted versions of Options. + // When present, the builder adds a rendering-markdown FHIR extension to + // the CodeSystem concept so the survey-be renders bold/italic/etc. + // Parallel to Options: OptionsMarkdown[i] corresponds to Options[i]. + // Empty strings mean "no markdown for this option". + OptionsMarkdown []string `yaml:"-"` + + // Required indicates whether the question must be answered. + Required bool `yaml:"required,omitempty"` + + // SubQuestions holds the individual fields of a compound question. + // Only used when Type is "compound_numeric" — the survey-be expects exactly + // two sub-fields with linkIds "{parent}/field1" and "{parent}/field2". + SubQuestions []Question `yaml:"-"` + + // MinValue and MaxValue hold numeric constraints extracted from + // PROP_CONSTRAINTS > PROP_NUMERIC > PROP_MIN_VALUE / PROP_MAX_VALUE. + MinValue string `yaml:"-"` + MaxValue string `yaml:"-"` + + // Units holds unit information from PROP_UNITS > PROP_UNIT. + // When present, the FHIR item type becomes "quantity" instead of "integer". + Units []QuestionUnit `yaml:"-"` +} + +// QuestionUnit holds a single unit option for a numeric question. +// Maps to the FHIR questionnaire-unit extension. +type QuestionUnit struct { + Display string // PROP_UNIT_DISPLAY + System string // PROP_UNIT_SYSTEM + Code string // PROP_UNIT_CODE +} + +// ConsentCheckbox defines a single agreement checkbox in a consent step. +type ConsentCheckbox struct { + // Text is the label shown next to the checkbox. + Text string `yaml:"text"` + + // Required indicates whether the checkbox must be checked to proceed. + Required bool `yaml:"required"` +} + +// --------------------------------------------------------------------------- +// Output types — written as JSON after program creation +// --------------------------------------------------------------------------- + +// ProgramOutput contains all IDs produced by creating a program. +// This is saved as JSON and can be consumed by the enrollment script. +type ProgramOutput struct { + Name string `json:"name"` + HealthcareServiceID string `json:"healthcare_service_id"` + PlanDefinitionID string `json:"plan_definition_id"` + GroupID string `json:"group_id"` + OrgID string `json:"org_id"` + Version string `json:"version"` + Resources []ResourceRef `json:"resources"` +} + +// ResourceRef records a single created FHIR resource. +type ResourceRef struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name,omitempty"` +} + +// --------------------------------------------------------------------------- +// Internal build-time types — used to wire resources together +// --------------------------------------------------------------------------- + +// buildContext holds all state accumulated while building a program. +// It is used to track temp UUIDs and the final FHIR transaction entries. +type buildContext struct { + tmpl Template + entries []bundleEntry + + // ID maps: tempUUID → assigned during build, resolved by FHIR server + rootPlanDefTempID string + groupTempID string + hcsTempID string + + // Collected output refs + outputResources []ResourceRef +} + +// bundleEntry is one entry in the FHIR transaction bundle. +type bundleEntry struct { + FullURL string `json:"fullUrl,omitempty"` + Resource map[string]interface{} `json:"resource"` + Request bundleRequest `json:"request"` +} + +// bundleRequest is the request portion of a transaction bundle entry. +type bundleRequest struct { + Method string `json:"method"` + URL string `json:"url"` +} + +// newBuildContext initializes a build context from a template. +func newBuildContext(tmpl Template) *buildContext { + return &buildContext{ + tmpl: tmpl, + rootPlanDefTempID: "urn:uuid:" + uuid.New().String(), + groupTempID: "urn:uuid:" + uuid.New().String(), + hcsTempID: "urn:uuid:" + uuid.New().String(), + } +} + +// addEntry adds a resource to the transaction bundle. +func (bc *buildContext) addEntry(tempID string, resourceType string, resource map[string]interface{}) { + entry := bundleEntry{ + FullURL: tempID, + Resource: resource, + Request: bundleRequest{ + Method: "POST", + URL: resourceType, + }, + } + bc.entries = append(bc.entries, entry) +} + +// addUpsertEntry adds a resource with PUT (create-or-update) semantics. +// The URL should include the resource ID, e.g. "Organization/abc-123". +// This ensures the resource exists at a known ID and that other bundle entries +// can reference it within the same transaction. +func (bc *buildContext) addUpsertEntry(tempID string, resourceURL string, resource map[string]interface{}) { + entry := bundleEntry{ + FullURL: tempID, + Resource: resource, + Request: bundleRequest{ + Method: "PUT", + URL: resourceURL, + }, + } + bc.entries = append(bc.entries, entry) +} + +// orgCompartmentRef returns the full org compartment reference URL. +func (bc *buildContext) orgCompartmentRef() string { + return bc.tmpl.EnvBaseURL + "/cortex-fhir-proxy/operational/fhir/Organization/" + bc.tmpl.OrgID +} + +// canonicalURL creates a canonical URL for a resource. +func canonicalURL(namespace, resourceType, name string) string { + return "http://fhir.verily.com/NamingSystem/" + namespace + "/" + resourceType + "/" + name +} diff --git a/src/program-generator/app/internal/seeder/workflow.go b/src/program-generator/app/internal/seeder/workflow.go new file mode 100644 index 00000000..ad6d43ed --- /dev/null +++ b/src/program-generator/app/internal/seeder/workflow.go @@ -0,0 +1,405 @@ +package seeder + +import ( + "fmt" +) + +// --------------------------------------------------------------------------- +// Workflow structure builders +// --------------------------------------------------------------------------- + +const ( + planDefProfile = "http://fhir.verily.com/StructureDefinition/verily-workflow-plandefinition" + actDefProfile = "http://fhir.verily.com/StructureDefinition/verily-workflow-activitydefinition" + contentExtURL = "http://fhir.verily.com/StructureDefinition/content" + bundleCardTypeExtURL = "http://fhir.verily.com/StructureDefinition/bundle-card-type" + lastStepExtURL = "http://fhir.verily.com/StructureDefinition/last-step-in-bundle" + + bundleStepSystem = "http://fhir.verily.com/CodeSystem/bundle-step" + bundleStepTypeSystem = "http://fhir.verily.com/CodeSystem/bundle-step-type" + actionCategorySystem = "http://fhir.verily.com/CodeSystem/action-category" + actionActivityType = "http://fhir.verily.com/CodeSystem/action-activity-type" + activitySystem = "http://fhir.verily.com/CodeSystem/activity" + bundleCardVCMSContent = "http://fhir.verily.com/CodeSystem/bundle-card-vcms-content" + bundleStepVCMSContent = "http://fhir.verily.com/CodeSystem/bundle-step-vcms-content" + + carePathwayExtURL = "http://fhir.verily.com/StructureDefinition/care-program-care-pathway" + enrolledExtURL = "http://fhir.verily.com/StructureDefinition/care-program-enrolled" + hcsProfile = "http://fhir.verily.com/StructureDefinition/verily-care-program" +) + +// --------------------------------------------------------------------------- +// Organization (prerequisite for org-compartment validation) +// --------------------------------------------------------------------------- + +const ( + orgProfile = "http://fhir.verily.com/StructureDefinition/verily-organization" + orgIdentifierSystem = "http://fhir.verily.com/NamingSystem/verily-organization-identifier" + orgTypeSystem = "http://fhir.verily.com/CodeSystem/verily-organization-type" + partOfOrgExtURL = "http://fhir.verily.com/StructureDefinition/verily-part-of-organization" + + // cortexBootstrapOrgID is the Organization that cortex bootstraps into every + // FHIR store (including ephemeral ones). We use it as the parent for newly + // seeded organizations. + // See cortex/internal/hermetic/bootstraporg.go. + cortexBootstrapOrgID = "c19068d3-f31f-46d8-93f9-74bac897dcad" +) + +// buildOrganization creates a minimal FHIR Organization resource that satisfies +// the cortex-fhir-proxy's org-compartment validation. Without this resource in +// the FHIR store, enrollment (which routes through cortex-fhir-proxy) fails with +// "organization-compartment URL is invalid". +// +// IMPORTANT: The caller (buildWorkflowStructure) only includes this resource in +// the transaction when the Organization does not already exist (checked via GET). +// In shared environments (dev-stable) the Organization has a real +// verily-part-of-organization hierarchy managed by other teams — a PUT would +// replace the entire resource and destroy that hierarchy. The seed-program tool +// writes directly to the GCP Healthcare API (bypassing cortex-fhir-proxy), so +// the proxy's validatePartOfOrganization guard does not protect against this. +func buildOrganization(bc *buildContext) map[string]interface{} { + return map[string]interface{}{ + "resourceType": "Organization", + "id": bc.tmpl.OrgID, + "meta": map[string]interface{}{ + "profile": []string{orgProfile}, + "extension": []map[string]interface{}{ + { + "url": orgCompartmentExtURL, + "valueReference": map[string]interface{}{ + "reference": "Organization/" + bc.tmpl.OrgID, + }, + }, + }, + }, + "active": true, + "name": fmt.Sprintf("Standalone Seeding Org (%s)", bc.tmpl.OrgID), + "identifier": []map[string]interface{}{ + { + "system": orgIdentifierSystem, + "value": bc.tmpl.OrgID, + }, + }, + "type": []map[string]interface{}{ + { + "coding": []map[string]interface{}{ + { + "system": orgTypeSystem, + "code": "CareDeliveryOrganization", + }, + }, + }, + }, + "extension": []map[string]interface{}{ + { + "url": partOfOrgExtURL, + "valueReference": map[string]interface{}{ + "reference": "Organization/" + cortexBootstrapOrgID, + "type": "Organization", + }, + }, + }, + } +} + +// --------------------------------------------------------------------------- +// Group (applicability) +// --------------------------------------------------------------------------- + +// buildGroup creates a FHIR Group resource for PlanDefinition applicability. +// Characteristics: org match + care-program-enrolled. +// The profile is required for workflow-be to read the Group (Data Contract). +func buildGroup(bc *buildContext) map[string]interface{} { + return map[string]interface{}{ + "resourceType": "Group", + "meta": buildOrgCompartmentMeta(bc, "http://fhir.verily.com/StructureDefinition/verily-workflow-group"), + "type": "person", + "actual": false, + "name": fmt.Sprintf("%s Applicability Group", bc.tmpl.Name), + // The characteristic uses a FHIRPath expression that the workflow engine + // evaluates for applicability. It checks: + // 1. Patient's managingOrganization matches the program's org + // 2. Patient has the care-program-enrolled extension + "characteristic": []map[string]interface{}{ + { + "code": map[string]interface{}{ + "text": fmt.Sprintf( + "Patient.managingOrganization.reference='Organization/%s' and "+ + "Patient.extension.where(url='%s').exists()", + bc.tmpl.OrgID, + enrolledExtURL, + ), + }, + "valueBoolean": true, + "exclude": false, + }, + }, + } +} + +// --------------------------------------------------------------------------- +// ActivityDefinition (for info steps) +// --------------------------------------------------------------------------- + +// buildActivityDefinition creates a FHIR ActivityDefinition that references +// a DocumentReference for an info step. +func buildActivityDefinition(bc *buildContext, actionID string, docRefTempID string) (tempID string, resource map[string]interface{}) { + adURL := canonicalURL("standalone-seeding", "ActivityDefinition", actionID) + tempID = newTempID() + + resource = map[string]interface{}{ + "resourceType": "ActivityDefinition", + "meta": buildOrgCompartmentMeta(bc, actDefProfile), + "status": "active", + "url": adURL, + "version": bc.tmpl.Version, + "kind": "Task", + "extension": []map[string]interface{}{ + { + "url": contentExtURL, + "valueReference": map[string]interface{}{ + "reference": docRefTempID, + "type": "DocumentReference", + }, + }, + }, + "dynamicValue": []map[string]interface{}{ + { + "path": "input[0].value as Reference", + "expression": map[string]interface{}{ + "expression": "%activity_definition.extension[0].value as Reference", + "language": "text/fhirpath", + }, + }, + }, + } + return tempID, resource +} + +// --------------------------------------------------------------------------- +// ActivityDefinition (for consent steps) +// --------------------------------------------------------------------------- + +// buildConsentActivityDefinition creates a FHIR ActivityDefinition for a consent step. +// Unlike info steps (which use valueReference → DocumentReference), consent steps +// use valueCanonical → Contract canonical URL. The workflow engine copies this +// canonical into the Task's input, which the action service's consent module reads +// to look up the Contract + Questionnaire + CodeSystems. +func buildConsentActivityDefinition(bc *buildContext, actionID string, contractCanonical string) (tempID string, resource map[string]interface{}) { + adURL := canonicalURL("standalone-seeding", "ActivityDefinition", actionID) + tempID = newTempID() + + resource = map[string]interface{}{ + "resourceType": "ActivityDefinition", + "meta": buildOrgCompartmentMeta(bc, actDefProfile), + "status": "active", + "url": adURL, + "version": bc.tmpl.Version, + "kind": "Task", + "extension": []map[string]interface{}{ + { + "url": contentExtURL, + "valueCanonical": contractCanonical, + }, + }, + "dynamicValue": []map[string]interface{}{ + { + "path": "input[0].value as canonical", + "expression": map[string]interface{}{ + "expression": "%activity_definition.extension[0].value as canonical", + "language": "text/fhirpath", + }, + }, + }, + } + return tempID, resource +} + +// --------------------------------------------------------------------------- +// Child PlanDefinition (bundle / mission) +// --------------------------------------------------------------------------- + +// bundleStepAction holds the data needed to create a PlanDefinition action for a bundle step. +type bundleStepAction struct { + StepType string // "info", "survey", or "consent" + ActionID string + DefinitionCanonical string // ActivityDefinition URL|version for info/consent; Questionnaire URL|version for survey + ContentOID string +} + +// buildChildPlanDefinition creates a child PlanDefinition (one per bundle/mission). +func buildChildPlanDefinition(bc *buildContext, bundle Bundle, bundleIdx int, cardDocRefTempID string, steps []bundleStepAction) (tempID string, resource map[string]interface{}) { + planDefURL := canonicalURL("standalone-seeding", "PlanDefinition", fmt.Sprintf("%s-%s", bc.tmpl.Name, bundle.Name)) + tempID = newTempID() + + // Build step actions + stepActions := []interface{}{} + for i, step := range steps { + action := buildStepAction(step) + // Mark the last step + if i == len(steps)-1 { + addLastStepExtension(action) + } + stepActions = append(stepActions, action) + } + + // Build the bundle action (wrapper) + activityID := fmt.Sprintf("%s-%s", bc.tmpl.Name, bundle.Name) + bundleAction := map[string]interface{}{ + "id": activityID, + "title": bundle.Card.Title, + "action": stepActions, + "code": []map[string]interface{}{ + { + "coding": []map[string]interface{}{ + {"system": actionActivityType, "code": "bundle"}, + {"system": actionCategorySystem, "code": "participant"}, + {"system": activitySystem, "code": activityID}, + }, + }, + }, + "extension": []map[string]interface{}{ + { + "url": contentExtURL, + "valueReference": map[string]interface{}{ + "reference": cardDocRefTempID, + "type": "DocumentReference", + }, + }, + { + "url": bundleCardTypeExtURL, + "valueCode": "task", + }, + }, + "dynamicValue": []map[string]interface{}{ + { + "path": "extension", + "expression": map[string]interface{}{ + "expression": "%action.extension[0]", + "language": "text/fhirpath", + }, + }, + { + "path": "extension", + "expression": map[string]interface{}{ + "expression": "%action.extension[1]", + "language": "text/fhirpath", + }, + }, + }, + } + + resource = map[string]interface{}{ + "resourceType": "PlanDefinition", + "meta": buildOrgCompartmentMeta(bc, planDefProfile), + "status": "active", + "name": bundle.Name, + "url": planDefURL, + "version": bc.tmpl.Version, + "action": []interface{}{bundleAction}, + } + return tempID, resource +} + +// buildStepAction creates a PlanDefinition.action for a single bundle step. +func buildStepAction(step bundleStepAction) map[string]interface{} { + codings := []map[string]interface{}{ + {"system": bundleStepSystem, "code": step.ActionID}, + {"system": bundleStepTypeSystem, "code": step.StepType}, + {"system": actionCategorySystem, "code": "participant"}, + } + + action := map[string]interface{}{ + "id": step.ActionID, + "code": []map[string]interface{}{ + {"coding": codings}, + }, + "dynamicValue": []map[string]interface{}{ + { + "path": "input[0].type.coding", + "expression": map[string]interface{}{ + "expression": "%action.code[0].coding[0]", + "language": "text/fhirpath", + }, + }, + { + "path": "input[0].type.coding", + "expression": map[string]interface{}{ + "expression": "%action.code[0].coding[1]", + "language": "text/fhirpath", + }, + }, + }, + } + + // Info steps use definitionCanonical pointing to ActivityDefinition + // Survey steps use definitionCanonical pointing to Questionnaire + if step.DefinitionCanonical != "" { + action["definitionCanonical"] = step.DefinitionCanonical + } + + return action +} + +// addLastStepExtension adds the last-step-in-bundle extension to an action. +func addLastStepExtension(action map[string]interface{}) { + ext, ok := action["extension"].([]map[string]interface{}) + if !ok { + ext = []map[string]interface{}{} + } + ext = append(ext, map[string]interface{}{ + "url": lastStepExtURL, + "valueBoolean": true, + }) + action["extension"] = ext +} + +// --------------------------------------------------------------------------- +// Root PlanDefinition (care pathway) +// --------------------------------------------------------------------------- + +// buildRootPlanDefinition creates the top-level care pathway PlanDefinition. +func buildRootPlanDefinition(bc *buildContext, childPlanDefCanonicals []string) map[string]interface{} { + rootURL := canonicalURL("standalone-seeding", "PlanDefinition", bc.tmpl.Name) + + actions := []map[string]interface{}{} + for i, canonical := range childPlanDefCanonicals { + actions = append(actions, map[string]interface{}{ + "id": fmt.Sprintf("bundle-%d", i), + "definitionCanonical": canonical, + }) + } + + return map[string]interface{}{ + "resourceType": "PlanDefinition", + "meta": buildOrgCompartmentMeta(bc, planDefProfile), + "status": "active", + "name": bc.tmpl.Name, + "url": rootURL, + "version": bc.tmpl.Version, + "subjectReference": map[string]interface{}{ + "reference": bc.groupTempID, + }, + "action": actions, + } +} + +// --------------------------------------------------------------------------- +// HealthcareService (program entry point) +// --------------------------------------------------------------------------- + +// buildHealthcareService creates a HealthcareService with a care-pathway extension. +func buildHealthcareService(bc *buildContext, rootPlanDefCanonical string) map[string]interface{} { + return map[string]interface{}{ + "resourceType": "HealthcareService", + "meta": buildOrgCompartmentMeta(bc, hcsProfile), + "active": true, + "name": bc.tmpl.Name, + "extension": []map[string]interface{}{ + { + "url": carePathwayExtURL, + "valueCanonical": rootPlanDefCanonical, + }, + }, + } +} diff --git a/src/program-generator/app/local-testing/README.md b/src/program-generator/app/local-testing/README.md new file mode 100644 index 00000000..cbf78819 --- /dev/null +++ b/src/program-generator/app/local-testing/README.md @@ -0,0 +1,59 @@ +# Local Testing + +## Prerequisites + +- Docker (for Postgres) +- Go 1.23+ +- Optional: `gcloud auth application-default login` (for AI generation — will be blocked by VPC-SC on the default project unless you override `VERTEX_PROJECT`) + +## Quick Start + +```bash +cd src/program-generator/app/local-testing +./dev.sh +``` + +This script: +1. Creates or starts a Postgres container (`pg-program-gen`) +2. Waits for Postgres to be ready +3. Starts the Go server on http://localhost:8080 + +Ctrl+C to stop the server. The Postgres container persists between runs so saved templates stick around. + +## What Works Locally + +| Feature | Works Locally? | Notes | +|---------|---------------|-------| +| UI loads | Yes | | +| Validate YAML (dry run) | Yes | Parses YAML and builds FHIR bundle in memory | +| Save / load templates | Yes | Stored in Postgres | +| Export YAML | Yes | Client-side download | +| AI generation | Partial | Needs ADC credentials; blocked by VPC-SC on default project | +| Seed to FHIR | No | Needs FHIR store access (inside Workbench VM or port-forwarding) | +| GCS consent PDF upload | No | Needs GCS access | + +## Test Workflow + +1. Open http://localhost:8080 +2. Copy the contents of `test-template.yaml` and paste into the YAML editor +3. Click **Validate** — should show "Valid!" with program name and bundle count +4. Click **Save Template** — give it a name, verify it appears in the sidebar +5. Click a saved template in the sidebar — verify it loads back +6. Click **Export YAML** — verify a `.yaml` file downloads + +## Environment Overrides + +Override any of these when running `dev.sh`: + +```bash +VERTEX_PROJECT=my-project VERTEX_REGION=us-central1 ./dev.sh +``` + +| Variable | Default | +|----------|---------| +| `DB_HOST` | `localhost` | +| `DB_PORT` | `5432` | +| `FHIR_STORE` | `projects/prj-d-1v-ucd/...` | +| `GCS_BUCKET` | `econsent-pdf-pilot-dev-oneverily-prj-d-1v-ucd` | +| `VERTEX_PROJECT` | `wb-agile-aubergine-8187` | +| `VERTEX_REGION` | `us-east5` | diff --git a/src/program-generator/app/local-testing/dev.sh b/src/program-generator/app/local-testing/dev.sh new file mode 100755 index 00000000..ee9e2a33 --- /dev/null +++ b/src/program-generator/app/local-testing/dev.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONTAINER_NAME="pg-program-gen" +DB_USER="pguser" +DB_PASS="pgpass" +DB_NAME="program_generator" +DB_PORT="5432" + +echo "=== Program Generator Local Dev ===" + +# --- Postgres --- +if docker inspect "$CONTAINER_NAME" &>/dev/null; then + state=$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME") + if [ "$state" = "true" ]; then + echo "Postgres already running." + else + echo "Starting existing Postgres container..." + docker start "$CONTAINER_NAME" + fi +else + echo "Creating Postgres container..." + docker run -d --name "$CONTAINER_NAME" \ + -e POSTGRES_USER="$DB_USER" \ + -e POSTGRES_PASSWORD="$DB_PASS" \ + -e POSTGRES_DB="$DB_NAME" \ + -p "$DB_PORT":5432 \ + postgres:18-alpine +fi + +# Wait for Postgres to be ready +echo -n "Waiting for Postgres" +for i in $(seq 1 30); do + if docker exec "$CONTAINER_NAME" pg_isready -U "$DB_USER" -d "$DB_NAME" &>/dev/null; then + echo " ready." + break + fi + echo -n "." + sleep 1 + if [ "$i" -eq 30 ]; then + echo " timed out!" + exit 1 + fi +done + +# --- Go app --- +echo "Starting Go server on http://localhost:8080" +echo "(Ctrl+C to stop)" +echo "" + +export DB_HOST=localhost +export DB_PORT="$DB_PORT" +export DB_USER="$DB_USER" +export DB_PASSWORD="$DB_PASS" +export DB_NAME="$DB_NAME" +export FHIR_STORE="${FHIR_STORE:-projects/prj-d-1v-ucd/locations/us-west1/datasets/operational-healthcare-dataset/fhirStores/operational-fhir-store}" +export GCS_BUCKET="${GCS_BUCKET:-econsent-pdf-pilot-dev-oneverily-prj-d-1v-ucd}" +export ENV_BASE_URL="${ENV_BASE_URL:-https://dev-stable.one.verily.com}" + +cd "$(dirname "$0")/.." +exec go run . diff --git a/src/program-generator/app/local-testing/test-template.yaml b/src/program-generator/app/local-testing/test-template.yaml new file mode 100644 index 00000000..239913db --- /dev/null +++ b/src/program-generator/app/local-testing/test-template.yaml @@ -0,0 +1,481 @@ +# simple-program.yaml — A minimal VerilyMe program for testing. +# +# This template creates a single-bundle program with: +# - One info step (welcome screen using component nodes) +# - One consent step (regulated consent: sign flow + review flow) +# - One survey step (a short health check-in with choice and boolean questions) +# - One info step (thank-you screen using component nodes) +# +# Node-tree format: +# The entire file is a single node tree. Every element uses the same schema, +# with field names matching the proto Node message (components_common.proto): +# node_type — SCREAMING_SNAKE_CASE node type (proto: NodeType node_type) +# value_string — optional string payload (proto: optional string value_string) +# html — optional HTML content (proto: optional string html) +# nodes — nested child nodes (proto: repeated Node nodes) +# uri — optional URL (proto: optional string uri) +# id — optional identifier (proto: optional string id) +# +# Four prefix categories: +# ADMIN_ — administrative structure (builder-processed, not rendered) +# CMPT_ — renderable UI components +# PROP_ — metadata properties +# ACTN_ — behavioral actions +# +# New types proposed (not yet in the proto): +# +# Admin: ADMIN_PROGRAM, ADMIN_BUNDLE, ADMIN_CARD, ADMIN_INFO_STEP, +# ADMIN_CONSENT_STEP, ADMIN_SURVEY_STEP, ADMIN_CONSENT_SIGN, +# ADMIN_CONSENT_REVIEW +# Components: CMPT_BUNDLE_LAYOUT, CMPT_HEADER, CMPT_FOOTER, +# CMPT_EXIT_BUTTON, CMPT_SURVEY_CONTEXT, CMPT_PAGE, +# CMPT_QUESTION_GROUP, CMPT_CHOICE_QUESTION, +# CMPT_FREE_TEXT_QUESTION, CMPT_TITLE, CMPT_PDF_VIEWER, +# CMPT_DIALOG, CMPT_CTA_BUTTON +# Properties: PROP_ORG_ID, PROP_VERSION, PROP_ENV_BASE_URL, +# PROP_TITLE, PROP_DESCRIPTION, PROP_LINK_ID, PROP_LABEL, +# PROP_REQUIRED, PROP_OPTION, PROP_BOOLEAN, PROP_SIGNATURE, +# PROP_CONSTRAINTS, PROP_NUMERIC, PROP_MIN_VALUE, +# PROP_MAX_VALUE, PROP_ALLOW_DECIMAL, PROP_MAX_DECIMAL_PLACES, +# PROP_UNITS, PROP_UNIT, PROP_UNIT_DISPLAY, PROP_UNIT_SYSTEM, +# PROP_UNIT_CODE, PROP_BODY +# Actions: ACTN_ON_CLICK +# +# Valueless markers (presence = true): +# PROP_REQUIRED, PROP_BOOLEAN, PROP_SIGNATURE, PROP_ALLOW_DECIMAL +# +# Numeric type convention: +# PROP_NUMERIC defaults to integer. Add PROP_ALLOW_DECIMAL +# (valueless marker) to opt into decimal. Optionally nest +# PROP_MAX_DECIMAL_PLACES under PROP_ALLOW_DECIMAL. +# +# Dialog trigger convention: +# CMPT_CTA_BUTTON with ACTN_ON_CLICK opens the CMPT_DIALOG whose +# value_string matches the action value (e.g. ACTN_ON_CLICK +# value_string="disagree" opens CMPT_DIALOG value_string="disagree"). +# +# To create a new program from this template: +# go run ./cmd/seed-program \ +# --template templates/simple-program.yaml \ +# --fhir-store "projects/prj-d-1v-ucd/locations/us-west1/datasets/operational-healthcare-dataset/fhirStores/operational-fhir-store" \ +# --gcs-bucket "econsent-pdf-pilot-dev-oneverily-prj-d-1v-ucd" \ +# --output /tmp/program-config.json +# +# The --gcs-bucket flag is required because this template has a consent step. +# The consent backend fetches the generated PDF from GCS at runtime. +# +# To preview the FHIR bundle without posting: +# go run ./cmd/seed-program \ +# --template templates/simple-program.yaml \ +# --dry-run + +node_type: ADMIN_PROGRAM +value_string: "simple-test-program" +nodes: + - node_type: PROP_ORG_ID + value_string: "264770f4-6a7b-496c-90e7-e895e3fe36d7" + - node_type: PROP_VERSION + value_string: "v1" + - node_type: PROP_ENV_BASE_URL + value_string: "https://dev-stable.one.verily.com" + + - node_type: ADMIN_BUNDLE + value_string: "health-check-in" + nodes: + - node_type: ADMIN_CARD + nodes: + - node_type: PROP_TITLE + value_string: "Test Health Check-In" + - node_type: PROP_DESCRIPTION + value_string: "Complete a quick health check-in to help us understand your wellness" + + # ── Info step (welcome) ───────────────────────────────────────── + - node_type: ADMIN_INFO_STEP + value_string: "Welcome to Your Health Check-In" + nodes: + - node_type: CMPT_BUNDLE_LAYOUT + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_VERTICAL_CONTAINER + nodes: + - node_type: CMPT_RICH_TEXT + html: "

About This Check-In

" + value_string: "## About This Check-In" + - node_type: CMPT_RICH_TEXT + html: "

This short health check-in will help us understand how you're doing. It should take about 2 minutes to complete.

" + value_string: "This short health check-in will help us understand how you're doing. It should take about **2 minutes** to complete." + - node_type: CMPT_SECTION_DIVIDER + - node_type: CMPT_RICH_TEXT + html: "

Your responses are confidential and will be used to personalize your experience in the program.

" + value_string: "Your responses are confidential and will be used to personalize your experience in the program." + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Continue" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "next" + + # ── Consent step ──────────────────────────────────────────────── + - node_type: ADMIN_CONSENT_STEP + value_string: "Research Participation Agreement" + nodes: + - node_type: ADMIN_CONSENT_SIGN + nodes: + - node_type: CMPT_BUNDLE_LAYOUT + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Decline" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "disagree" + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_PDF_VIEWER + + - node_type: CMPT_CHOICE_QUESTION + nodes: + - node_type: PROP_LABEL + value_string: "I agree to participate in this health check-in program" + - node_type: PROP_BOOLEAN + - node_type: PROP_REQUIRED + + - node_type: CMPT_CHOICE_QUESTION + nodes: + - node_type: PROP_LABEL + value_string: "I understand my responses will be used to personalize my experience" + - node_type: PROP_BOOLEAN + - node_type: PROP_REQUIRED + + - node_type: CMPT_FREE_TEXT_QUESTION + nodes: + - node_type: PROP_SIGNATURE + - node_type: PROP_REQUIRED + + - node_type: CMPT_DIALOG + value_string: "disagree" + nodes: + - node_type: PROP_LABEL + value_string: "Are you sure?" + - node_type: CMPT_CTA_BUTTON + value_string: "Yes, decline" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "confirm" + - node_type: CMPT_CTA_BUTTON + value_string: "Go back" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "cancel" + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "I Agree" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "submit" + + - node_type: ADMIN_CONSENT_REVIEW + nodes: + - node_type: CMPT_BUNDLE_LAYOUT + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_PDF_VIEWER + + - node_type: CMPT_DIALOG + value_string: "withdraw" + nodes: + - node_type: PROP_LABEL + value_string: "Withdraw Consent?" + - node_type: PROP_BODY + value_string: "If you withdraw, your previous responses will no longer be used." + - node_type: CMPT_CTA_BUTTON + value_string: "Withdraw" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "confirm" + - node_type: CMPT_CTA_BUTTON + value_string: "Cancel" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "cancel" + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Withdraw" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "withdraw" + + # ── Survey step ───────────────────────────────────────────────── + # + # Structure: CMPT_PAGE = one screen (navigation boundary), + # CMPT_QUESTION_GROUP = related questions within that screen. + # + # Identifier architecture: + # - Survey OID: auto-derived by the builder. Resource-level. + # - PROP_LINK_ID: per-question Questionnaire.item[].linkId. + # Optional — auto-generated from tree position if omitted. + # + # NOTE: The node tree declares richer structure than the flat FHIR + # Questionnaire converter currently supports. Specifically: + # - CMPT_PAGE grouping (page boundaries) + # - CMPT_QUESTION_GROUP semantics + # - CMPT_HORIZONTAL_CONTAINER (compound numeric layout) + # - PROP_CONSTRAINTS / PROP_NUMERIC (min/max validation) + # - PROP_UNITS (unit metadata) + # - CMPT_HEADER / CMPT_FOOTER / CMPT_CTA_BUTTON chrome + # These are faithfully declared in the DSL but the converter + # currently flattens all questions into a flat Questionnaire.item[]. + # A future converter could leverage these for richer FHIR output. + - node_type: ADMIN_SURVEY_STEP + value_string: "Health Check-In Survey" + nodes: + - node_type: CMPT_SURVEY_CONTEXT + nodes: + - node_type: CMPT_BUNDLE_LAYOUT + nodes: + # ── Page 1: Overall Health ─────────────────────── + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_QUESTION_GROUP + nodes: + - node_type: CMPT_CHOICE_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q1" + - node_type: PROP_LABEL + value_string: "How would you rate your overall health today?" + - node_type: PROP_OPTION + value_string: "Excellent" + - node_type: PROP_OPTION + value_string: "Very Good" + - node_type: PROP_OPTION + value_string: "Good" + - node_type: PROP_OPTION + value_string: "Fair" + - node_type: PROP_OPTION + value_string: "Poor" + - node_type: PROP_REQUIRED + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Next" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "next" + + # ── Page 2: Exercise ───────────────────────────── + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_QUESTION_GROUP + nodes: + - node_type: CMPT_CHOICE_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q2" + - node_type: PROP_LABEL + value_string: "Have you exercised in the past week?" + - node_type: PROP_BOOLEAN + - node_type: PROP_REQUIRED + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Next" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "next" + + # ── Page 3: Sleep ──────────────────────────────── + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_QUESTION_GROUP + nodes: + - node_type: CMPT_FREE_TEXT_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q3" + - node_type: PROP_LABEL + value_string: "How many hours of sleep did you get last night?" + - node_type: PROP_CONSTRAINTS + nodes: + - node_type: PROP_NUMERIC + nodes: + - node_type: PROP_MIN_VALUE + value_string: "0" + - node_type: PROP_MAX_VALUE + value_string: "24" + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Next" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "next" + + # ── Page 4: Blood Pressure (compound numeric) ──── + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_QUESTION_GROUP + nodes: + - node_type: CMPT_TITLE + nodes: + - node_type: CMPT_RICH_TEXT + html: "

What is your blood pressure?

" + value_string: "What is your blood pressure?" + - node_type: CMPT_HORIZONTAL_CONTAINER + nodes: + - node_type: CMPT_FREE_TEXT_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q4-systolic" + - node_type: PROP_LABEL + value_string: "Systolic" + - node_type: PROP_CONSTRAINTS + nodes: + - node_type: PROP_NUMERIC + nodes: + - node_type: PROP_MIN_VALUE + value_string: "50" + - node_type: PROP_MAX_VALUE + value_string: "250" + - node_type: PROP_UNITS + nodes: + - node_type: PROP_UNIT + nodes: + - node_type: PROP_UNIT_DISPLAY + value_string: "mmHg" + - node_type: PROP_UNIT_SYSTEM + value_string: "http://unitsofmeasure.org" + - node_type: PROP_UNIT_CODE + value_string: "mm[Hg]" + - node_type: PROP_REQUIRED + - node_type: CMPT_FREE_TEXT_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q4-diastolic" + - node_type: PROP_LABEL + value_string: "Diastolic" + - node_type: PROP_CONSTRAINTS + nodes: + - node_type: PROP_NUMERIC + nodes: + - node_type: PROP_MIN_VALUE + value_string: "30" + - node_type: PROP_MAX_VALUE + value_string: "150" + - node_type: PROP_UNITS + nodes: + - node_type: PROP_UNIT + nodes: + - node_type: PROP_UNIT_DISPLAY + value_string: "mmHg" + - node_type: PROP_UNIT_SYSTEM + value_string: "http://unitsofmeasure.org" + - node_type: PROP_UNIT_CODE + value_string: "mm[Hg]" + - node_type: PROP_REQUIRED + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Next" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "next" + + # ── Page 5: Open-ended ─────────────────────────── + - node_type: CMPT_PAGE + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_QUESTION_GROUP + nodes: + - node_type: CMPT_FREE_TEXT_QUESTION + nodes: + - node_type: PROP_LINK_ID + value_string: "q5" + - node_type: PROP_LABEL + value_string: "Is there anything else you'd like to share about your health?" + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Submit" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "submit" + + # ── Info step (thank you) ─────────────────────────────────────── + - node_type: ADMIN_INFO_STEP + value_string: "Thank You!" + nodes: + - node_type: CMPT_BUNDLE_LAYOUT + nodes: + - node_type: CMPT_HEADER + nodes: + - node_type: CMPT_EXIT_BUTTON + nodes: + - node_type: ACTN_ON_CLICK + value_string: "exit" + - node_type: CMPT_VERTICAL_CONTAINER + nodes: + - node_type: CMPT_RICH_TEXT + html: "

Check-In Complete

" + value_string: "## Check-In Complete" + - node_type: CMPT_SECTION_DIVIDER + - node_type: CMPT_RICH_TEXT + html: "

Thank you for completing your health check-in! Your responses have been recorded.

" + value_string: "Thank you for completing your health check-in! Your responses have been recorded." + - node_type: CMPT_HIGHLIGHT_CARD + nodes: + - node_type: CMPT_RICH_TEXT + html: "

What's next? Check back regularly for new missions and updates to your personalized health plan.

" + value_string: "**What's next?** Check back regularly for new missions and updates to your personalized health plan." + - node_type: CMPT_FOOTER + nodes: + - node_type: CMPT_CTA_BUTTON + value_string: "Done" + nodes: + - node_type: ACTN_ON_CLICK + value_string: "complete" diff --git a/src/program-generator/app/main.go b/src/program-generator/app/main.go new file mode 100644 index 00000000..f07186bb --- /dev/null +++ b/src/program-generator/app/main.go @@ -0,0 +1,287 @@ +package main + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "strconv" + "time" + + "github.com/verily-src/workbench-app-devcontainers/src/program-generator/app/internal/ai" + "github.com/verily-src/workbench-app-devcontainers/src/program-generator/app/internal/db" + "github.com/verily-src/workbench-app-devcontainers/src/program-generator/app/internal/seeder" +) + +//go:embed static/* +var staticFiles embed.FS + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + // Database + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + envOrDefault("DB_HOST", "localhost"), + envOrDefault("DB_PORT", "5432"), + envOrDefault("DB_USER", "pguser"), + envOrDefault("DB_PASSWORD", "pgpass"), + envOrDefault("DB_NAME", "program_generator"), + ) + + dbClient, err := db.NewClient(connStr) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer dbClient.Close() + + if err := dbClient.InitSchema(); err != nil { + log.Fatalf("Failed to init schema: %v", err) + } + log.Println("Database connected and schema initialized") + + // Vertex AI + ctx := context.Background() + vertexProject := envOrDefault("VERTEX_PROJECT", "wb-agile-aubergine-8187") + vertexRegion := envOrDefault("VERTEX_REGION", "us-east5") + aiModel := envOrDefault("AI_MODEL", "gemini-2.5-pro") + + aiClient, err := ai.NewClient(ctx, vertexProject, vertexRegion, aiModel) + if err != nil { + log.Printf("Warning: AI client init failed (generation won't work): %v", err) + aiClient = nil + } else { + defer aiClient.Close() + log.Printf("AI client initialized (project=%s, region=%s, model=%s)", vertexProject, vertexRegion, aiModel) + } + + // FHIR/seeder config + fhirStore := os.Getenv("FHIR_STORE") + gcsBucket := os.Getenv("GCS_BUCKET") + + // HTTP routes + mux := http.NewServeMux() + + // Static frontend + staticFS, err := fs.Sub(staticFiles, "static") + if err != nil { + log.Fatal(err) + } + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + data, err := staticFiles.ReadFile("static/index.html") + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) + return + } + // Serve other static files (JS, CSS) + http.FileServer(http.FS(staticFS)).ServeHTTP(w, r) + }) + + // Health + mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { + if err := dbClient.Ping(); err != nil { + http.Error(w, "db unhealthy", http.StatusServiceUnavailable) + return + } + writeJSON(w, map[string]string{"status": "ok"}) + }) + + // Generate program via AI + mux.HandleFunc("POST /api/generate", func(w http.ResponseWriter, r *http.Request) { + if aiClient == nil { + http.Error(w, "AI client not available", http.StatusServiceUnavailable) + return + } + + var req struct { + Prompt string `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if req.Prompt == "" { + http.Error(w, "prompt is required", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + + yaml, err := aiClient.GenerateProgram(ctx, req.Prompt) + if err != nil { + log.Printf("Generation error: %v", err) + http.Error(w, fmt.Sprintf("generation failed: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"yaml": yaml}) + }) + + // Validate template (dry-run) + mux.HandleFunc("POST /api/validate", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Yaml string `json:"yaml"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + tmpl, err := seeder.LoadTemplateFromBytes([]byte(req.Yaml)) + if err != nil { + writeJSON(w, map[string]interface{}{ + "valid": false, + "error": err.Error(), + }) + return + } + + // Dry-run: build the FHIR bundle without posting + builder := seeder.NewBuilder(nil, nil, "") + bundle, err := builder.DryRun(r.Context(), tmpl) + if err != nil { + writeJSON(w, map[string]interface{}{ + "valid": false, + "error": err.Error(), + }) + return + } + + writeJSON(w, map[string]interface{}{ + "valid": true, + "bundle": bundle, + "name": tmpl.Name, + "bundleCount": len(tmpl.Bundles), + }) + }) + + // Seed program to FHIR + mux.HandleFunc("POST /api/seed", func(w http.ResponseWriter, r *http.Request) { + if fhirStore == "" { + http.Error(w, "FHIR_STORE not configured", http.StatusServiceUnavailable) + return + } + + var req struct { + Yaml string `json:"yaml"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + tmpl, err := seeder.LoadTemplateFromBytes([]byte(req.Yaml)) + if err != nil { + http.Error(w, fmt.Sprintf("invalid template: %v", err), http.StatusBadRequest) + return + } + + ctx := r.Context() + + fhirClient, err := seeder.NewFHIRClient(ctx, fhirStore) + if err != nil { + http.Error(w, fmt.Sprintf("FHIR client error: %v", err), http.StatusInternalServerError) + return + } + + var gcsClient *seeder.GCSClient + if gcsBucket != "" && seeder.TemplateHasConsentSteps(tmpl) { + gcsClient, err = seeder.NewGCSClient(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("GCS client error: %v", err), http.StatusInternalServerError) + return + } + defer gcsClient.Close() + } + + builder := seeder.NewBuilder(fhirClient, gcsClient, gcsBucket) + output, err := builder.Build(ctx, tmpl) + if err != nil { + http.Error(w, fmt.Sprintf("seed failed: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, output) + }) + + // Template CRUD + mux.HandleFunc("GET /api/templates", func(w http.ResponseWriter, r *http.Request) { + templates, err := dbClient.ListTemplates() + if err != nil { + http.Error(w, "failed to list templates", http.StatusInternalServerError) + return + } + if templates == nil { + templates = []db.Template{} + } + writeJSON(w, templates) + }) + + mux.HandleFunc("GET /api/templates/{id}", func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + t, err := dbClient.GetTemplate(id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + writeJSON(w, t) + }) + + mux.HandleFunc("POST /api/templates", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Yaml string `json:"yaml"` + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + if req.Name == "" || req.Yaml == "" { + http.Error(w, "name and yaml are required", http.StatusBadRequest) + return + } + t, err := dbClient.SaveTemplate(req.Name, req.Yaml) + if err != nil { + http.Error(w, "save failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, t) + }) + + log.Printf("Server starting on port %s", port) + if err := http.ListenAndServe(":"+port, mux); err != nil { + log.Fatal(err) + } +} + +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/src/program-generator/app/scripts/portforward.sh b/src/program-generator/app/scripts/portforward.sh new file mode 100755 index 00000000..a900f75e --- /dev/null +++ b/src/program-generator/app/scripts/portforward.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# +# portforward.sh — Start all kubectl port-forwards for a given environment. +# +# Usage: +# ./scripts/portforward.sh [dev-stable] +# ./scripts/portforward.sh dev-stable --enrollment-only +# ./scripts/portforward.sh dev-stable --local-mode-only +# +# This script: +# 1. Switches kubectl context to the target environment's GKE cluster. +# 2. Starts all needed port-forwards in the background. +# 3. Waits for Ctrl-C and cleans up all port-forwards on exit. +# +# Modes: +# (default) All port-forwards: enrollment-be, ciam-be, workflow-be, grpc-web-envoy +# --enrollment-only Only enrollment port-forwards: enrollment-be, ciam-be, workflow-be +# --local-mode-only Only the local mode port-forward: grpc-web-envoy +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STANDALONE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +TARGET_ENV="${1:-dev-stable}" +MODE="all" # all | enrollment-only | local-mode-only + +shift || true +for arg in "$@"; do + case "$arg" in + --enrollment-only) MODE="enrollment-only" ;; + --local-mode-only) MODE="local-mode-only" ;; + --help|-h) + echo "Usage: $0 [dev-stable] [--enrollment-only|--local-mode-only]" + exit 0 + ;; + *) + echo "Unknown flag: $arg" >&2 + exit 1 + ;; + esac +done + +# ─── Load environment config ───────────────────────────────────────────────── +ENV_FILE="${STANDALONE_DIR}/envs/${TARGET_ENV}.env" +if [[ -f "${ENV_FILE}" ]]; then + set -a + source "${ENV_FILE}" + set +a +else + echo "⚠️ No env file found at ${ENV_FILE}. Using defaults." >&2 +fi + +GCP_PROJECT="${GCP_PROJECT:-prj-d-1v-ucd}" +GKE_CLUSTER="${GKE_CLUSTER:-gke-cluster}" +GKE_REGION="${GKE_REGION:-us-west1}" + +echo "" +echo "╔══════════════════════════════════════════════════════╗" +echo "║ Port-Forward Helper — ${TARGET_ENV}" +echo "╠══════════════════════════════════════════════════════╣" +echo "║ GCP Project: ${GCP_PROJECT}" +echo "║ GKE Cluster: ${GKE_CLUSTER}" +echo "║ GKE Region: ${GKE_REGION}" +echo "║ Mode: ${MODE}" +echo "╚══════════════════════════════════════════════════════╝" +echo "" + +# ─── Ensure kubectl context ────────────────────────────────────────────────── +EXPECTED_CONTEXT="gke_${GCP_PROJECT}_${GKE_REGION}_${GKE_CLUSTER}" +CURRENT_CONTEXT="$(kubectl config current-context 2>/dev/null || true)" + +if [[ "${CURRENT_CONTEXT}" != "${EXPECTED_CONTEXT}" ]]; then + echo "ℹ Switching kubectl context to ${EXPECTED_CONTEXT} ..." + gcloud container clusters get-credentials "${GKE_CLUSTER}" \ + --region "${GKE_REGION}" --project "${GCP_PROJECT}" 2>&1 + echo "✔ kubectl context set to ${EXPECTED_CONTEXT}" +else + echo "✔ kubectl context already set to ${EXPECTED_CONTEXT}" +fi +echo "" + +# ─── Port-forward PIDs (for cleanup) ───────────────────────────────────────── +PF_PIDS=() + +cleanup() { + echo "" + echo "Shutting down port-forwards..." + for pid in "${PF_PIDS[@]}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null + echo "✔ All port-forwards stopped." +} +trap cleanup EXIT INT TERM + +start_pf() { + local label="$1" + shift + echo " Starting: ${label}" + echo " → kubectl $*" + kubectl "$@" & + PF_PIDS+=($!) + sleep 1 # give it a moment to bind +} + +# ─── Enrollment port-forwards ──────────────────────────────────────────────── +if [[ "${MODE}" == "all" || "${MODE}" == "enrollment-only" ]]; then + echo "Starting enrollment port-forwards..." + + # enrollment-be (port 10293) + ENROLLMENT_POD="$(kubectl get pod --namespace enrollment \ + --selector="service=enrollment-be" \ + --output jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" + if [[ -n "${ENROLLMENT_POD}" ]]; then + start_pf "enrollment-be → :10293" port-forward --namespace enrollment "${ENROLLMENT_POD}" 10293:3000 + else + echo " ⚠️ No enrollment-be pod found. Skipping." + fi + + # ciam-be (port 10294) + CIAM_POD="$(kubectl get pod --namespace ciam \ + --selector="service=ciam-be" \ + --output jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" + if [[ -n "${CIAM_POD}" ]]; then + start_pf "ciam-be → :10294" port-forward --namespace ciam "${CIAM_POD}" 10294:3000 + else + echo " ⚠️ No ciam-be pod found. Skipping." + fi + + # workflow-be (port 10295) + start_pf "workflow-be → :10295" port-forward --namespace workflow service/workflow-be 10295:443 + + echo "" +fi + +# ─── Local mode port-forward ───────────────────────────────────────────────── +if [[ "${MODE}" == "all" || "${MODE}" == "local-mode-only" ]]; then + echo "Starting local mode port-forward..." + start_pf "grpc-web-envoy → :16443" port-forward --namespace grpcweb-envoy svc/grpc-web-envoy 16443:6443 + echo "" +fi + +# ─── Summary ───────────────────────────────────────────────────────────────── +echo "╔══════════════════════════════════════════════════════╗" +echo "║ All port-forwards running. Press Ctrl-C to stop. ║" +echo "╠══════════════════════════════════════════════════════╣" +if [[ "${MODE}" == "all" || "${MODE}" == "enrollment-only" ]]; then + echo "║ enrollment-be: localhost:10293 ║" + echo "║ ciam-be: localhost:10294 ║" + echo "║ workflow-be: localhost:10295 ║" +fi +if [[ "${MODE}" == "all" || "${MODE}" == "local-mode-only" ]]; then + echo "║ grpc-web-envoy: localhost:16443 ║" +fi +echo "╚══════════════════════════════════════════════════════╝" +echo "" + +# Keep running until Ctrl-C +wait diff --git a/src/program-generator/app/static/index.html b/src/program-generator/app/static/index.html new file mode 100644 index 00000000..118ecede --- /dev/null +++ b/src/program-generator/app/static/index.html @@ -0,0 +1,536 @@ + + + + + + VerilyMe Program Generator + + + +
+

VerilyMe Program Generator

+ Checking... +
+ +
+ +
+

1. Describe Your Program

+ + +
+ +
+
+
+ +
+ +
+
+

2. Review & Edit YAML

+ +
+ + + + +
+
+
+ +
+
+ + +
+
+

Saved Templates

+
+
No saved templates yet.
+
+
+
+
+
+ + + + + + + diff --git a/src/program-generator/caddy/Caddyfile b/src/program-generator/caddy/Caddyfile new file mode 100644 index 00000000..35aead72 --- /dev/null +++ b/src/program-generator/caddy/Caddyfile @@ -0,0 +1,9 @@ +{ + admin 0.0.0.0:2019 +} + +http://:8080 { + route { + reverse_proxy generator:8080 + } +} diff --git a/src/program-generator/devcontainer-template.json b/src/program-generator/devcontainer-template.json new file mode 100644 index 00000000..0a1a6f62 --- /dev/null +++ b/src/program-generator/devcontainer-template.json @@ -0,0 +1,6 @@ +{ + "id": "program-generator", + "version": "1.0.0", + "name": "Program Generator", + "description": "AI-powered VerilyMe program generator with visual preview and FHIR seeding" +} diff --git a/src/program-generator/docker-compose.yaml b/src/program-generator/docker-compose.yaml new file mode 100644 index 00000000..e11eae89 --- /dev/null +++ b/src/program-generator/docker-compose.yaml @@ -0,0 +1,74 @@ +services: + app: + container_name: "application-server" + image: "caddy:2.11-alpine" + restart: always + volumes: + - ./caddy:/etc/caddy:ro + - caddy_data:/data + ports: + - 8080:8080 + networks: + - app-network + - internal + + generator: + build: + context: ./app + container_name: "generator" + restart: always + depends_on: + app: + condition: service_started + db: + condition: service_healthy + environment: + PORT: "8080" + DB_HOST: "db" + DB_PORT: "5432" + DB_NAME: "program_generator" + DB_USER: "pguser" + DB_PASSWORD: "pgpass" + VERTEX_PROJECT: "${VERTEX_PROJECT:-wb-agile-aubergine-8187}" + VERTEX_REGION: "${VERTEX_REGION:-us-east5}" + FHIR_STORE: "projects/prj-d-1v-ucd/locations/us-west1/datasets/operational-healthcare-dataset/fhirStores/operational-fhir-store" + GCS_BUCKET: "econsent-pdf-pilot-dev-oneverily-prj-d-1v-ucd" + ENV_BASE_URL: "https://dev-stable.one.verily.com" + volumes: + - templates:/workspace/templates + networks: + - internal + cap_add: + - SYS_ADMIN + devices: + - /dev/fuse + security_opt: + - apparmor:unconfined + + db: + image: "postgres:18-alpine" + restart: always + environment: + POSTGRES_USER: "pguser" + POSTGRES_PASSWORD: "pgpass" + POSTGRES_DB: "program_generator" + volumes: + - pg_data:/var/lib/postgresql/data + networks: + - internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + caddy_data: + pg_data: + templates: + +networks: + app-network: + external: true + internal: