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 849f5ec84..7d3a8a4ea 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -18,10 +18,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: stable @@ -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 @@ -74,6 +77,12 @@ jobs: docker compose wait seed || true sleep 2 + - name: Run Handler Integration Tests + working-directory: ./api + env: + USER_API_KEY: test-user-api-key + run: go test -tags integration -v -timeout 60s ./pkg/handlers + - name: Run Integration Tests working-directory: ./tests run: go test -v -timeout 300s ./... diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 327615ea8..50813d153 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -34,10 +34,10 @@ jobs: - uses: pnpm/action-setup@v6 name: Install pnpm with: - version: 11 + version: 11.11.0 - name: Install dependencies 📦 - run: pnpm install + run: pnpm install --trust-lockfile - name: Run linter 👀 run: pnpm lint @@ -58,13 +58,18 @@ jobs: - name: Checkout 🛎 uses: actions/checkout@master + - name: Setup Node 🟢 + uses: actions/setup-node@v7 + with: + node-version: '24.18' + - uses: pnpm/action-setup@v6 name: Install pnpm with: - version: 11 + version: 11.11.0 - name: Install dependencies 📦 - run: pnpm install + run: pnpm install --trust-lockfile - name: Build 🏗️ run: mv .env.production .env && echo "GITHUB_SHA=${GITHUB_SHA}" >> .env && pnpm run generate @@ -78,7 +83,7 @@ jobs: gitHubToken: ${{ secrets.GITHUB_TOKEN }} - name: Firebase Deploy 🚀 - uses: FirebaseExtended/action-hosting-deploy@v0 + uses: FirebaseExtended/action-hosting-deploy@500ac625ca2dd40cbd15f7659af953801858032a with: repoToken: "${{ secrets.GITHUB_TOKEN }}" channelId: live diff --git a/.gitignore b/.gitignore index e15d590a9..cc68ad44b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,14 @@ android/app/debug/ android/app/release/ tests/firebase-credentials.json +tests/certs/ tests/emulator/emulator.exe SECURITY_AUDIT_REPORT.md *.exe -docs/ .output .agents/ skills-lock.json +.worktrees/ +docs/superpowers diff --git a/.mcp.json b/.mcp.json index ef3d2cf0a..d4cd93959 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,27 +1,9 @@ { "mcpServers": { - "playwright": { - "type": "stdio", - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-playwright", - "--base-url", - "http://localhost:3000" - ], - "env": { - "BROWSER": "chromium" - } - }, "context7": { "type": "stdio", "command": "npx", "args": ["@upstash/context7-mcp@latest"] - }, - "axiom": { - "type": "stdio", - "command": "npx", - "args": ["-y", "mcp-remote", "https://mcp.axiom.co/mcp"] } } } diff --git a/README.md b/README.md index 14690530a..42a6b0d36 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/NdoleStudio/httpsms/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/NdoleStudio/httpsms/?branch=main) [![Better Stack Badge](https://uptime.betterstack.com/status-badges/v1/monitor/ldl5.svg)](https://uptime.betterstack.com/?utm_source=status_badge) +[![Greptile: The War on Bugs](https://www.greptile.com/badge.svg)](https://www.greptile.com/?utm_source=oss_badge&utm_medium=readme&utm_campaign=greptile_for_open_source) [![Sponsors](https://img.shields.io/github/sponsors/ndolestudio?logo=github)](https://github.com/sponsors/ndolestudio) [![Discord](https://img.shields.io/discord/1095778291488653372?label=Discord)](https://discord.gg/kGk8HVqeEZ) @@ -63,7 +64,7 @@ It is hosted as a single page application on firebase. The source code is in the ## API The API https://api.httpsms.com is built using [Fiber](https://gofiber.io/), Go and [CockroachDB](https://www.cockroachlabs.com/) for the database. -It rus as a serverless application on Google Cloud Run. The API documentation can be found here https://api.httpsms.com/index.html +It runs as a serverless application on Google Cloud Run. The API documentation can be found here https://api.httpsms.com/index.html ```go // Sending an SMS Message using Go @@ -95,7 +96,7 @@ works best for you: ### End-to-end Encryption -You can encrypt your messages end-to-end ysubg the military grade [AES-256 encryption](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard) +You can encrypt your messages end-to-end using the military grade [AES-256 encryption](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard) algorithm. Your encryption key is stored only on our mobile phone so the even the server won't have any way to view the content of your SMS messages which are sent and received on your Android phone. @@ -244,7 +245,7 @@ docker compose up --build ### 7. Create the System User -- The application uses the concept of a system user to process events async. You should manually create this user in `users` table in your database. Make sure you use the same `id` and `api_key` as the `EVENTS_QUEUE_USER_ID`, and `EVENTS_QUEUE_USER_API_KEY` in your `.env` file. +- The application uses the concept of a system user to process events asynchronously. You should manually create this user in `users` table in your database. Make sure you use the same `id` and `api_key` as the `EVENTS_QUEUE_USER_ID`, and `EVENTS_QUEUE_USER_API_KEY` in your `.env` file. ```SQL INSERT INTO users (id, api_key, email ) VALUES ('your-system-user-id', 'your-system-api-key', 'system@domain.com'); diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 15857e708..f670b8bb7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,7 +1,8 @@ plugins { id("com.android.application") id("com.google.gms.google-services") - id("io.sentry.android.gradle") version "6.2.0" + id("io.sentry.android.gradle") version "6.14.0" + id("org.jetbrains.kotlin.plugin.compose") } val gitHash = providers.exec { @@ -9,12 +10,12 @@ val gitHash = providers.exec { }.standardOutput.asText.map { it.trim() } android { - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.httpsms" minSdk = 28 - targetSdk = 36 + targetSdk = 37 versionCode = 1 versionName = gitHash.getOrElse("unknown") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -38,26 +39,39 @@ android { buildFeatures { buildConfig = true + compose = true } } dependencies { - implementation(platform("com.google.firebase:firebase-bom:34.11.0")) + val composeBom = platform("androidx.compose:compose-bom:2026.06.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.13.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0") + + implementation(platform("com.google.firebase:firebase-bom:34.16.0")) implementation("com.journeyapps:zxing-android-embedded:4.3.0") implementation("com.google.firebase:firebase-analytics") implementation("com.google.firebase:firebase-messaging") - implementation("com.squareup.okhttp3:okhttp:5.3.2") + implementation("com.squareup.okhttp3:okhttp:5.4.0") implementation("com.jakewharton.timber:timber:5.0.1") implementation("androidx.preference:preference-ktx:1.2.1") - implementation("androidx.work:work-runtime-ktx:2.11.1") - implementation("androidx.core:core-ktx:1.18.0") + implementation("androidx.work:work-runtime-ktx:2.11.2") + implementation("androidx.core:core-ktx:1.19.0") implementation("androidx.cardview:cardview:1.0.0") implementation("com.beust:klaxon:5.6") implementation("androidx.appcompat:appcompat:1.7.1") implementation("org.apache.commons:commons-text:1.15.0") - implementation("com.google.android.material:material:1.13.0") + implementation("com.google.android.material:material:1.14.0") implementation("androidx.constraintlayout:constraintlayout:2.2.1") - implementation("com.googlecode.libphonenumber:libphonenumber:9.0.26") + implementation("com.googlecode.libphonenumber:libphonenumber:9.0.34") implementation("com.klinkerapps:android-smsmms:5.2.6") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.3.0") diff --git a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt index e4113289b..244b68cf8 100644 --- a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt +++ b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt @@ -145,6 +145,13 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } val message = getMessage(applicationContext, messageID) ?: return Result.failure() + + if (message.contact.isBlank()) { + Timber.w("message contact is blank, stopping processing") + handleFailed(applicationContext, messageID, "The contact phone number is empty.") + return Result.failure() + } + if (!Settings.getActiveStatus(applicationContext, message.sim)) { Timber.w("[${message.sim}] SIM is not active, stopping processing") handleFailed(applicationContext, messageID, "Outgoing messages have been disabled on the mobile app") diff --git a/android/app/src/main/java/com/httpsms/LoginActivity.kt b/android/app/src/main/java/com/httpsms/LoginActivity.kt index babe12df6..17bd6db66 100644 --- a/android/app/src/main/java/com/httpsms/LoginActivity.kt +++ b/android/app/src/main/java/com/httpsms/LoginActivity.kt @@ -8,56 +8,75 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.telephony.TelephonyManager -import android.view.View -import android.webkit.URLUtil -import android.widget.LinearLayout import android.widget.Toast +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.core.app.ActivityCompat -import androidx.lifecycle.MutableLiveData import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability -import com.google.android.material.button.MaterialButton -import com.google.android.material.progressindicator.LinearProgressIndicator -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import com.httpsms.validators.PhoneNumberValidator +import com.httpsms.ui.login.LoginScreen +import com.httpsms.ui.login.LoginViewModel +import com.httpsms.ui.theme.HttpSmsTheme import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import timber.log.Timber -import java.net.URI - class LoginActivity : AppCompatActivity() { + private val viewModel: LoginViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) redirectToMain() - setContentView(R.layout.activity_login) - registerListeners() - setPhoneNumber() - disableSim2() - setServerURL() - setupApiKeyInput() - } - - private fun setupApiKeyInput() { - val apiKeyInputLayout = findViewById(R.id.loginApiKeyTextInputLayout) - val apiKeyInput = findViewById(R.id.loginApiKeyTextInput) - - apiKeyInput.setOnClickListener { - startQrCodeScan() - } + + viewModel.initialize(this, getString(R.string.default_server_url)) + + setContent { + HttpSmsTheme { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState.loginSuccess) { + if (uiState.loginSuccess) { + redirectToMain() + } + } - apiKeyInputLayout.setEndIconOnClickListener { - startQrCodeScan() + LoginScreen( + viewModel = viewModel, + onQrScanClick = { startQrCodeScan() }, + onLoginClick = { + val error = isGooglePlayServicesAvailable() + if (error != null) { + Toast.makeText(this@LoginActivity, error, Toast.LENGTH_SHORT).show() + } else { + viewModel.login( + context = this@LoginActivity, + countryCode = getCountryCode(), + onGooglePlayServicesError = { + Toast.makeText(this@LoginActivity, it, Toast.LENGTH_SHORT).show() + }, + onFcmTokenMissing = { + Toast.makeText( + this@LoginActivity, + "Cannot find FCM token. Make sure you have Google Play Services installed", + Toast.LENGTH_LONG + ).show() + } + ) + } + } + ) + } } } private val barcodeLauncher = registerForActivityResult(ScanContract()) { result -> if (result.contents != null) { - val apiKeyInput = findViewById(R.id.loginApiKeyTextInput) - apiKeyInput.setText(result.contents) + viewModel.onApiKeyChange(result.contents) Toast.makeText(this, "Scanned: ${result.contents}", Toast.LENGTH_LONG).show() } else { Toast.makeText(this, "Scan cancelled", Toast.LENGTH_SHORT).show() @@ -79,46 +98,6 @@ class LoginActivity : AppCompatActivity() { requestPermissions() } - override fun onResume() { - super.onResume() - setPhoneNumber() - disableSim2() - } - - private fun registerListeners() { - loginButton().setOnClickListener { onLoginClick() } - } - - private fun disableSim2() { - if (SmsManagerService.isDualSIM(this)) { - Timber.d("dual sim detected") - val sim2Layout = findViewById(R.id.loginPhoneNumberLayoutSIM2) - sim2Layout.visibility = LinearLayout.VISIBLE - return - } - Timber.d("single sim detected") - val sim2Layout = findViewById(R.id.loginPhoneNumberLayoutSIM2) - sim2Layout.visibility = View.GONE - } - - private fun setPhoneNumber() { - val phoneNumber = getPhoneNumber(this) - if(phoneNumber == null) { - Timber.d("cannot get phone due to no permissions") - return - } - - val phoneInput = findViewById(R.id.loginPhoneNumberInputSIM1) - phoneInput.setText(phoneNumber) - Timber.d("[SIM1] phone number [$phoneNumber] set successfully") - } - - private fun setServerURL() { - val serverUrlInput = findViewById(R.id.loginServerUrlInput) - serverUrlInput.setText(getString(R.string.default_server_url)) - Timber.d("default server url [${serverUrlInput.text.toString()}] set successfully") - } - @SuppressLint("HardwareIds") @Suppress("DEPRECATION") private fun getPhoneNumber(context: Context): String? { @@ -139,13 +118,16 @@ class LoginActivity : AppCompatActivity() { return telephonyManager.line1Number } + private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + permissions.entries.forEach { + Timber.d("${it.key} = ${it.value}") + } + // Try to auto-detect phone numbers after permissions are granted + viewModel.autoDetectPhoneNumbers(this) + } + private fun requestPermissions() { Timber.d("requesting permissions") - val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - permissions.entries.forEach { - Timber.d("${it.key} = ${it.value}") - } - } var permissions = arrayOf( Manifest.permission.SEND_SMS, @@ -175,169 +157,19 @@ class LoginActivity : AppCompatActivity() { return null } - - private fun onLoginClick() { - Timber.d("login button clicked") - - val error = isGooglePlayServicesAvailable() - if (error != null) { - Timber.d("google play services not installed [${error}]") - Toast.makeText(this, error, Toast.LENGTH_SHORT).show() - return - } - - if (Settings.getFcmToken(this) == null) { - Timber.d("The FCM token is not set") - Toast.makeText(this, "Cannot find FCM token. Make sure you have Google Play Services installed", Toast.LENGTH_LONG).show() - return - } - - loginButton().isEnabled = false - val progressBar = findViewById(R.id.loginProgressIndicator) - progressBar.visibility = View.VISIBLE - - val apiKeyLayout = findViewById(R.id.loginApiKeyTextInputLayout) - apiKeyLayout.error = null - - val apiKey = findViewById(R.id.loginApiKeyTextInput) - apiKey.isEnabled = false - - val serverUrlLayout = findViewById(R.id.loginServerUrlLayout) - serverUrlLayout.error = null - - val serverUrl = findViewById(R.id.loginServerUrlInput) - serverUrl.isEnabled = false - - val phoneNumberLayout = findViewById(R.id.loginPhoneNumberLayoutSIM1) - phoneNumberLayout.error = null - - val phoneNumber = findViewById(R.id.loginPhoneNumberInputSIM1) - phoneNumber.isEnabled = false - - val phoneNumberLayoutSIM2 = findViewById(R.id.loginPhoneNumberLayoutSIM2) - phoneNumberLayoutSIM2.error = null - - val phoneNumberSIM2 = findViewById(R.id.loginPhoneNumberInputSIM2) - phoneNumberSIM2.isEnabled = false - - val countryCode = getCountryCode() - - val resetView = fun () { - apiKey.isEnabled = true - serverUrl.isEnabled = true - progressBar.visibility = View.INVISIBLE - phoneNumber.isEnabled = true - phoneNumberSIM2.isEnabled = true - loginButton().isEnabled = true - } - - if (!PhoneNumberValidator.isValidPhoneNumber(phoneNumber.text.toString().trim(), countryCode)) { - Timber.e("[SIM1] phone number [${phoneNumber.text.toString()}] is not valid") - resetView() - phoneNumberLayout.error = "Enter an international phone number in the E.164 format" - return - } - - if (SmsManagerService.isDualSIM(this) && !PhoneNumberValidator.isValidPhoneNumber(phoneNumberSIM2.text.toString().trim(), countryCode)) { - Timber.e("[SIM2] phone number [${phoneNumberSIM2.text.toString()}] is not valid") - resetView() - phoneNumberLayoutSIM2.error = "Enter an international phone number in the E.164 format" - return - } - - if(!URLUtil.isValidUrl(serverUrl.text.toString().trim())) { - Timber.e("url number [${serverUrl.text.toString()}] is not a valid URL") - resetView() - serverUrlLayout.error = "Server URL [${serverUrl.text.toString()}] is invalid" - return - } - - if (!URLUtil.isHttpsUrl(serverUrl.text.toString().trim())) { - Timber.e("url number [${serverUrl.text.toString()}] is not an https URL") - resetView() - serverUrlLayout.error = "Server URL [${serverUrl.text.toString()}] must be HTTPS" - return - } - - val liveData = MutableLiveData>() - liveData.observe(this) { authResult -> - run { - progressBar.visibility = View.INVISIBLE - if (authResult.first != null) { - resetView() - apiKeyLayout.error = authResult.first - return@run - } - - if (authResult.second != null) { - resetView() - serverUrlLayout.error = authResult.second - return@run - } - - Settings.setApiKeyAsync(this, apiKey.text.toString()) - Settings.setServerUrlAsync(this, serverUrl.text.toString().trim()) - - val e164PhoneNumber = PhoneNumberValidator.formatE164(phoneNumber.text.toString().trim(), countryCode) - Settings.setSIM1PhoneNumber(this, e164PhoneNumber) - - if(SmsManagerService.isDualSIM(this)) { - val sim2PhoneNumber = PhoneNumberValidator.formatE164(phoneNumberSIM2.text.toString().trim(), countryCode) - Settings.setSIM2PhoneNumber(this, sim2PhoneNumber) - } - - Timber.d("login successfully redirecting to main view") - redirectToMain() - } - } - - Thread { - val service = HttpSmsApiService(apiKey.text.toString(), URI(serverUrl.text.toString().trim())) - - var e164PhoneNumber = PhoneNumberValidator.formatE164(phoneNumber.text.toString().trim(), countryCode) - var response = service.updateFcmToken(e164PhoneNumber, Constants.SIM1, Settings.getFcmToken(this) ?: "") - if(response.second != null || response.third != null) { - Timber.e("error updating fcm token [${response.second}], third [${response.third}]") - liveData.postValue(Pair(response.second, response.third)) - return@Thread - } - - if (!SmsManagerService.isDualSIM(this)) { - Timber.d("single sim detected, no need to update sim2") - liveData.postValue(Pair(null, null)) - return@Thread - } - - e164PhoneNumber = PhoneNumberValidator.formatE164(phoneNumberSIM2.text.toString().trim(), countryCode) - response = service.updateFcmToken(e164PhoneNumber, Constants.SIM2, Settings.getFcmToken(this) ?: "") - - liveData.postValue(Pair(response.second, response.third)) - Timber.d("finished validating api URL") - }.start() - } - private fun redirectToMain() { if (!Settings.isLoggedIn(this)) { return } - finish() + finish() val switchActivityIntent = Intent(this, MainActivity::class.java) startActivity(switchActivityIntent) } - private fun loginButton(): MaterialButton { - return findViewById(R.id.loginButton) - } - private fun getCountryCode() : String { - // Get the TelephonyManager from the system services val tm = this.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager - - // Get the network country ISO code and convert it to uppercase val code = tm.networkCountryIso.uppercase() - - // If the country code is empty, retrieve the country code from the device's locale if (code.isEmpty()) { return this.resources.configuration.locales.get(0).country.uppercase() } diff --git a/android/app/src/main/java/com/httpsms/MainActivity.kt b/android/app/src/main/java/com/httpsms/MainActivity.kt index 363e7c19a..ff7587db2 100644 --- a/android/app/src/main/java/com/httpsms/MainActivity.kt +++ b/android/app/src/main/java/com/httpsms/MainActivity.kt @@ -10,37 +10,29 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.PowerManager -import android.telephony.PhoneNumberUtils -import android.view.View -import android.widget.LinearLayout -import android.widget.TextView +import android.provider.Settings as ProviderSettings import android.widget.Toast +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.MutableLiveData import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager -import com.google.android.material.button.MaterialButton -import com.google.android.material.card.MaterialCardView -import com.google.android.material.progressindicator.LinearProgressIndicator import com.httpsms.services.StickyNotificationService +import com.httpsms.ui.main.MainScreen +import com.httpsms.ui.main.MainViewModel +import com.httpsms.ui.theme.HttpSmsTheme import com.httpsms.worker.HeartbeatWorker import timber.log.Timber -import java.time.Instant -import java.time.ZoneId -import java.time.ZoneOffset -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.util.* import java.util.concurrent.TimeUnit -import android.provider.Settings as ProviderSettings class MainActivity : AppCompatActivity() { + private val viewModel: MainViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -48,20 +40,42 @@ class MainActivity : AppCompatActivity() { redirectToLogin() - setContentView(R.layout.activity_main) + viewModel.initialize(this, getString(R.string.app_version, BuildConfig.VERSION_NAME)) + + setContent { + HttpSmsTheme { + MainScreen( + viewModel = viewModel, + onSettingsClick = { onSettingsClick() }, + onSmsPermissionClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://httpsms.com/blog/grant-send-and-read-sms-permissions-on-android")) + startActivity(intent) + }, + onBatteryOptimizationClick = { + val intent = Intent() + intent.action = ProviderSettings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + intent.data = Uri.parse("package:$packageName") + startActivity(intent) + }, + onHeartbeatClick = { + viewModel.sendHeartbeat(this) { error -> + if (error != null) { + Timber.w("heartbeat sending failed with [$error]") + Toast.makeText(this, error, Toast.LENGTH_LONG).show() + } else { + Toast.makeText(this, "Heartbeat sent successfully", Toast.LENGTH_SHORT).show() + } + } + } + ) + } + } createChannel() - - setCardContent(this) - registerListeners() refreshToken(this) startStickyNotification(this) scheduleHeartbeatWorker(this) - setVersion() - setHeartbeatListener(this) - setSmsPermissionListener() - setBatteryOptimizationListener() } override fun onStart() { @@ -74,35 +88,7 @@ class MainActivity : AppCompatActivity() { Timber.d( "on activity resume") redirectToLogin() refreshToken(this) - setCardContent(this) - setSmsPermissionListener() - setBatteryOptimizationListener() - } - - private fun setVersion() { - val appVersionView = findViewById(R.id.mainAppVersion) - appVersionView.text = getString(R.string.app_version, BuildConfig.VERSION_NAME) - } - - private fun setCardContent(context: Context) { - val titleText = findViewById(R.id.cardPhoneNumber) - titleText.text = PhoneNumberUtils.formatNumber(Settings.getSIM1PhoneNumber(this), Locale.getDefault().country) - if(!Settings.getActiveStatus(context, Constants.SIM1)) { - titleText.setCompoundDrawables(null, null, null, null) - } - - val titleTextSIM2 = findViewById(R.id.cardPhoneNumberSIM2) - titleTextSIM2.text = PhoneNumberUtils.formatNumber(Settings.getSIM2PhoneNumber(this), Locale.getDefault().country) - if(!Settings.getActiveStatus(context, Constants.SIM2)) { - titleTextSIM2.setCompoundDrawables(null, null, null, null) - } - - setLastHeartbeatTimestamp(context) - - if(!Settings.isDualSIM(context)) { - val sim2Card = findViewById(R.id.mainPhoneCardSIM2) - sim2Card.visibility = MaterialCardView.GONE - } + viewModel.updateState(this, getString(R.string.app_version, BuildConfig.VERSION_NAME)) } private fun requestPermissions(context:Context) { @@ -116,7 +102,7 @@ class MainActivity : AppCompatActivity() { Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, false) } } - setSmsPermissionListener() + viewModel.updateState(context, getString(R.string.app_version, BuildConfig.VERSION_NAME)) } var permissions = arrayOf( @@ -229,10 +215,6 @@ class MainActivity : AppCompatActivity() { } } - private fun registerListeners() { - findViewById(R.id.mainSettingsButton).setOnClickListener { onSettingsClick() } - } - private fun onSettingsClick() { Timber.d("settings button clicked") val switchActivityIntent = Intent(this, SettingsActivity::class.java) @@ -248,28 +230,6 @@ class MainActivity : AppCompatActivity() { return true } - private fun setLastHeartbeatTimestamp(context: Context) { - val refreshTimestampView = findViewById(R.id.cardRefreshTime) - val timestamp = Settings.getHeartbeatTimestamp(context) - - if (timestamp == 0.toLong()) { - Timber.d("no heartbeat timestamp has been set") - refreshTimestampView.text = "--" - return - } - - val timestampZdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) - val localTime = timestampZdt.withZoneSameInstant(ZoneId.systemDefault()) - Timber.d("heartbeat timestamp in UTC is [${timestampZdt}] and local is [$localTime]") - - refreshTimestampView.text = localTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) - - if (Settings.isDualSIM(context)) { - val refreshTimestampViewSIM2 = findViewById(R.id.cardRefreshTimeSIM2) - refreshTimestampViewSIM2.text = localTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) - } - } - private fun createChannel() { // Create the NotificationChannel val name = getString(R.string.notification_channel_default) @@ -282,109 +242,4 @@ class MainActivity : AppCompatActivity() { val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(mChannel) } - - @SuppressLint("BatteryLife") - private fun setBatteryOptimizationListener() { - val pm = getSystemService(POWER_SERVICE) as PowerManager - val button = findViewById(R.id.batteryOptimizationButtonButton) - if (!pm.isIgnoringBatteryOptimizations(packageName)) { - button.visibility = View.VISIBLE - button.setOnClickListener { - val intent = Intent() - intent.action = ProviderSettings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - intent.data = Uri.parse("package:$packageName") - startActivity(intent) - } - } else { - button.visibility = View.GONE - } - updatePermissionLayoutVisibility() - } - - private fun setSmsPermissionListener() { - val smsPermissions = arrayOf( - Manifest.permission.SEND_SMS, - Manifest.permission.RECEIVE_SMS, - Manifest.permission.READ_SMS - ) - val allGranted = smsPermissions.all { - checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED - } - - val button = findViewById(R.id.smsPermissionButton) - if (!allGranted) { - button.visibility = View.VISIBLE - button.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://httpsms.com/blog/grant-send-and-read-sms-permissions-on-android")) - startActivity(intent) - } - } else { - button.visibility = View.GONE - } - updatePermissionLayoutVisibility() - } - - private fun updatePermissionLayoutVisibility() { - val smsButton = findViewById(R.id.smsPermissionButton) - val batteryButton = findViewById(R.id.batteryOptimizationButtonButton) - val layout = findViewById(R.id.batteryOptimizationLinearLayout) - - if (smsButton.visibility == View.GONE && batteryButton.visibility == View.GONE) { - layout.visibility = View.GONE - } else { - layout.visibility = View.VISIBLE - } - } - - private fun setHeartbeatListener(context: Context) { - findViewById(R.id.mainHeartbeatButton).setOnClickListener{onHeartbeatClick(context)} - } - - private fun onHeartbeatClick(context: Context) { - Timber.d("heartbeat button clicked") - val heartbeatButton = findViewById(R.id.mainHeartbeatButton) - heartbeatButton.isEnabled = false - - val progressBar = findViewById(R.id.mainProgressIndicator) - progressBar.visibility = View.VISIBLE - - val liveData = MutableLiveData() - liveData.observe(this) { exception -> - run { - progressBar.visibility = View.INVISIBLE - heartbeatButton.isEnabled = true - - if (exception != null) { - Timber.w("heartbeat sending failed with [$exception]") - Toast.makeText(context, exception, Toast.LENGTH_LONG).show() - return@run - } - Toast.makeText(context, "Heartbeat sent successfully", Toast.LENGTH_SHORT).show() - - setLastHeartbeatTimestamp(this) - } - } - - Thread { - val charging = Settings.isCharging(applicationContext) - var error: String? = null - try { - val phoneNumbers = mutableListOf() - phoneNumbers.add(Settings.getSIM1PhoneNumber(applicationContext)) - if (Settings.getActiveStatus(applicationContext, Constants.SIM2)) { - phoneNumbers.add(Settings.getSIM2PhoneNumber(applicationContext)) - } - val isStored = HttpSmsApiService.create(context).storeHeartbeat(phoneNumbers.toTypedArray(), charging) - if (!isStored) { - error = "Could not send heartbeat make sure the phone is connected to the internet" - } - Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis()) - } catch (exception: Exception) { - Timber.e(exception) - error = exception.javaClass.simpleName - } - liveData.postValue(error) - Timber.d("finished sending pulse") - }.start() - } } diff --git a/android/app/src/main/java/com/httpsms/SettingsActivity.kt b/android/app/src/main/java/com/httpsms/SettingsActivity.kt index cbc2831bc..37a8b3523 100644 --- a/android/app/src/main/java/com/httpsms/SettingsActivity.kt +++ b/android/app/src/main/java/com/httpsms/SettingsActivity.kt @@ -1,121 +1,33 @@ package com.httpsms -import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.widget.doAfterTextChanged -import com.google.android.material.appbar.MaterialToolbar -import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.switchmaterial.SwitchMaterial -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout +import com.httpsms.ui.settings.SettingsScreen +import com.httpsms.ui.settings.SettingsViewModel +import com.httpsms.ui.theme.HttpSmsTheme import timber.log.Timber class SettingsActivity : AppCompatActivity() { + private val viewModel: SettingsViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_settings) - fillSettings(this) - registerListeners() - } - - private fun fillSettings(context: Context) { - val debugLogs = findViewById(R.id.settingEnableDebugLogs) - debugLogs.isChecked = Settings.isDebugLogEnabled(context) - debugLogs.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setDebugLogEnabled(context, isChecked) } } - - - val phoneNumber = findViewById(R.id.settingsSIM1Input) - phoneNumber.setText(Settings.getSIM1PhoneNumber(context)) - phoneNumber.isEnabled = false - - val sim1IncomingMessages = findViewById(R.id.settings_sim1_incoming_messages) - sim1IncomingMessages.isChecked = Settings.isIncomingMessageEnabled(context, Constants.SIM1) - - sim1IncomingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setIncomingActiveSIM1(context, isChecked) } } - - val sim1OutgoingMessages = findViewById(R.id.settings_sim1_outgoing_messages) - sim1OutgoingMessages.isChecked = Settings.getActiveStatus(context, Constants.SIM1) - sim1OutgoingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setActiveStatusAsync(context, isChecked, Constants.SIM1) } } - - if (!Settings.isDualSIM(context)) { - val layout = findViewById(R.id.settingsSIM2Layout) - layout.visibility = TextInputLayout.GONE - val sim2Switch = findViewById(R.id.settings_sim2_incoming_messages) - sim2Switch.visibility = SwitchMaterial.GONE - val outgoingSwitch = findViewById(R.id.settings_sim2_outgoing_messages) - outgoingSwitch.visibility = SwitchMaterial.GONE - } else { - val phoneNumberSIM2 = findViewById(R.id.settingsSIM2InputEdit) - phoneNumberSIM2.setText(Settings.getSIM2PhoneNumber(context)) - phoneNumberSIM2.isEnabled = false - - val sim2IncomingMessages = findViewById(R.id.settings_sim2_incoming_messages) - sim2IncomingMessages.isChecked = Settings.isIncomingMessageEnabled(context, Constants.SIM2) - sim2IncomingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setIncomingActiveSIM2(context, isChecked) } } - - val sim2OutgoingMessages = findViewById(R.id.settings_sim2_outgoing_messages) - sim2OutgoingMessages.isChecked = Settings.getActiveStatus(context, Constants.SIM2) - sim2OutgoingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setActiveStatusAsync(context, isChecked, Constants.SIM2) } } - } - - handleEncryptionSettings(context) - handleIncomingCallEvents(context) - } - - private fun handleIncomingCallEvents(context: Context) { - val enableIncomingCallEvents = findViewById(R.id.settingsSim1EnableIncomingCallEvents) - enableIncomingCallEvents.isChecked = Settings.isIncomingCallEventsEnabled(context, Constants.SIM1) - enableIncomingCallEvents.setOnCheckedChangeListener{ _, isChecked -> run { - Settings.setIncomingCallEventsEnabled(context, Constants.SIM1, isChecked) - }} - - val sim2IncomingCalls = findViewById(R.id.settingsSim2EnableIncomingCallEvents) - if (!Settings.isDualSIM(context)) { - sim2IncomingCalls.visibility = SwitchMaterial.GONE - return - } - - sim2IncomingCalls.isChecked = Settings.isIncomingCallEventsEnabled(context, Constants.SIM2) - sim2IncomingCalls.setOnCheckedChangeListener{ _, isChecked -> run { - Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, isChecked) - }} - } - - private fun handleEncryptionSettings(context: Context) { - val encryptionKey = findViewById(R.id.settingsEncryptionKeyInputEdit) - val encryptReceivedMessages = findViewById(R.id.settingsEncryptReceivedMessages) - - val key = Settings.getEncryptionKey(context) - if(key.isNullOrEmpty()) { - encryptReceivedMessages.isEnabled = false - } else { - encryptionKey.setText(key.trim()) - } - - encryptionKey.doAfterTextChanged{ - if (it == null || it.toString().isEmpty()) { - Settings.setEncryptionKey(context, null) - Settings.setEncryptReceivedMessages(context, false) - encryptReceivedMessages.isChecked = false - encryptReceivedMessages.isEnabled = false - } else { - encryptReceivedMessages.isEnabled = true - Settings.setEncryptionKey(context, it.toString().trim()) + + viewModel.initialize(this) + + setContent { + HttpSmsTheme { + SettingsScreen( + viewModel = viewModel, + onBackClick = { onBackClicked() }, + onLogoutClick = { onLogoutClick() } + ) } } - - encryptReceivedMessages.isChecked = Settings.encryptReceivedMessages(context) - encryptReceivedMessages.setOnCheckedChangeListener{ _, isChecked -> run { - Settings.setEncryptReceivedMessages(context, isChecked) - }} - } - - private fun registerListeners() { - appToolbar().setOnClickListener { onBackClicked() } - findViewById(R.id.settingsLogoutButton).setOnClickListener { onLogoutClick() } } private fun onBackClicked() { @@ -129,10 +41,6 @@ class SettingsActivity : AppCompatActivity() { startActivity(switchActivityIntent) } - private fun appToolbar(): MaterialToolbar { - return findViewById(R.id.settings_toolbar) - } - private fun onLogoutClick() { Timber.d("logout button clicked") MaterialAlertDialogBuilder(this) @@ -141,20 +49,9 @@ class SettingsActivity : AppCompatActivity() { .setNeutralButton("Cancel"){ _, _ -> Timber.d("logout dialog canceled") } .setPositiveButton("Logout"){_, _ -> Timber.d("logging out user") - Settings.setApiKeyAsync(this, null) - Settings.setSIM1PhoneNumber(this, null) - Settings.setSIM2PhoneNumber(this, null) - Settings.setActiveStatusAsync(this, true, Constants.SIM1) - Settings.setActiveStatusAsync(this, true, Constants.SIM2) - Settings.setIncomingActiveSIM1(this, true) - Settings.setIncomingActiveSIM2(this, true) - Settings.setUserID(this, null) - Settings.setEncryptionKey(this, null) - Settings.setEncryptReceivedMessages(this, false) - Settings.setFcmTokenLastUpdateTimestampAsync(this, 0) - Settings.setIncomingCallEventsEnabled(this, Constants.SIM1, false) - Settings.setIncomingCallEventsEnabled(this, Constants.SIM2, false) - redirectToLogin() + viewModel.logout(this) { + redirectToLogin() + } } .show() } diff --git a/android/app/src/main/java/com/httpsms/SmsManagerService.kt b/android/app/src/main/java/com/httpsms/SmsManagerService.kt index c96a90a03..1236bb54b 100644 --- a/android/app/src/main/java/com/httpsms/SmsManagerService.kt +++ b/android/app/src/main/java/com/httpsms/SmsManagerService.kt @@ -36,7 +36,7 @@ class SmsManagerService { } else { context.getSystemService(SubscriptionManager::class.java) } - return localSubscriptionManager.activeSubscriptionInfoList!!.size > 1 + return (localSubscriptionManager.activeSubscriptionInfoList?.size ?: 0) > 1 } } @@ -45,11 +45,48 @@ class SmsManagerService { } fun sendMultipartMessage(context: Context, contact: String, parts: ArrayList, sim: String, sendIntents: ArrayList, deliveryIntents: ArrayList) { - getSmsManager(context, sim).sendMultipartTextMessage(contact, null, parts, sendIntents, deliveryIntents) + runSmsAction(context, sim) { smsManager -> + smsManager.sendMultipartTextMessage(contact, null, parts, sendIntents, deliveryIntents) + } + } + + fun sendTextMessage(context: Context, contact: String, content: String, sim: String, sentIntent: PendingIntent, deliveryIntent: PendingIntent) { + runSmsAction(context, sim) { smsManager -> + smsManager.sendTextMessage(contact, null, content, sentIntent, deliveryIntent) + } + } + + // Wrapper for the smsManager's sendMultimediaMessage + fun sendMultimediaMessage(context: Context, pduUri: android.net.Uri, sim: String, sentIntent: PendingIntent) { + runSmsAction(context, sim) { smsManager -> + smsManager.sendMultimediaMessage(context, pduUri, null, null, sentIntent) + } + } + + private fun runSmsAction(context: Context, sim: String, action: (SmsManager) -> Unit) { + try { + action(getSmsManager(context, sim)) + } catch (e: NullPointerException) { + if (e.isEmergencyNumberBug()) { + Timber.w(e, "Caught EmergencyNumber NPE, falling back to default SmsManager") + action(getDefaultSmsManager(context)) + } else { + throw e + } + } } - fun sendTextMessage(context: Context, contact: String, content: String, sim: String, sentIntent:PendingIntent, deliveryIntent: PendingIntent) { - getSmsManager(context, sim).sendTextMessage(contact, null, content, sentIntent, deliveryIntent) + private fun Throwable.isEmergencyNumberBug(): Boolean { + return this is NullPointerException && this.message?.contains("EmergencyNumber.getNumber()") == true + } + + @Suppress("DEPRECATION") + private fun getDefaultSmsManager(context: Context): SmsManager { + return if (Build.VERSION.SDK_INT >= 31) { + context.getSystemService(SmsManager::class.java) + } else { + SmsManager.getDefault() + } } @Suppress("DEPRECATION") @@ -61,25 +98,25 @@ class SmsManagerService { context.getSystemService(SubscriptionManager::class.java) } - Timber.d("active subscription info size: [${localSubscriptionManager.activeSubscriptionInfoList!!.size}]") - val subscriptionId = if (sim == Constants.SIM1 && localSubscriptionManager.activeSubscriptionInfoList!!.isNotEmpty()) { - localSubscriptionManager.activeSubscriptionInfoList!![0].subscriptionId - } else if (sim == Constants.SIM2 && localSubscriptionManager.activeSubscriptionInfoList!!.size > 1) { - localSubscriptionManager.activeSubscriptionInfoList!![1].subscriptionId + val infoList = localSubscriptionManager.activeSubscriptionInfoList + Timber.d("active subscription info size: [${infoList?.size ?: 0}]") + + val subscriptionId = if (sim == Constants.SIM1 && !infoList.isNullOrEmpty()) { + infoList[0].subscriptionId + } else if (sim == Constants.SIM2 && (infoList?.size ?: 0) > 1) { + infoList!![1].subscriptionId } else{ SubscriptionManager.getDefaultSmsSubscriptionId() } + if (subscriptionId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) { + return getDefaultSmsManager(context) + } + return if (Build.VERSION.SDK_INT < 31) { SmsManager.getSmsManagerForSubscriptionId(subscriptionId) } else { context.getSystemService(SmsManager::class.java).createForSubscriptionId(subscriptionId) } } - - // Wrapper for the smsManager's sendMultimediaMessage - fun sendMultimediaMessage(context: Context, pduUri: android.net.Uri, sim: String, sentIntent: PendingIntent) { - val smsManager = getSmsManager(context, sim) - smsManager.sendMultimediaMessage(context, pduUri, null, null, sentIntent) - } } diff --git a/android/app/src/main/java/com/httpsms/ui/login/LoginActivityContent.kt b/android/app/src/main/java/com/httpsms/ui/login/LoginActivityContent.kt new file mode 100644 index 000000000..0fc5e4c9d --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/login/LoginActivityContent.kt @@ -0,0 +1,217 @@ +package com.httpsms.ui.login + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.httpsms.R +import com.httpsms.ui.theme.Blue500 +import com.httpsms.ui.theme.Pink500 + +@Composable +fun LoginScreen( + viewModel: LoginViewModel, + onQrScanClick: () -> Unit, + onLoginClick: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(64.dp)) + + Image( + painter = painterResource(id = R.drawable.logo_cropped), + contentDescription = stringResource(id = R.string.img_http_sms_logo), + modifier = Modifier.size(100.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + val annotatedString = buildAnnotatedString { + val text = stringResource(id = R.string.get_your_api_key) + val linkText = "httpsms.com/settings" + val startIndex = text.indexOf(linkText) + + if (startIndex >= 0) { + append(text.substring(0, startIndex)) + + pushStringAnnotation(tag = "URL", annotation = "https://httpsms.com/settings") + withStyle(style = SpanStyle(color = Blue500, fontWeight = FontWeight.Bold)) { + append(linkText) + } + pop() + + append(text.substring(startIndex + linkText.length)) + } else { + append(text) + } + } + + ClickableText( + text = annotatedString, + onClick = { offset -> + annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset) + .firstOrNull()?.let { annotation -> + uriHandler.openUri(annotation.item) + } + }, + style = MaterialTheme.typography.bodyLarge.copy( + textAlign = TextAlign.Center, + fontSize = 20.sp, + lineHeight = 28.sp, + color = MaterialTheme.colorScheme.onBackground + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = uiState.apiKey, + onValueChange = { viewModel.onApiKeyChange(it) }, + label = { Text(stringResource(id = R.string.text_area_api_key)) }, + modifier = Modifier.fillMaxWidth(), + isError = uiState.apiKeyError != null, + supportingText = uiState.apiKeyError?.let { { Text(it) } }, + trailingIcon = { + IconButton(onClick = onQrScanClick) { + Icon( + imageVector = Icons.Default.QrCodeScanner, + contentDescription = "Scan QR Code" + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + enabled = !uiState.isLoading + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.phoneNumberSIM1, + onValueChange = { viewModel.onPhoneNumberSIM1Change(it) }, + label = { Text(stringResource(id = R.string.login_phone_number_sim1)) }, + placeholder = { Text(stringResource(id = R.string.login_phone_number_hint)) }, + modifier = Modifier.fillMaxWidth(), + isError = uiState.phoneNumberSIM1Error != null, + supportingText = uiState.phoneNumberSIM1Error?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Done + ), + enabled = !uiState.isLoading + ) + + if (uiState.isDualSim) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = uiState.phoneNumberSIM2, + onValueChange = { viewModel.onPhoneNumberSIM2Change(it) }, + label = { Text(stringResource(id = R.string.login_phone_number_sim2)) }, + placeholder = { Text(stringResource(id = R.string.login_phone_number_hint)) }, + modifier = Modifier.fillMaxWidth(), + isError = uiState.phoneNumberSIM2Error != null, + supportingText = uiState.phoneNumberSIM2Error?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Done + ), + enabled = !uiState.isLoading + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.serverUrl, + onValueChange = { viewModel.onServerUrlChange(it) }, + label = { Text(stringResource(id = R.string.server_url)) }, + placeholder = { Text(stringResource(id = R.string.login_server_url_hint)) }, + modifier = Modifier.fillMaxWidth(), + isError = uiState.serverUrlError != null, + supportingText = uiState.serverUrlError?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done + ), + enabled = !uiState.isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onLoginClick, + enabled = !uiState.isLoading, + modifier = Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.buttonColors(containerColor = Blue500), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_login), + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = Color.White + ) + Text( + text = stringResource(id = R.string.sign_in_button), + color = Color.White, + fontSize = 18.sp + ) + } + + if (uiState.isLoading) { + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Pink500 + ) + } + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/login/LoginViewModel.kt b/android/app/src/main/java/com/httpsms/ui/login/LoginViewModel.kt new file mode 100644 index 000000000..a4803e41e --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/login/LoginViewModel.kt @@ -0,0 +1,221 @@ +package com.httpsms.ui.login + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import android.webkit.URLUtil +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.httpsms.Constants +import com.httpsms.HttpSmsApiService +import com.httpsms.Settings +import com.httpsms.SmsManagerService +import com.httpsms.validators.PhoneNumberValidator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.net.URI +import java.net.URISyntaxException + +data class LoginUiState( + val apiKey: String = "", + val phoneNumberSIM1: String = "", + val phoneNumberSIM2: String = "", + val serverUrl: String = "", + val isLoading: Boolean = false, + val apiKeyError: String? = null, + val phoneNumberSIM1Error: String? = null, + val phoneNumberSIM2Error: String? = null, + val serverUrlError: String? = null, + val isDualSim: Boolean = false, + val loginSuccess: Boolean = false +) + +class LoginViewModel : ViewModel() { + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState = _uiState.asStateFlow() + + fun initialize(context: Context, defaultServerUrl: String) { + val isDualSim = SmsManagerService.isDualSIM(context) + val phoneNumberSIM1 = Settings.getSIM1PhoneNumber(context) + val phoneNumberSIM2 = Settings.getSIM2PhoneNumber(context) + + _uiState.value = _uiState.value.copy( + isDualSim = isDualSim, + phoneNumberSIM1 = phoneNumberSIM1, + phoneNumberSIM2 = phoneNumberSIM2, + serverUrl = defaultServerUrl + ) + + // Try to auto-detect if fields are empty + if (phoneNumberSIM1.isEmpty() || (isDualSim && phoneNumberSIM2.isEmpty())) { + autoDetectPhoneNumbers(context) + } + } + + fun autoDetectPhoneNumbers(context: Context) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) { + Timber.d("READ_PHONE_STATE permission not granted for auto-detecting phone numbers") + return + } + + val telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + + var detectedSIM1 = _uiState.value.phoneNumberSIM1 + var detectedSIM2 = _uiState.value.phoneNumberSIM2 + + try { + val subscriptionManager = if (Build.VERSION.SDK_INT >= 31) { + context.getSystemService(SubscriptionManager::class.java) + } else { + SubscriptionManager.from(context) + } + + val activeSubscriptions = try { subscriptionManager.activeSubscriptionInfoList } catch (e: Exception) { null } + + if (detectedSIM1.isEmpty()) { + val line1Number = try { telephonyManager.line1Number } catch (e: Exception) { null } + if (!line1Number.isNullOrEmpty()) { + detectedSIM1 = line1Number + } else if (activeSubscriptions != null && activeSubscriptions.isNotEmpty()) { + detectedSIM1 = activeSubscriptions[0].number ?: "" + } + } + + if (detectedSIM2.isEmpty() && activeSubscriptions != null && activeSubscriptions.size >= 2) { + detectedSIM2 = activeSubscriptions[1].number ?: "" + } + + Timber.d("Auto-detected numbers - SIM1: $detectedSIM1, SIM2: $detectedSIM2") + + } catch (e: SecurityException) { + Timber.e(e, "Security exception while auto-detecting phone numbers") + } + + _uiState.value = _uiState.value.copy( + phoneNumberSIM1 = detectedSIM1, + phoneNumberSIM2 = detectedSIM2, + isDualSim = SmsManagerService.isDualSIM(context) + ) + } + + fun onApiKeyChange(value: String) { + _uiState.value = _uiState.value.copy(apiKey = value, apiKeyError = null) + } + + fun onPhoneNumberSIM1Change(value: String) { + _uiState.value = _uiState.value.copy(phoneNumberSIM1 = value, phoneNumberSIM1Error = null) + } + + fun onPhoneNumberSIM2Change(value: String) { + _uiState.value = _uiState.value.copy(phoneNumberSIM2 = value, phoneNumberSIM2Error = null) + } + + fun onServerUrlChange(value: String) { + _uiState.value = _uiState.value.copy(serverUrl = value, serverUrlError = null) + } + + fun login(context: Context, countryCode: String, onGooglePlayServicesError: (String) -> Unit, onFcmTokenMissing: () -> Unit) { + val currentState = _uiState.value + + // Validation logic from LoginActivity.onLoginClick + if (Settings.getFcmToken(context) == null) { + onFcmTokenMissing() + return + } + + _uiState.value = currentState.copy(isLoading = true) + + viewModelScope.launch { + val apiKey = currentState.apiKey.trim() + val serverUrl = currentState.serverUrl.trim() + val phone1 = currentState.phoneNumberSIM1.trim() + val phone2 = currentState.phoneNumberSIM2.trim() + + if (!PhoneNumberValidator.isValidPhoneNumber(phone1, countryCode)) { + _uiState.value = _uiState.value.copy( + isLoading = false, + phoneNumberSIM1Error = "Enter an international phone number in the E.164 format" + ) + return@launch + } + + if (currentState.isDualSim && !PhoneNumberValidator.isValidPhoneNumber(phone2, countryCode)) { + _uiState.value = _uiState.value.copy( + isLoading = false, + phoneNumberSIM2Error = "Enter an international phone number in the E.164 format" + ) + return@launch + } + + if (!URLUtil.isValidUrl(serverUrl)) { + _uiState.value = _uiState.value.copy( + isLoading = false, + serverUrlError = "Server URL [$serverUrl] is invalid" + ) + return@launch + } + + if (!URLUtil.isHttpsUrl(serverUrl)) { + _uiState.value = _uiState.value.copy( + isLoading = false, + serverUrlError = "Server URL [$serverUrl] must be HTTPS" + ) + return@launch + } + + val authResult = try { + withContext(Dispatchers.IO) { + val service = HttpSmsApiService(apiKey, URI(serverUrl)) + val e164Phone1 = PhoneNumberValidator.formatE164(phone1, countryCode) + val response1 = service.updateFcmToken(e164Phone1, Constants.SIM1, Settings.getFcmToken(context) ?: "") + + if (response1.second != null || response1.third != null) { + return@withContext Pair(response1.second, response1.third) + } + + if (currentState.isDualSim) { + val e164Phone2 = PhoneNumberValidator.formatE164(phone2, countryCode) + val response2 = service.updateFcmToken(e164Phone2, Constants.SIM2, Settings.getFcmToken(context) ?: "") + return@withContext Pair(response2.second, response2.third) + } + + Pair(null, null) + } + } catch (e: URISyntaxException) { + Timber.e(e, "Invalid URI: $serverUrl") + Pair(null, "Server URL [$serverUrl] is invalid") + } catch (e: Exception) { + Timber.e(e, "Login error") + Pair(null, "An unexpected error occurred: ${e.message}") + } + + if (authResult.first != null) { + _uiState.value = _uiState.value.copy(isLoading = false, apiKeyError = authResult.first) + return@launch + } + + if (authResult.second != null) { + _uiState.value = _uiState.value.copy(isLoading = false, serverUrlError = authResult.second) + return@launch + } + + // Save settings + Settings.setApiKeyAsync(context, apiKey) + Settings.setServerUrlAsync(context, serverUrl) + Settings.setSIM1PhoneNumber(context, PhoneNumberValidator.formatE164(phone1, countryCode)) + if (currentState.isDualSim) { + Settings.setSIM2PhoneNumber(context, PhoneNumberValidator.formatE164(phone2, countryCode)) + } + + _uiState.value = _uiState.value.copy(isLoading = false, loginSuccess = true) + } + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/main/MainActivityContent.kt b/android/app/src/main/java/com/httpsms/ui/main/MainActivityContent.kt new file mode 100644 index 000000000..087bf7921 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/main/MainActivityContent.kt @@ -0,0 +1,243 @@ +package com.httpsms.ui.main + +import android.telephony.PhoneNumberUtils +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BatteryAlert +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.httpsms.R +import com.httpsms.ui.theme.Blue500 +import com.httpsms.ui.theme.Pink500 +import java.util.Locale + +@Composable +fun MainScreen( + viewModel: MainViewModel, + onSettingsClick: () -> Unit, + onSmsPermissionClick: () -> Unit, + onBatteryOptimizationClick: () -> Unit, + onHeartbeatClick: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(64.dp)) + + Image( + painter = painterResource(id = R.drawable.logo_cropped), + contentDescription = stringResource(id = R.string.img_http_sms_logo), + modifier = Modifier + .width(147.dp) + .height(92.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + PhoneCard( + phoneNumber = uiState.phoneNumberSIM1, + isActive = uiState.isActiveSIM1, + refreshTime = uiState.lastHeartbeatTime + ) + + if (uiState.isDualSim) { + Spacer(modifier = Modifier.height(24.dp)) + PhoneCard( + phoneNumber = uiState.phoneNumberSIM2, + isActive = uiState.isActiveSIM2, + refreshTime = uiState.lastHeartbeatTime + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (!uiState.isSmsPermissionGranted || !uiState.isBatteryOptimizationDisabled) { + Column(modifier = Modifier.fillMaxWidth()) { + if (!uiState.isSmsPermissionGranted) { + Button( + onClick = onSmsPermissionClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Text( + stringResource(id = R.string.enable_sms_permission), + color = Color.White, + fontSize = 18.sp + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(id = R.drawable.open_in_new_24), + contentDescription = null, + tint = Color.White + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + if (!uiState.isBatteryOptimizationDisabled) { + Button( + onClick = onBatteryOptimizationClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.buttonColors(containerColor = Pink500), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Icon( + imageVector = Icons.Default.BatteryAlert, + contentDescription = null, + tint = Color.White + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(id = R.string.disable_battery_optimization), + color = Color.White, + fontSize = 18.sp + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier.width(IntrinsicSize.Max), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = onHeartbeatClick, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isHeartbeatLoading, + colors = ButtonDefaults.buttonColors(containerColor = Blue500), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = null, + tint = if (uiState.isHeartbeatLoading) LocalContentColor.current else Pink500 + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(id = R.string.send_heartbeat), + color = Color.White, + fontSize = 18.sp + ) + } + + if (uiState.isHeartbeatLoading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + color = Pink500 + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = uiState.appVersion, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onSettingsClick, + colors = ButtonDefaults.buttonColors(containerColor = Color.Black), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Icon(Icons.Default.Settings, contentDescription = null, tint = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(id = R.string.main_app_settings), + color = Color.White, + fontSize = 18.sp + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +fun PhoneCard( + phoneNumber: String, + isActive: Boolean, + refreshTime: String +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = PhoneNumberUtils.formatNumber(phoneNumber, Locale.getDefault().country) ?: phoneNumber, + fontSize = 28.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface + ) + if (isActive) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Active", + tint = Color(0xFF70AB5C), + modifier = Modifier.size(24.dp) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = refreshTime, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/main/MainViewModel.kt b/android/app/src/main/java/com/httpsms/ui/main/MainViewModel.kt new file mode 100644 index 000000000..273cfe786 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/main/MainViewModel.kt @@ -0,0 +1,116 @@ +package com.httpsms.ui.main + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.PowerManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.httpsms.Constants +import com.httpsms.HttpSmsApiService +import com.httpsms.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +data class MainUiState( + val phoneNumberSIM1: String = "", + val isActiveSIM1: Boolean = false, + val phoneNumberSIM2: String = "", + val isActiveSIM2: Boolean = false, + val isDualSim: Boolean = false, + val lastHeartbeatTime: String = "--", + val isSmsPermissionGranted: Boolean = true, + val isBatteryOptimizationDisabled: Boolean = true, + val isHeartbeatLoading: Boolean = false, + val appVersion: String = "" +) + +class MainViewModel : ViewModel() { + private val _uiState = MutableStateFlow(MainUiState()) + val uiState = _uiState.asStateFlow() + + fun initialize(context: Context, appVersion: String) { + updateState(context, appVersion) + } + + fun updateState(context: Context, appVersion: String) { + val isDualSim = Settings.isDualSIM(context) + val phone1 = Settings.getSIM1PhoneNumber(context) ?: "" + val active1 = Settings.getActiveStatus(context, Constants.SIM1) + val phone2 = Settings.getSIM2PhoneNumber(context) ?: "" + val active2 = Settings.getActiveStatus(context, Constants.SIM2) + + val timestamp = Settings.getHeartbeatTimestamp(context) + val lastHeartbeat = if (timestamp == 0L) { + "--" + } else { + val timestampZdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) + val localTime = timestampZdt.withZoneSameInstant(ZoneId.systemDefault()) + localTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + } + + val smsPermissions = arrayOf( + Manifest.permission.SEND_SMS, + Manifest.permission.RECEIVE_SMS, + Manifest.permission.READ_SMS + ) + val allGranted = smsPermissions.all { + context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val batteryOptimized = pm.isIgnoringBatteryOptimizations(context.packageName) + + _uiState.value = _uiState.value.copy( + phoneNumberSIM1 = phone1, + isActiveSIM1 = active1, + phoneNumberSIM2 = phone2, + isActiveSIM2 = active2, + isDualSim = isDualSim, + lastHeartbeatTime = lastHeartbeat, + isSmsPermissionGranted = allGranted, + isBatteryOptimizationDisabled = batteryOptimized, + appVersion = appVersion + ) + } + + fun sendHeartbeat(context: Context, onComplete: (String?) -> Unit) { + _uiState.value = _uiState.value.copy(isHeartbeatLoading = true) + + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + val charging = Settings.isCharging(context) + try { + val phoneNumbers = mutableListOf() + phoneNumbers.add(Settings.getSIM1PhoneNumber(context)) + if (Settings.getActiveStatus(context, Constants.SIM2)) { + phoneNumbers.add(Settings.getSIM2PhoneNumber(context)) + } + val isStored = HttpSmsApiService.create(context).storeHeartbeat(phoneNumbers.toTypedArray(), charging) + if (!isStored) { + "Could not send heartbeat make sure the phone is connected to the internet" + } else { + Settings.setHeartbeatTimestampAsync(context, System.currentTimeMillis()) + null + } + } catch (exception: Exception) { + Timber.e(exception) + exception.javaClass.simpleName + } + } + + _uiState.value = _uiState.value.copy(isHeartbeatLoading = false) + updateState(context, _uiState.value.appVersion) + onComplete(result) + } + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/settings/SettingsActivityContent.kt b/android/app/src/main/java/com/httpsms/ui/settings/SettingsActivityContent.kt new file mode 100644 index 000000000..3335eb2c2 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/settings/SettingsActivityContent.kt @@ -0,0 +1,221 @@ +package com.httpsms.ui.settings + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat +import com.httpsms.R +import com.httpsms.ui.theme.LogoGreen + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel, + onBackClick: () -> Unit, + onLogoutClick: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() + val primaryColor = if (isDarkTheme) Color.Black else LogoGreen + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = primaryColor.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("App Settings") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = primaryColor, + titleContentColor = Color.White, + navigationIconContentColor = Color.White + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // SIM 1 Settings + OutlinedTextField( + value = uiState.phoneNumberSIM1, + onValueChange = { }, + label = { Text(stringResource(id = R.string.settings_sim1)) }, + modifier = Modifier.fillMaxWidth(), + enabled = false + ) + + SwitchSetting( + text = stringResource(id = R.string.settings_outgoing_messages_sim1), + checked = uiState.isActiveSIM1, + onCheckedChange = { viewModel.setActiveSIM1(context, it) } + ) + + SwitchSetting( + text = stringResource(id = R.string.settings_incoming_messages_sim1), + checked = uiState.isIncomingSIM1Enabled, + onCheckedChange = { viewModel.setIncomingSIM1Enabled(context, it) } + ) + + SwitchSetting( + text = stringResource(id = R.string.enable_incoming_call_events_sim1), + checked = uiState.isIncomingCallEventsSIM1Enabled, + onCheckedChange = { viewModel.setIncomingCallEventsSIM1Enabled(context, it) } + ) + + if (uiState.isDualSim) { + Spacer(modifier = Modifier.height(16.dp)) + // SIM 2 Settings + OutlinedTextField( + value = uiState.phoneNumberSIM2, + onValueChange = { }, + label = { Text(stringResource(id = R.string.settings_sim_2)) }, + modifier = Modifier.fillMaxWidth(), + enabled = false + ) + + SwitchSetting( + text = stringResource(id = R.string.settings_outgoing_messages_sim2), + checked = uiState.isActiveSIM2, + onCheckedChange = { viewModel.setActiveSIM2(context, it) } + ) + + SwitchSetting( + text = stringResource(id = R.string.settings_incoming_messages_sim2), + checked = uiState.isIncomingSIM2Enabled, + onCheckedChange = { viewModel.setIncomingSIM2Enabled(context, it) } + ) + + SwitchSetting( + text = stringResource(id = R.string.enable_sim2_incoming_call_events), + checked = uiState.isIncomingCallEventsSIM2Enabled, + onCheckedChange = { viewModel.setIncomingCallEventsSIM2Enabled(context, it) } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.encryptionKey, + onValueChange = { viewModel.setEncryptionKey(context, it) }, + label = { Text(stringResource(id = R.string.encryption_key)) }, + modifier = Modifier.fillMaxWidth() + ) + + SwitchSetting( + text = stringResource(id = R.string.encrypt_received_messages), + checked = uiState.isEncryptReceivedMessagesEnabled, + onCheckedChange = { viewModel.setEncryptReceivedMessagesEnabled(context, it) }, + enabled = uiState.encryptionKey.isNotEmpty() + ) + + SwitchSetting( + text = stringResource(id = R.string.enable_debug_logs), + checked = uiState.isDebugLogEnabled, + onCheckedChange = { viewModel.setDebugLogEnabled(context, it) } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onLogoutClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.buttonColors(containerColor = Color.Black), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Logout, + contentDescription = null, + tint = Color.White + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(id = R.string.main_log_out), + color = Color.White, + fontSize = 18.sp + ) + } + } + } +} + +@Composable +fun SwitchSetting( + text: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + modifier = Modifier.weight(1f), + fontSize = 18.sp, + color = if (enabled) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f) + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled + ) + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/httpsms/ui/settings/SettingsViewModel.kt new file mode 100644 index 000000000..33a23e574 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/settings/SettingsViewModel.kt @@ -0,0 +1,114 @@ +package com.httpsms.ui.settings + +import android.content.Context +import androidx.lifecycle.ViewModel +import com.httpsms.Constants +import com.httpsms.Settings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class SettingsUiState( + val isDebugLogEnabled: Boolean = false, + val phoneNumberSIM1: String = "", + val isIncomingSIM1Enabled: Boolean = false, + val isActiveSIM1: Boolean = false, + val isIncomingCallEventsSIM1Enabled: Boolean = false, + val isDualSim: Boolean = false, + val phoneNumberSIM2: String = "", + val isIncomingSIM2Enabled: Boolean = false, + val isActiveSIM2: Boolean = false, + val isIncomingCallEventsSIM2Enabled: Boolean = false, + val encryptionKey: String = "", + val isEncryptReceivedMessagesEnabled: Boolean = false +) + +class SettingsViewModel : ViewModel() { + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState = _uiState.asStateFlow() + + fun initialize(context: Context) { + _uiState.value = SettingsUiState( + isDebugLogEnabled = Settings.isDebugLogEnabled(context), + phoneNumberSIM1 = Settings.getSIM1PhoneNumber(context) ?: "", + isIncomingSIM1Enabled = Settings.isIncomingMessageEnabled(context, Constants.SIM1), + isActiveSIM1 = Settings.getActiveStatus(context, Constants.SIM1), + isIncomingCallEventsSIM1Enabled = Settings.isIncomingCallEventsEnabled(context, Constants.SIM1), + isDualSim = Settings.isDualSIM(context), + phoneNumberSIM2 = Settings.getSIM2PhoneNumber(context) ?: "", + isIncomingSIM2Enabled = Settings.isIncomingMessageEnabled(context, Constants.SIM2), + isActiveSIM2 = Settings.getActiveStatus(context, Constants.SIM2), + isIncomingCallEventsSIM2Enabled = Settings.isIncomingCallEventsEnabled(context, Constants.SIM2), + encryptionKey = Settings.getEncryptionKey(context) ?: "", + isEncryptReceivedMessagesEnabled = Settings.encryptReceivedMessages(context) + ) + } + + fun setDebugLogEnabled(context: Context, enabled: Boolean) { + Settings.setDebugLogEnabled(context, enabled) + _uiState.value = _uiState.value.copy(isDebugLogEnabled = enabled) + } + + fun setIncomingSIM1Enabled(context: Context, enabled: Boolean) { + Settings.setIncomingActiveSIM1(context, enabled) + _uiState.value = _uiState.value.copy(isIncomingSIM1Enabled = enabled) + } + + fun setActiveSIM1(context: Context, enabled: Boolean) { + Settings.setActiveStatusAsync(context, enabled, Constants.SIM1) + _uiState.value = _uiState.value.copy(isActiveSIM1 = enabled) + } + + fun setIncomingCallEventsSIM1Enabled(context: Context, enabled: Boolean) { + Settings.setIncomingCallEventsEnabled(context, Constants.SIM1, enabled) + _uiState.value = _uiState.value.copy(isIncomingCallEventsSIM1Enabled = enabled) + } + + fun setIncomingSIM2Enabled(context: Context, enabled: Boolean) { + Settings.setIncomingActiveSIM2(context, enabled) + _uiState.value = _uiState.value.copy(isIncomingSIM2Enabled = enabled) + } + + fun setActiveSIM2(context: Context, enabled: Boolean) { + Settings.setActiveStatusAsync(context, enabled, Constants.SIM2) + _uiState.value = _uiState.value.copy(isActiveSIM2 = enabled) + } + + fun setIncomingCallEventsSIM2Enabled(context: Context, enabled: Boolean) { + Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, enabled) + _uiState.value = _uiState.value.copy(isIncomingCallEventsSIM2Enabled = enabled) + } + + fun setEncryptionKey(context: Context, key: String) { + val trimmedKey = key.trim() + if (trimmedKey.isEmpty()) { + Settings.setEncryptionKey(context, null) + Settings.setEncryptReceivedMessages(context, false) + _uiState.value = _uiState.value.copy(encryptionKey = "", isEncryptReceivedMessagesEnabled = false) + } else { + Settings.setEncryptionKey(context, trimmedKey) + _uiState.value = _uiState.value.copy(encryptionKey = trimmedKey) + } + } + + fun setEncryptReceivedMessagesEnabled(context: Context, enabled: Boolean) { + Settings.setEncryptReceivedMessages(context, enabled) + _uiState.value = _uiState.value.copy(isEncryptReceivedMessagesEnabled = enabled) + } + + fun logout(context: Context, onLogoutComplete: () -> Unit) { + Settings.setApiKeyAsync(context, null) + Settings.setSIM1PhoneNumber(context, null) + Settings.setSIM2PhoneNumber(context, null) + Settings.setActiveStatusAsync(context, true, Constants.SIM1) + Settings.setActiveStatusAsync(context, true, Constants.SIM2) + Settings.setIncomingActiveSIM1(context, true) + Settings.setIncomingActiveSIM2(context, true) + Settings.setUserID(context, null) + Settings.setEncryptionKey(context, null) + Settings.setEncryptReceivedMessages(context, false) + Settings.setFcmTokenLastUpdateTimestampAsync(context, 0) + Settings.setIncomingCallEventsEnabled(context, Constants.SIM1, false) + Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, false) + onLogoutComplete() + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/theme/Theme.kt b/android/app/src/main/java/com/httpsms/ui/theme/Theme.kt new file mode 100644 index 000000000..88c032fa6 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/theme/Theme.kt @@ -0,0 +1,75 @@ +package com.httpsms.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +val Blue500 = Color(0xFF2196F3) +val Pink500 = Color(0xFFE91E63) +val LogoGreen = Color(0xFF70AB5C) + +private val DarkColorScheme = darkColorScheme( + primary = Blue500, + secondary = Pink500, + tertiary = Color.White +) + +private val LightColorScheme = lightColorScheme( + primary = Blue500, + secondary = Pink500, + tertiary = Color.Black + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun HttpSmsTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/android/app/src/main/java/com/httpsms/ui/theme/Typography.kt b/android/app/src/main/java/com/httpsms/ui/theme/Typography.kt new file mode 100644 index 000000000..d8030fef1 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/theme/Typography.kt @@ -0,0 +1,17 @@ +package com.httpsms.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/android/app/src/main/res/layout/activity_login.xml b/android/app/src/main/res/layout/activity_login.xml deleted file mode 100644 index 03552be57..000000000 --- a/android/app/src/main/res/layout/activity_login.xml +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 758494754..000000000 --- a/android/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml deleted file mode 100644 index bfc078cae..000000000 --- a/android/app/src/main/res/layout/activity_settings.xml +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/build.gradle.kts b/android/build.gradle.kts index ee8d1b8d2..3be829b64 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -5,13 +5,15 @@ buildscript { mavenCentral() } dependencies { - classpath("com.google.gms:google-services:4.4.2") + classpath("com.google.gms:google-services:4.5.0") } } plugins { id("com.android.application") version "9.2.1" apply false id("com.android.library") version "9.2.1" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.4.0" apply false } tasks.register("clean") { diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000..8b5316926 --- /dev/null +++ b/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5c55020ad1e1758e65cf84d78a6991d9/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/057be4d72513d094c747045acda26562/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5c55020ad1e1758e65cf84d78a6991d9/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/057be4d72513d094c747045acda26562/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/690f22c7d5fabdba81db34b2bcbdfd40/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/19d7c2d18fdc600f184666745a0567c4/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5c55020ad1e1758e65cf84d78a6991d9/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/057be4d72513d094c747045acda26562/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/38d4fc46de6f02af9912aa3f66104600/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/34480c4bfcee5d3bd65f2a0592c5217b/redirect +toolchainVendor=JETBRAINS +toolchainVersion=25 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index ff340ba9e..731777d4a 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Jun 23 15:32:32 EEST 2022 +#Sun Aug 23 12:56:53 EEST 2026 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 75be430ae..e3069c218 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -5,6 +5,9 @@ pluginManagement { mavenCentral() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { diff --git a/api/docs/docs.go b/api/docs/docs.go index c57458255..eb72ac76d 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -242,6 +242,372 @@ const docTemplate = `{ } } }, + "/contacts": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the paginated list of contacts for the authenticated user. The top-level \"total\" field is the number of contacts matching the query filter, independent of skip/limit, so clients can drive server-side pagination.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Contacts" + ], + "summary": "List contacts", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of contacts to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter contacts containing query", + "name": "query", + "in": "query" + }, + { + "enum": [ + "name", + "updated_at" + ], + "type": "string", + "description": "field to sort contacts by", + "name": "sort_by", + "in": "query" + }, + { + "type": "boolean", + "description": "sort contacts in descending order", + "name": "sort_descending", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "description": "number of contacts to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.ContactsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a single contact or a batch of contacts. Accepts a JSON array or an object with a \"contacts\" array.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Contacts" + ], + "summary": "Create one or many contacts", + "parameters": [ + { + "description": "Contact(s) to create", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.ContactStoreRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.ContactsCreatedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "402": { + "description": "Payment Required", + "schema": { + "$ref": "#/definitions/responses.PaymentRequired" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/contacts/upload": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Uploads a CSV file (multipart field \"document\") of contacts. Columns: Name, Emails, PhoneNumbers (multi-values separated by \";\").", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Contacts" + ], + "summary": "Import contacts from CSV", + "parameters": [ + { + "type": "file", + "description": "CSV file of contacts", + "name": "document", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.ContactsCreatedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "402": { + "description": "Payment Required", + "schema": { + "$ref": "#/definitions/responses.PaymentRequired" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/contacts/{contactID}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the details of a single contact.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Contacts" + ], + "summary": "Update a contact", + "parameters": [ + { + "type": "string", + "description": "ID of the contact", + "name": "contactID", + "in": "path", + "required": true + }, + { + "description": "Contact details to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.ContactUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.ContactResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a single contact from the database.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Contacts" + ], + "summary": "Delete a contact", + "parameters": [ + { + "type": "string", + "description": "ID of the contact", + "name": "contactID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/discord-integrations": { "get": { "security": [ @@ -794,6 +1160,12 @@ const docTemplate = `{ "description": "number of messages to return", "name": "limit", "in": "query" + }, + { + "type": "boolean", + "description": "include matching contact details", + "name": "contacts", + "in": "query" } ], "responses": { @@ -871,7 +1243,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/responses.PhoneResponse" + "$ref": "#/definitions/responses.MessageThreadResponse" } }, "400": { @@ -886,6 +1258,12 @@ const docTemplate = `{ "$ref": "#/definitions/responses.Unauthorized" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, "422": { "description": "Unprocessable Entity", "schema": { @@ -2042,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" ], @@ -2105,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" ], @@ -3379,7 +3757,7 @@ const docTemplate = `{ }, "request_id": { "type": "string", - "example": "bulk-csv-a1B2c3D4e5" + "example": "bulk-httpsms-file.csv" }, "scheduled_count": { "type": "integer", @@ -3395,6 +3773,66 @@ const docTemplate = `{ } } }, + "entities.Contact": { + "type": "object", + "required": [ + "created_at", + "emails", + "id", + "name", + "phone_numbers", + "properties", + "updated_at", + "user_id" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "emails": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "alice@example.com" + ] + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "name": { + "type": "string", + "example": "Alice Smith" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "+18005550199", + "+18005550100" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } + }, "entities.Discord": { "type": "object", "required": [ @@ -3696,6 +4134,7 @@ const docTemplate = `{ "order_timestamp", "owner", "status", + "unread_count", "updated_at", "user_id" ], @@ -3708,6 +4147,14 @@ const docTemplate = `{ "type": "string", "example": "+18005550100" }, + "contact_details": { + "description": "ContactDetails is resolved at read time and never persisted.", + "allOf": [ + { + "$ref": "#/definitions/entities.Contact" + } + ] + }, "created_at": { "type": "string", "example": "2022-06-05T14:26:09.527976+03:00" @@ -3740,6 +4187,10 @@ const docTemplate = `{ "type": "string", "example": "PENDING" }, + "unread_count": { + "type": "integer", + "example": 0 + }, "updated_at": { "type": "string", "example": "2022-06-05T14:26:09.527976+03:00" @@ -3760,6 +4211,7 @@ const docTemplate = `{ "messages_per_minute", "phone_number", "sim", + "unarchive_thread", "updated_at", "user_id" ], @@ -3804,6 +4256,11 @@ const docTemplate = `{ "sim": { "$ref": "#/definitions/entities.SIM" }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false + }, "updated_at": { "type": "string", "example": "2022-06-05T14:26:10.303278+03:00" @@ -4063,6 +4520,82 @@ const docTemplate = `{ } } }, + "requests.ContactItem": { + "type": "object", + "required": [ + "name", + "phone_numbers" + ], + "properties": { + "emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string", + "example": "Alice Smith" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "requests.ContactStoreRequest": { + "type": "object", + "required": [ + "contacts" + ], + "properties": { + "contacts": { + "type": "array", + "items": { + "$ref": "#/definitions/requests.ContactItem" + } + } + } + }, + "requests.ContactUpdateRequest": { + "type": "object", + "required": [ + "name", + "phone_numbers" + ], + "properties": { + "emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string", + "example": "Alice Smith" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "requests.DiscordStore": { "type": "object", "required": [ @@ -4382,13 +4915,14 @@ const docTemplate = `{ }, "requests.MessageThreadUpdate": { "type": "object", - "required": [ - "is_archived" - ], "properties": { "is_archived": { "type": "boolean", "example": true + }, + "unread_count": { + "type": "integer", + "example": 0 } } }, @@ -4413,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....." }, @@ -4436,10 +4971,12 @@ const docTemplate = `{ "messages_per_minute", "missed_call_auto_reply", "phone_number", - "sim" + "sim", + "unarchive_thread" ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, @@ -4473,6 +5010,11 @@ const docTemplate = `{ "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", "type": "string", "example": "SIM1" + }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false } } }, @@ -4719,6 +5261,81 @@ const docTemplate = `{ } } }, + "responses.ContactResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.Contact" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.ContactsCreatedResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Contact" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.ContactsResponse": { + "type": "object", + "required": [ + "data", + "message", + "status", + "total" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Contact" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + }, + "total": { + "description": "Total is the number of contacts matching the request filter for the\nuser, independent of the pagination skip/limit applied to Data.", + "type": "integer", + "example": 57 + } + } + }, "responses.DiscordResponse": { "type": "object", "required": [ @@ -4892,6 +5509,27 @@ const docTemplate = `{ } } }, + "responses.MessageThreadResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.MessageThread" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, "responses.MessageThreadsResponse": { "type": "object", "required": [ diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 009797ca9..59c5637ba 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -239,6 +239,372 @@ } } }, + "/contacts": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the paginated list of contacts for the authenticated user. The top-level \"total\" field is the number of contacts matching the query filter, independent of skip/limit, so clients can drive server-side pagination.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Contacts" + ], + "summary": "List contacts", + "parameters": [ + { + "minimum": 0, + "type": "integer", + "description": "number of contacts to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter contacts containing query", + "name": "query", + "in": "query" + }, + { + "enum": [ + "name", + "updated_at" + ], + "type": "string", + "description": "field to sort contacts by", + "name": "sort_by", + "in": "query" + }, + { + "type": "boolean", + "description": "sort contacts in descending order", + "name": "sort_descending", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "description": "number of contacts to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.ContactsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a single contact or a batch of contacts. Accepts a JSON array or an object with a \"contacts\" array.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Contacts" + ], + "summary": "Create one or many contacts", + "parameters": [ + { + "description": "Contact(s) to create", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.ContactStoreRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.ContactsCreatedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "402": { + "description": "Payment Required", + "schema": { + "$ref": "#/definitions/responses.PaymentRequired" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/contacts/upload": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Uploads a CSV file (multipart field \"document\") of contacts. Columns: Name, Emails, PhoneNumbers (multi-values separated by \";\").", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Contacts" + ], + "summary": "Import contacts from CSV", + "parameters": [ + { + "type": "file", + "description": "CSV file of contacts", + "name": "document", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/responses.ContactsCreatedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "402": { + "description": "Payment Required", + "schema": { + "$ref": "#/definitions/responses.PaymentRequired" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/contacts/{contactID}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates the details of a single contact.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Contacts" + ], + "summary": "Update a contact", + "parameters": [ + { + "type": "string", + "description": "ID of the contact", + "name": "contactID", + "in": "path", + "required": true + }, + { + "description": "Contact details to update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.ContactUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.ContactResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a single contact from the database.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Contacts" + ], + "summary": "Delete a contact", + "parameters": [ + { + "type": "string", + "description": "ID of the contact", + "name": "contactID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.NoContent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/discord-integrations": { "get": { "security": [ @@ -791,6 +1157,12 @@ "description": "number of messages to return", "name": "limit", "in": "query" + }, + { + "type": "boolean", + "description": "include matching contact details", + "name": "contacts", + "in": "query" } ], "responses": { @@ -868,7 +1240,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/responses.PhoneResponse" + "$ref": "#/definitions/responses.MessageThreadResponse" } }, "400": { @@ -883,6 +1255,12 @@ "$ref": "#/definitions/responses.Unauthorized" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, "422": { "description": "Unprocessable Entity", "schema": { @@ -2039,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" ], @@ -2102,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" ], @@ -3376,7 +3754,7 @@ }, "request_id": { "type": "string", - "example": "bulk-csv-a1B2c3D4e5" + "example": "bulk-httpsms-file.csv" }, "scheduled_count": { "type": "integer", @@ -3392,6 +3770,66 @@ } } }, + "entities.Contact": { + "type": "object", + "required": [ + "created_at", + "emails", + "id", + "name", + "phone_numbers", + "properties", + "updated_at", + "user_id" + ], + "properties": { + "created_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "emails": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "alice@example.com" + ] + }, + "id": { + "type": "string", + "example": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "name": { + "type": "string", + "example": "Alice Smith" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "+18005550199", + "+18005550100" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "updated_at": { + "type": "string", + "example": "2022-06-05T14:26:02.302718+03:00" + }, + "user_id": { + "type": "string", + "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + } + } + }, "entities.Discord": { "type": "object", "required": [ @@ -3693,6 +4131,7 @@ "order_timestamp", "owner", "status", + "unread_count", "updated_at", "user_id" ], @@ -3705,6 +4144,14 @@ "type": "string", "example": "+18005550100" }, + "contact_details": { + "description": "ContactDetails is resolved at read time and never persisted.", + "allOf": [ + { + "$ref": "#/definitions/entities.Contact" + } + ] + }, "created_at": { "type": "string", "example": "2022-06-05T14:26:09.527976+03:00" @@ -3737,6 +4184,10 @@ "type": "string", "example": "PENDING" }, + "unread_count": { + "type": "integer", + "example": 0 + }, "updated_at": { "type": "string", "example": "2022-06-05T14:26:09.527976+03:00" @@ -3757,6 +4208,7 @@ "messages_per_minute", "phone_number", "sim", + "unarchive_thread", "updated_at", "user_id" ], @@ -3801,6 +4253,11 @@ "sim": { "$ref": "#/definitions/entities.SIM" }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false + }, "updated_at": { "type": "string", "example": "2022-06-05T14:26:10.303278+03:00" @@ -4060,6 +4517,82 @@ } } }, + "requests.ContactItem": { + "type": "object", + "required": [ + "name", + "phone_numbers" + ], + "properties": { + "emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string", + "example": "Alice Smith" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "requests.ContactStoreRequest": { + "type": "object", + "required": [ + "contacts" + ], + "properties": { + "contacts": { + "type": "array", + "items": { + "$ref": "#/definitions/requests.ContactItem" + } + } + } + }, + "requests.ContactUpdateRequest": { + "type": "object", + "required": [ + "name", + "phone_numbers" + ], + "properties": { + "emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string", + "example": "Alice Smith" + }, + "phone_numbers": { + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "requests.DiscordStore": { "type": "object", "required": [ @@ -4379,13 +4912,14 @@ }, "requests.MessageThreadUpdate": { "type": "object", - "required": [ - "is_archived" - ], "properties": { "is_archived": { "type": "boolean", "example": true + }, + "unread_count": { + "type": "integer", + "example": 0 } } }, @@ -4410,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....." }, @@ -4433,10 +4968,12 @@ "messages_per_minute", "missed_call_auto_reply", "phone_number", - "sim" + "sim", + "unarchive_thread" ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, @@ -4470,6 +5007,11 @@ "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", "type": "string", "example": "SIM1" + }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false } } }, @@ -4716,6 +5258,81 @@ } } }, + "responses.ContactResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.Contact" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.ContactsCreatedResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Contact" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.ContactsResponse": { + "type": "object", + "required": [ + "data", + "message", + "status", + "total" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Contact" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + }, + "total": { + "description": "Total is the number of contacts matching the request filter for the\nuser, independent of the pagination skip/limit applied to Data.", + "type": "integer", + "example": 57 + } + } + }, "responses.DiscordResponse": { "type": "object", "required": [ @@ -4889,6 +5506,27 @@ } } }, + "responses.MessageThreadResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.MessageThread" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, "responses.MessageThreadsResponse": { "type": "object", "required": [ diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index bb0041717..6dc240216 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -58,7 +58,7 @@ definitions: example: 30 type: integer request_id: - example: bulk-csv-a1B2c3D4e5 + example: bulk-httpsms-file.csv type: string scheduled_count: example: 50 @@ -80,6 +80,50 @@ definitions: - sent_count - total type: object + entities.Contact: + properties: + created_at: + example: "2022-06-05T14:26:02.302718+03:00" + type: string + emails: + example: + - alice@example.com + items: + type: string + type: array + id: + example: 32343a19-da5e-4b1b-a767-3298a73703cb + type: string + name: + example: Alice Smith + type: string + phone_numbers: + example: + - "+18005550199" + - "+18005550100" + items: + type: string + type: array + properties: + additionalProperties: + type: string + type: object + updated_at: + example: "2022-06-05T14:26:02.302718+03:00" + type: string + user_id: + example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC + type: string + required: + - created_at + - emails + - id + - name + - phone_numbers + - properties + - updated_at + - user_id + type: object entities.Discord: properties: created_at: @@ -310,6 +354,10 @@ definitions: contact: example: "+18005550100" type: string + contact_details: + allOf: + - $ref: '#/definitions/entities.Contact' + description: ContactDetails is resolved at read time and never persisted. created_at: example: "2022-06-05T14:26:09.527976+03:00" type: string @@ -334,6 +382,9 @@ definitions: status: example: PENDING type: string + unread_count: + example: 0 + type: integer updated_at: example: "2022-06-05T14:26:09.527976+03:00" type: string @@ -351,6 +402,7 @@ definitions: - order_timestamp - owner - status + - unread_count - updated_at - user_id type: object @@ -388,6 +440,11 @@ definitions: type: string sim: $ref: '#/definitions/entities.SIM' + unarchive_thread: + description: UnarchiveThread moves an archived message thread back to the + inbox when a new message is received on this phone. + example: false + type: boolean updated_at: example: "2022-06-05T14:26:10.303278+03:00" type: string @@ -402,6 +459,7 @@ definitions: - messages_per_minute - phone_number - sim + - unarchive_thread - updated_at - user_id type: object @@ -595,6 +653,57 @@ definitions: - url - user_id type: object + requests.ContactItem: + properties: + emails: + items: + type: string + type: array + name: + example: Alice Smith + type: string + phone_numbers: + items: + type: string + type: array + properties: + additionalProperties: + type: string + type: object + required: + - name + - phone_numbers + type: object + requests.ContactStoreRequest: + properties: + contacts: + items: + $ref: '#/definitions/requests.ContactItem' + type: array + required: + - contacts + type: object + requests.ContactUpdateRequest: + properties: + emails: + items: + type: string + type: array + name: + example: Alice Smith + type: string + phone_numbers: + items: + type: string + type: array + properties: + additionalProperties: + type: string + type: object + required: + - name + - phone_numbers + type: object requests.DiscordStore: properties: incoming_channel_id: @@ -847,8 +956,9 @@ definitions: is_archived: example: true type: boolean - required: - - is_archived + unread_count: + example: 0 + type: integer type: object requests.PhoneAPIKeyStoreRequest: properties: @@ -861,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: @@ -879,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: @@ -908,6 +1022,11 @@ definitions: 1 SIM slot example: SIM1 type: string + unarchive_thread: + description: UnarchiveThread moves an archived thread back to the inbox when + a new message is received on this phone. + example: false + type: boolean required: - fcm_token - max_send_attempts @@ -916,6 +1035,7 @@ definitions: - missed_call_auto_reply - phone_number - sim + - unarchive_thread type: object requests.UserNotificationUpdate: properties: @@ -1092,6 +1212,62 @@ definitions: - message - status type: object + responses.ContactResponse: + properties: + data: + $ref: '#/definitions/entities.Contact' + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status + type: object + responses.ContactsCreatedResponse: + properties: + data: + items: + $ref: '#/definitions/entities.Contact' + type: array + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status + type: object + responses.ContactsResponse: + properties: + data: + items: + $ref: '#/definitions/entities.Contact' + type: array + message: + example: Request handled successfully + type: string + status: + example: success + type: string + total: + description: |- + Total is the number of contacts matching the request filter for the + user, independent of the pagination skip/limit applied to Data. + example: 57 + type: integer + required: + - data + - message + - status + - total + type: object responses.DiscordResponse: properties: data: @@ -1215,6 +1391,21 @@ definitions: - message - status type: object + responses.MessageThreadResponse: + properties: + data: + $ref: '#/definitions/entities.MessageThread' + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status + type: object responses.MessageThreadsResponse: properties: data: @@ -1715,6 +1906,247 @@ paths: summary: Store bulk SMS file tags: - BulkSMS + /contacts: + get: + consumes: + - application/json + description: Returns the paginated list of contacts for the authenticated user. + The top-level "total" field is the number of contacts matching the query filter, + independent of skip/limit, so clients can drive server-side pagination. + parameters: + - description: number of contacts to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter contacts containing query + in: query + name: query + type: string + - description: field to sort contacts by + enum: + - name + - updated_at + in: query + name: sort_by + type: string + - description: sort contacts in descending order + in: query + name: sort_descending + type: boolean + - description: number of contacts to return + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/responses.ContactsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/responses.BadRequest' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/responses.UnprocessableEntity' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: List contacts + tags: + - Contacts + post: + consumes: + - application/json + description: Creates a single contact or a batch of contacts. Accepts a JSON + array or an object with a "contacts" array. + parameters: + - description: Contact(s) to create + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.ContactStoreRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/responses.ContactsCreatedResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/responses.BadRequest' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "402": + description: Payment Required + schema: + $ref: '#/definitions/responses.PaymentRequired' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/responses.UnprocessableEntity' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Create one or many contacts + tags: + - Contacts + /contacts/{contactID}: + delete: + consumes: + - application/json + description: Deletes a single contact from the database. + parameters: + - description: ID of the contact + in: path + name: contactID + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + schema: + $ref: '#/definitions/responses.NoContent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/responses.BadRequest' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "404": + description: Not Found + schema: + $ref: '#/definitions/responses.NotFound' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/responses.UnprocessableEntity' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Delete a contact + tags: + - Contacts + put: + consumes: + - application/json + description: Updates the details of a single contact. + parameters: + - description: ID of the contact + in: path + name: contactID + required: true + type: string + - description: Contact details to update + in: body + name: payload + required: true + schema: + $ref: '#/definitions/requests.ContactUpdateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/responses.ContactResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/responses.BadRequest' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "404": + description: Not Found + schema: + $ref: '#/definitions/responses.NotFound' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/responses.UnprocessableEntity' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Update a contact + tags: + - Contacts + /contacts/upload: + post: + consumes: + - multipart/form-data + description: 'Uploads a CSV file (multipart field "document") of contacts. Columns: + Name, Emails, PhoneNumbers (multi-values separated by ";").' + parameters: + - description: CSV file of contacts + in: formData + name: document + required: true + type: file + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/responses.ContactsCreatedResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/responses.BadRequest' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "402": + description: Payment Required + schema: + $ref: '#/definitions/responses.PaymentRequired' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/responses.UnprocessableEntity' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Import contacts from CSV + tags: + - Contacts /discord-integrations: get: consumes: @@ -2074,6 +2506,10 @@ paths: minimum: 1 name: limit type: integer + - description: include matching contact details + in: query + name: contacts + type: boolean produces: - application/json responses: @@ -2170,7 +2606,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/responses.PhoneResponse' + $ref: '#/definitions/responses.MessageThreadResponse' "400": description: Bad Request schema: @@ -2179,6 +2615,10 @@ paths: description: Unauthorized schema: $ref: '#/definitions/responses.Unauthorized' + "404": + description: Not Found + schema: + $ref: '#/definitions/responses.NotFound' "422": description: Unprocessable Entity schema: @@ -2901,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 @@ -2981,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 d8f600c72..1f305a110 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,29 +1,31 @@ module github.com/NdoleStudio/httpsms -go 1.25.8 +go 1.26.0 require ( - cloud.google.com/go/cloudtasks v1.18.0 - cloud.google.com/go/storage v1.62.2 + 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.56.0 - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.32.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.1 + github.com/NdoleStudio/lemonsqueezy-go v1.3.2 github.com/NdoleStudio/plunk-go v0.0.2 + github.com/NdoleStudio/stacktrace v1.1.0 github.com/avast/retry-go/v5 v5.0.0 github.com/axiomhq/axiom-go v0.32.0 - github.com/carlmjohnson/requests v0.25.1 + github.com/carlmjohnson/requests v0.26.1 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cockroachdb/cockroach-go/v2 v2.4.3 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc - github.com/dgraph-io/ristretto/v2 v2.4.0 + github.com/dgraph-io/ristretto/v2 v2.4.2 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/otelfiber v1.0.10 - github.com/gofiber/fiber/v2 v2.52.13 - github.com/gofiber/swagger v1.1.1 + 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 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 @@ -33,33 +35,32 @@ require ( github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/jszwec/csvutil v1.10.0 github.com/lib/pq v1.12.3 - github.com/nyaruka/phonenumbers v1.7.5 - github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177 + github.com/nyaruka/phonenumbers v1.8.1 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.20.0 - github.com/redis/go-redis/v9 v9.20.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.11.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.10.1 - go.mongodb.org/mongo-driver/v2 v2.6.0 - go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260513205827-ba143fc95a5e - go.opentelemetry.io/otel v1.44.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/metric v1.44.0 - go.opentelemetry.io/otel/sdk v1.44.0 - go.opentelemetry.io/otel/sdk/metric v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/sync v0.20.0 - google.golang.org/api v0.282.0 - google.golang.org/protobuf v1.36.11 + github.com/xuri/excelize/v2 v2.11.0 + 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.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.23.0 + google.golang.org/api v0.295.0 + google.golang.org/protobuf v1.36.12 gorm.io/driver/postgres v1.6.0 - gorm.io/gorm v1.31.1 + gorm.io/gorm v1.31.2 gorm.io/plugin/opentelemetry v0.1.16 ) @@ -76,28 +77,27 @@ require ( ) require ( - cel.dev/expr v0.25.1 // indirect + cel.dev/expr v0.25.2 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth v0.23.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/firestore v1.22.0 // indirect - cloud.google.com/go/iam v1.10.0 // indirect - cloud.google.com/go/longrunning v0.12.0 // indirect - cloud.google.com/go/monitoring v1.28.0 // indirect - cloud.google.com/go/trace v1.15.0 // indirect + cloud.google.com/go/firestore v1.24.0 // indirect + cloud.google.com/go/iam v1.12.0 // indirect + cloud.google.com/go/longrunning v1.2.0 // indirect + cloud.google.com/go/monitoring v1.30.0 // indirect + cloud.google.com/go/trace v1.16.0 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.2.0 // indirect - github.com/ClickHouse/ch-go v0.71.0 // indirect + 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.32.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.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 - github.com/andybalholm/brotli v1.2.1 // indirect - github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/buger/jsonparser v1.1.2 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/andybalholm/cascadia v1.3.4 // indirect + github.com/buger/jsonparser v1.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -107,68 +107,72 @@ require ( github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/fatih/color v1.19.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/go-logr/logr v1.4.3 // 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.23.1 // indirect - github.com/go-openapi/jsonreference v0.21.5 // indirect - github.com/go-openapi/spec v0.22.4 // indirect - github.com/go-openapi/swag/conv v0.26.0 // indirect - github.com/go-openapi/swag/jsonname v0.26.0 // indirect - github.com/go-openapi/swag/jsonutils v0.26.0 // indirect - github.com/go-openapi/swag/loading v0.26.0 // indirect - github.com/go-openapi/swag/stringutils v0.26.0 // indirect - github.com/go-openapi/swag/typeutils v0.26.0 // indirect - github.com/go-openapi/swag/yamlutils v0.26.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.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.16 // indirect - github.com/googleapis/gax-go/v2 v2.22.0 // 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 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect 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.18.6 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect - github.com/mattn/go-runewidth v0.0.23 // 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 github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect 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/pierrec/lz4/v4 v4.1.26 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/redis/go-redis/extra/rediscmd/v9 v9.20.0 // indirect - github.com/richardlehane/mscfb v1.0.6 // 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 github.com/shopspring/decimal v1.4.0 // indirect - github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/swaggo/files/v2 v2.0.2 // indirect 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.71.0 // indirect + github.com/valyala/fasthttp v1.74.0 // indirect github.com/vanng822/css v1.0.1 // indirect - github.com/vanng822/go-premailer v1.33.0 // indirect + github.com/vanng822/go-premailer v1.34.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect @@ -176,36 +180,35 @@ require ( github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib v1.43.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 // indirect - go.opentelemetry.io/contrib/processors/minsev v0.16.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect - go.opentelemetry.io/otel/log v0.19.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // 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.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.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.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.4 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.55.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // 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.45.0 // indirect - golang.org/x/text v0.37.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.44.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-20260504160031-60b97b32f348 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.81.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + google.golang.org/genproto 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 gorm.io/driver/mysql v1.6.0 // indirect ) diff --git a/api/go.sum b/api/go.sum index 33e3dfbb2..809da4525 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,51 +1,53 @@ bou.ke/monkey v1.0.2 h1:kWcnsrCNUatbxncxR/ThdYqbytgOIArtYWqcQLQzKLI= bou.ke/monkey v1.0.2/go.mod h1:OqickVX3tNx6t33n1xvtTtu85YN5s6cKwVug+oHMaIA= -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= -cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo= +cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/cloudtasks v1.18.0 h1:KzT7hfix/9/xAf20tNPIxwX59XGpRF0Lun2t8LHOj9E= -cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE= +cloud.google.com/go/cloudtasks v1.19.0 h1:+RK0lPIB6TlcBP7JyqmmhCNihp1Iw4QQ8uxcvlKhBVQ= +cloud.google.com/go/cloudtasks v1.19.0/go.mod h1:8q8wNubq0jFvXW5Pz8P3O7QWJBXOmfrY918FqTgIqHA= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E= -cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= -cloud.google.com/go/iam v1.10.0 h1:cWWt8u8jXv3MzpvBmQgNClvvbVCRukruCJAnoK3fIJY= -cloud.google.com/go/iam v1.10.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= -cloud.google.com/go/logging v1.17.0 h1:rUFekZYwHiKElXCyz3zYBGz4BOeIqzgCKxVLdgrZ5mY= -cloud.google.com/go/logging v1.17.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= -cloud.google.com/go/longrunning v0.12.0 h1:wLv2hXvID9zHejLtcPo1B0JBjErnwZCYAPKSTa65xpY= -cloud.google.com/go/longrunning v0.12.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= -cloud.google.com/go/monitoring v1.28.0 h1:jOe0Wkm+a56ptZnEeyHevXo7+KPWAPPP5wUTEJdP7GY= -cloud.google.com/go/monitoring v1.28.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= -cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4= -cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= -cloud.google.com/go/trace v1.15.0 h1:kAYkTwKyYHkGtAGFuu6qaUFRBkOVr+d1Yo44yZtGtgg= -cloud.google.com/go/trace v1.15.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= +cloud.google.com/go/firestore v1.24.0 h1:x0Z3hrgjYgo2wI9whuBRQcNc2hYwzZDQy/7pkUXbXcs= +cloud.google.com/go/firestore v1.24.0/go.mod h1:5aojyjN4olKUnBZDCRWwM+NsdrrCX3t1qfyERZGOonM= +cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk= +cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY= +cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q= +cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= +cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= +cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg= +cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= +cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= -github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= -github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/ch-go v0.72.0 h1:DSyUd4kuxisOVXlZSXyIQYBAajSErZWC379651DpAMU= +github.com/ClickHouse/ch-go v0.72.0/go.mod h1:eeWlJavWDsMf5fZzLNCYaBiMxVoREJYK00aiZ9FJ3E0= github.com/ClickHouse/clickhouse-go/v2 v2.46.0 h1:s3eRy+hYmu5uzotB6ZhDofgHu8kDgGN/fpmjxRkqSpk= github.com/ClickHouse/clickhouse-go/v2 v2.46.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.32.0 h1:ftVmySBwuOJafpEJnnZvco+iV3p6Lokgu2sd89/qY7M= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.32.0/go.mod h1:nikqFGPI5OGwEsdxXzd3f58sB3tzkjqpqwYOV/S1rmo= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= +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/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= @@ -56,16 +58,18 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/NdoleStudio/go-otelroundtripper v0.0.15 h1:lClvnSNRKfdPejUcMaFa3vv9nkIdONJOzO/WSNBVY2w= github.com/NdoleStudio/go-otelroundtripper v0.0.15/go.mod h1:YkRryIMC2i4a/6S86isXH+bht3Qp4RRB0rOnnnHBPjU= -github.com/NdoleStudio/lemonsqueezy-go v1.3.1 h1:lMUVgdAx2onbOUJIVPR05xAANYuCMXBRaGWpAdA4LiM= -github.com/NdoleStudio/lemonsqueezy-go v1.3.1/go.mod h1:xKRsRX1jSI6mLrVXyWh2sF/1isxTioZrSjWy6HpA3xQ= +github.com/NdoleStudio/lemonsqueezy-go v1.3.2 h1:XUNOzpMriXErxRQRW0dQT0OvkNBP6VAyZKlFWypyGrc= +github.com/NdoleStudio/lemonsqueezy-go v1.3.2/go.mod h1:xKRsRX1jSI6mLrVXyWh2sF/1isxTioZrSjWy6HpA3xQ= github.com/NdoleStudio/plunk-go v0.0.2 h1:afPW7MHK4Z3rsybpJBnmTmxKCLKF1M7sPI+BNGPf35A= github.com/NdoleStudio/plunk-go v0.0.2/go.mod h1:pqG3zKhpn/A2bL1K+WsWzvfTpOeSkYgXhNk5H65uEc8= +github.com/NdoleStudio/stacktrace v1.1.0 h1:Ai0j//4tDo/So3nR8bZgS3pQ4NYmViHlarpIReiyb5k= +github.com/NdoleStudio/stacktrace v1.1.0/go.mod h1:WOBcbXShYu+v8JtjFEM+rRV9GJW50m+dwIvp4sLuVTg= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= -github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= -github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= +github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k= github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8= github.com/axiomhq/axiom-go v0.32.0 h1:aRpbqUAn01hY8aJXQftvWHyXfnrNB2KzN5ZquBWvFcE= @@ -74,10 +78,10 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= -github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= -github.com/carlmjohnson/requests v0.25.1 h1:17zNRLecxtAjhtdEIV+F+wrYfe+AGZUjWJtpndcOUYA= -github.com/carlmjohnson/requests v0.25.1/go.mod h1:z3UEf8IE4sZxZ78spW6/tLdqBkfCu1Fn4RaYMnZ8SRM= +github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g= +github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/carlmjohnson/requests v0.26.1 h1:kFt5j1XsynNKoladW2qJdP9S+UdigvGLW+53DXbN5F4= +github.com/carlmjohnson/requests v0.26.1/go.mod h1:qMGUsG9/8MbvYRMGwvMBVSQQmRswVcVMAzexVyy038g= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -98,8 +102,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU= -github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= +github.com/dgraph-io/ristretto/v2 v2.4.2 h1:x0cvjmUKxt764Yxdk2nr94we1AvPPAMh1rh5TQ+Jo80= +github.com/dgraph-io/ristretto/v2 v2.4.2/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -114,10 +118,12 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= +github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= @@ -129,47 +135,51 @@ github.com/go-hermes/hermes/v2 v2.6.2/go.mod h1:RLVNk31/1KqF35vK3mAaQVuJvMH+K5// github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/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.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= -github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= -github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= -github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= -github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= -github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= -github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= -github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= -github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= -github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= -github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= -github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= -github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= -github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= -github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +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= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= 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/otelfiber v1.0.10 h1:Bu28Pi4pfYmGfIc/9+sNaBbFwTHGY/zpSIK5jBxuRtM= -github.com/gofiber/contrib/otelfiber v1.0.10/go.mod h1:jN6AvS1HolDHTQHFURsV+7jSX96FpXYeKH6nmkq8AIw= -github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk= -github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gofiber/swagger v1.1.1 h1:FZVhVQQ9s1ZKLHL/O0loLh49bYB5l1HEAgxDlcTtkRA= -github.com/gofiber/swagger v1.1.1/go.mod h1:vtvY/sQAMc/lGTUCg0lqmBL7Ht9O7uzChpbvJeJQINw= +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.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= @@ -191,14 +201,14 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= -github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= -github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= -github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk= +github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnwsS76wlg= +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= @@ -217,8 +227,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= -github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jaswdr/faker/v2 v2.9.1 h1:J0Rjqb2/FquZnoZplzkGVL5LmhNkeIpvsSMoJKzn+8E= @@ -235,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.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/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= @@ -245,12 +255,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -262,8 +272,10 @@ 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/nyaruka/phonenumbers v1.7.5 h1:xXnwzxyuoRtZOk0VKboO0XqFEpcG7KvrTejcUJOSJBU= -github.com/nyaruka/phonenumbers v1.7.5/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs= +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= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U= @@ -272,14 +284,14 @@ github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= -github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177 h1:nRlQD0u1871kaznCnn1EvYiMbum36v7hw1DLPEjds4o= -github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177/go.mod h1:ao5zGxj8Z4x60IOVYZUbDSmt3R8Ddo080vEgPosHpak= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= -github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= -github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -289,37 +301,39 @@ 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.20.0 h1:hx/fwTZnY+7hJ3P+zcdFthE6tw/uAEN9TNIZ1CQ7z8Y= -github.com/redis/go-redis/extra/rediscmd/v9 v9.20.0/go.mod h1:IL+oJIDTKpwgW/ld8CA7ldqO2ACgmbOd2WK9WgPqywY= -github.com/redis/go-redis/extra/redisotel/v9 v9.20.0 h1:+Bf7EiyYpSyYf5/JBnsHPDR+tBQ4CgRlBt4WV+QPS0k= -github.com/redis/go-redis/extra/redisotel/v9 v9.20.0/go.mod h1:dRCuOymGRykV8SJVDjvOID00wi2gOthNGg3iLEyrq0A= -github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= -github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= -github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= -github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +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= github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= 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.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= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= -github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= @@ -336,16 +350,20 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/uptrace/uptrace-go v1.43.0 h1:5QuCdyFJdWUEXx6Fr6sYfezdgO6n6lnkOvUTLlyQO7U= 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.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= -github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= +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.33.0 h1:nglIpKn/7e3kIAwYByiH5xpauFur7RwAucqyZ59hcic= -github.com/vanng822/go-premailer v1.33.0/go.mod h1:LGYI7ym6FQ7KcHN16LiQRF+tlan7qwhP1KEhpTINFpo= +github.com/vanng822/go-premailer v1.34.0 h1:CW7RUnjCfXrkuCbgC2wi/Cub7IwKslJWD/OkIBlcQUk= +github.com/vanng822/go-premailer v1.34.0/go.mod h1:LGYI7ym6FQ7KcHN16LiQRF+tlan7qwhP1KEhpTINFpo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= @@ -354,8 +372,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= -github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -367,60 +385,58 @@ 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.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= -go.mongodb.org/mongo-driver/v2 v2.6.0/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.43.0 h1:rv+pngknCr4qpZDxSpEvEoRioutgfbkk82x6MChJQ3U= -go.opentelemetry.io/contrib v1.43.0/go.mod h1:JYdNU7Pl/2ckKMGp8/G7zeyhEbtRmy9Q8bcrtv75Znk= -go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= -go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= -go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260513205827-ba143fc95a5e h1:OX282aWfZNOrSVUPF59HlRhyA+MDcyi4kI8WWXt6A8I= -go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260513205827-ba143fc95a5e/go.mod h1:lw7VQzmNsmkZBRQqOQiREGxO3GtzG/pOVEmKufablmA= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU= -go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY= -go.opentelemetry.io/contrib/processors/minsev v0.16.0 h1:bjTZkvAKnG1mqWgCjU7RkOkHRTMsGlJO/UlqjRCweeU= -go.opentelemetry.io/contrib/processors/minsev v0.16.0/go.mod h1:R2mmaDsqsWb+Y0mQkPifiCwifdotrG4fFoD4z0tim+g= -go.opentelemetry.io/contrib/propagators/b3 v1.19.0 h1:ulz44cpm6V5oAeg5Aw9HyqGFMS6XM7untlMEhD7YzzA= -go.opentelemetry.io/contrib/propagators/b3 v1.19.0/go.mod h1:OzCmE2IVS+asTI+odXQstRGVfXQ4bXv9nMBRK0nNyqQ= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/contrib v1.46.0 h1:PVrao7T/FJZ814/+QbMcIMIIpdjQXC3pi3V11+6DT40= +go.opentelemetry.io/contrib v1.46.0/go.mod h1:1DF/GVJ9wpq32v0EOs2sbV77TFtM3IRzgAVPyjGSe88= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= +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.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= +go.opentelemetry.io/contrib/processors/minsev v0.16.1/go.mod h1:VnF+kZnkagrTfTb2mV+6PTMAtPulgjpcUajm8sRk3tQ= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww= +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/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= -go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= -go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/oteltest v1.0.0-RC3 h1:MjaeegZTaX0Bv9uB9CrdVjOFM/8slRjReoWoV9xDCpY= -go.opentelemetry.io/otel/oteltest v1.0.0-RC3/go.mod h1:xpzajI9JBRr7gX63nO6kAmImmYIAtuQblZ36Z+LfCjE= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= -go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= -go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= -go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -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/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.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= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY= +go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +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.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= @@ -429,117 +445,74 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= -golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +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= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= -google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/api v0.295.0 h1:SSqFeEVjnK5SKo6t7D0E0M7EfX8SP7K0+OJd2Ly5FzU= +google.golang.org/api v0.295.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8= -google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +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-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= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M= gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -553,7 +526,7 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= -gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc= gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y= diff --git a/api/pkg/cache/memory_cache.go b/api/pkg/cache/memory_cache.go index 2e32bcffb..a5b0487d7 100644 --- a/api/pkg/cache/memory_cache.go +++ b/api/pkg/cache/memory_cache.go @@ -2,11 +2,10 @@ package cache import ( "context" - "fmt" "time" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" ttlCache "github.com/patrickmn/go-cache" ) @@ -31,7 +30,7 @@ func (cache *memoryCache) Get(ctx context.Context, key string) (value string, er response, ok := cache.store.Get(key) if !ok { - return "", stacktrace.NewError(fmt.Sprintf("no item found in cache with key [%s]", key)) + return "", stacktrace.NewErrorf("no item found in cache with key [%s]", key) } return response.(string), nil diff --git a/api/pkg/cache/redis_cache.go b/api/pkg/cache/redis_cache.go index 18a6a887b..46d9044bd 100644 --- a/api/pkg/cache/redis_cache.go +++ b/api/pkg/cache/redis_cache.go @@ -3,11 +3,10 @@ package cache import ( "context" "errors" - "fmt" "time" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "github.com/redis/go-redis/v9" ) @@ -32,10 +31,10 @@ func (cache *redisCache) Get(ctx context.Context, key string) (value string, err response, err := cache.client.Get(ctx, key).Result() if errors.Is(err, redis.Nil) { - return "", stacktrace.Propagate(err, fmt.Sprintf("no item found in redis with key [%s]", key)) + return "", stacktrace.Propagatef(err, "no item found in redis with key [%s]", key) } if err != nil { - return "", stacktrace.Propagate(err, fmt.Sprintf("cannot get item in redis with key [%s]", key)) + return "", stacktrace.Propagatef(err, "cannot get item in redis with key [%s]", key) } return response, nil } @@ -47,7 +46,7 @@ func (cache *redisCache) Set(ctx context.Context, key string, value string, ttl err := cache.client.Set(ctx, key, value, ttl).Err() if err != nil { - return cache.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot set item in redis")) + return cache.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot set item in redis")) } return nil } diff --git a/api/pkg/di/config.go b/api/pkg/di/config.go index 586934f98..0c6b86800 100644 --- a/api/pkg/di/config.go +++ b/api/pkg/di/config.go @@ -3,6 +3,7 @@ package di import ( "log" "os" + "strings" "github.com/joho/godotenv" ) @@ -23,3 +24,15 @@ func getEnvWithDefault(key, defaultValue string) string { return value } + +func splitCommaEnv(key, defaultValue string) []string { + value := getEnvWithDefault(key, defaultValue) + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 154be4980..8d9e778d4 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -20,7 +20,7 @@ import ( "github.com/dgraph-io/ristretto/v2" - "github.com/gofiber/contrib/otelfiber" + otelfiber "github.com/gofiber/contrib/v3/otel" "gorm.io/plugin/opentelemetry/tracing" "github.com/NdoleStudio/httpsms/pkg/discord" @@ -60,17 +60,17 @@ import ( "github.com/NdoleStudio/httpsms/pkg/middlewares" "google.golang.org/api/option" - "github.com/gofiber/fiber/v2/middleware/compress" - "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v3/middleware/compress" + "github.com/gofiber/fiber/v3/middleware/cors" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/listeners" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/services" - "github.com/gofiber/fiber/v2" - fiberLogger "github.com/gofiber/fiber/v2/middleware/logger" - "github.com/gofiber/swagger" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" + swagger "github.com/gofiber/contrib/v3/swaggo" + "github.com/gofiber/fiber/v3" + fiberLogger "github.com/gofiber/fiber/v3/middleware/logger" ttlCache "github.com/patrickmn/go-cache" "gorm.io/gorm" @@ -84,18 +84,20 @@ import ( // Container is used to resolve services at runtime type Container struct { - projectID string - db *gorm.DB - dedicatedDB *gorm.DB - mongoDB *mongoDriver.Database - version string - app *fiber.App - eventDispatcher *services.EventDispatcher - logger telemetry.Logger - attachmentRepository repositories.AttachmentRepository - userRistrettoCache *ristretto.Cache[string, entities.AuthContext] - phoneRistrettoCache *ristretto.Cache[string, *entities.Phone] - inMemoryCache cache.Cache + projectID string + db *gorm.DB + dedicatedDB *gorm.DB + mongoDB *mongoDriver.Database + version string + app *fiber.App + eventDispatcher *services.EventDispatcher + logger telemetry.Logger + attachmentRepository repositories.AttachmentRepository + contactService *services.ContactService + userRistrettoCache *ristretto.Cache[string, entities.AuthContext] + phoneRistrettoCache *ristretto.Cache[string, *entities.Phone] + contactRistrettoCache *ristretto.Cache[string, services.ContactCacheEntry] + inMemoryCache cache.Cache } // NewLiteContainer creates a Container without any routes or listeners @@ -123,6 +125,9 @@ func NewContainer(projectID string, version string) (container *Container) { container.RegisterMessageThreadRoutes() container.RegisterMessageThreadListeners() + container.RegisterContactRoutes() + container.RegisterContactListeners() + container.RegisterHeartbeatRoutes() container.RegisterHeartbeatListeners() @@ -176,7 +181,7 @@ func (container *Container) App() (app *fiber.App) { app = fiber.New() // Health check endpoint registered before middleware for reliable Docker health checks - app.Get("/health", func(c *fiber.Ctx) error { + app.Get("/health", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) @@ -192,11 +197,11 @@ func (container *Container) App() (app *fiber.App) { app.Use( cors.New( cors.Config{ - AllowOrigins: getEnvWithDefault("CORS_ALLOW_ORIGINS", "*"), - AllowHeaders: getEnvWithDefault("CORS_ALLOW_HEADERS", "*"), - AllowMethods: getEnvWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,DELETE,OPTIONS"), + AllowOrigins: splitCommaEnv("CORS_ALLOW_ORIGINS", "*"), + AllowHeaders: splitCommaEnv("CORS_ALLOW_HEADERS", "*"), + AllowMethods: splitCommaEnv("CORS_ALLOW_METHODS", "GET,POST,PUT,DELETE,OPTIONS"), AllowCredentials: false, - ExposeHeaders: getEnvWithDefault("CORS_EXPOSE_HEADERS", "*"), + ExposeHeaders: splitCommaEnv("CORS_EXPOSE_HEADERS", "*"), }, ), ) @@ -268,7 +273,7 @@ func (container *Container) DedicatedDB() (db *gorm.DB) { } if err = db.Use(tracing.NewPlugin()); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot use GORM tracing plugin")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot use GORM tracing plugin")) } container.dedicatedDB = db @@ -279,11 +284,11 @@ func (container *Container) DedicatedDB() (db *gorm.DB) { container.logger.Debug(fmt.Sprintf("Running migrations for dedicated [%T]", db)) if err = db.AutoMigrate(&entities.Heartbeat{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Heartbeat{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.Heartbeat{})) } if err = db.AutoMigrate(&entities.HeartbeatMonitor{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.HeartbeatMonitor{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.HeartbeatMonitor{})) } return container.dedicatedDB @@ -326,7 +331,7 @@ func (container *Container) DBWithoutMigration() (db *gorm.DB) { container.db = db if err = db.Use(tracing.NewPlugin()); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot use GORM tracing plugin")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot use GORM tracing plugin")) } return container.db } @@ -351,7 +356,7 @@ func (container *Container) DB() (db *gorm.DB) { container.db = db if err = db.Use(tracing.NewPlugin()); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot use GORM tracing plugin")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot use GORM tracing plugin")) } if os.Getenv("DATABASE_MIGRATION_SKIP") != "" { @@ -371,47 +376,51 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK ( } if err = db.AutoMigrate(&entities.Message{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Message{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.Message{})) } if err = db.AutoMigrate(&entities.MessageThread{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.MessageThread{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.MessageThread{})) } if err = db.AutoMigrate(&entities.User{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.User{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.User{})) } if err = db.AutoMigrate(&entities.MessageSendSchedule{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.MessageSendSchedule{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.MessageSendSchedule{})) } if err = db.AutoMigrate(&entities.Phone{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Phone{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.Phone{})) } if err = db.AutoMigrate(&entities.PhoneNotification{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.PhoneNotification{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.PhoneNotification{})) } if err = db.AutoMigrate(&entities.BillingUsage{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.BillingUsage{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.BillingUsage{})) } if err = db.AutoMigrate(&entities.Webhook{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Webhook{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.Webhook{})) } if err = db.AutoMigrate(&entities.Discord{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Discord{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.Discord{})) } if err = db.AutoMigrate(&entities.Integration3CX{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Integration3CX{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.Integration3CX{})) } if err = db.AutoMigrate(&entities.PhoneAPIKey{}); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.PhoneAPIKey{}))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.PhoneAPIKey{})) + } + + if err = db.AutoMigrate(&entities.Contact{}); err != nil { + container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.Contact{})) } return container.db @@ -423,8 +432,7 @@ func (container *Container) FirebaseApp() (app *firebase.App) { app, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsJSON(container.FirebaseCredentials())) if err != nil { - msg := "cannot initialize firebase application" - container.logger.Fatal(stacktrace.Propagate(err, msg)) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot initialize firebase application")) } return app } @@ -445,7 +453,7 @@ func (container *Container) Cache() cache.Cache { container.logger.Debug("creating cache.Cache") opt, err := redis.ParseURL(os.Getenv("REDIS_URL")) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot parse redis url [%s]", os.Getenv("REDIS_URL")))) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot parse redis url [%s]", os.Getenv("REDIS_URL"))) } if strings.HasPrefix(os.Getenv("REDIS_URL"), "rediss://") { opt.TLSConfig = &tls.Config{ @@ -457,12 +465,12 @@ func (container *Container) Cache() cache.Cache { // Enable tracing instrumentation. if err = redisotel.InstrumentTracing(redisClient); err != nil { - container.logger.Error(stacktrace.Propagate(err, "cannot instrument redis tracing")) + container.logger.Error(stacktrace.Propagatef(err, "cannot instrument redis tracing")) } // Enable metrics instrumentation. if err = redisotel.InstrumentMetrics(redisClient); err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot instrument redis metrics")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot instrument redis metrics")) } return cache.NewRedisCache(container.Tracer(), redisClient) @@ -473,8 +481,7 @@ func (container *Container) FirebaseAuthClient() (client *auth.Client) { container.logger.Debug(fmt.Sprintf("creating %T", client)) authClient, err := container.FirebaseApp().Auth(context.Background()) if err != nil { - msg := "cannot initialize firebase auth client" - container.logger.Fatal(stacktrace.Propagate(err, msg)) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot initialize firebase auth client")) } return authClient } @@ -485,7 +492,7 @@ func (container *Container) CloudTasksClient() (client *cloudtasks.Client) { client, err := cloudtasks.NewClient(context.Background(), option.WithCredentialsJSON(container.FirebaseCredentials())) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot initialize cloud tasks client")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot initialize cloud tasks client")) } return client @@ -553,12 +560,29 @@ func (container *Container) FCMClient() services.FCMClient { container.logger.Debug("creating FirebaseFCMClient") messagingClient, err := container.FirebaseApp().Messaging(context.Background()) if err != nil { - msg := "cannot initialize firebase messaging client" - container.logger.Fatal(stacktrace.Propagate(err, msg)) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot initialize firebase messaging client")) } 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") @@ -582,7 +606,6 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes container.Tracer(), container.PhoneService(), container.TurnstileTokenValidator(), - container.Cache(), ) } @@ -605,7 +628,6 @@ func (container *Container) BulkMessageHandlerValidator() (validator *validators container.Tracer(), container.PhoneService(), container.UserService(), - container.Cache(), ) } @@ -700,6 +722,27 @@ func (container *Container) MessageThreadHandlerValidator() (validator *validato ) } +// ContactHandlerValidator creates a new instance of validators.ContactHandlerValidator +func (container *Container) ContactHandlerValidator() (validator *validators.ContactHandlerValidator) { + container.logger.Debug(fmt.Sprintf("creating %T", validator)) + return validators.NewContactHandlerValidator( + container.Logger(), + container.Tracer(), + ) +} + +// ContactHandler creates a new instance of handlers.ContactHandler +func (container *Container) ContactHandler() (h *handlers.ContactHandler) { + container.logger.Debug(fmt.Sprintf("creating %T", h)) + return handlers.NewContactHandler( + container.Logger(), + container.Tracer(), + container.ContactHandlerValidator(), + container.ContactService(), + container.EntitlementService(), + ) +} + // PhoneHandlerValidator creates a new instance of validators.PhoneHandlerValidator func (container *Container) PhoneHandlerValidator() (validator *validators.PhoneHandlerValidator) { container.logger.Debug(fmt.Sprintf("creating %T", validator)) @@ -748,7 +791,7 @@ func (container *Container) Float64Histogram(name, unit, description string) ote ) histogram, err := meter.Float64Histogram(name, otelMetric.WithUnit(unit), otelMetric.WithDescription(description)) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create float64 histogram")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot create float64 histogram")) } return histogram } @@ -898,6 +941,26 @@ func (container *Container) MessageThreadRepository() (repository repositories.M ) } +// ContactRepository creates a new instance of repositories.ContactRepository +func (container *Container) ContactRepository() (repository repositories.ContactRepository) { + switch os.Getenv("CONTACT_DB_BACKEND") { + case "mongodb": + container.logger.Debug("creating MongoDB repositories.ContactRepository") + return repositories.NewMongoContactRepository( + container.Logger(), + container.Tracer(), + container.MongoDB(), + ) + default: + container.logger.Debug("creating GORM repositories.ContactRepository") + return repositories.NewGormContactRepository( + container.Logger(), + container.Tracer(), + container.DB(), + ) + } +} + // HeartbeatMonitorRepository creates a new instance of repositories.HeartbeatMonitorRepository func (container *Container) HeartbeatMonitorRepository() (repository repositories.HeartbeatMonitorRepository) { switch os.Getenv("HEARTBEAT_DB_BACKEND") { @@ -1111,10 +1174,27 @@ func (container *Container) MessageThreadService() (service *services.MessageThr container.Logger(), container.Tracer(), container.MessageThreadRepository(), + container.PhoneRepository(), container.EventDispatcher(), + container.ContactService(), ) } +// ContactService creates a new instance of services.ContactService +func (container *Container) ContactService() (service *services.ContactService) { + if container.contactService != nil { + return container.contactService + } + container.logger.Debug(fmt.Sprintf("creating %T", service)) + container.contactService = services.NewContactService( + container.Logger(), + container.Tracer(), + container.ContactRepository(), + container.ContactRistrettoCache(), + ) + return container.contactService +} + // EmailNotificationService creates a new instance of services.EmailNotificationService func (container *Container) EmailNotificationService() (service *services.EmailNotificationService) { container.logger.Debug(fmt.Sprintf("creating %T", service)) @@ -1376,6 +1456,20 @@ func (container *Container) RegisterMessageThreadListeners() { } } +// RegisterContactListeners registers event listeners for listeners.ContactListener. +func (container *Container) RegisterContactListeners() { + container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.ContactListener{})) + _, routes := listeners.NewContactListener( + container.Logger(), + container.Tracer(), + container.ContactService(), + ) + + for event, handler := range routes { + container.EventDispatcher().Subscribe(event, handler) + } +} + // RegisterEmailNotificationListeners registers event listeners for listeners.EmailNotificationListener func (container *Container) RegisterEmailNotificationListeners() { container.logger.Debug(fmt.Sprintf("registering listners for %T", listeners.EmailNotificationListener{})) @@ -1527,7 +1621,7 @@ func (container *Container) RegisterWebsocketListeners() { container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.WebsocketListener{})) if os.Getenv("PUSHER_SECRET") == "" { - container.logger.Warn(stacktrace.NewError("skipping websocket listeners because the PUSHER_SECRET env variable is not set")) + container.logger.Warn(stacktrace.NewErrorf("skipping websocket listeners because the PUSHER_SECRET env variable is not set")) return } @@ -1581,7 +1675,7 @@ func (container *Container) AttachmentRepository() repositories.AttachmentReposi container.logger.Debug("creating GoogleCloudStorageAttachmentRepository") client, err := storage.NewClient(context.Background(), option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials())) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create GCS client")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot create GCS client")) } container.attachmentRepository = repositories.NewGoogleCloudStorageAttachmentRepository( container.Logger(), @@ -1639,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(), @@ -1666,6 +1760,12 @@ func (container *Container) RegisterMessageThreadRoutes() { container.MessageThreadHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } +// RegisterContactRoutes registers routes for the /contacts prefix +func (container *Container) RegisterContactRoutes() { + container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.ContactHandler{})) + container.ContactHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) +} + // RegisterHeartbeatRoutes registers routes for the /heartbeats prefix func (container *Container) RegisterHeartbeatRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.HeartbeatHandler{})) @@ -1769,12 +1869,30 @@ func (container *Container) PhoneRistrettoCache() *ristretto.Cache[string, *enti BufferItems: 64, }) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create phone ristretto cache")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot create phone ristretto cache")) } container.phoneRistrettoCache = ristrettoCache return container.phoneRistrettoCache } +// ContactRistrettoCache creates an in-memory cache keyed by user and phone number. +func (container *Container) ContactRistrettoCache() *ristretto.Cache[string, services.ContactCacheEntry] { + if container.contactRistrettoCache != nil { + return container.contactRistrettoCache + } + container.logger.Debug(fmt.Sprintf("creating %T", container.contactRistrettoCache)) + ristrettoCache, err := ristretto.NewCache[string, services.ContactCacheEntry](&ristretto.Config[string, services.ContactCacheEntry]{ + MaxCost: 5000, + NumCounters: 5000 * 10, + BufferItems: 64, + }) + if err != nil { + container.logger.Fatal(stacktrace.Propagatef(err, "cannot create contact ristretto cache")) + } + container.contactRistrettoCache = ristrettoCache + return container.contactRistrettoCache +} + // UserRistrettoCache creates an in-memory *ristretto.Cache[string, entities.AuthContext] func (container *Container) UserRistrettoCache() *ristretto.Cache[string, entities.AuthContext] { if container.userRistrettoCache != nil { @@ -1787,7 +1905,7 @@ func (container *Container) UserRistrettoCache() *ristretto.Cache[string, entiti BufferItems: 64, }) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create user ristretto cache")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot create user ristretto cache")) } container.userRistrettoCache = ristrettoCache return ristrettoCache @@ -1803,7 +1921,7 @@ func (container *Container) initializeGoogleTraceProvider(version string, namesp traceExporter, err := cloudtrace.New(cloudtrace.WithProjectID(os.Getenv("GCP_PROJECT_ID"))) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create cloud trace traceExporter")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot create cloud trace traceExporter")) } tp := trace.NewTracerProvider( @@ -1815,7 +1933,7 @@ func (container *Container) initializeGoogleTraceProvider(version string, namesp metricExporter, err := mexporter.New(mexporter.WithProjectID(os.Getenv("GCP_PROJECT_ID"))) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create cloud metric traceExporter")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot create cloud metric traceExporter")) } meterProvider := metric.NewMeterProvider( @@ -1826,10 +1944,10 @@ func (container *Container) initializeGoogleTraceProvider(version string, namesp return func() { if err = metricExporter.Shutdown(context.Background()); err != nil { - container.logger.Error(stacktrace.Propagate(err, "cannot shutdown cloud metric metric exporter")) + container.logger.Error(stacktrace.Propagatef(err, "cannot shutdown cloud metric metric exporter")) } if err = traceExporter.Shutdown(context.Background()); err != nil { - container.logger.Error(stacktrace.Propagate(err, "cannot shutdown cloud trace trace exporter")) + container.logger.Error(stacktrace.Propagatef(err, "cannot shutdown cloud trace trace exporter")) } } } @@ -1848,7 +1966,7 @@ func (container *Container) initializeAxiomTraceProvider(version string, namespa otlptracehttp.WithHeaders(traceHeaders), ) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create axiom OTLP trace exporter")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot create axiom OTLP trace exporter")) } tp := trace.NewTracerProvider( @@ -1874,7 +1992,7 @@ func (container *Container) initializeAxiomTraceProvider(version string, namespa otlpmetrichttp.WithHeaders(metricHeaders), ) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot create axiom OTLP metric exporter")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot create axiom OTLP metric exporter")) } meterProvider := metric.NewMeterProvider( @@ -1885,10 +2003,10 @@ func (container *Container) initializeAxiomTraceProvider(version string, namespa return func() { if err := tp.Shutdown(context.Background()); err != nil { - container.logger.Error(stacktrace.Propagate(err, "cannot shutdown axiom trace provider")) + container.logger.Error(stacktrace.Propagatef(err, "cannot shutdown axiom trace provider")) } if err := meterProvider.Shutdown(context.Background()); err != nil { - container.logger.Error(stacktrace.Propagate(err, "cannot shutdown axiom meter provider")) + container.logger.Error(stacktrace.Propagatef(err, "cannot shutdown axiom meter provider")) } } } @@ -1939,7 +2057,7 @@ func axiomLogger(skipFrameCount int) *zerodriver.Logger { axiomzerolog.SetDataset(os.Getenv("AXIOM_DATASET_EVENTS")), ) if err != nil { - log.Fatal(stacktrace.Propagate(err, "cannot create axiom zerolog writer")) + log.Fatal(stacktrace.Propagatef(err, "cannot create axiom zerolog writer")) } zl := zerolog.New(axiomWriter).With().Timestamp().CallerWithSkipFrameCount(skipFrameCount).Logger() 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/emails/event_payload_formatter.go b/api/pkg/emails/event_payload_formatter.go new file mode 100644 index 000000000..0a8638540 --- /dev/null +++ b/api/pkg/emails/event_payload_formatter.go @@ -0,0 +1,128 @@ +package emails + +import ( + "bytes" + "encoding/json" + "html" + "html/template" + "strings" +) + +const ( + eventPayloadCodeBlockStyle = "margin:0;padding:12px;border:1px solid #D0D7DE;border-radius:6px;background:#F6F8FA;color:#24292F;font-family:Consolas,Monaco,'Courier New',monospace;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;" + jsonKeyStyle = "color:#0550AE;font-weight:600;" + jsonStringStyle = "color:#0A3069;" + jsonNumberStyle = "color:#953800;" + jsonLiteralStyle = "color:#8250DF;" +) + +func formatEventPayload(payload string) (string, template.HTML) { + formattedPayload, isJSON := indentEventPayloadJSON(payload) + content := html.EscapeString(formattedPayload) + if isJSON { + content = highlightEventPayloadJSON(formattedPayload) + } + + // #nosec G203 -- every dynamic payload token is escaped before the static wrapper is added. + richPayload := template.HTML(`
` + content + `
`) + return formattedPayload, richPayload +} + +func indentEventPayloadJSON(payload string) (string, bool) { + var formatted bytes.Buffer + if err := json.Indent(&formatted, []byte(payload), "", " "); err != nil { + return payload, false + } + + return formatted.String(), true +} + +func highlightEventPayloadJSON(payload string) string { + var highlighted strings.Builder + highlighted.Grow(len(payload)) + + for index := 0; index < len(payload); { + switch { + case payload[index] == '"': + end := eventPayloadJSONStringEnd(payload, index) + style := jsonStringStyle + if eventPayloadNextNonSpace(payload, end) == ':' { + style = jsonKeyStyle + } + writeEventPayloadToken(&highlighted, style, payload[index:end]) + index = end + case payload[index] == '-' || isEventPayloadDigit(payload[index]): + end := index + 1 + // json.Indent already validated this as JSON, so this continuation set only sees JSON number bytes. + for end < len(payload) && isEventPayloadNumberCharacter(payload[end]) { + end++ + } + writeEventPayloadToken(&highlighted, jsonNumberStyle, payload[index:end]) + index = end + case strings.HasPrefix(payload[index:], "true"): + writeEventPayloadToken(&highlighted, jsonLiteralStyle, "true") + index += len("true") + case strings.HasPrefix(payload[index:], "false"): + writeEventPayloadToken(&highlighted, jsonLiteralStyle, "false") + index += len("false") + case strings.HasPrefix(payload[index:], "null"): + writeEventPayloadToken(&highlighted, jsonLiteralStyle, "null") + index += len("null") + default: + highlighted.WriteString(html.EscapeString(payload[index : index+1])) + index++ + } + } + + return highlighted.String() +} + +func eventPayloadJSONStringEnd(payload string, start int) int { + escaped := false + for index := start + 1; index < len(payload); index++ { + switch { + case escaped: + escaped = false + case payload[index] == '\\': + escaped = true + case payload[index] == '"': + return index + 1 + } + } + + return len(payload) +} + +func eventPayloadNextNonSpace(payload string, start int) byte { + for index := start; index < len(payload); index++ { + switch payload[index] { + case ' ', '\n', '\r', '\t': + continue + default: + return payload[index] + } + } + + return 0 +} + +func isEventPayloadDigit(value byte) bool { + return value >= '0' && value <= '9' +} + +func isEventPayloadNumberCharacter(value byte) bool { + return isEventPayloadDigit(value) || + value == '-' || + value == '+' || + value == '.' || + value == 'e' || + value == 'E' +} + +func writeEventPayloadToken(builder *strings.Builder, style string, token string) { + builder.WriteString(``) + builder.WriteString(html.EscapeString(token)) + builder.WriteString(``) +} diff --git a/api/pkg/emails/event_payload_formatter_test.go b/api/pkg/emails/event_payload_formatter_test.go new file mode 100644 index 000000000..9617ebb0e --- /dev/null +++ b/api/pkg/emails/event_payload_formatter_test.go @@ -0,0 +1,113 @@ +package emails + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatEventPayloadIndentsAndHighlightsJSON(t *testing.T) { + payload := `{"message":"hello","count":2,"ratio":1.5,"enabled":true,"disabled":false,"missing":null,"nested":{"value":"ok"}}` + + plain, rich := formatEventPayload(payload) + html := string(rich) + + assert.Equal(t, `{ + "message": "hello", + "count": 2, + "ratio": 1.5, + "enabled": true, + "disabled": false, + "missing": null, + "nested": { + "value": "ok" + } +}`, plain) + assert.Contains(t, html, `"message"`) + assert.Contains(t, html, `"hello"`) + assert.Contains(t, html, `2`) + assert.Contains(t, html, `1.5`) + assert.Contains(t, html, `true`) + assert.Contains(t, html, `false`) + assert.Contains(t, html, `null`) + assert.Contains(t, html, `white-space:pre-wrap`) +} + +func TestFormatEventPayloadEscapesPayloadHTML(t *testing.T) { + plain, rich := formatEventPayload(`{"message":"&"}`) + html := string(rich) + + assert.Contains(t, plain, ` - - - - -``` - -- [ ] **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 ` -``` - -**After (Vue 3):** -```vue - -``` - -#### Vuetify Breakpoints: `$vuetify.breakpoint` → `useDisplay()` - -**Before:** -```vue - -``` - -**After:** -```vue - - -``` - -#### State: Vuex → Pinia - -**Before:** -```ts -this.$store.dispatch('loadPhones', true) -this.$store.getters.getAuthUser -``` - -**After:** -```ts -const phonesStore = usePhonesStore() -await phonesStore.loadPhones(true) -phonesStore.authUser -``` - -#### Firebase: `this.$fire.auth` → VueFire composables - -**Before:** -```ts -await this.$fire.auth.currentUser?.getIdToken() -``` - -**After:** -```ts -import { useCurrentUser } from 'vuefire' -const user = useCurrentUser() -const token = await user.value?.getIdToken() -``` - -#### Dynamic Routes: `_id` → `[id]` - -- `pages/threads/_id/index.vue` → `pages/threads/[id]/index.vue` -- `pages/heartbeats/_id.vue` → `pages/heartbeats/[id].vue` - -### Vuetify 4 Breaking Changes to Address - -Using the Vuetify MCP for each component, the key changes are: - -1. **CSS Layers** — mandatory in v4; adjust any custom style overrides -2. **Theme** — default is now "system" (we want dark, configure explicitly) -3. **Typography** — MD2 → MD3 type scale (text-h1 → text-display-large, etc.) -4. **Breakpoints** — reduced default sizes (restore v3 values via config) -5. **Elevation** — 25 levels → 6 levels (MD3) -6. **VBtn** — no default uppercase, grid → flex layout -7. **VSnackbar** — removed multi-line prop -8. **VSelect** — "item" slot → "internalItem" -9. **Grid** — v-row/v-col overhauled -10. **CSS Reset** — mostly removed, add selective resets - -### Vuetify MCP Usage Per Component - -For EVERY component/page being migrated, the implementation must: -1. Call `vuetify-mcp-get_component_api_by_version` for each Vuetify component used -2. Call `vuetify-mcp-get_v4_breaking_changes` filtered by relevant category -3. Apply the correct v4 API (props, slots, events) based on MCP output -4. Verify no deprecated props/events remain - -### Pinia Store Design - -Split the monolithic Vuex store into domain stores: - -| Store | Responsibility | -|-------|---------------| -| `auth.ts` | Firebase auth state, user profile, onAuthStateChanged | -| `messages.ts` | Messages CRUD, search | -| `threads.ts` | Message threads, current thread | -| `phones.ts` | Phone list, heartbeats, polling | -| `billing.ts` | Usage, subscription, payments | -| `notifications.ts` | Toast/snackbar queue | -| `app.ts` | App metadata, polling state, runtime config | - -### Plugin Migrations - -| Old Plugin | New Approach | -|-----------|-------------| -| `plugins/axios.ts` | `composables/useApi.ts` using `$fetch` with auth header | -| `plugins/filters.ts` | `utils/filters.ts` (import explicitly or app.config globalProperties) | -| `plugins/vue-glow.ts` | `plugins/vue-glow.client.ts` (client-only plugin) | -| `plugins/chart.ts` | `plugins/chart.client.ts` (client-only plugin) | -| `plugins/errors.ts` | `utils/errors.ts` | -| `plugins/bag.ts` | `utils/bag.ts` | -| `plugins/capitalize.ts` | `utils/capitalize.ts` | -| `plugins/veutify.ts` | `plugins/vuetify.ts` (createVuetify setup) | - -## Migration Order (Tasks) - -### Phase 1: Scaffold & Configuration -1. Initialize fresh Nuxt 4 project in `web/` (backup old code) -2. Install dependencies (vuetify, pinia, nuxt-vuefire, sass, @mdi/js, pusher-js, etc.) -3. Configure `nuxt.config.ts` (SSG, runtime config, modules) -4. Set up Vuetify plugin with dark theme, restored breakpoints, MDI SVG icons -5. Set up nuxt-vuefire with Firebase config -6. Configure TypeScript strictly - -### Phase 2: Foundation -7. Port `shared/types/` (API models — mostly copy) -8. Port `utils/` (errors, filters, bag, capitalize) -9. Create `composables/useApi.ts` (replace Axios plugin) -10. Create `composables/useAuth.ts` (Firebase auth helpers) - -### Phase 3: State Management -11. Create Pinia store: `stores/auth.ts` -12. Create Pinia store: `stores/notifications.ts` -13. Create Pinia store: `stores/app.ts` -14. Create Pinia store: `stores/phones.ts` -15. Create Pinia store: `stores/messages.ts` -16. Create Pinia store: `stores/threads.ts` -17. Create Pinia store: `stores/billing.ts` - -### Phase 4: Layouts & Middleware -18. Port `middleware/auth.ts` -19. Port `middleware/guest.ts` -20. Port `layouts/default.vue` (with Vuetify MCP) -21. Port `layouts/website.vue` (with Vuetify MCP) -22. Port `layouts/error.vue` (with Vuetify MCP) -23. Create `app.vue` - -### Phase 5: Components (use Vuetify MCP for each) -24. Port `components/Toast.vue` -25. Port `components/LoadingDashboard.vue` -26. Port `components/LoadingButton.vue` -27. Port `components/BackButton.vue` -28. Port `components/CopyButton.vue` -29. Port `components/FixedHeader.vue` -30. Port `components/BlogAuthorBio.vue` -31. Port `components/BlogInfo.vue` -32. Port `components/NuxtLogo.vue` -33. Port `components/FirebaseAuth.vue` -34. Port `components/MessageThread.vue` -35. Port `components/MessageThreadHeader.vue` - -### Phase 6: Pages (use Vuetify MCP for each) -36. Port `pages/index.vue` (homepage) -37. Port `pages/login.vue` -38. Port `pages/threads/index.vue` -39. Port `pages/threads/[id]/index.vue` -40. Port `pages/messages/index.vue` -41. Port `pages/search-messages/index.vue` -42. Port `pages/bulk-messages/index.vue` -43. Port `pages/settings/index.vue` -44. Port `pages/billing/index.vue` -45. Port `pages/heartbeats/[id].vue` -46. Port `pages/phone-api-keys/index.vue` -47. Port `pages/privacy-policy/index.vue` -48. Port `pages/terms-and-conditions/index.vue` -49. Port `pages/blog/index.vue` -50. Port `pages/blog/how-to-send-sms-messages-from-excel.vue` -51. Port `pages/blog/grant-send-and-read-sms-permissions-on-android.vue` -52. Port `pages/blog/forward-incoming-sms-from-phone-to-webhook.vue` -53. Port `pages/blog/end-to-end-encryption-to-sms-messages.vue` -54. Port `pages/blog/send-bulk-sms-from-csv-file-with-no-code.vue` -55. Port `pages/blog/send-sms-from-android-phone-with-python.vue` -56. Port `pages/blog/send-sms-when-new-row-is-added-to-google-sheets-using-zapier.vue` - -### Phase 7: Final Setup -57. Port static assets (`public/`) -58. Port environment files (`.env`, `.env.production`) -59. Update Dockerfile and nginx.conf -60. Update sitemap configuration -61. Configure highlight.js (nuxt-highlightjs or manual) - -### Phase 8: Verification (EVERY component and page) -62. Verify `app.vue` renders -63. Verify `layouts/default.vue` renders correctly -64. Verify `layouts/website.vue` renders correctly -65. Verify `layouts/error.vue` renders correctly -66. Verify `components/Toast.vue` renders correctly -67. Verify `components/LoadingDashboard.vue` renders correctly -68. Verify `components/LoadingButton.vue` renders correctly -69. Verify `components/BackButton.vue` renders correctly -70. Verify `components/CopyButton.vue` renders correctly -71. Verify `components/FixedHeader.vue` renders correctly -72. Verify `components/BlogAuthorBio.vue` renders correctly -73. Verify `components/BlogInfo.vue` renders correctly -74. Verify `components/NuxtLogo.vue` renders correctly -75. Verify `components/FirebaseAuth.vue` renders correctly -76. Verify `components/MessageThread.vue` renders correctly -77. Verify `components/MessageThreadHeader.vue` renders correctly -78. Verify `pages/index.vue` renders correctly -79. Verify `pages/login.vue` renders correctly -80. Verify `pages/threads/index.vue` renders correctly -81. Verify `pages/threads/[id]/index.vue` renders correctly -82. Verify `pages/messages/index.vue` renders correctly -83. Verify `pages/search-messages/index.vue` renders correctly -84. Verify `pages/bulk-messages/index.vue` renders correctly -85. Verify `pages/settings/index.vue` renders correctly -86. Verify `pages/billing/index.vue` renders correctly -87. Verify `pages/heartbeats/[id].vue` renders correctly -88. Verify `pages/phone-api-keys/index.vue` renders correctly -89. Verify `pages/privacy-policy/index.vue` renders correctly -90. Verify `pages/terms-and-conditions/index.vue` renders correctly -91. Verify `pages/blog/index.vue` renders correctly -92. Verify all blog subpages render correctly -93. Run `pnpm build` (static generation) successfully -94. Verify no TypeScript errors (`pnpm typecheck`) -95. Verify lint passes (`pnpm lint`) - -## Verification Strategy - -Each verification task in Phase 8 means: -1. Start the dev server (`pnpm dev`) -2. Navigate to the page/route in question -3. Confirm no console errors, no hydration mismatches -4. Confirm visual layout matches intent (Vuetify components render, dark theme active, responsive breakpoints work) -5. For interactive components (forms, modals, auth), confirm basic interactions work - -The build verification (`pnpm build`) confirms all pages can be statically generated without errors. - -## Risk Mitigations - -- **Backup old code**: Keep old `web/` contents in a branch before starting -- **Incremental porting**: Each file is ported and verified before moving to the next -- **Vuetify MCP**: Use for every Vuetify component to catch breaking changes -- **Restored breakpoints**: Keep v2/v3 breakpoint values to minimize layout drift -- **CSS Reset compatibility**: Add selective reset CSS to maintain existing spacing behavior diff --git a/docs/superpowers/specs/2026-06-24-login-last-used-badge-design.md b/docs/superpowers/specs/2026-06-24-login-last-used-badge-design.md deleted file mode 100644 index f264a926c..000000000 --- a/docs/superpowers/specs/2026-06-24-login-last-used-badge-design.md +++ /dev/null @@ -1,75 +0,0 @@ -# Login "Last Used" Badge — Design - -## Problem - -The login page (`web/app/pages/login.vue` → `web/app/components/FirebaseAuth.vue`) -offers three authentication methods: Continue with Google, Continue with GitHub, -and Continue with email. Returning users don't get any hint about which method -they used last, so they may pick the wrong provider and end up creating a second -account or failing to sign in. - -## Goal - -Show a small "Last Used" badge on the button corresponding to the login method -the user most recently used successfully on this device, so they can quickly pick -the right one next time. - -## Scope - -Single component change: `web/app/components/FirebaseAuth.vue`. No API, store, or -backend changes. - -## Approach - -### Storage - -- Persist the last successful method in `localStorage` under the key - `httpsms_last_login_method`. -- Value is one of `'google' | 'github' | 'email'`. -- The value is written only **after a successful login** (inside `onSuccess`), - never on click/attempt. - -### Recording the method - -- `onSuccess(user)` is currently shared by all three flows. Extend it to - `onSuccess(user, method)` where `method` is `'google' | 'github' | 'email'`. - - `signInWithGoogle` → `onSuccess(result.user, 'google')` - - `signInWithGithub` → `onSuccess(result.user, 'github')` - - `submitEmail` → `onSuccess(result.user, 'email')` -- Inside `onSuccess`, write the method to `localStorage` before redirecting. - -### Reading the method - -- A reactive `lastUsedMethod = ref(null)`. -- Populated in `onMounted` from `localStorage` (client-only, SSR-safe; the - component is already rendered inside `` on the login page). - -### Display - -- Each of the three method buttons gets `class="position-relative"`. -- A floating `v-chip` is rendered in the top-right corner of the matching button: - - Vuetify `v-chip` with `label`, `size="x-small"`, `color="primary"`. - - `class="position-absolute"` pinned to the top-right corner (slightly - overlapping), text `Last Used`. - - Shown via `v-if="lastUsedMethod === 'google'"` (and `'github'`, `'email'` - respectively). - -### Edge cases - -- The email button is hidden once the inline email form opens - (`v-if="!showEmailForm"`), so its badge hides with it automatically. No extra - handling needed. -- An unknown/empty stored value shows no badge anywhere. -- `localStorage` access is guarded (wrapped in try/catch or `onMounted` only) so - it never runs during SSR. - -## Testing - -- Manual verification: sign in with each method, confirm the badge appears on the - correct button on the next visit to the login page. -- Confirm no SSR/hydration errors (badge logic runs client-side only). - -## Out of scope - -- Syncing the preference across devices. -- Remembering the specific email address used. diff --git a/tests/.env.test b/tests/.env.test index 78bb614b2..c4c1e3ef9 100644 --- a/tests/.env.test +++ b/tests/.env.test @@ -30,4 +30,5 @@ GCS_BUCKET_NAME= UPTRACE_DSN= CLOUDFLARE_TURNSTILE_SECRET_KEY= HEARTBEAT_DB_BACKEND=mongodb +CONTACT_DB_BACKEND=mongodb MONGODB_URI=mongodb://httpsms:testpassword@mongodb:27017/?authSource=admin&appName=httpsms diff --git a/tests/README.md b/tests/README.md index a37653e53..35c144b68 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,202 +1,225 @@ # Integration Tests -End-to-end integration tests for the httpSMS API. These tests validate the complete SMS lifecycle by running the full application stack in Docker alongside a phone emulator service. +End-to-end tests for the httpSMS API. The suite runs the API and its data +stores in Docker, keeps the existing Firebase/WireMock coverage, and adds a +standard-library-only HTTPS adapter emulator for URL-backed phone gateways. ## Architecture -``` -┌──────────────┐ HTTP ┌──────────────┐ -│ Test Runner │─────────────▶│ API (Go) │ -│ (Go test) │ │ Port 8000 │ -└──────────────┘ └──────┬───────┘ - │ - FCM Push │ Events - (HTTP) │ (HTTP) - ▼ - ┌──────────────┐ - │ Emulator │ - │ (Fiber v3) │ - │ Port 9090 │ - └──────────────┘ - │ - ┌──────┴───────┐ - │ CockroachDB │ │ Redis │ - │ Port 26257 │ │ Port 6379 │ - └──────────────┘ └─────────────┘ +```text + ┌──────────────────┐ + │ API (Go) │ + │ Port 8000 │ + └───────┬──────────┘ + │ + FCM HTTP │ HTTPS callbacks + ┌──────────────────┐ │ ┌──────────────────────┐ + │ WireMock │◀──────┘ │ Adapter emulator │ + │ Port 8080 │ │ HTTPS callback :9091│ + └──────────────────┘ │ HTTP control :9092│ + └──────────┬───────────┘ + │ phone API calls + └──────────▶ API + +┌──────────────────┐ HTTP ┌────────────────────────────┐ +│ Test runner │───────────────────▶│ API, WireMock, and adapter │ +│ Go test on host │ │ control endpoints │ +└──────────────────┘ └────────────────────────────┘ + +Data stores: CockroachDB, Redis, and MongoDB. ``` ### Components -| Component | Description | -| --------------- | -------------------------------------------------------- | -| **API** | The httpSMS Go API server running in Docker | -| **Emulator** | A Fiber v3 Go service that simulates an Android phone | -| **CockroachDB** | Database for the API (single-node, insecure mode) | -| **Redis** | Cache and queue backend | -| **Seed** | One-shot container that seeds test data into CockroachDB | -| **Test Runner** | Go test binary that runs on the host machine | - -### How It Works - -1. **Send SMS flow**: Test sends `POST /v1/messages/send` → API pushes FCM notification to emulator → Emulator calls `GET /v1/messages/outstanding` → Emulator fires `SENT` and `DELIVERED` events → Test polls `GET /v1/messages/{id}` until status is `delivered` - -2. **Receive SMS flow**: Test sends `POST /v1/messages/receive` (as the phone) → API stores message → Test verifies via `GET /v1/messages/{id}` - -### FCM Redirect - -The API's Firebase SDK is configured (via `FCM_ENDPOINT` env var) to redirect all FCM HTTP requests to the emulator instead of Google's servers. The emulator serves: - -- `/token` — Fake OAuth2 token endpoint (Firebase SDK requests tokens before sending) -- `/v1/projects/:project/messages:send` — Fake FCM push endpoint +| Component | Description | +| --- | --- | +| **API** | The httpSMS Go API server | +| **WireMock** | Existing fake Firebase and webhook endpoints | +| **Adapter emulator** | HTTPS URL-backed phone gateway with an HTTP-only host control API | +| **CockroachDB** | Relational database for API data | +| **Redis** | Cache and local event queue backend | +| **MongoDB** | Heartbeat and contact backend | +| **Seed** | One-shot container that inserts integration users and API keys | +| **Test runner** | Go tests running on the host | + +### Gateway Flows + +1. **Existing FCM flow:** the API sends Firebase-compatible requests to + WireMock. Existing tests fetch outstanding messages and submit phone events + without changing their transport. +2. **URL-backed outgoing flow:** the API posts an FCM-compatible envelope to + `https://adapter-emulator:9091/notifications/{gatewayID}`. The adapter uses + its registered phone API key to fetch the outstanding message and post + `SENT` followed by `DELIVERED`. +3. **URL-backed incoming flow:** the test calls the adapter control API on + host port `9092`; the adapter posts `/v1/messages/receive` as the registered + phone. +4. **Heartbeat wake-up:** the test dispatches `phone.heartbeat.missed` through + `/v1/events`. The API sends an HTTPS callback containing + `KEY_HEARTBEAT_ID`, and the adapter posts `/v1/heartbeats`. + +The HTTPS endpoint uses a two-day throwaway CA and server certificate with the +DNS SAN `adapter-emulator`. The API container trusts only that generated CA via +`SSL_CERT_FILE`; HTTPS verification is never bypassed. The notification sender +uses the standard OpenTelemetry-instrumented Go HTTP transport. ## Test Coverage -- [x] **Send SMS E2E** — Full send lifecycle: API → FCM push → emulator responds with SENT/DELIVERED events → message reaches `delivered` status -- [x] **Receive SMS E2E** — Phone submits received message to API → message is stored and retrievable via GET endpoint +- [x] Existing encrypted send/receive phone scenarios through WireMock +- [x] Existing rate-limit, webhook, contacts, bulk, and thread scenarios +- [x] URL-backed outgoing message reaches `delivered` +- [x] URL-backed incoming message reaches `received` +- [x] URL-backed heartbeat callback stores a heartbeat +- [x] Adapter callback notification IDs are deduplicated in memory +- [x] HTTPS certificate trust is exercised ## Prerequisites - [Docker](https://docs.docker.com/get-docker/) with Docker Compose -- [Go 1.22+](https://go.dev/dl/) -- [jq](https://jqlang.github.io/jq/download/) (for Firebase credentials generation) -- [OpenSSL](https://www.openssl.org/) (for RSA key generation) +- [Go 1.25+](https://go.dev/dl/) +- [jq](https://jqlang.github.io/jq/download/) +- [OpenSSL](https://www.openssl.org/) + +On Windows, the scripts can be run with Git Bash, for example +`C:\Program Files\Git\bin\bash.exe`. ## Running Locally -### 1. Generate Firebase Credentials +### 1. Generate throwaway credentials and certificates -The integration tests use a fake Firebase service account. Generate it with: +Run both scripts before starting Docker: ```bash cd tests -bash generate-firebase-credentials.sh -``` - -This creates `firebase-credentials.json` with a throwaway RSA key (the emulator doesn't validate tokens). - -### 2. Set Environment Variable - -```bash +bash generate-firebase-credentials.sh firebase-credentials.json +bash generate-adapter-certificates.sh certs export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) ``` -### 3. Start the Stack - -```bash -docker compose up -d --build --wait -``` +The generated Firebase credential and the complete `certs/` directory are +ignored by Git. -This starts CockroachDB, Redis, the API, and the emulator. The `--wait` flag blocks until all health checks pass. - -### 4. Wait for Seeding +### 2. Start the stack and wait for seeding ```bash +docker compose up -d --build --wait docker compose wait seed sleep 2 ``` -The seed container inserts test users, phones, and API keys into CockroachDB after the API has run its GORM migrations. - -### 5. Run Tests +### 3. Run the complete suite ```bash -go test -v -timeout 120s ./... +go test -v -timeout 300s ./... ``` -### 6. Tear Down +### 4. Tear down ```bash docker compose down -v ``` -The `-v` flag removes volumes (database data) for a clean slate next run. - -### One-Liner +### One-liner ```bash cd tests && \ - bash generate-firebase-credentials.sh && \ + bash generate-firebase-credentials.sh firebase-credentials.json && \ + bash generate-adapter-certificates.sh certs && \ export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) && \ docker compose up -d --build --wait && \ docker compose wait seed && \ sleep 2 && \ - go test -v -timeout 120s ./... ; \ + go test -v -timeout 300s ./... ; \ docker compose down -v ``` ## CI/CD -Integration tests run automatically via GitHub Actions (`.github/workflows/integration-test.yml`): - -- **Trigger**: Push to `main` or pull request targeting `main` -- **Flow**: Generates credentials → Starts Docker stack → Seeds DB → Runs tests → Collects logs on failure → Tears down -- **Gate**: Deployment should only proceed if integration tests pass +`.github/workflows/api.yml` generates both the fake Firebase credential and +the adapter CA/server certificate before building the Compose stack. The +workflow runs API handler integration tests and this complete host-side suite, +collects service logs on failure, and always tears the stack down. ## Test Data -| Entity | Value | -| -------------- | -------------------------------------- | -| User API Key | `test-user-api-key` | -| Phone API Key | `pk_test-phone-api-key` | -| Phone Number | `+18005550199` | -| Contact Number | `+18005550100` | -| User ID | `test-user-id` | -| Phone ID | `a1b2c3d4-e5f6-7890-abcd-ef1234567890` | +| Entity | Value | +| --- | --- | +| User API key | `test-user-api-key` | +| System API key | `system-user-api-key` | +| User ID | `test-user-id` | +| System user ID | `system-user-id` | -See [`seed.sql`](./seed.sql) for the complete seed data. +Adapter tests create a unique gateway UUID, phone number, phone API key, and +callback path per test. See [`seed.sql`](./seed.sql) for shared seed data. ## Project Structure -``` +```text tests/ -├── docker-compose.yml # Full stack orchestration -├── seed.sql # Database seed data -├── .env.test # API environment variables -├── generate-firebase-credentials.sh # Generates fake Firebase credentials -├── go.mod # Test runner Go module -├── go.sum -├── helpers_test.go # Test utilities (HTTP client, polling) -├── integration_test.go # E2E test cases -└── emulator/ # Phone emulator service - ├── Dockerfile - ├── go.mod - ├── go.sum - ├── main.go # Fiber v3 entry point - ├── emulator.go # Emulator struct and config - ├── token_handler.go # Fake OAuth2 token endpoint - ├── fcm_handler.go # Fake FCM push receiver - └── events.go # Event firing logic (SENT/DELIVERED) +├── adapter-emulator/ +│ ├── Dockerfile +│ ├── go.mod +│ ├── main.go +│ ├── emulator.go +│ ├── api_client.go +│ ├── notification_handler.go +│ ├── control_handler.go +│ └── emulator_test.go +├── wiremock/ +│ └── mappings/ +├── adapter_integration_test.go +├── integration_test.go +├── helpers_test.go +├── docker-compose.yml +├── .env.test +├── seed.sql +├── generate-firebase-credentials.sh +├── generate-adapter-certificates.sh +├── go.mod +└── go.sum ``` ## Troubleshooting -### API fails to start +### API or adapter fails to start + +```bash +docker compose logs --tail 200 api adapter-emulator +``` + +Confirm `tests/certs/ca.pem`, `server.pem`, and `server-key.pem` exist. TLS +errors should be fixed by regenerating certificates; do not disable HTTPS +verification. -Check the API logs: +### URL-backed outgoing message times out ```bash -docker compose logs api +docker compose logs --tail 200 api adapter-emulator ``` -Common issues: +Adapter logs should show callback receipt, the outstanding-message fetch, +`SENT`, and `DELIVERED`. Confirm `SSL_CERT_FILE=/adapter-certs/ca.pem` is +present in the API container. -- `FIREBASE_CREDENTIALS` env var not set or malformed -- CockroachDB not ready (increase `start_period` in healthcheck) +### URL-backed incoming message times out -### Tests timeout waiting for `delivered` status +Adapter logs should show the control request followed by a call to +`/v1/messages/receive`. The gateway registration contains the per-test phone +number and phone API key. -Check the emulator logs: +### Heartbeat callback times out -```bash -docker compose logs emulator -``` +API logs should show `phone.heartbeat.missed`. Adapter logs should show +`KEY_HEARTBEAT_ID` followed by a successful heartbeat POST. -The emulator should show: +### Existing FCM scenario times out -1. `[FCM]` — Receiving the push notification -2. `[EVENTS]` — Fetching outstanding messages and firing events +```bash +docker compose logs --tail 200 api wiremock +``` -If no `[FCM]` entries appear, the API isn't reaching the emulator (check `FCM_ENDPOINT` in `.env.test`). +Keep `FCM_ENDPOINT=http://wiremock:8080`; the adapter service does not replace +or weaken the WireMock phone tests. ### Seed container fails @@ -204,11 +227,5 @@ If no `[FCM]` entries appear, the API isn't reaching the emulator (check `FCM_EN docker compose logs seed ``` -If you see "relation does not exist" errors, the API hasn't finished GORM migrations yet. Increase the API's `start_period` in `docker-compose.yml`. - -## Adding New Tests - -1. Add test functions to `integration_test.go` (or create new `*_test.go` files) -2. Use `doRequest()` helper for authenticated HTTP calls -3. Use `pollMessageStatus()` to wait for async state changes -4. Update the test coverage checklist in this README +If a relation does not exist, inspect API migration/startup logs before +increasing health-check timing. diff --git a/tests/adapter-emulator/Dockerfile b/tests/adapter-emulator/Dockerfile new file mode 100644 index 000000000..d752fcaa6 --- /dev/null +++ b/tests/adapter-emulator/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/adapter-emulator . + +FROM alpine:3.22 +RUN adduser -D -u 10001 app +USER app +COPY --from=build /out/adapter-emulator /usr/local/bin/adapter-emulator +ENTRYPOINT ["adapter-emulator"] diff --git a/tests/adapter-emulator/api_client.go b/tests/adapter-emulator/api_client.go new file mode 100644 index 000000000..17ca2031a --- /dev/null +++ b/tests/adapter-emulator/api_client.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "time" +) + +const maxAPIErrorBodyBytes = 4 * 1024 + +func (instance *emulator) fetchOutstanding( + ctx context.Context, + registeredGateway gateway, + messageID string, +) (map[string]any, error) { + endpoint, err := url.Parse(instance.apiBaseURL + "/v1/messages/outstanding") + if err != nil { + return nil, fmt.Errorf("build outstanding message URL: %w", err) + } + query := endpoint.Query() + query.Set("message_id", messageID) + endpoint.RawQuery = query.Encode() + + log.Printf("[ADAPTER] fetching outstanding message %s", messageID) + var response struct { + Data map[string]any `json:"data"` + } + if err := instance.doAPIRequest(ctx, registeredGateway, http.MethodGet, endpoint.String(), nil, &response); err != nil { + return nil, fmt.Errorf("fetch outstanding message %s: %w", messageID, err) + } + return response.Data, nil +} + +func (instance *emulator) fireMessageEvent( + ctx context.Context, + registeredGateway gateway, + messageID string, + eventName string, +) error { + payload := map[string]any{ + "event_name": eventName, + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + } + endpoint := fmt.Sprintf("%s/v1/messages/%s/events", instance.apiBaseURL, url.PathEscape(messageID)) + log.Printf("[ADAPTER] posting %s for message %s", eventName, messageID) + if err := instance.doAPIRequest(ctx, registeredGateway, http.MethodPost, endpoint, payload, nil); err != nil { + return fmt.Errorf("post %s event for message %s: %w", eventName, messageID, err) + } + return nil +} + +func (instance *emulator) receiveMessage( + ctx context.Context, + registeredGateway gateway, + request incomingMessageRequest, +) (map[string]any, error) { + payload := map[string]any{ + "from": request.Contact, + "to": registeredGateway.PhoneNumber, + "content": request.Content, + "encrypted": request.Encrypted, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + } + log.Printf("[ADAPTER] posting incoming message for gateway phone %s", registeredGateway.PhoneNumber) + + var response struct { + Data map[string]any `json:"data"` + } + if err := instance.doAPIRequest( + ctx, + registeredGateway, + http.MethodPost, + instance.apiBaseURL+"/v1/messages/receive", + payload, + &response, + ); err != nil { + return nil, fmt.Errorf("post incoming message: %w", err) + } + return response.Data, nil +} + +func (instance *emulator) storeHeartbeat(ctx context.Context, registeredGateway gateway) error { + payload := map[string]any{ + "phone_numbers": []string{registeredGateway.PhoneNumber}, + "charging": true, + } + log.Printf("[ADAPTER] posting heartbeat for %s", registeredGateway.PhoneNumber) + if err := instance.doAPIRequest( + ctx, + registeredGateway, + http.MethodPost, + instance.apiBaseURL+"/v1/heartbeats", + payload, + nil, + ); err != nil { + return fmt.Errorf("post heartbeat for %s: %w", registeredGateway.PhoneNumber, err) + } + return nil +} + +func (instance *emulator) doAPIRequest( + ctx context.Context, + registeredGateway gateway, + method string, + endpoint string, + payload any, + result any, +) error { + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("encode request body: %w", err) + } + body = bytes.NewReader(encoded) + } + + request, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + request.Header.Set("x-api-key", registeredGateway.PhoneAPIKey) + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + + response, err := instance.client.Do(request) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + errorBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxAPIErrorBodyBytes)) + if readErr != nil { + return fmt.Errorf("unexpected status %d and read error body: %w", response.StatusCode, readErr) + } + return fmt.Errorf("unexpected status %d: %s", response.StatusCode, string(errorBody)) + } + if result == nil || response.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, response.Body) + return nil + } + if err := json.NewDecoder(response.Body).Decode(result); err != nil { + return fmt.Errorf("decode response body: %w", err) + } + return nil +} diff --git a/tests/adapter-emulator/control_handler.go b/tests/adapter-emulator/control_handler.go new file mode 100644 index 000000000..7211a034a --- /dev/null +++ b/tests/adapter-emulator/control_handler.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const maxControlBodyBytes = 1024 * 1024 + +type gatewayRegistration struct { + PhoneNumber string `json:"phone_number"` + PhoneAPIKey string `json:"phone_api_key"` + PhoneID string `json:"phone_id"` +} + +type incomingMessageRequest struct { + Contact string `json:"contact"` + Content string `json:"content"` + Encrypted bool `json:"encrypted"` +} + +func (instance *emulator) controlHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("PUT /test/gateways/{gatewayID}", instance.handleGatewayRegistration) + mux.HandleFunc("POST /test/gateways/{gatewayID}/incoming", instance.handleIncomingMessage) + mux.HandleFunc("GET /test/gateways/{gatewayID}/notifications", instance.handleNotificationRecords) + mux.HandleFunc("GET /health", instance.handleHealth) + return mux +} + +func (instance *emulator) handleGatewayRegistration(writer http.ResponseWriter, request *http.Request) { + var registration gatewayRegistration + if err := decodeControlJSON(writer, request, ®istration); err != nil { + writeControlError(writer, http.StatusBadRequest, err) + return + } + registration.PhoneNumber = strings.TrimSpace(registration.PhoneNumber) + registration.PhoneAPIKey = strings.TrimSpace(registration.PhoneAPIKey) + registration.PhoneID = strings.TrimSpace(registration.PhoneID) + if registration.PhoneNumber == "" || registration.PhoneAPIKey == "" || registration.PhoneID == "" { + writeControlError(writer, http.StatusBadRequest, errors.New("phone_number, phone_api_key and phone_id are required")) + return + } + + instance.registerGateway(request.PathValue("gatewayID"), registration) + writer.WriteHeader(http.StatusNoContent) +} + +func (instance *emulator) handleIncomingMessage(writer http.ResponseWriter, request *http.Request) { + registeredGateway, ok := instance.loadGateway(request.PathValue("gatewayID")) + if !ok { + writeControlError(writer, http.StatusNotFound, errors.New("unknown gateway")) + return + } + + var incoming incomingMessageRequest + if err := decodeControlJSON(writer, request, &incoming); err != nil { + writeControlError(writer, http.StatusBadRequest, err) + return + } + incoming.Contact = strings.TrimSpace(incoming.Contact) + if incoming.Contact == "" { + writeControlError(writer, http.StatusBadRequest, errors.New("contact is required")) + return + } + + message, err := instance.receiveMessage(request.Context(), registeredGateway, incoming) + if err != nil { + writeControlError(writer, http.StatusBadGateway, err) + return + } + writeControlJSON(writer, http.StatusOK, map[string]any{"data": message}) +} + +func (instance *emulator) handleNotificationRecords(writer http.ResponseWriter, request *http.Request) { + gatewayID := request.PathValue("gatewayID") + if _, ok := instance.loadGateway(gatewayID); !ok { + writeControlError(writer, http.StatusNotFound, errors.New("unknown gateway")) + return + } + records := instance.listGatewayRecords(gatewayID) + if messageID := strings.TrimSpace(request.URL.Query().Get("message_id")); messageID != "" { + filtered := make([]notificationRecord, 0, len(records)) + for _, record := range records { + if record.MessageID == messageID { + filtered = append(filtered, record) + } + } + records = filtered + } + writeControlJSON(writer, http.StatusOK, map[string]any{ + "data": records, + }) +} + +func (*emulator) handleHealth(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "text/plain; charset=utf-8") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte("ok\n")) +} + +func decodeControlJSON(writer http.ResponseWriter, request *http.Request, result any) error { + request.Body = http.MaxBytesReader(writer, request.Body, maxControlBodyBytes) + decoder := json.NewDecoder(request.Body) + if err := decoder.Decode(result); err != nil { + return fmt.Errorf("decode request body: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("request body must contain one JSON value") + } + return nil +} + +func writeControlError(writer http.ResponseWriter, status int, err error) { + writeControlJSON(writer, status, map[string]any{"error": err.Error()}) +} + +func writeControlJSON(writer http.ResponseWriter, status int, payload any) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(payload) +} diff --git a/tests/adapter-emulator/emulator.go b/tests/adapter-emulator/emulator.go new file mode 100644 index 000000000..bcc2fe70a --- /dev/null +++ b/tests/adapter-emulator/emulator.go @@ -0,0 +1,124 @@ +package main + +import ( + "net/http" + "strings" + "sync" +) + +type gateway struct { + PhoneNumber string + PhoneAPIKey string + PhoneID string +} + +type notificationRecord struct { + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Processed bool `json:"processed"` + Error string `json:"error,omitempty"` + Authorization string `json:"authorization,omitempty"` +} + +type emulator struct { + apiBaseURL string + client *http.Client + mu sync.RWMutex + gateways map[string]gateway + records []*notificationRecord +} + +func newEmulator(apiBaseURL string, client *http.Client) *emulator { + return &emulator{ + apiBaseURL: strings.TrimRight(apiBaseURL, "/"), + client: client, + gateways: make(map[string]gateway), + } +} + +func (instance *emulator) registerGateway(gatewayID string, registration gatewayRegistration) { + instance.mu.Lock() + defer instance.mu.Unlock() + + instance.gateways[gatewayID] = gateway{ + PhoneNumber: registration.PhoneNumber, + PhoneAPIKey: registration.PhoneAPIKey, + PhoneID: registration.PhoneID, + } +} + +func (instance *emulator) loadGateway(gatewayID string) (gateway, bool) { + instance.mu.RLock() + defer instance.mu.RUnlock() + + registeredGateway, ok := instance.gateways[gatewayID] + return registeredGateway, ok +} + +func (instance *emulator) recordNotification( + gatewayID string, + data map[string]string, + kind string, + messageID string, + authorization string, +) *notificationRecord { + instance.mu.Lock() + defer instance.mu.Unlock() + + record := ¬ificationRecord{ + GatewayID: gatewayID, + Data: copyStringMap(data), + MessageID: messageID, + Kind: kind, + Authorization: authorization, + } + instance.records = append(instance.records, record) + + return record +} + +func (instance *emulator) markNotificationProcessed(record *notificationRecord) { + instance.mu.Lock() + defer instance.mu.Unlock() + + record.Processed = true + record.Error = "" +} + +func (instance *emulator) markNotificationFailed(record *notificationRecord, err error) { + instance.mu.Lock() + defer instance.mu.Unlock() + + record.Processed = false + record.Error = err.Error() +} + +func (instance *emulator) listGatewayRecords(gatewayID string) []notificationRecord { + instance.mu.RLock() + defer instance.mu.RUnlock() + + records := make([]notificationRecord, 0) + for _, record := range instance.records { + if record.GatewayID == gatewayID { + records = append(records, *copyNotificationRecord(record)) + } + } + + return records +} + +func copyNotificationRecord(record *notificationRecord) *notificationRecord { + copied := *record + copied.Data = copyStringMap(record.Data) + return &copied +} + +func copyStringMap(values map[string]string) map[string]string { + copied := make(map[string]string, len(values)) + for key, value := range values { + copied[key] = value + } + return copied +} diff --git a/tests/adapter-emulator/emulator_test.go b/tests/adapter-emulator/emulator_test.go new file mode 100644 index 000000000..28d5b1dd0 --- /dev/null +++ b/tests/adapter-emulator/emulator_test.go @@ -0,0 +1,576 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const testGatewayPhoneID = "11111111-1111-1111-1111-111111111111" + +func TestRecordNotificationCopiesRecords(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + record := instance.recordNotification( + "gateway-1", + map[string]string{"KEY_MESSAGE_ID": "message-1"}, + "message", + "message-1", + "Bearer test-token", + ) + instance.markNotificationProcessed(record) + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 1 { + t.Fatalf("record count = %d, want 1", len(records)) + } + if !records[0].Processed { + t.Fatal("processed state was not retained") + } + + records[0].Data["KEY_MESSAGE_ID"] = "mutated" + fresh := instance.listGatewayRecords("gateway-1") + if fresh[0].Data["KEY_MESSAGE_ID"] != "message-1" { + t.Fatalf("record list returned mutable state: %#v", fresh[0]) + } +} + +func TestNotificationHandlerProcessesMessage(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var outstandingCalls int + var events []string + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("x-api-key") != "phone-key" { + t.Errorf("x-api-key = %q, want phone-key", request.Header.Get("x-api-key")) + } + + switch { + case request.Method == http.MethodGet && request.URL.Path == "/v1/messages/outstanding": + mu.Lock() + outstandingCalls++ + mu.Unlock() + if request.URL.Query().Get("message_id") != "message-1" { + t.Errorf("message_id = %q, want message-1", request.URL.Query().Get("message_id")) + } + writeJSON(writer, http.StatusOK, map[string]any{ + "data": map[string]any{"id": "message-1"}, + }) + case request.Method == http.MethodPost && request.URL.Path == "/v1/messages/message-1/events": + var payload struct { + EventName string `json:"event_name"` + Timestamp string `json:"timestamp"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Errorf("decode event: %v", err) + } + if payload.Timestamp == "" { + t.Error("event timestamp is empty") + } + mu.Lock() + events = append(events, payload.EventName) + mu.Unlock() + writeJSON(writer, http.StatusOK, map[string]any{"data": map[string]any{"id": "message-1"}}) + default: + http.NotFound(writer, request) + } + })) + defer api.Close() + + instance := newEmulator(api.URL, api.Client()) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}) + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(body), + ) + request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("callback status = %d, want 204: %s", response.Code, response.Body.String()) + } + + mu.Lock() + defer mu.Unlock() + if outstandingCalls != 1 { + t.Fatalf("outstanding calls = %d, want 1", outstandingCalls) + } + if !reflect.DeepEqual(events, []string{"SENT", "DELIVERED"}) { + t.Fatalf("events = %#v, want SENT then DELIVERED", events) + } + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 1 { + t.Fatalf("record count = %d, want 1", len(records)) + } + record := records[0] + if record.Kind != "message" || record.MessageID != "message-1" || !record.Processed { + t.Fatalf("unexpected message record: %#v", record) + } +} + +func TestNotificationHandlerStoresHeartbeat(t *testing.T) { + t.Parallel() + + var heartbeatPayload map[string]any + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/v1/heartbeats" { + http.NotFound(writer, request) + return + } + if request.Header.Get("x-api-key") != "phone-key" { + t.Errorf("x-api-key = %q, want phone-key", request.Header.Get("x-api-key")) + } + if err := json.NewDecoder(request.Body).Decode(&heartbeatPayload); err != nil { + t.Errorf("decode heartbeat: %v", err) + } + writeJSON(writer, http.StatusCreated, map[string]any{"data": []any{}}) + })) + defer api.Close() + + instance := newEmulator(api.URL, api.Client()) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"})), + ) + request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("callback status = %d, want 204: %s", response.Code, response.Body.String()) + } + + if !reflect.DeepEqual(heartbeatPayload["phone_numbers"], []any{"+18005550199"}) { + t.Fatalf("phone_numbers = %#v, want gateway phone", heartbeatPayload["phone_numbers"]) + } + if heartbeatPayload["charging"] != true { + t.Fatalf("charging = %#v, want true", heartbeatPayload["charging"]) + } + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 1 || records[0].Kind != "heartbeat" || !records[0].Processed { + t.Fatalf("unexpected heartbeat records: %#v", records) + } +} + +func TestNotificationHandlerRetainsProcessingFailure(t *testing.T) { + t.Parallel() + + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Error(writer, "outstanding unavailable", http.StatusServiceUnavailable) + })) + defer api.Close() + + instance := newEmulator(api.URL, api.Client()) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusInternalServerError { + t.Fatalf("callback status = %d, want 500: %s", response.Code, response.Body.String()) + } + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 1 { + t.Fatalf("record count = %d, want 1", len(records)) + } + if records[0].Processed { + t.Fatal("failed notification was marked processed") + } + if !strings.Contains(records[0].Error, "fetch outstanding message") { + t.Fatalf("record error = %q, want fetch context", records[0].Error) + } +} + +func TestNotificationHandlerProcessesRetryAfterFailure(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var outstandingCalls int + var events []string + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && request.URL.Path == "/v1/messages/outstanding": + mu.Lock() + outstandingCalls++ + firstCall := outstandingCalls == 1 + mu.Unlock() + if firstCall { + http.Error(writer, "outstanding unavailable", http.StatusServiceUnavailable) + return + } + writeJSON(writer, http.StatusOK, map[string]any{ + "data": map[string]any{"id": "message-1"}, + }) + case request.Method == http.MethodPost && request.URL.Path == "/v1/messages/message-1/events": + var payload struct { + EventName string `json:"event_name"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Errorf("decode event: %v", err) + } + mu.Lock() + events = append(events, payload.EventName) + mu.Unlock() + writeJSON(writer, http.StatusOK, map[string]any{"data": map[string]any{"id": "message-1"}}) + default: + http.NotFound(writer, request) + } + })) + defer api.Close() + + instance := newEmulator(api.URL, api.Client()) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + handler := instance.notificationHandler() + body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}) + + firstRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body)) + firstRequest.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) + firstResponse := httptest.NewRecorder() + handler.ServeHTTP(firstResponse, firstRequest) + if firstResponse.Code != http.StatusInternalServerError { + t.Fatalf("first callback status = %d, want 500: %s", firstResponse.Code, firstResponse.Body.String()) + } + secondRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body)) + secondRequest.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) + secondResponse := httptest.NewRecorder() + handler.ServeHTTP(secondResponse, secondRequest) + if secondResponse.Code != http.StatusNoContent { + t.Fatalf("retry callback status = %d, want 204: %s", secondResponse.Code, secondResponse.Body.String()) + } + + mu.Lock() + defer mu.Unlock() + if outstandingCalls != 2 { + t.Fatalf("outstanding calls = %d, want 2", outstandingCalls) + } + if !reflect.DeepEqual(events, []string{"SENT", "DELIVERED"}) { + t.Fatalf("events = %#v, want SENT then DELIVERED", events) + } + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 2 { + t.Fatalf("record count = %d, want 2", len(records)) + } + if records[0].Processed || records[0].Error == "" { + t.Fatalf("unexpected failed record: %#v", records[0]) + } + if !records[1].Processed || records[1].Error != "" { + t.Fatalf("unexpected successful retry record: %#v", records[1]) + } +} + +func TestControlHandlerRegistersGatewayAndReceivesIncomingMessage(t *testing.T) { + t.Parallel() + + var receivePayload map[string]any + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/v1/messages/receive" { + http.NotFound(writer, request) + return + } + if request.Header.Get("x-api-key") != "phone-key" { + t.Errorf("x-api-key = %q, want phone-key", request.Header.Get("x-api-key")) + } + if err := json.NewDecoder(request.Body).Decode(&receivePayload); err != nil { + t.Errorf("decode receive payload: %v", err) + } + writeJSON(writer, http.StatusOK, map[string]any{ + "data": map[string]any{"id": "message-1"}, + }) + })) + defer api.Close() + + instance := newEmulator(api.URL, api.Client()) + handler := instance.controlHandler() + + registration := performJSONRequest(t, handler, http.MethodPut, "/test/gateways/gateway-1", map[string]any{ + "phone_number": "+18005550199", + "phone_api_key": "phone-key", + "phone_id": testGatewayPhoneID, + }) + if registration.Code != http.StatusNoContent { + t.Fatalf("registration status = %d, want 204: %s", registration.Code, registration.Body.String()) + } + + incoming := performJSONRequest(t, handler, http.MethodPost, "/test/gateways/gateway-1/incoming", map[string]any{ + "contact": "+18005550100", + "content": "hello", + "encrypted": true, + }) + if incoming.Code != http.StatusOK { + t.Fatalf("incoming status = %d, want 200: %s", incoming.Code, incoming.Body.String()) + } + + if receivePayload["to"] != "+18005550199" || + receivePayload["from"] != "+18005550100" || + receivePayload["content"] != "hello" || + receivePayload["encrypted"] != true || + receivePayload["sim"] != "SIM1" || + receivePayload["timestamp"] == "" { + t.Fatalf("unexpected receive payload: %#v", receivePayload) + } + + var incomingResponse struct { + Data map[string]any `json:"data"` + } + if err := json.NewDecoder(incoming.Body).Decode(&incomingResponse); err != nil { + t.Fatalf("decode incoming response: %v", err) + } + if incomingResponse.Data["id"] != "message-1" { + t.Fatalf("incoming message id = %#v, want message-1", incomingResponse.Data["id"]) + } + + health := httptest.NewRecorder() + handler.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/health", nil)) + if health.Code != http.StatusOK { + t.Fatalf("health status = %d, want 200", health.Code) + } + + records := httptest.NewRecorder() + handler.ServeHTTP(records, httptest.NewRequest(http.MethodGet, "/test/gateways/gateway-1/notifications", nil)) + if records.Code != http.StatusOK { + t.Fatalf("records status = %d, want 200", records.Code) + } +} + +func TestControlHandlerFiltersNotificationRecordsByMessageID(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + instance.recordNotification( + "gateway-1", + map[string]string{"KEY_MESSAGE_ID": "message-1"}, + "message", + "message-1", + "Bearer test-token", + ) + instance.recordNotification( + "gateway-1", + map[string]string{"KEY_MESSAGE_ID": "message-2"}, + "message", + "message-2", + "Bearer test-token", + ) + + response := httptest.NewRecorder() + instance.controlHandler().ServeHTTP( + response, + httptest.NewRequest( + http.MethodGet, + "/test/gateways/gateway-1/notifications?message_id=message-2", + nil, + ), + ) + if response.Code != http.StatusOK { + t.Fatalf("records status = %d, want 200", response.Code) + } + + var payload struct { + Data []notificationRecord `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode records: %v", err) + } + if len(payload.Data) != 1 || payload.Data[0].MessageID != "message-2" { + t.Fatalf("filtered records = %#v, want message-2 only", payload.Data) + } +} + +func TestNotificationHandlerRejectsMissingAuthorization(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String()) + } + if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 { + t.Fatalf("record count = %d, want 0 for an unauthenticated request", len(records)) + } +} + +func TestNotificationHandlerRejectsTokenSignedWithWrongSecret(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + request.Header.Set("Authorization", validNotificationToken(t, "some-other-phone-id")) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String()) + } + if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 { + t.Fatalf("record count = %d, want 0 for a request signed with the wrong secret", len(records)) + } +} + +func TestNotificationHandlerRejectsNonHS256SigningMethod(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + now := time.Now().UTC() + token := jwt.NewWithClaims(jwt.SigningMethodHS384, jwt.RegisteredClaims{ + Audience: []string{"https://adapter-emulator:9091/notifications/gateway-1"}, + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: notificationJWTIssuer, + NotBefore: jwt.NewNumericDate(now.Add(-10 * time.Minute)), + }) + signed, err := token.SignedString([]byte(testGatewayPhoneID)) + if err != nil { + t.Fatalf("sign notification token: %v", err) + } + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + request.Header.Set("Authorization", "Bearer "+signed) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String()) + } + if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 { + t.Fatalf("record count = %d, want 0 for a request signed with a non-HS256 method", len(records)) + } +} + +func validNotificationToken(t *testing.T, phoneID string) string { + t.Helper() + + now := time.Now().UTC() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Audience: []string{"https://adapter-emulator:9091/notifications/gateway-1"}, + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: notificationJWTIssuer, + NotBefore: jwt.NewNumericDate(now.Add(-10 * time.Minute)), + }) + signed, err := token.SignedString([]byte(phoneID)) + if err != nil { + t.Fatalf("sign notification token: %v", err) + } + return "Bearer " + signed +} + +func callbackBody(t *testing.T, data map[string]string) []byte { + t.Helper() + + body, err := json.Marshal(map[string]any{ + "message": map[string]any{ + "token": "https://adapter-emulator:9091/notifications/gateway-1", + "data": data, + }, + }) + if err != nil { + t.Fatalf("marshal callback: %v", err) + } + return body +} + +func performJSONRequest( + t *testing.T, + handler http.Handler, + method string, + target string, + payload map[string]any, +) *httptest.ResponseRecorder { + t.Helper() + + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + request := httptest.NewRequest(method, target, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func writeJSON(writer http.ResponseWriter, status int, payload any) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(payload) +} diff --git a/tests/adapter-emulator/go.mod b/tests/adapter-emulator/go.mod new file mode 100644 index 000000000..31faad6df --- /dev/null +++ b/tests/adapter-emulator/go.mod @@ -0,0 +1,5 @@ +module github.com/NdoleStudio/httpsms/tests/adapter-emulator + +go 1.25.0 + +require github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/tests/adapter-emulator/go.sum b/tests/adapter-emulator/go.sum new file mode 100644 index 000000000..c0f729031 --- /dev/null +++ b/tests/adapter-emulator/go.sum @@ -0,0 +1,2 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= diff --git a/tests/adapter-emulator/main.go b/tests/adapter-emulator/main.go new file mode 100644 index 000000000..c8d93f50a --- /dev/null +++ b/tests/adapter-emulator/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "crypto/tls" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +const ( + callbackAddress = ":9091" + controlAddress = ":9092" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + apiBaseURL := environmentOrDefault("API_BASE_URL", "http://api:8000") + tlsCertificate := environmentOrDefault("ADAPTER_TLS_CERT", "/certs/server.pem") + tlsKey := environmentOrDefault("ADAPTER_TLS_KEY", "/certs/server-key.pem") + + instance := newEmulator(apiBaseURL, &http.Client{Timeout: 15 * time.Second}) + callbackServer := newHTTPServer(callbackAddress, instance.notificationHandler()) + callbackServer.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} + controlServer := newHTTPServer(controlAddress, instance.controlHandler()) + + serverErrors := make(chan error, 2) + go func() { + log.Printf("[ADAPTER] HTTPS callback server listening on %s", callbackAddress) + serverErrors <- callbackServer.ListenAndServeTLS(tlsCertificate, tlsKey) + }() + go func() { + log.Printf("[ADAPTER] HTTP control server listening on %s", controlAddress) + serverErrors <- controlServer.ListenAndServe() + }() + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + + var serveErr error + select { + case <-signals: + log.Printf("[ADAPTER] shutdown signal received") + case err := <-serverErrors: + if !errors.Is(err, http.ErrServerClosed) { + serveErr = err + } + } + + shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + shutdownErr := errors.Join( + callbackServer.Shutdown(shutdownContext), + controlServer.Shutdown(shutdownContext), + ) + return errors.Join(serveErr, shutdownErr) +} + +func newHTTPServer(address string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: address, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } +} + +func environmentOrDefault(name string, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/tests/adapter-emulator/notification_handler.go b/tests/adapter-emulator/notification_handler.go new file mode 100644 index 000000000..e17415f4f --- /dev/null +++ b/tests/adapter-emulator/notification_handler.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +const maxCallbackBodyBytes = 1024 * 1024 + +// notificationJWTIssuer must match the issuer the httpSMS API signs adapter notification +// tokens with (see api/pkg/services/http_notification_sender.go). +const notificationJWTIssuer = "api.httpsms.com" + +type callbackEnvelope struct { + Message struct { + Token string `json:"token"` + Data map[string]string `json:"data"` + } `json:"message"` +} + +func (instance *emulator) notificationHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /notifications/{gatewayID}", instance.handleNotification) + return mux +} + +func (instance *emulator) handleNotification(writer http.ResponseWriter, request *http.Request) { + gatewayID := request.PathValue("gatewayID") + registeredGateway, ok := instance.loadGateway(gatewayID) + if !ok { + http.Error(writer, "unknown gateway", http.StatusNotFound) + return + } + + authorization := request.Header.Get("Authorization") + if err := verifyNotificationAuth(authorization, registeredGateway.PhoneID); err != nil { + log.Printf("[ADAPTER] rejected notification for gateway=%s: %v", gatewayID, err) + http.Error(writer, fmt.Sprintf("invalid notification token: %v", err), http.StatusUnauthorized) + return + } + + request.Body = http.MaxBytesReader(writer, request.Body, maxCallbackBodyBytes) + var envelope callbackEnvelope + if err := json.NewDecoder(request.Body).Decode(&envelope); err != nil { + http.Error(writer, "invalid callback payload", http.StatusBadRequest) + return + } + + kind, messageID, validationErr := notificationKind(envelope.Message.Data) + record := instance.recordNotification( + gatewayID, + envelope.Message.Data, + kind, + messageID, + authorization, + ) + log.Printf( + "[ADAPTER] callback gateway=%s data=%v", + gatewayID, + envelope.Message.Data, + ) + if validationErr != nil { + instance.markNotificationFailed(record, validationErr) + http.Error(writer, validationErr.Error(), http.StatusBadRequest) + return + } + + var processingErr error + switch kind { + case "message": + _, processingErr = instance.fetchOutstanding(request.Context(), registeredGateway, messageID) + if processingErr == nil { + processingErr = instance.fireMessageEvent(request.Context(), registeredGateway, messageID, "SENT") + } + if processingErr == nil { + processingErr = instance.fireMessageEvent(request.Context(), registeredGateway, messageID, "DELIVERED") + } + case "heartbeat": + processingErr = instance.storeHeartbeat(request.Context(), registeredGateway) + } + if processingErr != nil { + instance.markNotificationFailed(record, processingErr) + log.Printf("[ADAPTER] notification failed: %v", processingErr) + http.Error(writer, "notification processing failed", http.StatusInternalServerError) + return + } + + instance.markNotificationProcessed(record) + log.Printf("[ADAPTER] notification processed as %s", kind) + writer.WriteHeader(http.StatusNoContent) +} + +func notificationKind(data map[string]string) (kind string, messageID string, err error) { + messageID = strings.TrimSpace(data["KEY_MESSAGE_ID"]) + heartbeatID := strings.TrimSpace(data["KEY_HEARTBEAT_ID"]) + + switch { + case messageID != "" && heartbeatID == "": + return "message", messageID, nil + case heartbeatID != "" && messageID == "": + return "heartbeat", "", nil + default: + return "", "", fmt.Errorf("unsupported notification data") + } +} + +// verifyNotificationAuth validates the JWT the httpSMS API signs notification requests with, +// using the gateway's phone ID as the HMAC-SHA256 secret (see +// api/pkg/services/http_notification_sender.go getAuthToken). The phone ID is never carried in +// a token claim, only used as the secret, so verification relies on the gateway's own +// registration to know which phone ID to check against rather than trusting a claim. +func verifyNotificationAuth(authorization string, phoneID string) error { + tokenString, ok := strings.CutPrefix(authorization, "Bearer ") + if !ok || strings.TrimSpace(tokenString) == "" { + return fmt.Errorf("missing bearer token") + } + + claims := jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(phoneID), nil + }) + if err != nil { + return fmt.Errorf("parse token: %w", err) + } + if !token.Valid { + return fmt.Errorf("token is not valid") + } + if claims.Issuer != notificationJWTIssuer { + return fmt.Errorf("issuer mismatch") + } + return nil +} diff --git a/tests/adapter_integration_test.go b/tests/adapter_integration_test.go new file mode 100644 index 000000000..70dbbe894 --- /dev/null +++ b/tests/adapter_integration_test.go @@ -0,0 +1,93 @@ +package tests + +import ( + "context" + "net/http" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAdapterGatewayOutgoingMessage(t *testing.T) { + ctx := context.Background() + phone := setupAdapterPhone(ctx, t, 60) + contact := randomPhoneNumber() + content := "Adapter outgoing " + randomEncryptionKey() + + response, httpResponse, err := newAPIClient().Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contact, + Content: content, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, httpResponse.HTTPResponse.StatusCode) + + messageID := response.Data.ID.String() + message := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second) + + assert.Equal(t, phone.PhoneNumber, message.Owner) + assert.Equal(t, contact, message.Contact) + assert.Equal(t, content, message.Content) + records := waitForAdapterMessageRecords(t, phone.GatewayID, messageID, 30*time.Second) + require.Len(t, records, 1) + assert.Equal(t, "message", records[0].Kind) + assert.True(t, records[0].Processed) + assert.Equal(t, messageID, records[0].Data["KEY_MESSAGE_ID"]) + assertAdapterNotificationJWT(t, records[0], phone.PhoneID) +} + +func TestAdapterGatewayIncomingMessage(t *testing.T) { + ctx := context.Background() + phone := setupAdapterPhone(ctx, t, 60) + contact := randomPhoneNumber() + content := "Adapter incoming " + randomEncryptionKey() + + messageID := triggerAdapterIncoming(ctx, t, phone, contact, content) + message := pollMessageStatus(ctx, t, messageID, "received", 15*time.Second) + + assert.Equal(t, phone.PhoneNumber, message.Owner) + assert.Equal(t, contact, message.Contact) + assert.Equal(t, content, message.Content) + assert.Equal(t, "received", message.Status) +} + +func TestAdapterGatewayHeartbeatWakeUp(t *testing.T) { + ctx := context.Background() + phone := setupAdapterPhone(ctx, t, 60) + monitorID := uuid.NewString() + + dispatchInternalEvent(ctx, t, map[string]any{ + "specversion": "1.0", + "id": uuid.NewString(), + "source": "/tests/adapter-emulator", + "type": "phone.heartbeat.missed", + "time": time.Now().UTC().Format(time.RFC3339), + "datacontenttype": "application/json", + "data": map[string]any{ + "phone_id": phone.PhoneID, + "user_id": "test-user-id", + "last_heartbeat_timestamp": time.Now().UTC().Add(-20 * time.Minute).Format(time.RFC3339), + "timestamp": time.Now().UTC().Format(time.RFC3339), + "monitor_id": monitorID, + "owner": phone.PhoneNumber, + }, + }) + + record := waitForAdapterHeartbeatRecord(t, phone.GatewayID, 30*time.Second) + assert.Equal(t, "heartbeat", record.Kind) + assert.NotEmpty(t, record.Data["KEY_HEARTBEAT_ID"]) + assertAdapterNotificationJWT(t, record, phone.PhoneID) + + heartbeats, response, err := newAPIClient().Heartbeats.Index(ctx, &httpsms.HeartbeatIndexParams{ + Owner: phone.PhoneNumber, + Limit: 1, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + require.NotEmpty(t, heartbeats.Data) + assert.Equal(t, phone.PhoneNumber, heartbeats.Data[0].Owner) +} diff --git a/tests/contacts_integration_test.go b/tests/contacts_integration_test.go new file mode 100644 index 000000000..2dcd4d5c0 --- /dev/null +++ b/tests/contacts_integration_test.go @@ -0,0 +1,332 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type integrationContact struct { + ID string `json:"id"` + Name string `json:"name"` + Emails []string `json:"emails"` + PhoneNumbers []string `json:"phone_numbers"` + Properties map[string]string `json:"properties"` +} + +type integrationContactsResponse struct { + Data []integrationContact `json:"data"` + Total int64 `json:"total"` +} + +type integrationContactResponse struct { + Data integrationContact `json:"data"` +} + +type integrationContactThread struct { + ID string `json:"id"` + Contact string `json:"contact"` + ContactDetails *integrationContact `json:"contact_details"` +} + +func createIntegrationContacts( + ctx context.Context, + t *testing.T, + contacts []map[string]any, +) []integrationContact { + t.Helper() + + var response integrationContactsResponse + requestJSON( + ctx, + t, + http.MethodPost, + "/v1/contacts", + userAPIKey, + contacts, + http.StatusCreated, + &response, + ) + require.Len(t, response.Data, len(contacts)) + return response.Data +} + +func deleteIntegrationContact(ctx context.Context, t *testing.T, contactID string) { + t.Helper() + + requestJSON( + ctx, + t, + http.MethodDelete, + "/v1/contacts/"+contactID, + userAPIKey, + nil, + http.StatusNoContent, + nil, + ) +} + +func cleanupIntegrationContact(t *testing.T, contactID string) { + t.Helper() + + request, err := http.NewRequest( + http.MethodDelete, + apiBaseURL+"/v1/contacts/"+contactID, + nil, + ) + require.NoError(t, err) + request.Header.Set("x-api-key", userAPIKey) + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Contains( + t, + []int{http.StatusNoContent, http.StatusNotFound}, + response.StatusCode, + "response: %s", + string(responseBody), + ) +} + +func listIntegrationContacts( + ctx context.Context, + t *testing.T, + query string, + skip int, + limit int, +) integrationContactsResponse { + t.Helper() + + path := fmt.Sprintf( + "/v1/contacts?query=%s&skip=%d&limit=%d", + url.QueryEscape(query), + skip, + limit, + ) + var response integrationContactsResponse + requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &response) + return response +} + +func uploadIntegrationContactsCSV( + ctx context.Context, + t *testing.T, + contents string, +) integrationContactsResponse { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("document", "contacts.csv") + require.NoError(t, err) + _, err = part.Write([]byte(contents)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + apiBaseURL+"/v1/contacts/upload", + &body, + ) + require.NoError(t, err) + request.Header.Set("x-api-key", userAPIKey) + request.Header.Set("Content-Type", writer.FormDataContentType()) + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, response.StatusCode, "response: %s", string(responseBody)) + + var result integrationContactsResponse + require.NoError(t, json.Unmarshal(responseBody, &result)) + return result +} + +func waitForThreadWithContact( + ctx context.Context, + t *testing.T, + owner string, + phoneNumber string, + timeout time.Duration, +) integrationContactThread { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + path := fmt.Sprintf( + "/v1/message-threads?owner=%s&contacts=true&skip=0&limit=20", + url.QueryEscape(owner), + ) + var response struct { + Data []integrationContactThread `json:"data"` + } + requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &response) + for _, thread := range response.Data { + if thread.Contact == phoneNumber && thread.ContactDetails != nil { + return thread + } + } + time.Sleep(500 * time.Millisecond) + } + + t.Fatalf("thread for %s did not include contact details within %v", phoneNumber, timeout) + return integrationContactThread{} +} + +func TestContacts_CRUDSearchAndPagination(t *testing.T) { + ctx := context.Background() + marker := strings.ToLower(uuid.New().String()) + firstPhone := randomPhoneNumber() + secondPhone := randomPhoneNumber() + + created := createIntegrationContacts(ctx, t, []map[string]any{ + { + "name": " Alice " + marker + " ", + "emails": []string{" ALICE-" + marker + "@EXAMPLE.COM "}, + "phone_numbers": []string{strings.TrimPrefix(firstPhone, "+")}, + "properties": map[string]string{"company": "Acme"}, + }, + { + "name": "Bob " + marker, + "emails": []string{"bob-" + marker + "@example.com"}, + "phone_numbers": []string{secondPhone}, + }, + }) + for _, contact := range created { + contactID := contact.ID + t.Cleanup(func() { + cleanupIntegrationContact(t, contactID) + }) + } + + require.Len(t, created, 2) + assert.Equal(t, "Alice "+marker, created[0].Name) + assert.Equal(t, []string{"alice-" + marker + "@example.com"}, created[0].Emails) + assert.Equal(t, []string{firstPhone}, created[0].PhoneNumbers) + assert.Equal(t, map[string]string{"company": "Acme"}, created[0].Properties) + + page := listIntegrationContacts(ctx, t, marker, 0, 1) + assert.EqualValues(t, 2, page.Total) + require.Len(t, page.Data, 1) + + emailMatch := listIntegrationContacts(ctx, t, "ALICE-"+marker+"@EXAMPLE.COM", 0, 20) + assert.EqualValues(t, 1, emailMatch.Total) + require.Len(t, emailMatch.Data, 1) + assert.Equal(t, created[0].ID, emailMatch.Data[0].ID) + + var updated integrationContactResponse + requestJSON( + ctx, + t, + http.MethodPut, + "/v1/contacts/"+created[0].ID, + userAPIKey, + map[string]any{ + "name": "Updated " + marker, + "emails": []string{"updated-" + marker + "@example.com"}, + "phone_numbers": []string{firstPhone}, + "properties": map[string]string{"company": "NdoleStudio"}, + }, + http.StatusOK, + &updated, + ) + assert.Equal(t, "Updated "+marker, updated.Data.Name) + assert.Equal(t, []string{"updated-" + marker + "@example.com"}, updated.Data.Emails) + assert.Equal(t, map[string]string{"company": "NdoleStudio"}, updated.Data.Properties) + + deleteIntegrationContact(ctx, t, created[0].ID) + + deletedMatch := listIntegrationContacts(ctx, t, "updated-"+marker+"@example.com", 0, 20) + assert.Zero(t, deletedMatch.Total) + assert.Empty(t, deletedMatch.Data) +} + +func TestContacts_CSVUpload(t *testing.T) { + ctx := context.Background() + marker := strings.ToLower(uuid.New().String()) + phoneNumber := randomPhoneNumber() + csv := fmt.Sprintf( + "Name,Emails,PhoneNumbers\nCSV %s,\"FIRST-%s@EXAMPLE.COM;second-%s@example.com\",%s\n", + marker, + marker, + marker, + strings.TrimPrefix(phoneNumber, "+"), + ) + + response := uploadIntegrationContactsCSV(ctx, t, csv) + require.Len(t, response.Data, 1) + contact := response.Data[0] + t.Cleanup(func() { + cleanupIntegrationContact(t, contact.ID) + }) + + assert.Equal(t, "CSV "+marker, contact.Name) + assert.Equal(t, []string{ + "first-" + marker + "@example.com", + "second-" + marker + "@example.com", + }, contact.Emails) + assert.Equal(t, []string{phoneNumber}, contact.PhoneNumbers) + + listed := listIntegrationContacts(ctx, t, marker, 0, 20) + assert.EqualValues(t, 1, listed.Total) + require.Len(t, listed.Data, 1) + assert.Equal(t, contact.ID, listed.Data[0].ID) +} + +func TestContacts_MessageThreadsIncludeContactDetails(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + contactPhone := randomPhoneNumber() + marker := strings.ToLower(uuid.New().String()) + + created := createIntegrationContacts(ctx, t, []map[string]any{ + { + "name": "Thread Contact " + marker, + "emails": []string{"thread-" + marker + "@example.com"}, + "phone_numbers": []string{contactPhone}, + "properties": map[string]string{"source": "integration-test"}, + }, + }) + contact := created[0] + t.Cleanup(func() { + cleanupIntegrationContact(t, contact.ID) + }) + + messageID := receiveInbound( + ctx, + t, + phone.PhoneAPIKey, + contactPhone, + phone.PhoneNumber, + "Contact enrichment "+marker, + time.Now(), + ) + pollMessageStatus(ctx, t, messageID, "received", 15*time.Second) + + thread := waitForThreadWithContact(ctx, t, phone.PhoneNumber, contactPhone, 20*time.Second) + require.NotNil(t, thread.ContactDetails) + assert.Equal(t, contact.ID, thread.ContactDetails.ID) + assert.Equal(t, contact.Name, thread.ContactDetails.Name) + assert.Equal(t, contact.PhoneNumbers, thread.ContactDetails.PhoneNumbers) + assert.Equal(t, contact.Properties, thread.ContactDetails.Properties) +} diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 3d82d47c1..ca03a3130 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -76,6 +76,23 @@ services: timeout: 5s retries: 10 + adapter-emulator: + build: + context: ./adapter-emulator + ports: + - "9092:9092" + environment: + API_BASE_URL: http://api:8000 + ADAPTER_TLS_CERT: /certs/server.pem + ADAPTER_TLS_KEY: /certs/server-key.pem + volumes: + - ./certs:/certs:ro + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9092/health"] + interval: 5s + timeout: 5s + retries: 10 + api: build: context: ../api @@ -90,10 +107,15 @@ services: condition: service_healthy mongodb: condition: service_healthy + adapter-emulator: + condition: service_healthy env_file: - .env.test environment: FIREBASE_CREDENTIALS: "${FIREBASE_CREDENTIALS}" + SSL_CERT_FILE: /adapter-certs/ca.pem + volumes: + - ./certs/ca.pem:/adapter-certs/ca.pem:ro healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 5s diff --git a/tests/generate-adapter-certificates.sh b/tests/generate-adapter-certificates.sh new file mode 100644 index 000000000..c42eb805c --- /dev/null +++ b/tests/generate-adapter-certificates.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +export MSYS2_ARG_CONV_EXCL="/CN=" + +output_dir="${1:-certs}" +mkdir -p "$output_dir" + +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$output_dir/ca-key.pem" \ + -out "$output_dir/ca.pem" \ + -days 2 \ + -subj "/CN=httpSMS integration adapter CA" + +openssl req -newkey rsa:2048 -nodes \ + -keyout "$output_dir/server-key.pem" \ + -out "$output_dir/server.csr" \ + -subj "/CN=adapter-emulator" + +cat >"$output_dir/server.ext" <<'EOF' +subjectAltName=DNS:adapter-emulator +extendedKeyUsage=serverAuth +EOF + +openssl x509 -req \ + -in "$output_dir/server.csr" \ + -CA "$output_dir/ca.pem" \ + -CAkey "$output_dir/ca-key.pem" \ + -CAcreateserial \ + -out "$output_dir/server.pem" \ + -days 2 \ + -extfile "$output_dir/server.ext" + +# The emulator runs as an unprivileged container user and must read this +# bind-mounted throwaway key. +chmod 0644 "$output_dir/server-key.pem" diff --git a/tests/go.mod b/tests/go.mod index 1cc657c08..a213d5238 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -1,6 +1,6 @@ module github.com/NdoleStudio/httpsms/tests -go 1.24.0 +go 1.25.0 require ( github.com/NdoleStudio/httpsms-go v0.0.8 @@ -8,19 +8,19 @@ require ( github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 github.com/wiremock/go-wiremock v1.14.0 - github.com/xuri/excelize/v2 v2.10.1 + github.com/xuri/excelize/v2 v2.11.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/mscfb v1.0.7 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/text v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/tests/go.sum b/tests/go.sum index 441bc37ef..089f085cc 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -68,8 +68,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= -github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +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= github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= @@ -92,8 +92,8 @@ github.com/wiremock/go-wiremock v1.14.0 h1:cVAV98Odg+hySEYKDRUasVo30q7JE/ysrdx5q github.com/wiremock/go-wiremock v1.14.0/go.mod h1:T5XkKnsKS2asycbUrk2cpxXTEXwa6klHfCWVN8BkhkU= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= -github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= @@ -106,24 +106,24 @@ go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPi go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= -golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= -golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= diff --git a/tests/helpers_test.go b/tests/helpers_test.go index 49a7a6545..797b2f09c 100644 --- a/tests/helpers_test.go +++ b/tests/helpers_test.go @@ -10,6 +10,7 @@ import ( "math/big" "mime/multipart" "net/http" + "net/url" "strings" "testing" "time" @@ -26,7 +27,9 @@ const ( apiBaseURL = "http://localhost:8000" wiremockURL = "http://localhost:8080" wiremockWebhookURL = "http://wiremock.local:8080" // reachable from API container, passes URL validation (needs a dot) + adapterControlURL = "http://localhost:9092" userAPIKey = "test-user-api-key" + systemAPIKey = "system-user-api-key" ) type testPhone struct { @@ -35,6 +38,22 @@ type testPhone struct { FcmToken string } +type adapterTestPhone struct { + testPhone + PhoneID string + GatewayID string +} + +type notificationRecord struct { + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Processed bool `json:"processed"` + Error string `json:"error,omitempty"` + Authorization string `json:"authorization,omitempty"` +} + func newAPIClient() *httpsms.Client { return httpsms.New( httpsms.WithBaseURL(apiBaseURL), @@ -110,6 +129,8 @@ func setupPhone(ctx context.Context, t *testing.T, messagesPerMinute uint) testP require.NoError(t, err) require.Equal(t, http.StatusOK, resp.HTTPResponse.StatusCode, "fcm token bind failed") + waitForPhoneAuthorization(ctx, t, phoneAPIKeyValue, phoneNumber, 20*time.Second) + return testPhone{ PhoneNumber: phoneNumber, PhoneAPIKey: phoneAPIKeyValue, @@ -117,6 +138,286 @@ func setupPhone(ctx context.Context, t *testing.T, messagesPerMinute uint) testP } } +func setupAdapterPhone(ctx context.Context, t *testing.T, messagesPerMinute uint) adapterTestPhone { + t.Helper() + + gatewayID := uuid.NewString() + phoneNumber := randomPhoneNumber() + client := newAPIClient() + + apiKeyResponse, response, err := client.PhoneAPIKeys.Store(ctx, &httpsms.PhoneAPIKeyStoreParams{ + Name: "adapter-test-key-" + uuid.NewString(), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode, "phone api key store failed") + + phoneAPIKey := apiKeyResponse.Data.APIKey + require.NotEmpty(t, phoneAPIKey) + + // Upsert the phone first so its ID is known before the adapter emulator is registered: + // the API signs notification requests with a JWT keyed by the phone ID, and the emulator + // needs that ID up front to validate the JWT on every notification it receives. + callbackURL := fmt.Sprintf("https://adapter-emulator:9091/notifications/%s", gatewayID) + phoneResponse, response, err := client.Phones.Upsert(ctx, &httpsms.PhoneUpsertParams{ + PhoneNumber: phoneNumber, + FcmToken: callbackURL, + MessagesPerMinute: messagesPerMinute, + MaxSendAttempts: 2, + MessageExpirationSeconds: 600, + SIM: "SIM1", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode, "phone upsert failed") + require.NotEmpty(t, phoneResponse.Data.ID) + + registrationBody, err := json.Marshal(map[string]any{ + "phone_number": phoneNumber, + "phone_api_key": phoneAPIKey, + "phone_id": phoneResponse.Data.ID, + }) + require.NoError(t, err) + registrationRequest, err := http.NewRequestWithContext( + ctx, + http.MethodPut, + fmt.Sprintf("%s/test/gateways/%s", adapterControlURL, gatewayID), + bytes.NewReader(registrationBody), + ) + require.NoError(t, err) + registrationRequest.Header.Set("Content-Type", "application/json") + registrationResponse, err := http.DefaultClient.Do(registrationRequest) + require.NoError(t, err) + registrationResponseBody, err := io.ReadAll(registrationResponse.Body) + registrationResponse.Body.Close() + require.NoError(t, err) + require.Equal( + t, + http.StatusNoContent, + registrationResponse.StatusCode, + "adapter gateway registration failed: %s", + string(registrationResponseBody), + ) + + phoneClient := newPhoneClient(phoneAPIKey) + _, response, err = phoneClient.Phones.UpsertFCMToken(ctx, &httpsms.PhoneFCMTokenParams{ + PhoneNumber: phoneNumber, + FcmToken: callbackURL, + SIM: "SIM1", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode, "adapter callback bind failed") + + waitForPhoneAuthorization(ctx, t, phoneAPIKey, phoneNumber, 20*time.Second) + + return adapterTestPhone{ + testPhone: testPhone{ + PhoneNumber: phoneNumber, + PhoneAPIKey: phoneAPIKey, + FcmToken: callbackURL, + }, + PhoneID: phoneResponse.Data.ID, + GatewayID: gatewayID, + } +} + +func dispatchInternalEvent(ctx context.Context, t *testing.T, event map[string]any) { + t.Helper() + + body, err := json.Marshal(event) + require.NoError(t, err) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/v1/events", bytes.NewReader(body)) + require.NoError(t, err) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("x-api-key", systemAPIKey) + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, response.StatusCode, "event dispatch failed: %s", string(responseBody)) +} + +func waitForAdapterMessageRecords( + t *testing.T, + gatewayID string, + messageID string, + timeout time.Duration, +) []notificationRecord { + t.Helper() + + deadline := time.Now().Add(timeout) + var records []notificationRecord + var lastErr error + for time.Now().Before(deadline) { + records, lastErr = fetchAdapterNotificationRecords(gatewayID, messageID) + if lastErr == nil && len(records) > 0 && adapterRecordsProcessed(records) { + return records + } + time.Sleep(500 * time.Millisecond) + } + + require.NoError(t, lastErr) + require.NotEmpty(t, records, "adapter message record for %s was not available within %v", messageID, timeout) + require.True(t, adapterRecordsProcessed(records), "adapter message records were not processed: %#v", records) + return records +} + +func waitForAdapterHeartbeatRecord( + t *testing.T, + gatewayID string, + timeout time.Duration, +) notificationRecord { + t.Helper() + + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + records, err := fetchAdapterNotificationRecords(gatewayID, "") + lastErr = err + if err == nil { + for _, record := range records { + if record.Kind == "heartbeat" && record.Processed { + return record + } + } + } + time.Sleep(500 * time.Millisecond) + } + + require.NoError(t, lastErr) + t.Fatalf("processed adapter heartbeat record was not available within %v", timeout) + return notificationRecord{} +} + +func triggerAdapterIncoming( + ctx context.Context, + t *testing.T, + phone adapterTestPhone, + contact string, + content string, +) string { + t.Helper() + + body, err := json.Marshal(map[string]any{ + "contact": contact, + "content": content, + "encrypted": false, + }) + require.NoError(t, err) + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + fmt.Sprintf("%s/test/gateways/%s/incoming", adapterControlURL, phone.GatewayID), + bytes.NewReader(body), + ) + require.NoError(t, err) + request.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer response.Body.Close() + responseBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode, "adapter incoming trigger failed: %s", string(responseBody)) + + var result struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(responseBody, &result)) + require.NotEmpty(t, result.Data.ID) + return result.Data.ID +} + +func fetchAdapterNotificationRecords(gatewayID string, messageID string) ([]notificationRecord, error) { + endpoint := fmt.Sprintf("%s/test/gateways/%s/notifications", adapterControlURL, gatewayID) + if messageID != "" { + endpoint += "?message_id=" + url.QueryEscape(messageID) + } + + response, err := (&http.Client{Timeout: 5 * time.Second}).Get(endpoint) + if err != nil { + return nil, fmt.Errorf("fetch adapter notification records: %w", err) + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("read adapter notification records: %w", err) + } + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch adapter notification records: status %d: %s", response.StatusCode, string(responseBody)) + } + + var result struct { + Data []notificationRecord `json:"data"` + } + if err := json.Unmarshal(responseBody, &result); err != nil { + return nil, fmt.Errorf("decode adapter notification records: %w", err) + } + return result.Data, nil +} + +func adapterRecordsProcessed(records []notificationRecord) bool { + for _, record := range records { + if !record.Processed { + return false + } + } + return true +} + +func waitForPhoneAuthorization( + ctx context.Context, + t *testing.T, + phoneAPIKey string, + phoneNumber string, + timeout time.Duration, +) { + t.Helper() + + body, err := json.Marshal(map[string]interface{}{ + "phone_numbers": []string{phoneNumber}, + "charging": true, + }) + require.NoError(t, err) + + var responseBody []byte + var statusCode int + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + request, requestErr := http.NewRequestWithContext( + ctx, + http.MethodPost, + apiBaseURL+"/v1/heartbeats", + bytes.NewReader(body), + ) + require.NoError(t, requestErr) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("x-api-key", phoneAPIKey) + + response, requestErr := http.DefaultClient.Do(request) + require.NoError(t, requestErr) + responseBody, requestErr = io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, requestErr) + + statusCode = response.StatusCode + if statusCode == http.StatusCreated { + return + } + if statusCode != http.StatusUnauthorized { + require.Equal(t, http.StatusCreated, statusCode, "phone authorization check failed: %s", string(responseBody)) + } + + time.Sleep(500 * time.Millisecond) + } + + require.Equal(t, http.StatusCreated, statusCode, "phone authorization was not ready within %v: %s", timeout, string(responseBody)) +} + func setupWebhook(ctx context.Context, t *testing.T, phoneNumber string, events []string) (signingKey string, webhookPath string) { t.Helper() @@ -284,6 +585,39 @@ func assertWebhookJWT(t *testing.T, request wmJournal.Request, signingKey string require.True(t, nbf.Before(time.Now()), "token not yet valid") } +// assertAdapterNotificationJWT validates the JWT the API signs adapter notification requests +// with, using the receiving phone's ID as the HMAC-SHA256 secret (see +// api/pkg/services/http_notification_sender.go getAuthToken). The phone ID is only used as the +// secret and is never embedded in a claim, so this only checks the signature and issuer, not a +// subject; the adapter emulator itself rejects notifications with an invalid signature (401). +func assertAdapterNotificationJWT(t *testing.T, record notificationRecord, phoneID string) { + t.Helper() + + require.NotEmpty(t, record.Authorization, "adapter notification record missing Authorization header") + require.True(t, strings.HasPrefix(record.Authorization, "Bearer "), "Authorization header must start with Bearer") + + tokenString := strings.TrimPrefix(record.Authorization, "Bearer ") + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + require.Equal(t, jwt.SigningMethodHS256, token.Method, "unexpected signing method") + return []byte(phoneID), nil + }) + require.NoError(t, err, "JWT validation failed") + require.True(t, token.Valid, "JWT token is not valid") + + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok, "cannot parse claims") + require.Equal(t, "api.httpsms.com", claims["iss"], "issuer mismatch") + require.Empty(t, claims["sub"], "phone ID must not be embedded in a claim since it is also the signing secret") + + exp, err := claims.GetExpirationTime() + require.NoError(t, err) + require.True(t, exp.After(time.Now()), "token is expired") + + nbf, err := claims.GetNotBefore() + require.NoError(t, err) + require.True(t, nbf.Before(time.Now()), "token not yet valid") +} + func waitForWebhookEvents(t *testing.T, webhookPath string, expectedCount int, timeout time.Duration) []wmJournal.GetRequestResponse { t.Helper() deadline := time.Now().Add(timeout) diff --git a/tests/integration_test.go b/tests/integration_test.go index 66abecdd5..8b7436642 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -56,7 +56,7 @@ func TestSendSMS_Encrypted(t *testing.T) { assert.NotEqual(t, plaintext, outstanding["content"]) fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT") - time.Sleep(200 * time.Millisecond) + pollMessageStatus(ctx, t, messageID, "sent", 15*time.Second) fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "DELIVERED") msg := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second) @@ -200,12 +200,14 @@ func TestSendSMS_RateLimit(t *testing.T) { assert.GreaterOrEqual(t, gapMs, int64(5500), "rate limit gap should be >= 5500ms (6s minus timing tolerance), got %dms", gapMs) fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "SENT") - fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "DELIVERED") + pollMessageStatus(ctx, t, msgID1, "sent", 30*time.Second) fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "SENT") + pollMessageStatus(ctx, t, msgID2, "sent", 30*time.Second) + fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "DELIVERED") fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "DELIVERED") - msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 15*time.Second) - msg2 := pollMessageStatus(ctx, t, msgID2, "delivered", 15*time.Second) + msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 30*time.Second) + msg2 := pollMessageStatus(ctx, t, msgID2, "delivered", 30*time.Second) assert.Equal(t, "delivered", msg1.Status) assert.Equal(t, "delivered", msg2.Status) @@ -335,7 +337,7 @@ func TestSendSMS_OutstandingFlow(t *testing.T) { assert.Equal(t, contactNumber, outstanding["contact"]) fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT") - time.Sleep(200 * time.Millisecond) + pollMessageStatus(ctx, t, messageID, "sent", 15*time.Second) fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "DELIVERED") msg := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second) @@ -447,6 +449,7 @@ func TestBulkSMS_CSV(t *testing.T) { // Wait for FCM push waitForFCMPush(t, messageID, 30*time.Second) + pollMessageStatus(ctx, t, messageID, "scheduled", 15*time.Second) // Fire SENT event fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT") @@ -535,10 +538,11 @@ func TestBulkSMS_Excel(t *testing.T) { msgID2 := bulkMessages[1].ID.String() waitForFCMPush(t, msgID1, 30*time.Second) waitForFCMPush(t, msgID2, 30*time.Second) + pollMessageStatus(ctx, t, msgID1, "scheduled", 15*time.Second) // Fire SENT then DELIVERED on message 1, leave message 2 pending fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "SENT") - time.Sleep(200 * time.Millisecond) + pollMessageStatus(ctx, t, msgID1, "sent", 15*time.Second) fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "DELIVERED") // Poll until message 1 reaches "delivered" diff --git a/tests/read_receipts_test.go b/tests/read_receipts_test.go new file mode 100644 index 000000000..178c720de --- /dev/null +++ b/tests/read_receipts_test.go @@ -0,0 +1,206 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type integrationMessageThread struct { + ID string `json:"id"` + Contact string `json:"contact"` + UnreadCount uint `json:"unread_count"` + LastMessageContent *string `json:"last_message_content"` +} + +func requestJSON( + ctx context.Context, + t *testing.T, + method string, + path string, + apiKey string, + payload any, + expectedStatus int, + output any, +) { + t.Helper() + + var encoded []byte + if payload != nil { + var err error + encoded, err = json.Marshal(payload) + require.NoError(t, err) + } + + deadline := time.Now().Add(20 * time.Second) + for { + request, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, bytes.NewReader(encoded)) + require.NoError(t, err) + request.Header.Set("x-api-key", apiKey) + request.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + + responseBody, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + + if response.StatusCode == http.StatusUnauthorized && + apiKey != userAPIKey && + time.Now().Before(deadline) { + time.Sleep(500 * time.Millisecond) + continue + } + + require.Equal(t, expectedStatus, response.StatusCode, "response: %s", string(responseBody)) + if output != nil { + require.NoError(t, json.Unmarshal(responseBody, output)) + } + return + } +} + +func fetchMessageThreads(ctx context.Context, t *testing.T, owner string) []integrationMessageThread { + t.Helper() + + var response struct { + Data []integrationMessageThread `json:"data"` + } + path := fmt.Sprintf( + "/v1/message-threads?owner=%s&skip=0&limit=20&is_archived=false", + url.QueryEscape(owner), + ) + requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &response) + return response.Data +} + +func waitForMessageThread( + ctx context.Context, + t *testing.T, + owner string, + contact string, + timeout time.Duration, + matches func(integrationMessageThread) bool, +) integrationMessageThread { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + for _, thread := range fetchMessageThreads(ctx, t, owner) { + if thread.Contact == contact && matches(thread) { + return thread + } + } + time.Sleep(500 * time.Millisecond) + } + + t.Fatalf("thread %s -> %s did not reach the expected state within %v", owner, contact, timeout) + return integrationMessageThread{} +} + +func clearMessageThreadUnreadCount(ctx context.Context, t *testing.T, threadID string) integrationMessageThread { + t.Helper() + + var response struct { + Data integrationMessageThread `json:"data"` + } + requestJSON( + ctx, + t, + http.MethodPut, + "/v1/message-threads/"+threadID, + userAPIKey, + map[string]any{"unread_count": 0}, + http.StatusOK, + &response, + ) + return response.Data +} + +func TestMessageThreadUnreadCount(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + contact := randomPhoneNumber() + + requestJSON( + ctx, + t, + http.MethodPost, + "/v1/messages/receive", + phone.PhoneAPIKey, + map[string]any{ + "from": contact, + "to": phone.PhoneNumber, + "content": "Unread inbound message", + "encrypted": false, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + }, + http.StatusOK, + nil, + ) + + thread := waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return thread.UnreadCount == 1 + }) + assert.Equal(t, uint(1), thread.UnreadCount) + + updated := clearMessageThreadUnreadCount(ctx, t, thread.ID) + assert.Zero(t, updated.UnreadCount) + assert.Equal(t, contact, updated.Contact) + require.NotNil(t, updated.LastMessageContent) + assert.Equal(t, "Unread inbound message", *updated.LastMessageContent) + waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 10*time.Second, func(thread integrationMessageThread) bool { + return thread.UnreadCount == 0 + }) + + requestJSON( + ctx, + t, + http.MethodPost, + "/v1/messages/calls/missed", + phone.PhoneAPIKey, + map[string]any{ + "from": contact, + "to": phone.PhoneNumber, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + }, + http.StatusOK, + nil, + ) + + thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return thread.UnreadCount == 1 && + thread.LastMessageContent != nil && + *thread.LastMessageContent == "Missed phone call" + }) + assert.Equal(t, uint(1), thread.UnreadCount) + + outboundContent := "Outbound activity preserves unread" + client := newAPIClient() + _, response, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contact, + Content: outboundContent, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + + thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return thread.LastMessageContent != nil && + *thread.LastMessageContent == outboundContent + }) + assert.Equal(t, uint(1), thread.UnreadCount, "outbound activity must not clear unread count") +} diff --git a/tests/unarchive_thread_integration_test.go b/tests/unarchive_thread_integration_test.go new file mode 100644 index 000000000..35ee8bf56 --- /dev/null +++ b/tests/unarchive_thread_integration_test.go @@ -0,0 +1,236 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type integrationThread struct { + ID string `json:"id"` + Contact string `json:"contact"` + Owner string `json:"owner"` + IsArchived bool `json:"is_archived"` + LastMessageContent *string `json:"last_message_content"` +} + +// setUnarchiveThread flips the per-phone unarchive_thread flag via a raw PUT +// (the httpsms-go client has no field for it). +func setUnarchiveThread(ctx context.Context, t *testing.T, phoneNumber string, enabled bool) { + t.Helper() + + // sim is required by PUT /v1/phones validation, so it must be sent; setupPhone + // always provisions the test phone on SIM1, so this preserves the existing slot. + payload := map[string]interface{}{ + "phone_number": phoneNumber, + "sim": "SIM1", + "unarchive_thread": enabled, + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBaseURL+"/v1/phones", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "set unarchive_thread failed: %s", string(respBody)) +} + +// receiveInbound submits an inbound message as the phone and returns the message ID. +// The phone-to-API-key association is applied asynchronously after setupPhone (via +// the phone_api_key event listener), so a freshly provisioned phone can briefly return +// 401 until the auth cache clears. Retry on that transient authorization failure. +func receiveInbound(ctx context.Context, t *testing.T, phoneAPIKey, from, to, content string, ts time.Time) string { + t.Helper() + + payload := map[string]interface{}{ + "from": from, + "to": to, + "content": content, + "sim": "SIM1", + "timestamp": ts.UTC().Format(time.RFC3339), + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + var respBody []byte + deadline := time.Now().Add(20 * time.Second) + for { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/v1/messages/receive", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", phoneAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + respBody, err = io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + + if resp.StatusCode == http.StatusOK { + break + } + if resp.StatusCode == http.StatusUnauthorized && time.Now().Before(deadline) { + time.Sleep(500 * time.Millisecond) + continue + } + require.Equal(t, http.StatusOK, resp.StatusCode, "receive failed: %s", string(respBody)) + } + + var result httpsms.MessageResponse + require.NoError(t, json.Unmarshal(respBody, &result)) + id := result.Data.ID.String() + require.NotEmpty(t, id) + return id +} + +// fetchThreads returns threads for an owner filtered by archived state. +func fetchThreads(ctx context.Context, t *testing.T, owner string, archived bool) []integrationThread { + t.Helper() + + reqURL := fmt.Sprintf("%s/v1/message-threads?owner=%s&is_archived=%t&limit=20", apiBaseURL, url.QueryEscape(owner), archived) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + require.NoError(t, err) + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "fetch threads failed: %s", string(respBody)) + + var result struct { + Data []integrationThread `json:"data"` + } + require.NoError(t, json.Unmarshal(respBody, &result)) + return result.Data +} + +func findThreadByContact(threads []integrationThread, contact string) *integrationThread { + for i := range threads { + if threads[i].Contact == contact { + return &threads[i] + } + } + return nil +} + +// waitForThread polls the archived/unarchived thread list until a thread for the +// contact appears (optionally matching a last-message content), then returns it. +func waitForThread(ctx context.Context, t *testing.T, owner, contact string, archived bool, wantContent string, timeout time.Duration) *integrationThread { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + thread := findThreadByContact(fetchThreads(ctx, t, owner, archived), contact) + if thread != nil && (wantContent == "" || (thread.LastMessageContent != nil && *thread.LastMessageContent == wantContent)) { + return thread + } + time.Sleep(500 * time.Millisecond) + } + return nil +} + +// archiveThread archives a thread by ID. +func archiveThread(ctx context.Context, t *testing.T, threadID string) { + t.Helper() + + body, err := json.Marshal(map[string]interface{}{"is_archived": true}) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBaseURL+"/v1/message-threads/"+threadID, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "archive thread failed: %s", string(respBody)) +} + +func TestUnarchiveThreadOnReceive_Enabled(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + setUnarchiveThread(ctx, t, phone.PhoneNumber, true) + + contact := randomPhoneNumber() + content1 := "first inbound " + randomEncryptionKey() + content2 := "second inbound " + randomEncryptionKey() + + // First inbound message creates the thread. + msgID1 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content1, time.Now().Add(-1*time.Minute)) + pollMessageStatus(ctx, t, msgID1, "received", 15*time.Second) + + thread := waitForThread(ctx, t, phone.PhoneNumber, contact, false, "", 15*time.Second) + require.NotNil(t, thread, "thread not created for contact %s", contact) + + // Archive it. + archiveThread(ctx, t, thread.ID) + archived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, "", 10*time.Second) + require.NotNil(t, archived, "thread was not archived") + + // Second inbound message should unarchive it. + msgID2 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content2, time.Now()) + pollMessageStatus(ctx, t, msgID2, "received", 15*time.Second) + + unarchived := waitForThread(ctx, t, phone.PhoneNumber, contact, false, content2, 20*time.Second) + require.NotNil(t, unarchived, "thread was not unarchived after inbound message") + assert.False(t, unarchived.IsArchived) + require.NotNil(t, unarchived.LastMessageContent) + assert.Equal(t, content2, *unarchived.LastMessageContent) +} + +func TestUnarchiveThreadOnReceive_Disabled(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) // unarchive_thread defaults to false; do not enable it + + contact := randomPhoneNumber() + content1 := "first inbound " + randomEncryptionKey() + content2 := "second inbound " + randomEncryptionKey() + + msgID1 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content1, time.Now().Add(-1*time.Minute)) + pollMessageStatus(ctx, t, msgID1, "received", 15*time.Second) + + thread := waitForThread(ctx, t, phone.PhoneNumber, contact, false, "", 15*time.Second) + require.NotNil(t, thread, "thread not created for contact %s", contact) + + archiveThread(ctx, t, thread.ID) + archived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, "", 10*time.Second) + require.NotNil(t, archived, "thread was not archived") + + // Second inbound message must NOT unarchive it. Sync on the archived thread's + // last_message_content updating to content2 (proves the listener processed it), + // then assert it is still archived. + msgID2 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content2, time.Now()) + pollMessageStatus(ctx, t, msgID2, "received", 15*time.Second) + + stillArchived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, content2, 20*time.Second) + require.NotNil(t, stillArchived, "archived thread did not reflect the second inbound message") + assert.True(t, stillArchived.IsArchived, "thread should remain archived when unarchive_thread is disabled") + + // And it must not have leaked into the unarchived list. + assert.Nil(t, findThreadByContact(fetchThreads(ctx, t, phone.PhoneNumber, false), contact), + "thread should not appear in the unarchived list") +} diff --git a/web/.env.production b/web/.env.production index 379e711a5..7d50c7d57 100644 --- a/web/.env.production +++ b/web/.env.production @@ -10,7 +10,7 @@ CHECKOUT_URL=https://httpsms.lemonsqueezy.com/checkout/buy/706c5638-4c8d-40db-a6 ENTERPRISE_CHECKOUT_URL=https://httpsms.lemonsqueezy.com/checkout/buy/d107cf05-4b13-4ebd-a770-c2cc75c69a14 FIREBASE_API_KEY=AIzaSyClL8AX2H_F77_n8yu5FgLzBmJTiSM0NsQ -FIREBASE_AUTH_DOMAIN=httpsms-86c51.firebaseapp.com +FIREBASE_AUTH_DOMAIN=auth.httpsms.com FIREBASE_PROJECT_ID=httpsms-86c51 FIREBASE_STORAGE_BUCKET=httpsms-86c51.appspot.com FIREBASE_MESSAGING_SENDER_ID=877524083399 diff --git a/web/app/components/AppToast.vue b/web/app/components/AppToast.vue index cc62eb097..8cdf5f836 100644 --- a/web/app/components/AppToast.vue +++ b/web/app/components/AppToast.vue @@ -17,6 +17,7 @@ const notificationActive = computed({ variant="tonal" :color="notificationsStore.notification.type" :timeout="notificationsStore.notification.timeout" + :z-index="10000" > - + Go Back diff --git a/web/app/components/ContactDialog.vue b/web/app/components/ContactDialog.vue new file mode 100644 index 000000000..2df329a93 --- /dev/null +++ b/web/app/components/ContactDialog.vue @@ -0,0 +1,411 @@ + + + diff --git a/web/app/components/FirebaseAuth.vue b/web/app/components/FirebaseAuth.vue index 8492e42ac..5f783e07c 100644 --- a/web/app/components/FirebaseAuth.vue +++ b/web/app/components/FirebaseAuth.vue @@ -6,9 +6,13 @@ import { GithubAuthProvider, signInWithEmailAndPassword, createUserWithEmailAndPassword, + sendPasswordResetEmail, + updateProfile, + deleteUser, } from 'firebase/auth' import { mdiGoogle, mdiGithub, mdiEmail } from '@mdi/js' import type { User as FirebaseUser } from 'firebase/auth' +import { ErrorMessages } from '~/utils/errors' const props = withDefaults( defineProps<{ @@ -25,9 +29,13 @@ const appStore = useAppStore() const loading = ref(false) const showEmailForm = ref(false) const isSignUp = ref(false) +const showForgotPassword = ref(false) +const resetEmailSent = ref(false) +const name = ref('') const email = ref('') const password = ref('') -const emailError = ref('') +const generalError = ref('') +const errorMessages = ref(new ErrorMessages()) type LoginMethod = 'google' | 'github' | 'email' const LAST_LOGIN_METHOD_KEY = 'httpsms_last_login_method' @@ -44,6 +52,49 @@ onMounted(() => { } }) +function clearErrors() { + errorMessages.value = new ErrorMessages() + generalError.value = '' +} + +function validateEmail(): boolean { + clearErrors() + if (!email.value.trim()) { + errorMessages.value.add('email', 'Please provide an email address') + return false + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(email.value.trim())) { + errorMessages.value.add('email', 'Please enter a valid email address') + return false + } + return true +} + +function validateLoginForm(): boolean { + clearErrors() + let valid = true + if (isSignUp.value && !name.value.trim()) { + errorMessages.value.add('name', 'Please provide your name') + valid = false + } + if (!email.value.trim()) { + errorMessages.value.add('email', 'Please provide an email address') + valid = false + } else { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(email.value.trim())) { + errorMessages.value.add('email', 'Please enter a valid email address') + valid = false + } + } + if (!password.value) { + errorMessages.value.add('password', 'Please enter your password') + valid = false + } + return valid +} + async function signInWithGoogle() { loading.value = true try { @@ -71,7 +122,7 @@ async function signInWithGithub() { } async function submitEmail() { - emailError.value = '' + if (!validateLoginForm()) return loading.value = true try { const auth = getAuth() @@ -79,13 +130,25 @@ async function submitEmail() { if (isSignUp.value) { result = await createUserWithEmailAndPassword( auth, - email.value, + email.value.trim(), password.value, ) + try { + await updateProfile(result.user, { + displayName: name.value.trim(), + }) + } catch (error) { + try { + await deleteUser(result.user) + } catch (deleteError) { + console.error(deleteError) + } + throw error + } } else { result = await signInWithEmailAndPassword( auth, - email.value, + email.value.trim(), password.value, ) } @@ -97,6 +160,37 @@ async function submitEmail() { } } +async function submitPasswordReset() { + if (!validateEmail()) return + loading.value = true + try { + const auth = getAuth() + await sendPasswordResetEmail(auth, email.value.trim()) + resetEmailSent.value = true + } catch (error: unknown) { + handleError(error) + } finally { + loading.value = false + } +} + +function showForgotPasswordForm() { + clearErrors() + resetEmailSent.value = false + showForgotPassword.value = true +} + +function backToSignIn() { + clearErrors() + resetEmailSent.value = false + showForgotPassword.value = false +} + +function toggleAuthMode() { + clearErrors() + isSignUp.value = !isSignUp.value +} + function onSuccess(user: FirebaseUser, method: LoginMethod) { try { localStorage.setItem(LAST_LOGIN_METHOD_KEY, method) @@ -114,33 +208,94 @@ function onSuccess(user: FirebaseUser, method: LoginMethod) { function handleError(error: unknown, isSocial = false) { const firebaseError = error as { code?: string; message?: string } const code = firebaseError.code || '' - let message = '' - if (code === 'auth/user-not-found' || code === 'auth/invalid-credential') { - message = 'Invalid email or password' - } else if (code === 'auth/email-already-in-use') { - message = 'An account with this email already exists' - } else if (code === 'auth/weak-password') { - message = 'Password must be at least 6 characters' - } else if ( + + if ( code === 'auth/popup-closed-by-user' || code === 'auth/cancelled-popup-request' ) { - // User closed the popup, no error to show return - } else { - message = firebaseError.message || 'An error occurred' } if (isSocial) { + const message = getGeneralErrorMessage(code, firebaseError.message) notificationsStore.addNotification({ message, type: 'error' }) - } else { - emailError.value = message + return + } + + clearErrors() + + switch (code) { + case 'auth/wrong-password': + errorMessages.value.add('password', 'Incorrect password') + break + case 'auth/invalid-credential': + errorMessages.value.add('email', 'Invalid email or password') + errorMessages.value.add('password', 'Invalid email or password') + break + case 'auth/user-not-found': + errorMessages.value.add( + 'email', + 'No account found with this email address', + ) + break + case 'auth/invalid-email': + errorMessages.value.add('email', 'Please enter a valid email address') + break + case 'auth/email-already-in-use': + errorMessages.value.add( + 'email', + 'An account already exists with this email', + ) + break + case 'auth/weak-password': + errorMessages.value.add( + 'password', + 'Password should be at least 6 characters', + ) + break + case 'auth/user-disabled': + errorMessages.value.add('email', 'This account has been disabled') + break + case 'auth/too-many-requests': + generalError.value = 'Too many failed attempts. Please try again later' + break + case 'auth/network-request-failed': + generalError.value = + 'Unable to connect to the server. Please check your internet connection' + break + case 'auth/missing-email': + errorMessages.value.add('email', 'Please provide an email address') + break + default: + generalError.value = + firebaseError.message || 'An unexpected error occurred' + } +} + +function getGeneralErrorMessage( + code: string, + fallback: string | undefined, +): string { + switch (code) { + case 'auth/user-not-found': + return 'No account found with this email address' + case 'auth/wrong-password': + case 'auth/invalid-credential': + return 'The provided credentials are invalid.' + case 'auth/user-disabled': + return 'This account has been disabled' + case 'auth/too-many-requests': + return 'Too many failed attempts. Please try again later' + case 'auth/network-request-failed': + return 'Unable to connect to the server. Please check your internet connection' + default: + return fallback || 'An unexpected error occurred' } }