diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dd2341311..d1a887310 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -116,6 +116,11 @@ The API uses a **DI container** (`pkg/di/container.go`) that lazily initializes - **HTTP client**: OkHttp with `x-api-key` authentication against the API. - **Encryption**: AES-256/CFB with SHA-256 key derivation (`Encrypter.kt`). +## Documentation & PR Workflow + +- **Superpowers docs (`docs/superpowers/`) are never committed to git** — the directory is gitignored. Do not remove it from `.gitignore` or add its files with `git add -f`. +- When a change is driven by a superpowers SPEC, include a **summarized version of the SPEC in the PR description** (goal, key decisions, scope) instead of the full doc, so reviewers/agents can understand the change without needing the uncommitted file. + ## Key Conventions ### API (Go) diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index f9b756ed6..7d3a8a4ea 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -30,6 +30,9 @@ jobs: bash tests/generate-firebase-credentials.sh tests/firebase-credentials.json echo "FIREBASE_CREDENTIALS=$(jq -c . tests/firebase-credentials.json)" >> $GITHUB_ENV + - name: Generate adapter certificates + run: bash tests/generate-adapter-certificates.sh tests/certs + - name: Start Services working-directory: ./tests run: docker compose up -d --build diff --git a/.gitignore b/.gitignore index 8f4caf4b5..cc68ad44b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ android/app/debug/ android/app/release/ tests/firebase-credentials.json +tests/certs/ tests/emulator/emulator.exe SECURITY_AUDIT_REPORT.md @@ -16,3 +17,5 @@ SECURITY_AUDIT_REPORT.md .output .agents/ skills-lock.json +.worktrees/ +docs/superpowers diff --git a/api/docs/docs.go b/api/docs/docs.go index 96cf0e559..eb72ac76d 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -2420,7 +2420,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.", "consumes": [ "application/json" ], @@ -2483,7 +2483,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "description": "Updates the FCM token or adapter callback URL of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.", "consumes": [ "application/json" ], @@ -4947,6 +4947,7 @@ const docTemplate = `{ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, @@ -4975,6 +4976,7 @@ const docTemplate = `{ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 4926b5e55..59c5637ba 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -2417,7 +2417,7 @@ "ApiKeyAuth": [] } ], - "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.", "consumes": [ "application/json" ], @@ -2480,7 +2480,7 @@ "ApiKeyAuth": [] } ], - "description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "description": "Updates the FCM token or adapter callback URL of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.", "consumes": [ "application/json" ], @@ -4944,6 +4944,7 @@ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, @@ -4972,6 +4973,7 @@ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 9cfefbf56..6dc240216 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -971,6 +971,8 @@ definitions: requests.PhoneFCMToken: properties: fcm_token: + description: FcmToken is either a Firebase registration token or a public + HTTPS adapter callback URL. example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... type: string phone_number: @@ -989,6 +991,8 @@ definitions: requests.PhoneUpsert: properties: fcm_token: + description: FcmToken is either a Firebase registration token or a public + HTTPS adapter callback URL. example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... type: string max_send_attempts: @@ -3337,7 +3341,8 @@ paths: consumes: - application/json description: Updates properties of a user's phone. If the phone with this number - does not exist, a new one will be created. Think of this method like an 'upsert' + does not exist, a new one will be created. Think of this method like an 'upsert'. + URL-backed phone gateways receive FCM-compatible HTTP wake-ups. parameters: - description: Payload of new phone number. in: body @@ -3417,8 +3422,10 @@ paths: put: consumes: - application/json - description: Updates the FCM token of a phone. If the phone with this number - does not exist, a new one will be created. Think of this method like an 'upsert' + description: Updates the FCM token or adapter callback URL of a phone. If the + phone with this number does not exist, a new one will be created. Think of + this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible + HTTP wake-ups. parameters: - description: Payload of new FCM token. in: body diff --git a/api/go.mod b/api/go.mod index 3eaf5cd5a..1f305a110 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,14 +1,14 @@ module github.com/NdoleStudio/httpsms -go 1.25.8 +go 1.26.0 require ( cloud.google.com/go/cloudtasks v1.19.0 cloud.google.com/go/compute/metadata v0.9.0 cloud.google.com/go/storage v1.64.0 firebase.google.com/go v3.13.0+incompatible - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.36.0 + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.38.0 github.com/NdoleStudio/go-otelroundtripper v0.0.15 github.com/NdoleStudio/lemonsqueezy-go v1.3.2 github.com/NdoleStudio/plunk-go v0.0.2 @@ -23,7 +23,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/gertd/go-pluralize v0.2.1 github.com/go-hermes/hermes/v2 v2.6.2 - github.com/gofiber/contrib/v3/otel v1.2.4 + github.com/gofiber/contrib/v3/otel v1.2.5 github.com/gofiber/contrib/v3/swaggo v1.0.8 github.com/gofiber/fiber/v3 v3.5.0 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -39,24 +39,24 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 github.com/pusher/pusher-http-go/v5 v5.1.1 - github.com/redis/go-redis/extra/redisotel/v9 v9.21.0 - github.com/redis/go-redis/v9 v9.21.0 + github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 + github.com/redis/go-redis/v9 v9.22.0 github.com/rs/zerolog v1.35.1 github.com/stretchr/testify v1.12.1 github.com/swaggo/swag v1.16.6 github.com/thedevsaddam/govalidator v1.9.10 github.com/uptrace/uptrace-go v1.43.0 github.com/xuri/excelize/v2 v2.11.0 - go.mongodb.org/mongo-driver/v2 v2.8.2 + go.mongodb.org/mongo-driver/v2 v2.9.1 go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260624193928-df9c7a836708 go.opentelemetry.io/otel v1.46.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 go.opentelemetry.io/otel/metric v1.46.0 go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/sdk/metric v1.46.0 go.opentelemetry.io/otel/trace v1.46.0 - golang.org/x/sync v0.22.0 + golang.org/x/sync v0.23.0 google.golang.org/api v0.295.0 google.golang.org/protobuf v1.36.12 gorm.io/driver/postgres v1.6.0 @@ -91,7 +91,7 @@ require ( github.com/ClickHouse/ch-go v0.72.0 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.46.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/PuerkitoBio/goquery v1.12.0 // indirect @@ -113,27 +113,27 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.24.0 // indirect - github.com/go-openapi/jsonreference v0.21.6 // indirect - github.com/go-openapi/spec v0.22.6 // indirect - github.com/go-openapi/swag/conv v0.27.0 // indirect - github.com/go-openapi/swag/jsonname v0.27.0 // indirect - github.com/go-openapi/swag/jsonutils v0.27.0 // indirect - github.com/go-openapi/swag/loading v0.27.0 // indirect - github.com/go-openapi/swag/stringutils v0.27.0 // indirect - github.com/go-openapi/swag/typeutils v0.27.0 // indirect - github.com/go-openapi/swag/yamlutils v0.27.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/goccy/go-json v0.10.6 // indirect - github.com/gofiber/schema v1.8.4 // indirect - github.com/gofiber/utils/v2 v2.4.2 // indirect + github.com/gofiber/schema v1.8.6 // indirect + github.com/gofiber/utils/v2 v2.5.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect github.com/googleapis/gax-go/v2 v2.24.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect @@ -144,7 +144,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/compress v1.20.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect @@ -153,12 +153,13 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/molecule-man/go-brrr v1.0.1 // indirect github.com/olekukonko/tablewriter v1.1.4 // indirect github.com/paulmach/orb v0.13.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 // indirect github.com/richardlehane/mscfb v1.0.7 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -169,7 +170,7 @@ require ( github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.73.0 // indirect + github.com/valyala/fasthttp v1.74.0 // indirect github.com/vanng822/css v1.0.1 // indirect github.com/vanng822/go-premailer v1.34.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect @@ -181,31 +182,31 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib v1.46.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 // indirect go.opentelemetry.io/contrib/processors/minsev v0.16.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect go.opentelemetry.io/otel/log v0.20.0 // indirect go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/mod v0.38.0 // indirect - golang.org/x/net v0.58.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.48.0 // indirect + golang.org/x/tools v0.49.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/grpc v1.83.2 // indirect gorm.io/driver/clickhouse v0.7.0 // indirect diff --git a/api/go.sum b/api/go.sum index 7040f1fe7..809da4525 100644 --- a/api/go.sum +++ b/api/go.sum @@ -40,12 +40,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 h1:HldzheTs05E3ybqSitI/wHaof6+XERRudgZLjYbs3eE= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0/go.mod h1:evkqaSczW9g2BQm1veCtgNhJ4wCCsRrOsSgNIn9LHQk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.36.0 h1:gBlHWOs3hqcN7cqRbyrvI6q1QwSZHyp76FajMUzgdpQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.36.0/go.mod h1:LmYRYS/wzrxoHi0OKmwhCujGpXFmab6c9xCyP58Hh9U= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0 h1:Fx8NtDCmKH4ML2hUkPz4Dq250903vRDojMjVCDKwQuc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 h1:Oblia1QXBJlM/wOY9ARRUtsXdDYiMCzk3eCMikqoLbI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0/go.mod h1:SRAbhyZ4R4FagHMM9VtRgSY/lheRoht2fKelZXQUenk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 h1:5y2LR2pripbccOte6BpZ+guv5O5W0OQIfKvUYZrGaAQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0/go.mod h1:b3f7EifFRqkVt142USoGIP62uUeZm7ALCPrs1ZjWXYo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.38.0 h1:Smjr+Zjtrz0GEI2AxvHxew0V1vkDlvcm+0hyArVNkKY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.38.0/go.mod h1:Ov3/u9CUq4/paK8CRyJyvGY7N5YWeneZVLLtiL2sx8E= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0 h1:tRsXhJnn0w0TNwJpVrB94wsrimzjWKFzdQlUhRdNUzc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0/go.mod h1:ifhe5teRNcbxg9p353DhOkOgczzL/IBLYFqT1cm1zpM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 h1:aPMa5qZU9dp4fU34o+jQgVIPFIOi20oxYg8TLigmZLg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0/go.mod h1:sg6Wqbl0BKKZ5zKnQj2q0qic/3ziF894SCx1LpbX37Y= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -137,29 +139,29 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.24.0 h1:AA6mCjHYHmZ+1RU2Js089EaOK/iwXXNwQsTgnsTha2M= -github.com/go-openapi/jsonpointer v0.24.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= -github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= -github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= -github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= -github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= -github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE= -github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk= -github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE= -github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0 h1:+d7C7Ur/SsGg/UZ9G0JEovnfRqtMNZCJQGKc2h/ojoE= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= -github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI= -github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4= -github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio= -github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= -github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= -github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM= -github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= @@ -168,16 +170,16 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/gofiber/contrib/v3/otel v1.2.4 h1:wID72pBdMhWcoUzmj4T8OYH2mVabDHtw/hZG5BmRvWQ= -github.com/gofiber/contrib/v3/otel v1.2.4/go.mod h1:aK0uuKzhSJ8X6lN6I7AlDCVoAr4ES0ZjkLl0Cny/WXE= +github.com/gofiber/contrib/v3/otel v1.2.5 h1:1zvCRB5Qreey12RCSPZUlawcTvYb3wb4QRKzJXk+V9M= +github.com/gofiber/contrib/v3/otel v1.2.5/go.mod h1:YPPFGXIr/SLAVI2+u0Kq9ET6C9Hwdfh8CXRZbQZIDs0= github.com/gofiber/contrib/v3/swaggo v1.0.8 h1:11YmMVbkMO7zv6YR0gdNfRWcs5ZVUkDpR21AhjhbK00= github.com/gofiber/contrib/v3/swaggo v1.0.8/go.mod h1:7jyBZxqQ4MjOxIidOvLQ9jNAxNk05yKpid40OYb3pLE= github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8= github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w= -github.com/gofiber/schema v1.8.4 h1:ctANnOE2uXft17l5cw78qYqoLt2nfZGRgZ2QUugefFQ= -github.com/gofiber/schema v1.8.4/go.mod h1:JxOlqaEBpuyGKBLI9wY8BAsnWt9z+cFGLaijlAF/IF0= -github.com/gofiber/utils/v2 v2.4.2 h1:OA4LAEG3zKkPwFU7Jippxdk//N5hKMU12qXSCd0C4lQ= -github.com/gofiber/utils/v2 v2.4.2/go.mod h1:AGosyllO+RVdfXyyKoUp91D0qj86BuknRN4I6mciqe8= +github.com/gofiber/schema v1.8.6 h1:xVlbSg3vsyKVlfzRBps2tJ7pHx1d8mSgiaJye6Lb3xs= +github.com/gofiber/schema v1.8.6/go.mod h1:Cxh3loYVG3FLA7FzW4C5G83pL/pmCmAnuaIJ5y2HwkU= +github.com/gofiber/utils/v2 v2.5.1 h1:Yrt/ljbfsgnDSHymoMvVX5PFrb9kCV/OJnDu6sYuT4A= +github.com/gofiber/utils/v2 v2.5.1/go.mod h1:5GyHTNQA1I6Qwlw7BWxQsZl8iuFS4yPjLFck+wYR304= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -205,8 +207,8 @@ github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnws github.com/googleapis/gax-go/v2 v2.24.0/go.mod h1:IaTHBDd7NHxSCiu0vEs8pQZu4dGZrWwuSoxCnk16OFM= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -243,8 +245,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI= github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I= -github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= -github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -270,6 +272,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/molecule-man/go-brrr v1.0.1 h1:cEjgx8hgNw6UGdhQ94SPDbPkKuRbkUcxBO3IzbGpA/o= +github.com/molecule-man/go-brrr v1.0.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/nyaruka/phonenumbers v1.8.1 h1:2K9YMQuv1dCGqjjzB1DwmdCe89khT4KPBQb2CxAMMlU= github.com/nyaruka/phonenumbers v1.8.1/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= @@ -297,12 +301,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pusher/pusher-http-go/v5 v5.1.1 h1:ZLUGdLA8yXMvByafIkS47nvuXOHrYmlh4bsQvuZnYVQ= github.com/pusher/pusher-http-go/v5 v5.1.1/go.mod h1:Ibji4SGoUDtOy7CVRhCiEpgy+n5Xv6hSL/QqYOhmWW8= -github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0 h1:jsV3tyMeJrEoc2f3EhNf7qoBW3NEZW7l/4ziT3M+OJI= -github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0/go.mod h1:e5t17bY9cEpVV+xw2U7jsPOKkXBtL5IQmNVABShnHUk= -github.com/redis/go-redis/extra/redisotel/v9 v9.21.0 h1:36qq3rbF2If2CP0zGHHF8o/4XDluErn6DD0c9/L2iNI= -github.com/redis/go-redis/extra/redisotel/v9 v9.21.0/go.mod h1:7y2cVB/LXXLHqHOO2jCVzBqimIQk1w7Rp9WSpyVY/o8= -github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= -github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 h1:MQPzEEnpD0BMPufBLABnMYLJVwM7xi7vZ+srO8Nr0s8= +github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0/go.mod h1:eve0JFcLRwFVj3RA85rrrV5+UJ+K9LDyU7kf2UdSueM= +github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 h1:t5ul1Gl0o1rYQj5f5bK12G9xcg1niq2ON4yZFjvy1kA= +github.com/redis/go-redis/extra/redisotel/v9 v9.22.0/go.mod h1:hcS9L2RBBjYXkrfSOF26ZGejgo+yOC+28ZD2fkk3sGs= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= @@ -313,8 +317,8 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8= -github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo= +github.com/shamaton/msgpack/v3 v3.2.2 h1:lZITRsXLQkdJ9J2qvh6gjGpWxci/dwuiUf+jm9AudJM= +github.com/shamaton/msgpack/v3 v3.2.2/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -352,8 +356,8 @@ github.com/uptrace/uptrace-go v1.43.0 h1:5QuCdyFJdWUEXx6Fr6sYfezdgO6n6lnkOvUTLly github.com/uptrace/uptrace-go v1.43.0/go.mod h1:ehDTIdtBSolg4Z0CCvg1C8yR6VX1YFDqBcg2KmsXWn0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s= -github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k= +github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU= +github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE= github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8= github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w= github.com/vanng822/go-premailer v1.34.0 h1:CW7RUnjCfXrkuCbgC2wi/Cub7IwKslJWD/OkIBlcQUk= @@ -381,8 +385,8 @@ github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w= -go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ= +go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib v1.46.0 h1:PVrao7T/FJZ814/+QbMcIMIIpdjQXC3pi3V11+6DT40= @@ -391,10 +395,10 @@ go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9d go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260624193928-df9c7a836708 h1:s5k/FTgmE4Mj2jFG38Dj5TQgxDRMG92fRK3puTdMHew= go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260624193928-df9c7a836708/go.mod h1:VgtIsrVXKo8KmG2sFvQazHcQ98P2FHQ+ZePXtpTPjqs= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0= go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0/go.mod h1:FYTxnpsm+UPD0erZNq20GvnM8T2YQHiHtT2vokdpoac= go.opentelemetry.io/contrib/processors/minsev v0.16.1 h1:DYL02u57VGQjrG8c09i6Bx5R74h4XxLj75wEk5h8/DA= @@ -405,14 +409,16 @@ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 h1:AP23h/mFgb/lc7tdck1Kfn9qxsM8TAeNPCU5C3pzaps= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0/go.mod h1:K4EqCe1b4kGk5WR690ntg9LaBfsPoV32FwthbyoptuA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= @@ -429,8 +435,8 @@ go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3 go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -444,48 +450,48 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -496,8 +502,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= -google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d h1:QwnJwPte4XXAkhPu26LTDIahnsMSUV0kK8HkxbC+Pc4= -google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index ebe57662e..8d9e778d4 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -565,6 +565,24 @@ func (container *Container) FCMClient() services.FCMClient { return services.NewFirebaseFCMClient(messagingClient) } +// NotificationHTTPClient creates the OpenTelemetry-instrumented client for phone notification adapters. +func (container *Container) NotificationHTTPClient() *http.Client { + return &http.Client{ + Transport: container.HTTPRoundTripperWithoutRetry("phone_notification_http"), + } +} + +// PhoneNotificationClients creates notification clients keyed by phone transport. +func (container *Container) PhoneNotificationClients() map[entities.NotificationTransport]services.FCMClient { + return map[entities.NotificationTransport]services.FCMClient{ + entities.NotificationTransportFCM: container.FCMClient(), + entities.NotificationTransportHTTP: services.NewHTTPNotificationSender( + container.Logger(), + container.NotificationHTTPClient(), + ), + } +} + // FirebaseCredentials returns firebase credentials as bytes. func (container *Container) FirebaseCredentials() []byte { container.logger.Debug("creating firebase credentials") @@ -1715,7 +1733,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi return services.NewNotificationService( container.Logger(), container.Tracer(), - container.FCMClient(), + container.PhoneNotificationClients(), container.PhoneRepository(), container.PhoneNotificationRepository(), container.MessageSendScheduleRepository(), diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go new file mode 100644 index 000000000..611708d31 --- /dev/null +++ b/api/pkg/di/container_test.go @@ -0,0 +1,36 @@ +package di + +import ( + "reflect" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNotificationHTTPClientUsesOTelRoundTripperWithoutRetries(t *testing.T) { + t.Setenv("ENV", "local") + client := NewLiteContainer().NotificationHTTPClient() + + assert.Zero(t, client.Timeout) + assert.Equal(t, "*otelroundtripper.otelRoundTripper", reflect.TypeOf(client.Transport).String()) + assert.Nil(t, client.CheckRedirect) +} + +func TestPhoneNotificationClientsMapsConfiguredTransports(t *testing.T) { + t.Setenv("ENV", "local") + t.Setenv("FCM_ENDPOINT", "http://localhost") + + clients := NewLiteContainer().PhoneNotificationClients() + + require.Len(t, clients, 2) + assert.IsType(t, &services.EmulatorFCMClient{}, clients[entities.NotificationTransportFCM]) + httpSender, ok := clients[entities.NotificationTransportHTTP].(*services.HTTPNotificationSender) + require.True(t, ok) + client := reflect.ValueOf(httpSender).Elem().FieldByName("client").Elem() + transport := client.FieldByName("Transport").Elem() + + assert.Equal(t, "*otelroundtripper.otelRoundTripper", transport.Type().String()) +} diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 4f3c33f28..36be28aeb 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -1,8 +1,11 @@ package entities import ( + "net/url" + "strings" "time" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" ) @@ -31,6 +34,25 @@ type Phone struct { UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` } +// NotificationTransport identifies how a phone receives wake-up notifications. +type NotificationTransport string + +const ( + // NotificationTransportFCM sends notifications through Firebase. + NotificationTransportFCM NotificationTransport = "fcm" + // NotificationTransportHTTP sends notifications to an HTTPS endpoint. + NotificationTransportHTTP NotificationTransport = "http" +) + +func isNotificationURLCandidate(token string) bool { + lower := strings.ToLower(token) + + return strings.Contains(token, "://") || + strings.HasPrefix(lower, "http:") || + strings.HasPrefix(lower, "https:") || + strings.HasPrefix(lower, "ftp:") +} + // MessageExpirationDuration returns the message expiration as time.Duration func (phone *Phone) MessageExpirationDuration() time.Duration { return time.Duration(int(phone.MessageExpirationSecondsSanitized())) * time.Second @@ -51,3 +73,52 @@ func (phone *Phone) MaxSendAttemptsSanitized() uint { } return phone.MaxSendAttempts } + +// NotificationTransport returns the transport encoded by FcmToken. +func (phone *Phone) NotificationTransport() (NotificationTransport, error) { + if phone == nil || phone.FcmToken == nil { + return "", stacktrace.NewErrorf("phone has no notification token") + } + + token := strings.TrimSpace(*phone.FcmToken) + if token == "" { + return "", stacktrace.NewErrorf("phone has no notification token") + } + + if !isNotificationURLCandidate(token) { + return NotificationTransportFCM, nil + } + + endpoint, err := url.Parse(token) + if err != nil { + return "", stacktrace.NewError("invalid notification URL") + } + + if !strings.EqualFold(endpoint.Scheme, "https") { + return "", stacktrace.NewErrorf("notification URL must use https") + } + if endpoint.Hostname() == "" { + return "", stacktrace.NewErrorf("notification URL must include a hostname") + } + + return NotificationTransportHTTP, nil +} + +// NotificationURL returns the parsed endpoint for an HTTP notification token. +func (phone *Phone) NotificationURL() (*url.URL, error) { + transport, err := phone.NotificationTransport() + if err != nil { + return nil, err + } + + if transport != NotificationTransportHTTP { + return nil, stacktrace.NewErrorf("phone notification transport is [%s], not HTTP", transport) + } + + endpoint, err := url.Parse(strings.TrimSpace(*phone.FcmToken)) + if err != nil { + return nil, stacktrace.NewError("cannot parse notification URL") + } + + return endpoint, nil +} diff --git a/api/pkg/entities/phone_notification.go b/api/pkg/entities/phone_notification.go index 8720579ae..ab3fc5908 100644 --- a/api/pkg/entities/phone_notification.go +++ b/api/pkg/entities/phone_notification.go @@ -18,7 +18,7 @@ const ( // PhoneNotificationStatus is the status of a phone notification type PhoneNotificationStatus string -// PhoneNotification represents an FCM notification to a mobile phone +// PhoneNotification represents a scheduled wake-up notification for a phone gateway. type PhoneNotification struct { ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;"` MessageID uuid.UUID `json:"message_id"` diff --git a/api/pkg/entities/phone_test.go b/api/pkg/entities/phone_test.go new file mode 100644 index 000000000..886d31f6d --- /dev/null +++ b/api/pkg/entities/phone_test.go @@ -0,0 +1,85 @@ +package entities + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func stringPointer(value string) *string { + return &value +} + +func TestPhoneNotificationTransport(t *testing.T) { + tests := []struct { + name string + token *string + transport NotificationTransport + hasError bool + }{ + {name: "firebase token with colon", token: stringPointer("fcm-token:value"), transport: NotificationTransportFCM}, + {name: "opaque token with slash", token: stringPointer("projects/alpha/messages/123"), transport: NotificationTransportFCM}, + {name: "public https url", token: stringPointer("https://adapter.example.com/notify"), transport: NotificationTransportHTTP}, + {name: "missing token", token: nil, hasError: true}, + {name: "empty token", token: stringPointer(" "), hasError: true}, + {name: "http url", token: stringPointer("http://adapter.example.com/notify"), hasError: true}, + {name: "ftp url", token: stringPointer("ftp://adapter.example.com/notify"), hasError: true}, + {name: "scheme-like https token", token: stringPointer("https:adapter.example.com"), hasError: true}, + {name: "scheme-like http token", token: stringPointer("http:foo"), hasError: true}, + {name: "scheme-like ftp token", token: stringPointer("ftp:foo"), hasError: true}, + {name: "missing host", token: stringPointer("https:///notify"), hasError: true}, + {name: "embedded user information", token: stringPointer("https://user:password@adapter.example.com/notify"), transport: NotificationTransportHTTP}, + {name: "malformed url", token: stringPointer("https://[::1"), hasError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + phone := &Phone{FcmToken: test.token} + + transport, err := phone.NotificationTransport() + + if test.hasError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.transport, transport) + }) + } +} + +func TestPhoneNotificationURL(t *testing.T) { + phone := &Phone{FcmToken: stringPointer("https://user:password@adapter.example.com/notify?tenant=42")} + + endpoint, err := phone.NotificationURL() + + require.NoError(t, err) + assert.Equal(t, "https", endpoint.Scheme) + assert.Equal(t, "user", endpoint.User.Username()) + password, hasPassword := endpoint.User.Password() + assert.True(t, hasPassword) + assert.Equal(t, "password", password) + assert.Equal(t, "adapter.example.com", endpoint.Hostname()) + assert.Equal(t, "/notify", endpoint.Path) + assert.Equal(t, "tenant=42", endpoint.RawQuery) +} + +func TestPhoneNotificationURLRejectsFCMToken(t *testing.T) { + phone := &Phone{FcmToken: stringPointer("fcm-token:value")} + + _, err := phone.NotificationURL() + + require.Error(t, err) +} + +func TestPhoneNotificationTransportDoesNotExposeMalformedToken(t *testing.T) { + token := "https://[::1/secret?token=customer-secret" + phone := &Phone{FcmToken: &token} + + _, err := phone.NotificationTransport() + + require.Error(t, err) + assert.NotContains(t, err.Error(), token) + assert.NotContains(t, err.Error(), "customer-secret") +} diff --git a/api/pkg/handlers/phone_handler.go b/api/pkg/handlers/phone_handler.go index 55380ed91..c81ef4ddd 100644 --- a/api/pkg/handlers/phone_handler.go +++ b/api/pkg/handlers/phone_handler.go @@ -94,7 +94,7 @@ func (h *PhoneHandler) Index(c fiber.Ctx) error { // Upsert a phone // @Summary Upsert Phone -// @Description Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' +// @Description Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups. // @Security ApiKeyAuth // @Tags Phones // @Accept json @@ -172,7 +172,7 @@ func (h *PhoneHandler) Delete(c fiber.Ctx) error { // UpsertFCMToken upserts the FCM token of a phone // @Summary Upserts the FCM token of a phone -// @Description Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' +// @Description Updates the FCM token or adapter callback URL of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups. // @Security ApiKeyAuth // @Tags Phones // @Accept json diff --git a/api/pkg/middlewares/http_request_logger_middleware.go b/api/pkg/middlewares/http_request_logger_middleware.go index e8de42e2a..cd11c6009 100644 --- a/api/pkg/middlewares/http_request_logger_middleware.go +++ b/api/pkg/middlewares/http_request_logger_middleware.go @@ -24,7 +24,7 @@ func HTTPRequestLogger(tracer telemetry.Tracer, logger telemetry.Logger) fiber.H statusCode := c.Response().StatusCode() span.AddEvent(fmt.Sprintf("finished handling request with traceID: [%s], statusCode: [%d]", span.SpanContext().TraceID().String(), statusCode)) if statusCode >= 300 && len(c.Request().Body()) > 0 && !slices.Contains([]int{401, 402}, statusCode) { - ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewErrorf("http.status [%d], body [%s]", statusCode, string(c.Request().Body()))) + ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewErrorf("http.status [%d], body [%s]", statusCode, c.Request().Body())) } return response diff --git a/api/pkg/requests/phone_fcm_token_request.go b/api/pkg/requests/phone_fcm_token_request.go index dde935b53..bdd3ab1a6 100644 --- a/api/pkg/requests/phone_fcm_token_request.go +++ b/api/pkg/requests/phone_fcm_token_request.go @@ -13,7 +13,8 @@ import ( type PhoneFCMToken struct { request PhoneNumber string `json:"phone_number" example:"[+18005550199]"` - FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` + // FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. + FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot SIM string `json:"sim" example:"SIM1"` } diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go index 96b2882e1..cedd51a0d 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -25,6 +25,7 @@ type PhoneUpsert struct { // MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline. MaxSendAttempts uint `json:"max_send_attempts" example:"2"` + // FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"e.g. This phone cannot receive calls. Please send an SMS instead."` diff --git a/api/pkg/services/emulator_fcm_client.go b/api/pkg/services/emulator_fcm_client.go index 6b890a14a..09dce8e54 100644 --- a/api/pkg/services/emulator_fcm_client.go +++ b/api/pkg/services/emulator_fcm_client.go @@ -11,6 +11,7 @@ import ( "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" ) // EmulatorFCMClient sends FCM messages to the phone emulator via HTTP. @@ -50,7 +51,7 @@ type emulatorFCMResponse struct { } // Send sends a message to the emulator's FCM endpoint. -func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { +func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message, _ uuid.UUID) (string, error) { payload := &emulatorFCMRequest{ Message: &emulatorFCMMessage{ Token: message.Token, diff --git a/api/pkg/services/fcm_client.go b/api/pkg/services/fcm_client.go index 4e56f3167..8139c808d 100644 --- a/api/pkg/services/fcm_client.go +++ b/api/pkg/services/fcm_client.go @@ -4,12 +4,15 @@ import ( "context" "firebase.google.com/go/messaging" + "github.com/google/uuid" ) -// FCMClient is the interface for sending Firebase Cloud Messaging notifications. +// FCMClient sends Firebase-compatible messages through a phone notification transport. type FCMClient interface { - // Send sends a message via FCM and returns the message name on success. - Send(ctx context.Context, message *messaging.Message) (string, error) + // Send sends a message and returns the transport's delivery identifier on success. + // phoneID identifies the receiving phone (the notification's target) and is used by HTTP + // adapter transports to sign the request. + Send(ctx context.Context, message *messaging.Message, phoneID uuid.UUID) (string, error) } // FirebaseFCMClient wraps the real Firebase messaging.Client. @@ -23,6 +26,6 @@ func NewFirebaseFCMClient(client *messaging.Client) *FirebaseFCMClient { } // Send sends a message via the real Firebase SDK. -func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { +func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message, _ uuid.UUID) (string, error) { return c.client.Send(ctx, message) } diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go new file mode 100644 index 000000000..5fc115823 --- /dev/null +++ b/api/pkg/services/http_notification_sender.go @@ -0,0 +1,265 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/avast/retry-go/v5" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const ( + maxNotificationResponseDiscardBytes = 4 * 1024 + notificationHTTPAttempts = 3 + notificationHTTPTimeout = 5 * time.Second + notificationHTTPRetryDelay = 250 * time.Millisecond + notificationJWTIssuer = "api.httpsms.com" + notificationJWTValidity = 10 * time.Minute +) + +// HTTPNotificationSender sends FCM-compatible gateway notifications to HTTPS adapters. +type HTTPNotificationSender struct { + logger telemetry.Logger + client *http.Client + retrier *retry.Retrier + timeout time.Duration +} + +var _ FCMClient = (*HTTPNotificationSender)(nil) + +// NewHTTPNotificationSender creates an HTTP notification sender. +func NewHTTPNotificationSender( + logger telemetry.Logger, + client *http.Client, +) *HTTPNotificationSender { + return newHTTPNotificationSenderWithRetrier( + logger, + client, + newHTTPNotificationRetrier(notificationHTTPRetryDelay), + ) +} + +func newHTTPNotificationSenderWithRetrier( + logger telemetry.Logger, + client *http.Client, + retrier *retry.Retrier, +) *HTTPNotificationSender { + return &HTTPNotificationSender{ + logger: logger, + client: client, + retrier: retrier, + timeout: notificationHTTPTimeout, + } +} + +// Send delivers a notification to an HTTPS adapter. A successful response only accepts wake-up delivery. +func (sender *HTTPNotificationSender) Send( + ctx context.Context, + message *messaging.Message, + phoneID uuid.UUID, +) (string, error) { + if message == nil { + return "", sender.notificationError("", "notification message is nil") + } + + endpoint, err := url.Parse(message.Token) + if err != nil { + return "", sender.notificationError("", "cannot parse notification endpoint") + } + hostname := endpoint.Hostname() + + body, err := encodeHTTPNotificationPayload(message) + if err != nil { + return "", sender.notificationError(hostname, "cannot encode notification") + } + + authToken, err := sender.getAuthToken(endpoint, phoneID) + if err != nil { + return "", sender.notificationError(hostname, "cannot generate notification auth token") + } + + err = sender.retrier.Do(func() error { + return sender.deliver(ctx, endpoint, body, authToken) + }) + if err == nil { + return "http/success", nil + } + if ctx.Err() != nil { + return "", sender.notificationError(hostname, "notification request cancelled") + } + + return "", sender.notificationError(hostname, "notification request failed") +} + +// getAuthToken generates a JWT bearer token for the HTTPS adapter, signed with the phone ID +// the same way webhook requests are signed with the webhook signing key. The phone ID is only +// used as the HMAC secret and is intentionally not embedded in any claim: the adapter already +// knows which phone ID to verify against from its own gateway registration, and putting the +// phone ID in a readable claim would let anyone who intercepts one token read the signing +// secret and forge further tokens. +func (sender *HTTPNotificationSender) getAuthToken(endpoint *url.URL, phoneID uuid.UUID) (string, error) { + audience := *endpoint + audience.User = nil + + now := time.Now().UTC() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Audience: []string{audience.String()}, + ExpiresAt: jwt.NewNumericDate(now.Add(notificationJWTValidity)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: notificationJWTIssuer, + NotBefore: jwt.NewNumericDate(now.Add(-notificationJWTValidity)), + }) + return token.SignedString([]byte(phoneID.String())) +} + +func encodeHTTPNotificationPayload(message *messaging.Message) ([]byte, error) { + return json.Marshal(map[string]any{ + "message": message, + }) +} + +func (sender *HTTPNotificationSender) deliver( + ctx context.Context, + endpoint *url.URL, + body []byte, + authToken string, +) error { + if err := ctx.Err(); err != nil { + return terminalNotificationRequestError{cause: err} + } + + attemptCtx, cancel := context.WithTimeout(ctx, sender.timeout) + defer cancel() + + request, err := createHTTPNotificationRequest(attemptCtx, endpoint, body, authToken) + if err != nil { + return terminalNotificationRequestError{cause: err} + } + + err = sender.sendAttempt(request) + if ctx.Err() != nil { + return terminalNotificationRequestError{cause: ctx.Err()} + } + return err +} + +func createHTTPNotificationRequest( + ctx context.Context, + endpoint *url.URL, + body []byte, + authToken string, +) (*http.Request, error) { + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + endpoint.String(), + bytes.NewReader(body), + ) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) + return request, nil +} + +func (sender *HTTPNotificationSender) sendAttempt(request *http.Request) error { + response, err := sender.client.Do(request) + if err != nil { + return err + } + if response.Body != nil { + _, _ = io.CopyN(io.Discard, response.Body, maxNotificationResponseDiscardBytes) + _ = response.Body.Close() + } + if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices { + return nil + } + if isRetryableNotificationStatus(response.StatusCode) { + return retryableNotificationStatusError{statusCode: response.StatusCode} + } + return terminalNotificationStatusError{statusCode: response.StatusCode} +} + +func newHTTPNotificationRetrier(delay time.Duration) *retry.Retrier { + return retry.New( + retry.Attempts(notificationHTTPAttempts), + retry.Delay(delay), + retry.DelayType(retry.BackOffDelay), + retry.LastErrorOnly(true), + retry.RetryIf(isRetryableNotificationError), + ) +} + +func (sender *HTTPNotificationSender) notificationError(hostname string, message string) error { + if hostname == "" { + hostname = "unknown" + } + err := stacktrace.Propagatef(stacktrace.NewErrorf("%s", message), "cannot send notification to [%s]", hostname) + if sender.logger != nil { + sender.logger.Error(err) + } + return err +} + +func isRetryableNotificationStatus(statusCode int) bool { + return statusCode == http.StatusRequestTimeout || + statusCode == http.StatusTooManyRequests || + (statusCode >= http.StatusInternalServerError && statusCode < 600) +} + +type retryableNotificationStatusError struct { + statusCode int +} + +func (error retryableNotificationStatusError) Error() string { + return http.StatusText(error.statusCode) +} + +type terminalNotificationStatusError struct { + statusCode int +} + +func (error terminalNotificationStatusError) Error() string { + return http.StatusText(error.statusCode) +} + +type terminalNotificationRequestError struct { + cause error +} + +func (notificationError terminalNotificationRequestError) Error() string { + return notificationError.cause.Error() +} + +func (notificationError terminalNotificationRequestError) Unwrap() error { + return notificationError.cause +} + +func isRetryableNotificationError(err error) bool { + if err == nil || isTerminalNotificationError(err) { + return false + } + return true +} + +func isTerminalNotificationError(err error) bool { + var statusError terminalNotificationStatusError + if errors.As(err, &statusError) { + return true + } + + var requestError terminalNotificationRequestError + return errors.As(err, &requestError) +} diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go new file mode 100644 index 000000000..018765cf0 --- /dev/null +++ b/api/pkg/services/http_notification_sender_test.go @@ -0,0 +1,466 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "reflect" + "strings" + "testing" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +var testNotificationPhoneID = uuid.New() + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTrip(request) +} + +type httpNotificationPayload struct { + Message struct { + Token string `json:"token"` + Data map[string]string `json:"data"` + Android struct { + Priority string `json:"priority"` + TTL string `json:"ttl,omitempty"` + } `json:"android"` + } `json:"message"` +} + +func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { + ttl := 10 * time.Minute + message := &messaging.Message{ + Token: "https://adapter.example.com/notify", + Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, + Android: &messaging.AndroidConfig{ + Priority: "high", + TTL: &ttl, + }, + } + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "application/json", request.Header.Get("Content-Type")) + + var payload httpNotificationPayload + require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) + assert.Equal(t, "https://adapter.example.com/notify", request.URL.String()) + assert.Equal(t, "https://adapter.example.com/notify", payload.Message.Token) + assert.Equal(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}, payload.Message.Data) + assert.Equal(t, "high", payload.Message.Android.Priority) + assert.Equal(t, "600s", payload.Message.Android.TTL) + + token, err := jwt.Parse(strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer "), func(*jwt.Token) (interface{}, error) { + return []byte(testNotificationPhoneID.String()), nil + }) + require.NoError(t, err) + assert.True(t, token.Valid) + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + assert.Empty(t, claims["sub"], "phone ID must not be embedded in a claim since it is also the signing secret") + assert.Equal(t, "api.httpsms.com", claims["iss"]) + + return response(http.StatusNoContent, http.NoBody), nil + })) + + result, err := sender.Send(context.Background(), message, testNotificationPhoneID) + + require.NoError(t, err) + assert.Equal(t, "http/success", result) +} + +func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { + tests := []struct { + name string + outcomes []roundTripOutcome + wantCalls int + wantErr bool + }{ + { + name: "network error then accepted", + outcomes: []roundTripOutcome{ + {err: errors.New("connection reset")}, + {statusCode: http.StatusAccepted}, + }, + wantCalls: 2, + }, + { + name: "request timeout then success", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusRequestTimeout}, + {statusCode: http.StatusOK}, + }, + wantCalls: 2, + }, + { + name: "rate limited then no content", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusTooManyRequests}, + {statusCode: http.StatusNoContent}, + }, + wantCalls: 2, + }, + { + name: "server errors then no content", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusInternalServerError}, + {statusCode: http.StatusBadGateway}, + {statusCode: http.StatusNoContent}, + }, + wantCalls: 3, + }, + { + name: "bad request fails immediately", + outcomes: []roundTripOutcome{{statusCode: http.StatusBadRequest}}, + wantCalls: 1, + wantErr: true, + }, + { + name: "three service unavailable responses fail", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusServiceUnavailable}, + {statusCode: http.StatusServiceUnavailable}, + {statusCode: http.StatusServiceUnavailable}, + }, + wantCalls: 3, + wantErr: true, + }, + { + name: "redirect fails immediately", + outcomes: []roundTripOutcome{{statusCode: http.StatusFound}}, + wantCalls: 1, + wantErr: true, + }, + { + name: "nonstandard 6xx response fails immediately", + outcomes: []roundTripOutcome{{statusCode: 600}}, + wantCalls: 1, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + outcome := test.outcomes[calls] + calls++ + if outcome.err != nil { + return nil, outcome.err + } + return response(outcome.statusCode, http.NoBody), nil + })) + result, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, + ) + + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, test.wantCalls, calls) + if !test.wantErr { + assert.Equal(t, "http/success", result) + } + }) + } +} + +func TestHTTPNotificationSenderReusesRetrierAcrossSends(t *testing.T) { + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + calls++ + if calls%2 == 1 { + return response(http.StatusServiceUnavailable, http.NoBody), nil + } + return response(http.StatusNoContent, http.NoBody), nil + })) + + for range 2 { + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, + ) + require.NoError(t, err) + } + + assert.Equal(t, 4, calls) +} + +func TestHTTPNotificationSenderCreatesFreshRequestAndBodyForEveryAttempt(t *testing.T) { + var requests []*http.Request + var bodies [][]byte + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + requests = append(requests, request) + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + bodies = append(bodies, body) + if len(requests) < 3 { + return response(http.StatusServiceUnavailable, http.NoBody), nil + } + return response(http.StatusNoContent, http.NoBody), nil + })) + + _, err := sender.Send( + context.Background(), + &messaging.Message{ + Token: "https://adapter.example.com/notify", + Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, + }, + testNotificationPhoneID, + ) + + require.NoError(t, err) + require.Len(t, requests, 3) + assert.NotSame(t, requests[0], requests[1]) + assert.NotSame(t, requests[1], requests[2]) + require.Len(t, bodies, 3) + assert.NotEmpty(t, bodies[0]) + assert.Equal(t, bodies[0], bodies[1]) + assert.Equal(t, bodies[1], bodies[2]) +} + +func TestHTTPNotificationSenderBoundsResponseBodyDiscard(t *testing.T) { + body := &boundedReadCloser{remaining: 8192} + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, body), nil + })) + + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, + ) + + require.NoError(t, err) + assert.Equal(t, int64(4096), body.read) + assert.True(t, body.closed) +} + +func TestHTTPNotificationSenderOmitsTTLForHeartbeat(t *testing.T) { + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload httpNotificationPayload + require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) + assert.Equal(t, "high", payload.Message.Android.Priority) + assert.Empty(t, payload.Message.Android.TTL) + assert.Equal(t, "heartbeat-1", payload.Message.Data["KEY_HEARTBEAT_ID"]) + return response(http.StatusNoContent, http.NoBody), nil + })) + + _, err := sender.Send( + context.Background(), + &messaging.Message{ + Token: "https://adapter.example.com/notify", + Data: map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"}, + Android: &messaging.AndroidConfig{ + Priority: "high", + }, + }, + testNotificationPhoneID, + ) + + require.NoError(t, err) +} + +func TestHTTPNotificationSenderUsesInjectedHTTPClientUnchanged(t *testing.T) { + transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, http.NoBody), nil + }) + client := &http.Client{ + Transport: transport, + Timeout: time.Minute, + } + + sender := NewHTTPNotificationSender(nil, client) + + assert.Same(t, client, sender.client) + assert.Equal(t, reflect.ValueOf(transport).Pointer(), reflect.ValueOf(sender.client.Transport).Pointer()) + assert.Equal(t, time.Minute, sender.client.Timeout) +} + +func TestHTTPNotificationSenderIgnoresEndpointUserInformation(t *testing.T) { + // Adapter endpoints must not rely on HTTP basic auth embedded in the URL; the Authorization + // header always carries the phone-signed JWT instead. + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + _, _, ok := request.BasicAuth() + assert.False(t, ok) + + authorization := request.Header.Get("Authorization") + assert.True(t, strings.HasPrefix(authorization, "Bearer ")) + + token, err := jwt.Parse(strings.TrimPrefix(authorization, "Bearer "), func(*jwt.Token) (interface{}, error) { + return []byte(testNotificationPhoneID.String()), nil + }) + require.NoError(t, err) + assert.True(t, token.Valid) + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + audience, err := claims.GetAudience() + require.NoError(t, err) + require.Len(t, audience, 1) + assert.NotContains(t, audience[0], "adapter-user") + assert.NotContains(t, audience[0], "adapter-password") + + return response(http.StatusNoContent, http.NoBody), nil + })) + endpoint := &url.URL{ + Scheme: "https", + User: url.UserPassword("adapter-user", "adapter-password"), + Host: "adapter.example.com", + Path: "/notify", + } + + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: endpoint.String()}, + testNotificationPhoneID, + ) + + require.NoError(t, err) +} + +func TestHTTPNotificationSenderBoundsEveryAttemptByTimeout(t *testing.T) { + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + <-request.Context().Done() + return nil, request.Context().Err() + })) + sender.timeout = 10 * time.Millisecond + + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, + ) + + require.Error(t, err) + assert.Equal(t, 3, calls) +} + +func TestHTTPNotificationSenderStopsRetriesWhenParentContextIsCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + cancel() + <-request.Context().Done() + return nil, request.Context().Err() + })) + + _, err := sender.Send( + ctx, + &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, + ) + + require.Error(t, err) + assert.Equal(t, 1, calls) +} + +func TestHTTPNotificationSenderRejectsNilMessage(t *testing.T) { + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, http.NoBody), nil + })) + + _, err := sender.Send(context.Background(), nil, testNotificationPhoneID) + + require.Error(t, err) + assert.Contains(t, err.Error(), "notification message is nil") +} + +type roundTripOutcome struct { + statusCode int + err error +} + +type boundedReadCloser struct { + remaining int64 + read int64 + closed bool +} + +func (reader *boundedReadCloser) Read(buffer []byte) (int, error) { + if reader.remaining == 0 { + return 0, io.EOF + } + read := int64(len(buffer)) + if read > reader.remaining { + read = reader.remaining + } + reader.remaining -= read + reader.read += read + return int(read), nil +} + +func (reader *boundedReadCloser) Close() error { + reader.closed = true + return nil +} + +type httpNotificationRecordingLogger struct { + errors []string +} + +func (logger *httpNotificationRecordingLogger) Error(err error) { + logger.errors = append(logger.errors, err.Error()) +} + +func (logger *httpNotificationRecordingLogger) WithService(string) telemetry.Logger { return logger } + +func (logger *httpNotificationRecordingLogger) WithString(string, string) telemetry.Logger { + return logger +} + +func (logger *httpNotificationRecordingLogger) WithSpan(trace.SpanContext) telemetry.Logger { + return logger +} + +func (logger *httpNotificationRecordingLogger) Trace(string) {} +func (logger *httpNotificationRecordingLogger) Info(string) {} +func (logger *httpNotificationRecordingLogger) Warn(error) {} +func (logger *httpNotificationRecordingLogger) Debug(string) {} +func (logger *httpNotificationRecordingLogger) Fatal(error) {} +func (logger *httpNotificationRecordingLogger) Printf(string, ...interface{}) {} + +func newHTTPNotificationSender(t *testing.T, transport roundTripFunc) *HTTPNotificationSender { + t.Helper() + return newHTTPNotificationSenderWithLogger(t, nil, transport) +} + +func newHTTPNotificationSenderWithLogger( + t *testing.T, + logger telemetry.Logger, + transport roundTripFunc, +) *HTTPNotificationSender { + t.Helper() + return newHTTPNotificationSenderWithRetrier( + logger, + &http.Client{Transport: transport}, + newHTTPNotificationRetrier(0), + ) +} + +func response(statusCode int, body io.ReadCloser) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: body, + Header: make(http.Header), + } +} diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 0d4d8d209..2c63fcd7c 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -4,12 +4,13 @@ import ( "context" "errors" "fmt" + "strings" "time" + "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/events" cloudevents "github.com/cloudevents/sdk-go/v2" - "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -18,7 +19,7 @@ import ( "go.opentelemetry.io/otel/trace" ) -// PhoneNotificationService sends out notifications to mobile phones +// PhoneNotificationService sends wake-up notifications to phone gateways. type PhoneNotificationService struct { service logger telemetry.Logger @@ -26,7 +27,7 @@ type PhoneNotificationService struct { phoneNotificationRepository repositories.PhoneNotificationRepository phoneRepository repositories.PhoneRepository messageSendScheduleRepository repositories.MessageSendScheduleRepository - messagingClient FCMClient + phoneNotificationClients map[entities.NotificationTransport]FCMClient eventDispatcher *EventDispatcher } @@ -34,20 +35,20 @@ type PhoneNotificationService struct { func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - messagingClient FCMClient, + phoneNotificationClients map[entities.NotificationTransport]FCMClient, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, - dispatcher *EventDispatcher, + eventDispatcher *EventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), tracer: tracer, - messagingClient: messagingClient, + phoneNotificationClients: phoneNotificationClients, phoneNotificationRepository: phoneNotificationRepository, phoneRepository: phoneRepository, messageSendScheduleRepository: messageSendScheduleRepository, - eventDispatcher: dispatcher, + eventDispatcher: eventDispatcher, } } @@ -77,7 +78,7 @@ func (service *PhoneNotificationService) DeleteByMessageID(ctx context.Context, return nil } -// SendHeartbeatFCM sends a heartbeat message so the phone can request a heartbeat +// SendHeartbeatFCM sends a heartbeat notification so the phone gateway can request a heartbeat. func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, payload *events.PhoneHeartbeatMissedPayload) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -88,25 +89,27 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p } if phone.FcmToken == nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "phone with id [%s] has no FCM token", phone.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("phone with id [%s] has no notification token", phone.ID)) } - result, err := service.messagingClient.Send(ctx, &messaging.Message{ + result, _, err := service.sendPhoneNotification(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), }, - Android: &messaging.AndroidConfig{ - Priority: "high", - }, - Token: *phone.FcmToken, + Android: &messaging.AndroidConfig{Priority: "high"}, }) if err != nil { - ctxLogger.Warn(stacktrace.Propagatef(err, "cannot send heartbeat FCM to phone with id [%s] for user [%s]", phone.ID, phone.UserID)) + ctxLogger.Warn(stacktrace.Propagatef( + err, + "cannot send heartbeat notification to phone with id [%s] for user [%s]", + phone.ID, + phone.UserID, + )) return nil } ctxLogger.Info(fmt.Sprintf( - "successfully sent heartbeat FCM [%s] to phone with ID [%s] for user [%s] and monitor [%s]", + "successfully sent heartbeat notification [%s] to phone with ID [%s] for user [%s] and monitor [%s]", result, payload.PhoneID, payload.UserID, @@ -125,7 +128,7 @@ type PhoneNotificationSendParams struct { MessageID uuid.UUID } -// Send sends a message when a message is sent +// Send sends a phone gateway notification when a message is sent. func (service *PhoneNotificationService) Send(ctx context.Context, params *PhoneNotificationSendParams) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -142,7 +145,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone } ttl := phone.MessageExpirationDuration() - result, err := service.messagingClient.Send(ctx, &messaging.Message{ + result, transport, err := service.sendPhoneNotification(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_MESSAGE_ID": params.MessageID.String(), }, @@ -150,24 +153,80 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone Priority: "normal", TTL: &ttl, }, - Token: *phone.FcmToken, }) if err != nil { + if transport == "" { + ctxLogger.Warn(stacktrace.Propagatef( + err, + "cannot determine notification transport for phone with ID [%s] for user with ID [%s] and message [%s]", + phone.ID, + phone.UserID, + params.MessageID, + )) + msg := fmt.Sprintf("cannot send notification to phone [%s]. Check the notification configuration.", phone.PhoneNumber) + return service.handleNotificationFailed(ctx, errors.New(msg), params) + } + ctxLogger.Warn(stacktrace.Propagatef( err, - - "cannot send FCM to phone with ID [%s] for user with ID [%s] and message [%s]", + "cannot send %s notification to phone with ID [%s] for user with ID [%s] and message [%s]", + transport, phone.ID, phone.UserID, params.MessageID, )) msg := fmt.Sprintf("cannot send notification to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber) + if transport == entities.NotificationTransportHTTP { + msg = fmt.Sprintf( + "cannot notify the configured adapter for phone [%s]. Check the adapter URL and availability.", + phone.PhoneNumber, + ) + } return service.handleNotificationFailed(ctx, errors.New(msg), params) } return service.handleNotificationSent(ctx, phone, result, params) } +func (service *PhoneNotificationService) sendPhoneNotification( + ctx context.Context, + phone *entities.Phone, + message *messaging.Message, +) (string, entities.NotificationTransport, error) { + if message == nil { + return "", "", stacktrace.NewErrorf("notification message is nil") + } + + transport, err := phone.NotificationTransport() + if err != nil { + return "", "", stacktrace.Propagatef( + err, + "cannot determine notification transport for phone [%s]", + phone.ID, + ) + } + + client, ok := service.phoneNotificationClients[transport] + if !ok || client == nil { + return "", transport, stacktrace.NewErrorf( + "notification client is not configured for transport [%s]", + transport, + ) + } + + message.Token = strings.TrimSpace(*phone.FcmToken) + result, err := client.Send(ctx, message, phone.ID) + if err != nil { + return "", transport, stacktrace.Propagatef( + err, + "cannot send [%s] notification to phone [%s]", + transport, + phone.ID, + ) + } + return result, transport, nil +} + // PhoneNotificationScheduleParams are parameters for sending a notification type PhoneNotificationScheduleParams struct { UserID entities.UserID diff --git a/api/pkg/services/phone_notification_service_test.go b/api/pkg/services/phone_notification_service_test.go new file mode 100644 index 000000000..9f0c42a46 --- /dev/null +++ b/api/pkg/services/phone_notification_service_test.go @@ -0,0 +1,329 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +type phoneNotificationPhoneRepository struct { + repositories.PhoneRepository + phone *entities.Phone + err error +} + +func (repository *phoneNotificationPhoneRepository) LoadByID( + _ context.Context, + _ entities.UserID, + _ uuid.UUID, +) (*entities.Phone, error) { + return repository.phone, repository.err +} + +type phoneNotificationRepository struct { + repositories.PhoneNotificationRepository + notificationID uuid.UUID + status entities.PhoneNotificationStatus +} + +func (repository *phoneNotificationRepository) UpdateStatus( + _ context.Context, + notificationID uuid.UUID, + status entities.PhoneNotificationStatus, +) error { + repository.notificationID = notificationID + repository.status = status + return nil +} + +type phoneNotificationEventQueue struct { + events []cloudevents.Event +} + +func (queue *phoneNotificationEventQueue) Enqueue( + _ context.Context, + task *PushQueueTask, + _ time.Duration, +) (string, error) { + var event cloudevents.Event + if err := json.Unmarshal(task.Body, &event); err != nil { + return "", err + } + queue.events = append(queue.events, event) + return "", nil +} + +type phoneNotificationLogger struct { + warnings []string +} + +var _ telemetry.Logger = (*phoneNotificationLogger)(nil) + +func (logger *phoneNotificationLogger) Error(error) {} +func (logger *phoneNotificationLogger) WithService(string) telemetry.Logger { return logger } +func (logger *phoneNotificationLogger) WithString(string, string) telemetry.Logger { + return logger +} + +func (logger *phoneNotificationLogger) WithSpan(trace.SpanContext) telemetry.Logger { return logger } +func (logger *phoneNotificationLogger) Trace(string) {} +func (logger *phoneNotificationLogger) Info(string) {} +func (logger *phoneNotificationLogger) Warn(err error) { + logger.warnings = append(logger.warnings, err.Error()) +} +func (logger *phoneNotificationLogger) Debug(string) {} +func (logger *phoneNotificationLogger) Fatal(error) {} +func (logger *phoneNotificationLogger) Printf(string, ...interface{}) {} + +type recordingPhoneNotificationClient struct { + message *messaging.Message + phoneID uuid.UUID + result string + err error + calls int +} + +func (client *recordingPhoneNotificationClient) Send( + _ context.Context, + message *messaging.Message, + phoneID uuid.UUID, +) (string, error) { + client.calls++ + client.message = message + client.phoneID = phoneID + return client.result, client.err +} + +func TestPhoneNotificationServiceSendPhoneNotificationUsesMappedClient(t *testing.T) { + endpoint := " https://adapter.example.com/notify " + phone := &entities.Phone{ID: uuid.New(), FcmToken: &endpoint} + httpClient := &recordingPhoneNotificationClient{result: "accepted"} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportHTTP: httpClient, + }, + } + message := &messaging.Message{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + + result, transport, err := service.sendPhoneNotification(context.Background(), phone, message) + + require.NoError(t, err) + assert.Equal(t, "accepted", result) + assert.Equal(t, entities.NotificationTransportHTTP, transport) + assert.Equal(t, "https://adapter.example.com/notify", message.Token) + assert.Same(t, message, httpClient.message) + assert.Equal(t, phone.ID, httpClient.phoneID) + assert.Equal(t, 1, httpClient.calls) +} + +func TestPhoneNotificationServiceSendPhoneNotificationRejectsMissingClient(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), FcmToken: &endpoint} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{}, + } + + _, transport, err := service.sendPhoneNotification( + context.Background(), + phone, + &messaging.Message{}, + ) + + require.Error(t, err) + assert.Equal(t, entities.NotificationTransportHTTP, transport) + assert.Contains(t, err.Error(), "notification client is not configured for transport [http]") +} + +func TestPhoneNotificationServiceSendPhoneNotificationRejectsNilMessage(t *testing.T) { + token := "fcm-token" + phone := &entities.Phone{ID: uuid.New(), FcmToken: &token} + fcmClient := &recordingPhoneNotificationClient{} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: fcmClient, + }, + } + + _, transport, err := service.sendPhoneNotification(context.Background(), phone, nil) + + require.Error(t, err) + assert.Empty(t, transport) + assert.Contains(t, err.Error(), "notification message is nil") + assert.Zero(t, fcmClient.calls) +} + +func TestPhoneNotificationServiceSendUsesHTTPSMessage(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ + ID: uuid.New(), + UserID: "user-1", + FcmToken: &endpoint, + PhoneNumber: "+18005550199", + MessageExpirationSeconds: 90, + } + httpClient := &recordingPhoneNotificationClient{result: "http/success"} + eventQueue := &phoneNotificationEventQueue{} + notificationRepository := &phoneNotificationRepository{} + service := newPhoneNotificationServiceForTest( + phone, + notificationRepository, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: httpClient, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + ScheduledAt: time.Now().UTC(), + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.NotNil(t, httpClient.message) + assert.Equal(t, endpoint, httpClient.message.Token) + assert.Equal(t, params.MessageID.String(), httpClient.message.Data["KEY_MESSAGE_ID"]) + require.NotNil(t, httpClient.message.Android) + assert.Equal(t, "normal", httpClient.message.Android.Priority) + require.NotNil(t, httpClient.message.Android.TTL) + assert.Equal(t, phone.MessageExpirationDuration(), *httpClient.message.Android.TTL) + require.Len(t, eventQueue.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationSent, eventQueue.events[0].Type()) + assert.Equal(t, params.PhoneNotificationID, notificationRepository.notificationID) + assert.Equal(t, entities.PhoneNotificationStatus(entities.PhoneNotificationStatusSent), notificationRepository.status) +} + +func TestPhoneNotificationServiceSendHTTPFailureUsesAdapterGuidance(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint, PhoneNumber: "+18005550199"} + eventQueue := &phoneNotificationEventQueue{} + notificationRepository := &phoneNotificationRepository{} + service := newPhoneNotificationServiceForTest( + phone, + notificationRepository, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: &recordingPhoneNotificationClient{ + err: errors.New("adapter unavailable"), + }, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.Len(t, eventQueue.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationFailed, eventQueue.events[0].Type()) + var payload events.MessageNotificationFailedPayload + require.NoError(t, eventQueue.events[0].DataAs(&payload)) + assert.Equal(t, "cannot notify the configured adapter for phone [+18005550199]. Check the adapter URL and availability.", payload.ErrorMessage) + assert.NotContains(t, payload.ErrorMessage, "Reinstall the httpSMS app") + assert.Equal(t, entities.PhoneNotificationStatus(entities.PhoneNotificationStatusFailed), notificationRepository.status) +} + +func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testing.T) { + token := "fcm-token" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &token, PhoneNumber: "+18005550199"} + eventQueue := &phoneNotificationEventQueue{} + fcmClient := &recordingPhoneNotificationClient{err: errors.New("firebase unavailable")} + service := newPhoneNotificationServiceForTest( + phone, + &phoneNotificationRepository{}, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: fcmClient, + entities.NotificationTransportHTTP: &recordingPhoneNotificationClient{}, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.Len(t, eventQueue.events, 1) + require.NotNil(t, fcmClient.message) + assert.Equal(t, token, fcmClient.message.Token) + assert.Equal(t, 1, fcmClient.calls) + var payload events.MessageNotificationFailedPayload + require.NoError(t, eventQueue.events[0].DataAs(&payload)) + assert.Equal(t, "cannot send notification to your phone [+18005550199]. Reinstall the httpSMS app on your Android phone.", payload.ErrorMessage) +} + +func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSMessage(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint} + httpClient := &recordingPhoneNotificationClient{err: errors.New("adapter unavailable")} + service := newPhoneNotificationServiceForTest( + phone, + &phoneNotificationRepository{}, + &phoneNotificationEventQueue{}, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: httpClient, + }, + ) + + err := service.SendHeartbeatFCM(context.Background(), &events.PhoneHeartbeatMissedPayload{ + UserID: phone.UserID, + PhoneID: phone.ID, + MonitorID: uuid.New(), + }) + + require.NoError(t, err) + require.NotNil(t, httpClient.message) + assert.Equal(t, endpoint, httpClient.message.Token) + heartbeatID := httpClient.message.Data["KEY_HEARTBEAT_ID"] + _, err = time.Parse(time.RFC3339, heartbeatID) + require.NoError(t, err) + require.NotNil(t, httpClient.message.Android) + assert.Equal(t, "high", httpClient.message.Android.Priority) + assert.Nil(t, httpClient.message.Android.TTL) +} + +func newPhoneNotificationServiceForTest( + phone *entities.Phone, + notificationRepository repositories.PhoneNotificationRepository, + eventQueue *phoneNotificationEventQueue, + clients map[entities.NotificationTransport]FCMClient, +) *PhoneNotificationService { + logger := &phoneNotificationLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + return NewNotificationService( + logger, + tracer, + clients, + &phoneNotificationPhoneRepository{phone: phone}, + notificationRepository, + nil, + NewEventDispatcher(logger, tracer, nil, eventQueue, PushQueueConfig{}), + ) +} diff --git a/api/pkg/validators/phone_handler_validator.go b/api/pkg/validators/phone_handler_validator.go index e9d4274e7..9e21d26a9 100644 --- a/api/pkg/validators/phone_handler_validator.go +++ b/api/pkg/validators/phone_handler_validator.go @@ -103,6 +103,11 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(ctx context.Context, user return result } + validator.validateNotificationToken(request.FcmToken, result) + if len(result) > 0 { + return result + } + if strings.TrimSpace(request.MessageSendScheduleID) != "" { scheduleID, _ := uuid.Parse(strings.TrimSpace(request.MessageSendScheduleID)) if _, err := validator.scheduleService.Load(ctx, userID, scheduleID); err != nil { @@ -133,7 +138,29 @@ func (validator *PhoneHandlerValidator) ValidateFCMToken(_ context.Context, requ }, }) - return v.ValidateStruct() + result := v.ValidateStruct() + if len(result) > 0 { + return result + } + + validator.validateNotificationToken(request.FcmToken, result) + return result +} + +func (validator *PhoneHandlerValidator) validateNotificationToken( + token string, + result url.Values, +) { + token = strings.TrimSpace(token) + if token == "" { + return + } + + phone := &entities.Phone{FcmToken: &token} + _, err := phone.NotificationTransport() + if err != nil { + result.Add("fcm_token", err.Error()) + } } // ValidateDelete ValidateUpsert validates requests.PhoneDelete diff --git a/api/pkg/validators/phone_handler_validator_test.go b/api/pkg/validators/phone_handler_validator_test.go new file mode 100644 index 000000000..c9dc72b63 --- /dev/null +++ b/api/pkg/validators/phone_handler_validator_test.go @@ -0,0 +1,144 @@ +package validators + +import ( + "context" + "net/url" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/stretchr/testify/assert" +) + +func TestPhoneHandlerValidatorAcceptsHTTPSNotificationURL(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: "https://adapter.example.com/notify", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsHTTPSNotificationURLOnUpsert(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateUpsert(context.Background(), "", requests.PhoneUpsert{ + PhoneNumber: "+18005550199", + FcmToken: "https://adapter.example.com/notify", + SIM: entities.SIM1.String(), + MessageExpirationSeconds: 60, + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsPrivateAndLoopbackNotificationHosts(t *testing.T) { + validator := newPhoneHandlerValidator() + + for _, token := range []string{ + "https://localhost/notify", + "https://127.0.0.1/notify", + "https://10.0.0.5/notify", + } { + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors, token) + } +} + +func TestPhoneHandlerValidatorRejectsInvalidNotificationURLs(t *testing.T) { + tests := []struct { + name string + token string + }{ + {name: "insecure HTTP", token: "http://adapter.example.com/notify"}, + {name: "missing host", token: "https:///notify"}, + {name: "malformed HTTPS", token: "https://%"}, + } + + validationPaths := []struct { + name string + validate func(*PhoneHandlerValidator, string) map[string][]string + }{ + { + name: "FCM token upsert", + validate: func(validator *PhoneHandlerValidator, token string) map[string][]string { + return validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + }) + }, + }, + { + name: "phone upsert", + validate: func(validator *PhoneHandlerValidator, token string) map[string][]string { + return validator.ValidateUpsert(context.Background(), "", requests.PhoneUpsert{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + MessageExpirationSeconds: 60, + }) + }, + }, + } + + for _, validationPath := range validationPaths { + t.Run(validationPath.name, func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + validationErrors := validationPath.validate(newPhoneHandlerValidator(), test.token) + + assert.NotEmpty(t, validationErrors["fcm_token"]) + }) + } + }) + } +} + +func TestPhoneHandlerValidatorAcceptsOpaqueFirebaseNotificationToken(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: "opaque-firebase-registration-token", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsNotificationURLWithUserInformation(t *testing.T) { + validator := newPhoneHandlerValidator() + endpoint := &url.URL{ + Scheme: "https", + User: url.UserPassword("adapter-user", "adapter-password"), + Host: "adapter.example.com", + Path: "/notify", + } + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: endpoint.String(), + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func newPhoneHandlerValidator() *PhoneHandlerValidator { + logger := &contactValidatorNoopLogger{} + return NewPhoneHandlerValidator( + logger, + telemetry.NewOtelLogger("test", logger), + nil, + ) +} diff --git a/docs/superpowers/plans/2026-05-26-nuxt4-vuetify4-migration.md b/docs/superpowers/plans/2026-05-26-nuxt4-vuetify4-migration.md deleted file mode 100644 index d5853283d..000000000 --- a/docs/superpowers/plans/2026-05-26-nuxt4-vuetify4-migration.md +++ /dev/null @@ -1,3300 +0,0 @@ -# Nuxt 4 + Vuetify 4 Migration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the httpSMS frontend from Nuxt 2 + Vue 2 + Vuetify 2 to Nuxt 4 + Vue 3 + Vuetify 4 with full TypeScript, Pinia state management, and ` - - - - -``` - -- [ ] **Step 2: Create website layout** - -```vue - - - - -``` - -- [ ] **Step 3: Create error layout** - -```vue - - - - -``` - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat(web): port layouts to Nuxt 4 with Vuetify 4" -``` - ---- - -## Task 13: Port Components — Toast, LoadingDashboard, LoadingButton, BackButton - -**Files:** -- Create: `web/app/components/Toast.vue` -- Create: `web/app/components/LoadingDashboard.vue` -- Create: `web/app/components/LoadingButton.vue` -- Create: `web/app/components/BackButton.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for v-snackbar, v-btn, v-progress-circular, v-icon, v-container, v-row, v-col before writing these. - -- [ ] **Step 1: Create Toast.vue** - -```vue - - - - -``` - -- [ ] **Step 2: Create LoadingDashboard.vue** - -```vue - - - - -``` - -- [ ] **Step 3: Create LoadingButton.vue** - -```vue - - - - -``` - -- [ ] **Step 4: Create BackButton.vue** - -```vue - - - - -``` - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(web): port Toast, LoadingDashboard, LoadingButton, BackButton components" -``` - ---- - -## Task 14: Port Components — CopyButton, FixedHeader, BlogAuthorBio, BlogInfo, NuxtLogo - -**Files:** -- Create: `web/app/components/CopyButton.vue` -- Create: `web/app/components/FixedHeader.vue` -- Create: `web/app/components/BlogAuthorBio.vue` -- Create: `web/app/components/BlogInfo.vue` -- Create: `web/app/components/NuxtLogo.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for each Vuetify component used. - -- [ ] **Step 1: Create CopyButton.vue** - -```vue - - - - -``` - -- [ ] **Step 2: Create FixedHeader.vue** - -```vue - - - - -``` - -- [ ] **Step 3: Create BlogAuthorBio.vue** - -```vue - - - - -``` - -- [ ] **Step 4: Create BlogInfo.vue** - -```vue - - - - -``` - -- [ ] **Step 5: Create NuxtLogo.vue (minimal placeholder)** - -```vue - - -``` - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat(web): port CopyButton, FixedHeader, BlogAuthorBio, BlogInfo, NuxtLogo" -``` - ---- - -## Task 15: Port Components — FirebaseAuth, MessageThread, MessageThreadHeader - -**Files:** -- Create: `web/app/components/FirebaseAuth.vue` -- Create: `web/app/components/MessageThread.vue` -- Create: `web/app/components/MessageThreadHeader.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for v-select, v-list, v-list-item, v-menu, v-tooltip, v-sheet, v-progress-linear before writing. - -- [ ] **Step 1: Create FirebaseAuth.vue** - -This component uses FirebaseUI which must be loaded client-side only. In Nuxt 4, wrap with `` or use `.client.vue` suffix. - -```vue - - - - -``` - -- [ ] **Step 2: Create MessageThread.vue** - -Port from the existing component. This is a larger component — read the full source from the backup branch and rewrite using ` - - -``` - -Note: The full MessageThread.vue content should be ported from the backup branch. The pattern above shows the migration approach. - -- [ ] **Step 3: Create MessageThreadHeader.vue** - -```vue - - - - -``` - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat(web): port FirebaseAuth, MessageThread, MessageThreadHeader components" -``` - ---- - -## Task 16: Port Pages — Login & Index (Homepage) - -**Files:** -- Create: `web/app/pages/login.vue` -- Create: `web/app/pages/index.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for all Vuetify components used. Port from the backup branch source, converting class-based syntax to `&"}`) - html := string(rich) - - assert.Contains(t, plain, ` - - -``` - -- [ ] **Step 2: Lint the page** - -Run: `cd web && pnpm lint:js && pnpm lint:prettier` -Expected: no errors for `app/pages/contacts/index.vue`. If Prettier reports formatting, run `pnpm lintfix` and re-check. - -- [ ] **Step 3: Build the site to confirm it compiles** - -Run: `cd web && pnpm generate` -Expected: build completes without type errors (the `/contacts` route is emitted). - -- [ ] **Step 4: Commit** - -```bash -git add web/app/pages/contacts/index.vue -git commit -m "feat(web): add contacts page with add/edit/delete/import dialogs" -``` - ---- - -### Task 13: Show contact names on threads + Contacts nav link - -**Files:** -- Modify: `web/app/stores/threads.ts` (send `contacts: true`) -- Modify: `web/app/components/MessageThread.vue` (list title + avatar initial) -- Modify: `web/app/pages/threads/[id]/index.vue` (toolbar title) -- Modify: `web/app/components/MessageThreadHeader.vue` (Contacts nav item + icon import) - -**Interfaces:** -- Consumes: `EntitiesMessageThread.contact_details?: EntitiesContact` (Task 10), the `/contacts` route (Task 12). -- Produces: no new exports — UI wiring only. - -- [ ] **Step 1: Request contact resolution when loading threads** - -In `web/app/stores/threads.ts`, add `contacts: true` to the `loadThreads` request params: - -```ts - const response = await apiFetch<{ data: EntitiesMessageThread[] }>( - '/v1/message-threads', - { - params: { - owner: phonesStore.owner ?? phonesStore.phones[0]?.phone_number, - limit: 100, - is_archived: archivedThreads.value, - contacts: true, - }, - }, - ) -``` - -- [ ] **Step 2: Show the contact name in the threads list** - -In `web/app/components/MessageThread.vue`, replace the list-item title expression: - -```vue - {{ - thread.contact_details?.name ?? formatPhoneNumber(thread.contact) - }} -``` - -And update the avatar initial to prefer the contact name (replace the existing `