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 79337711d..7d3a8a4ea 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -21,7 +21,7 @@ jobs: 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 diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 350bf3256..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 @@ -59,17 +59,17 @@ jobs: uses: actions/checkout@master - name: Setup Node 🟢 - uses: actions/setup-node@v6 + 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 diff --git a/.gitignore b/.gitignore index 8f4caf4b5..cc68ad44b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ android/app/debug/ android/app/release/ tests/firebase-credentials.json +tests/certs/ tests/emulator/emulator.exe SECURITY_AUDIT_REPORT.md @@ -16,3 +17,5 @@ SECURITY_AUDIT_REPORT.md .output .agents/ skills-lock.json +.worktrees/ +docs/superpowers diff --git a/.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/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/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/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties index 6c1139ec0..8b5316926 100644 --- a/android/gradle/gradle-daemon-jvm.properties +++ b/android/gradle/gradle-daemon-jvm.properties @@ -1,12 +1,13 @@ #This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect -toolchainVersion=21 +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 c7fcd1145..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" ], @@ -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" @@ -4069,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": [ @@ -4388,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 } } }, @@ -4419,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....." }, @@ -4447,6 +4976,7 @@ const docTemplate = `{ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, @@ -4731,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": [ @@ -4904,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 25db6fc31..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" ], @@ -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" @@ -4066,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": [ @@ -4385,13 +4912,14 @@ }, "requests.MessageThreadUpdate": { "type": "object", - "required": [ - "is_archived" - ], "properties": { "is_archived": { "type": "boolean", "example": true + }, + "unread_count": { + "type": "integer", + "example": 0 } } }, @@ -4416,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....." }, @@ -4444,6 +4973,7 @@ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, @@ -4728,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": [ @@ -4901,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 892c9ae45..6dc240216 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -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 @@ -601,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: @@ -853,8 +956,9 @@ definitions: is_archived: example: true type: boolean - required: - - is_archived + unread_count: + example: 0 + type: integer type: object requests.PhoneAPIKeyStoreRequest: properties: @@ -867,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: @@ -885,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: @@ -1104,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: @@ -1227,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: @@ -1727,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: @@ -2086,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: @@ -2182,7 +2606,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/responses.PhoneResponse' + $ref: '#/definitions/responses.MessageThreadResponse' "400": description: Bad Request schema: @@ -2191,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: @@ -2913,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 @@ -2993,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 565e15d4a..1f305a110 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,30 +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/cloudtasks v1.19.0 cloud.google.com/go/compute/metadata v0.9.0 - cloud.google.com/go/storage v1.63.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.57.0 - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.33.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/v3/otel v1.2.1 - github.com/gofiber/contrib/v3/swaggo v1.0.7 - github.com/gofiber/fiber/v3 v3.3.0 + 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 @@ -34,31 +35,30 @@ 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.8.0 - 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.21.0 - github.com/redis/go-redis/v9 v9.21.0 + github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 + github.com/redis/go-redis/v9 v9.22.0 github.com/rs/zerolog v1.35.1 - github.com/stretchr/testify v1.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.11.0 - go.mongodb.org/mongo-driver/v2 v2.7.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.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.21.0 - google.golang.org/api v0.287.0 - google.golang.org/protobuf v1.36.11 + 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.2 gorm.io/plugin/opentelemetry v0.1.16 @@ -79,23 +79,23 @@ require ( require ( 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/firestore v1.22.0 // indirect - cloud.google.com/go/iam v1.11.0 // indirect - cloud.google.com/go/longrunning v1.0.0 // indirect - cloud.google.com/go/monitoring v1.29.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.72.0 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.46.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.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/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 @@ -111,29 +111,29 @@ require ( 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.6 // indirect - github.com/go-openapi/spec v0.22.6 // indirect - github.com/go-openapi/swag/conv v0.26.1 // indirect - github.com/go-openapi/swag/jsonname v0.26.1 // indirect - github.com/go-openapi/swag/jsonutils v0.26.1 // indirect - github.com/go-openapi/swag/loading v0.26.1 // indirect - github.com/go-openapi/swag/stringutils v0.26.1 // indirect - github.com/go-openapi/swag/typeutils v0.26.1 // indirect - github.com/go-openapi/swag/yamlutils v0.26.1 // 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.0 // indirect - github.com/gofiber/utils/v2 v2.1.0 // 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.17 // 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 @@ -144,22 +144,22 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.6 // 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.22 // 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/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.21.0 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 // indirect github.com/richardlehane/mscfb v1.0.7 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -170,7 +170,7 @@ require ( github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.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.34.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect @@ -180,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.44.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.69.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 // indirect go.opentelemetry.io/contrib/processors/minsev v0.16.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect go.opentelemetry.io/otel/log v0.20.0 // indirect go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.56.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.46.0 // indirect - golang.org/x/text v0.38.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.46.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-20260622175928-b703f567277d // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // 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 dc7298013..809da4525 100644 --- a/api/go.sum +++ b/api/go.sum @@ -4,26 +4,26 @@ 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.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= -cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= -cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k= -cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= -cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= -cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= -cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= -cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= -cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ= -cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE= +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= @@ -38,14 +38,16 @@ github.com/ClickHouse/clickhouse-go/v2 v2.46.0 h1:s3eRy+hYmu5uzotB6ZhDofgHu8kDgG github.com/ClickHouse/clickhouse-go/v2 v2.46.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= 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.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.33.0 h1:92kCbSANHUnbS7tzc9uhUx4H+MF1Lr/QXInoPkjz6JU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.33.0/go.mod h1:n2lr2dT7IuNeGhW6iB/+nJB+Fo9vZxCGwEQOy2Hc15Y= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= +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,14 +58,16 @@ 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/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= @@ -76,8 +80,8 @@ 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.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.25.1 h1:17zNRLecxtAjhtdEIV+F+wrYfe+AGZUjWJtpndcOUYA= -github.com/carlmjohnson/requests v0.25.1/go.mod h1:z3UEf8IE4sZxZ78spW6/tLdqBkfCu1Fn4RaYMnZ8SRM= +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= @@ -118,8 +122,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO 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.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= -github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +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= @@ -131,51 +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.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= -github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= -github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= -github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= -github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= -github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= -github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= -github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= -github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= -github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= -github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= -github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= -github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= -github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= -github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= -github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= -github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= -github.com/go-openapi/testify/v2 v2.5.1/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/v3/otel v1.2.1 h1:S+ViO/LvMA8xLAitIQ6uMW7amlXlT88buD7az0PFtDk= -github.com/gofiber/contrib/v3/otel v1.2.1/go.mod h1:cvZ+7Lc3zk72ucH6hDiW46LzEFHFvBMvqJ8HakfITmo= -github.com/gofiber/contrib/v3/swaggo v1.0.7 h1:NwjBOldwhl4JaUVpo3XQ+L9d5vl7bjc24GJx4e5rqfs= -github.com/gofiber/contrib/v3/swaggo v1.0.7/go.mod h1:UouDgg79E4BRhRN7KAJlMMYnbW3VVXW2Sc2q6O5hfnU= -github.com/gofiber/fiber/v3 v3.3.0 h1:QBd3sYCqdy6Qs5gJYzSw4I4SbqL204jPqpdub/ueiw8= -github.com/gofiber/fiber/v3 v3.3.0/go.mod h1:YH7/TAoRaU4kF8slDCtQuFJ1NzC+3MtxUI4KfvQtaIA= -github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis= -github.com/gofiber/schema v1.8.0/go.mod h1:lmbXPQ8hvzXSLkdS2DS7pb4kpunC2Roh7Sj3HMjGfzA= -github.com/gofiber/utils/v2 v2.1.0 h1:WSu4COJhJw9moNfJu2nQvaM9AFvAQ/nZbigjhHqKgOQ= -github.com/gofiber/utils/v2 v2.1.0/go.mod h1:DdOgEVwQTi8cou/AKWPqhXOR4fHGRVhA/rEWL3IXG7Q= +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= @@ -197,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.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg= -github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= -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= @@ -241,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= @@ -253,8 +257,8 @@ 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.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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +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= @@ -268,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.8.0 h1:TrXNJmbwcAHajzDqin3mLWw57vqLUA6ZjVdeNds0heQ= -github.com/nyaruka/phonenumbers v1.8.0/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= @@ -278,8 +284,6 @@ 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= @@ -297,12 +301,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pusher/pusher-http-go/v5 v5.1.1 h1:ZLUGdLA8yXMvByafIkS47nvuXOHrYmlh4bsQvuZnYVQ= github.com/pusher/pusher-http-go/v5 v5.1.1/go.mod h1:Ibji4SGoUDtOy7CVRhCiEpgy+n5Xv6hSL/QqYOhmWW8= -github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0 h1:jsV3tyMeJrEoc2f3EhNf7qoBW3NEZW7l/4ziT3M+OJI= -github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0/go.mod h1:e5t17bY9cEpVV+xw2U7jsPOKkXBtL5IQmNVABShnHUk= -github.com/redis/go-redis/extra/redisotel/v9 v9.21.0 h1:36qq3rbF2If2CP0zGHHF8o/4XDluErn6DD0c9/L2iNI= -github.com/redis/go-redis/extra/redisotel/v9 v9.21.0/go.mod h1:7y2cVB/LXXLHqHOO2jCVzBqimIQk1w7Rp9WSpyVY/o8= -github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= -github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 h1:MQPzEEnpD0BMPufBLABnMYLJVwM7xi7vZ+srO8Nr0s8= +github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0/go.mod h1:eve0JFcLRwFVj3RA85rrrV5+UJ+K9LDyU7kf2UdSueM= +github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 h1:t5ul1Gl0o1rYQj5f5bK12G9xcg1niq2ON4yZFjvy1kA= +github.com/redis/go-redis/extra/redisotel/v9 v9.22.0/go.mod h1:hcS9L2RBBjYXkrfSOF26ZGejgo+yOC+28ZD2fkk3sGs= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= @@ -313,8 +317,8 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw= -github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +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= @@ -328,8 +332,8 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n 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= @@ -352,8 +356,8 @@ github.com/uptrace/uptrace-go v1.43.0 h1:5QuCdyFJdWUEXx6Fr6sYfezdgO6n6lnkOvUTLly github.com/uptrace/uptrace-go v1.43.0/go.mod h1:ehDTIdtBSolg4Z0CCvg1C8yR6VX1YFDqBcg2KmsXWn0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.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.34.0 h1:CW7RUnjCfXrkuCbgC2wi/Cub7IwKslJWD/OkIBlcQUk= @@ -381,56 +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.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c= -go.mongodb.org/mongo-driver/v2 v2.7.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.44.0 h1:cVL0yu3uyrXkAmxonxvzYysIo5EZa8jKh3740MzxBzI= -go.opentelemetry.io/contrib v1.44.0/go.mod h1:JYdNU7Pl/2ckKMGp8/G7zeyhEbtRmy9Q8bcrtv75Znk= +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.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0= go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0/go.mod h1:FYTxnpsm+UPD0erZNq20GvnM8T2YQHiHtT2vokdpoac= go.opentelemetry.io/contrib/processors/minsev v0.16.1 h1:DYL02u57VGQjrG8c09i6Bx5R74h4XxLj75wEk5h8/DA= go.opentelemetry.io/contrib/processors/minsev v0.16.1/go.mod h1:VnF+kZnkagrTfTb2mV+6PTMAtPulgjpcUajm8sRk3tQ= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +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/otlp/otlpmetric/otlpmetrichttp v1.46.0 h1:AP23h/mFgb/lc7tdck1Kfn9qxsM8TAeNPCU5C3pzaps= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0/go.mod h1:K4EqCe1b4kGk5WR690ntg9LaBfsPoV32FwthbyoptuA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= -go.opentelemetry.io/otel/metric v1.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/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/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.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/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= @@ -439,76 +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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +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.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= -golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +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.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4= -google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ= +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-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY= -google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA= -google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= -google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/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= diff --git a/api/pkg/cache/memory_cache.go b/api/pkg/cache/memory_cache.go index 4500640c0..a5b0487d7 100644 --- a/api/pkg/cache/memory_cache.go +++ b/api/pkg/cache/memory_cache.go @@ -5,7 +5,7 @@ import ( "time" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" ttlCache "github.com/patrickmn/go-cache" ) @@ -30,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("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 4bbdebcee..46d9044bd 100644 --- a/api/pkg/cache/redis_cache.go +++ b/api/pkg/cache/redis_cache.go @@ -6,7 +6,7 @@ import ( "time" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "github.com/redis/go-redis/v9" ) @@ -31,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, "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, "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 } @@ -46,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/container.go b/api/pkg/di/container.go index 62fee7624..8d9e778d4 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -67,10 +67,10 @@ import ( "github.com/NdoleStudio/httpsms/pkg/listeners" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/stacktrace" swagger "github.com/gofiber/contrib/v3/swaggo" "github.com/gofiber/fiber/v3" fiberLogger "github.com/gofiber/fiber/v3/middleware/logger" - "github.com/palantir/stacktrace" 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() @@ -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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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,7 +432,7 @@ func (container *Container) FirebaseApp() (app *firebase.App) { app, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsJSON(container.FirebaseCredentials())) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot initialize firebase application")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot initialize firebase application")) } return app } @@ -444,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, "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{ @@ -456,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) @@ -472,7 +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 { - container.logger.Fatal(stacktrace.Propagate(err, "cannot initialize firebase auth client")) + container.logger.Fatal(stacktrace.Propagatef(err, "cannot initialize firebase auth client")) } return authClient } @@ -483,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 @@ -551,11 +560,29 @@ func (container *Container) FCMClient() services.FCMClient { container.logger.Debug("creating FirebaseFCMClient") messagingClient, err := container.FirebaseApp().Messaging(context.Background()) if err != nil { - container.logger.Fatal(stacktrace.Propagate(err, "cannot initialize firebase messaging client")) + 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") @@ -579,7 +606,6 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes container.Tracer(), container.PhoneService(), container.TurnstileTokenValidator(), - container.Cache(), ) } @@ -602,7 +628,6 @@ func (container *Container) BulkMessageHandlerValidator() (validator *validators container.Tracer(), container.PhoneService(), container.UserService(), - container.Cache(), ) } @@ -697,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)) @@ -745,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 } @@ -895,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") { @@ -1110,9 +1176,25 @@ func (container *Container) MessageThreadService() (service *services.MessageThr 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)) @@ -1374,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{})) @@ -1525,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 } @@ -1579,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(), @@ -1637,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(), @@ -1664,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{})) @@ -1767,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 { @@ -1785,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 @@ -1801,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( @@ -1813,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( @@ -1824,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")) } } } @@ -1846,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( @@ -1872,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( @@ -1883,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")) } } } @@ -1937,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/factory.go b/api/pkg/emails/factory.go index 992f48646..63d85f5e2 100644 --- a/api/pkg/emails/factory.go +++ b/api/pkg/emails/factory.go @@ -3,6 +3,7 @@ package emails import ( "fmt" "net/http" + "strconv" "github.com/nyaruka/phonenumbers" ) @@ -21,6 +22,15 @@ func (factory *factory) formatBool(value bool) string { return "No" } +func (factory *factory) formatQuantity(value uint) string { + formatted := strconv.FormatUint(uint64(value), 10) + for index := len(formatted) - 3; index > 0; index -= 3 { + formatted = formatted[:index] + "," + formatted[index:] + } + + return formatted +} + func (factory *factory) formatHTTPResponseCode(code *int) string { responseCode := "-" if code != nil { diff --git a/api/pkg/emails/hermes_notification_email_factory.go b/api/pkg/emails/hermes_notification_email_factory.go index 2229a502a..d80515e72 100644 --- a/api/pkg/emails/hermes_notification_email_factory.go +++ b/api/pkg/emails/hermes_notification_email_factory.go @@ -8,8 +8,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/stacktrace" "github.com/go-hermes/hermes/v2" - "github.com/palantir/stacktrace" ) type hermesNotificationEmailFactory struct { @@ -62,12 +62,12 @@ func (factory *hermesNotificationEmailFactory) DiscordSendFailed(user *entities. html, err := factory.generator.GenerateHTML(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate html email") + return nil, stacktrace.Propagatef(err, "cannot generate html email") } text, err := factory.generator.GeneratePlainText(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate text email") + return nil, stacktrace.Propagatef(err, "cannot generate text email") } return &Email{ @@ -120,7 +120,7 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities. html, err := factory.generator.GenerateHTML(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate html email") + return nil, stacktrace.Propagatef(err, "cannot generate html email") } eventPayloadEntryIndex := len(email.Body.Dictionary) - 1 @@ -129,7 +129,7 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities. return factory.generator.GeneratePlainText(email) }, formattedPayload) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate text email") + return nil, stacktrace.Propagatef(err, "cannot generate text email") } return &Email{ @@ -198,12 +198,12 @@ func (factory *hermesNotificationEmailFactory) MessageExpired(user *entities.Use html, err := factory.generator.GenerateHTML(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate html email") + return nil, stacktrace.Propagatef(err, "cannot generate html email") } text, err := factory.generator.GeneratePlainText(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate text email") + return nil, stacktrace.Propagatef(err, "cannot generate text email") } return &Email{ @@ -249,12 +249,12 @@ func (factory *hermesNotificationEmailFactory) MessageFailed(user *entities.User html, err := factory.generator.GenerateHTML(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate html email") + return nil, stacktrace.Propagatef(err, "cannot generate html email") } text, err := factory.generator.GeneratePlainText(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate text email") + return nil, stacktrace.Propagatef(err, "cannot generate text email") } return &Email{ diff --git a/api/pkg/emails/hermes_user_email_factory.go b/api/pkg/emails/hermes_user_email_factory.go index cae4a3b9a..7f2ef25cb 100644 --- a/api/pkg/emails/hermes_user_email_factory.go +++ b/api/pkg/emails/hermes_user_email_factory.go @@ -5,8 +5,8 @@ import ( "time" "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/stacktrace" "github.com/go-hermes/hermes/v2" - "github.com/palantir/stacktrace" ) type hermesUserEmailFactory struct { @@ -52,12 +52,12 @@ func (factory *hermesUserEmailFactory) APIKeyRotated(emailAddress string, timest html, err := factory.generator.GenerateHTML(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate html email") + return nil, stacktrace.Propagatef(err, "cannot generate html email") } text, err := factory.generator.GeneratePlainText(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate text email") + return nil, stacktrace.Propagatef(err, "cannot generate text email") } return &Email{ @@ -73,8 +73,8 @@ func (factory *hermesUserEmailFactory) UsageLimitExceeded(user *entities.User, u email := hermes.Email{ Body: hermes.Body{ Intros: []string{ - fmt.Sprintf("You've reached your limit of %d messages on the %s plan, so new messages will not be processed until your usage resets.", user.SubscriptionName.Limit(), user.SubscriptionName), - fmt.Sprintf("Between %s and %s you sent %d messages and received %d, for a total of %d.", formatBillingDate(usage.StartTimestamp, user.Location()), formatBillingDate(usage.EndTimestamp, user.Location()), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages()), + fmt.Sprintf("You've reached your limit of %s messages on the %s plan, so new messages will not be processed until your usage resets.", factory.formatQuantity(user.SubscriptionName.Limit()), user.SubscriptionName), + fmt.Sprintf("Between %s and %s you sent %s messages and received %s, for a total of %s.", formatBillingDate(usage.StartTimestamp, user.Location()), formatBillingDate(usage.EndTimestamp, user.Location()), factory.formatQuantity(usage.SentMessages), factory.formatQuantity(usage.ReceivedMessages), factory.formatQuantity(usage.TotalMessages())), }, Actions: []hermes.Action{ { @@ -97,12 +97,12 @@ func (factory *hermesUserEmailFactory) UsageLimitExceeded(user *entities.User, u html, err := factory.generator.GenerateHTML(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate html email") + return nil, stacktrace.Propagatef(err, "cannot generate html email") } text, err := factory.generator.GeneratePlainText(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate text email") + return nil, stacktrace.Propagatef(err, "cannot generate text email") } return &Email{ @@ -120,7 +120,7 @@ func (factory *hermesUserEmailFactory) UsageLimitAlert(user *entities.User, usag Body: hermes.Body{ Intros: []string{ fmt.Sprintf("This is a friendly heads-up that you've used %d%% of your monthly SMS limit on the %s plan.", percent, user.SubscriptionName), - fmt.Sprintf("Between %s and %s you sent %d messages and received %d, for a total of %d out of your %d message limit.", formatBillingDate(usage.StartTimestamp, user.Location()), formatBillingDate(usage.EndTimestamp, user.Location()), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages(), user.SubscriptionName.Limit()), + fmt.Sprintf("Between %s and %s you sent %s messages and received %s, for a total of %s out of your %s message limit.", formatBillingDate(usage.StartTimestamp, user.Location()), formatBillingDate(usage.EndTimestamp, user.Location()), factory.formatQuantity(usage.SentMessages), factory.formatQuantity(usage.ReceivedMessages), factory.formatQuantity(usage.TotalMessages()), factory.formatQuantity(user.SubscriptionName.Limit())), }, Actions: []hermes.Action{ { @@ -143,12 +143,12 @@ func (factory *hermesUserEmailFactory) UsageLimitAlert(user *entities.User, usag html, err := factory.generator.GenerateHTML(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate html email") + return nil, stacktrace.Propagatef(err, "cannot generate html email") } text, err := factory.generator.GeneratePlainText(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate text email") + return nil, stacktrace.Propagatef(err, "cannot generate text email") } return &Email{ @@ -201,12 +201,12 @@ func (factory *hermesUserEmailFactory) PhoneDead(user *entities.User, lastHeartb html, err := factory.generator.GenerateHTML(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate html email") + return nil, stacktrace.Propagatef(err, "cannot generate html email") } text, err := factory.generator.GeneratePlainText(email) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate text email") + return nil, stacktrace.Propagatef(err, "cannot generate text email") } return &Email{ diff --git a/api/pkg/emails/hermes_user_email_factory_test.go b/api/pkg/emails/hermes_user_email_factory_test.go index df322e926..e10e3f487 100644 --- a/api/pkg/emails/hermes_user_email_factory_test.go +++ b/api/pkg/emails/hermes_user_email_factory_test.go @@ -51,10 +51,11 @@ func TestUsageLimitExceeded_IncludesBreakdownAndBillingPeriod(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "name@email.com", email.ToEmail) assert.Equal(t, "⚠️ You have exceeded your plan limit", email.Subject) - assert.Contains(t, email.Text, "limit of 5000 messages") + assert.Contains(t, email.Text, "limit of 5,000 messages") assert.Contains(t, email.Text, "Between 19 June 2026 and 18 July 2026") - assert.Contains(t, email.Text, "you sent 3000 messages and received 2000") - assert.Contains(t, email.Text, "for a total of 5000") + assert.Contains(t, email.Text, "you sent 3,000 messages and received 2,000") + assert.Contains(t, email.Text, "for a total of 5,000") + assert.Contains(t, email.HTML, "you sent 3,000 messages and received 2,000") } func TestUsageLimitAlert_IncludesPercentBreakdownAndLimit(t *testing.T) { @@ -62,11 +63,11 @@ func TestUsageLimitAlert_IncludesPercentBreakdownAndLimit(t *testing.T) { user := &entities.User{ Email: "name@email.com", Timezone: "UTC", - SubscriptionName: entities.SubscriptionNameProMonthly, + SubscriptionName: entities.SubscriptionName20KMonthly, } usage := &entities.BillingUsage{ - SentMessages: 2500, - ReceivedMessages: 1500, + SentMessages: 11_357, + ReceivedMessages: 6_643, StartTimestamp: time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC), EndTimestamp: time.Date(2026, 7, 18, 23, 59, 59, 0, time.UTC), } @@ -75,9 +76,10 @@ func TestUsageLimitAlert_IncludesPercentBreakdownAndLimit(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "name@email.com", email.ToEmail) - assert.Equal(t, "⚠️ 80% Usage Limit Alert", email.Subject) - assert.Contains(t, email.Text, "used 80% of your monthly SMS limit") + assert.Equal(t, "⚠️ 90% Usage Limit Alert", email.Subject) + assert.Contains(t, email.Text, "used 90% of your monthly SMS limit") assert.Contains(t, email.Text, "Between 19 June 2026 and 18 July 2026") - assert.Contains(t, email.Text, "you sent 2500 messages and received 1500") - assert.Contains(t, email.Text, "for a total of 4000 out of your 5000 message limit") + assert.Contains(t, email.Text, "you sent 11,357 messages and received 6,643") + assert.Contains(t, email.Text, "for a total of 18,000 out of your 20,000 message limit") + assert.Contains(t, email.HTML, "for a total of 18,000 out of your 20,000 message limit") } diff --git a/api/pkg/emails/smtp_mailer_service.go b/api/pkg/emails/smtp_mailer_service.go index bebea4a0c..ffe18432f 100644 --- a/api/pkg/emails/smtp_mailer_service.go +++ b/api/pkg/emails/smtp_mailer_service.go @@ -6,8 +6,8 @@ import ( "net/smtp" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" mail "github.com/jordan-wright/email" - "github.com/palantir/stacktrace" ) // SMTPConfig is the config for setting up the smtpMailer @@ -51,7 +51,7 @@ func (mailer *smtpMailer) Send(ctx context.Context, email *Email) (err error) { err = e.Send(mailer.address, mailer.auth) if err != nil { - return stacktrace.Propagate(err, "cannot send email") + return stacktrace.Propagatef(err, "cannot send email") } return nil diff --git a/api/pkg/entities/contact.go b/api/pkg/entities/contact.go new file mode 100644 index 000000000..a6c38d7b8 --- /dev/null +++ b/api/pkg/entities/contact.go @@ -0,0 +1,81 @@ +package entities + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" + "github.com/lib/pq" +) + +// EntityNameContact is the entitlement entity name for contacts. +const EntityNameContact = "Contact" + +// ContactProperties is a free-form key/value map persisted as a jsonb column. +type ContactProperties map[string]string + +var _ driver.Valuer = ContactProperties{} + +// Value implements driver.Valuer, serializing the map to JSON bytes. +func (p ContactProperties) Value() (driver.Value, error) { + if p == nil { + return []byte("{}"), nil + } + + data, err := json.Marshal(p) + if err != nil { + return nil, stacktrace.Propagatef(err, "cannot marshal ContactProperties") + } + + return data, nil +} + +// Scan implements sql.Scanner, deserializing jsonb bytes/string into the map. +func (p *ContactProperties) Scan(src any) error { + if src == nil { + *p = ContactProperties{} + return nil + } + + var data []byte + switch value := src.(type) { + case []byte: + data = value + case string: + data = []byte(value) + default: + return stacktrace.NewErrorf("unsupported type [%T] for ContactProperties", src) + } + + if len(data) == 0 { + *p = ContactProperties{} + return nil + } + + result := ContactProperties{} + if err := json.Unmarshal(data, &result); err != nil { + return stacktrace.Propagatef(err, "cannot unmarshal ContactProperties") + } + + *p = result + return nil +} + +// Contact represents a saved contact belonging to a user. +type Contact struct { + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid" bson:"_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` + UserID UserID `json:"user_id" gorm:"index" bson:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` + Name string `json:"name" bson:"name" example:"Alice Smith"` + Emails pq.StringArray `json:"emails" gorm:"type:text[]" bson:"emails" swaggertype:"array,string" example:"alice@example.com"` + PhoneNumbers pq.StringArray `json:"phone_numbers" gorm:"type:text[]" bson:"phone_numbers" swaggertype:"array,string" example:"+18005550199,+18005550100"` + Properties ContactProperties `json:"properties" gorm:"type:jsonb" bson:"properties" swaggertype:"object,string"` + CreatedAt time.Time `json:"created_at" bson:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` + UpdatedAt time.Time `json:"updated_at" bson:"updated_at" example:"2022-06-05T14:26:02.302718+03:00"` +} + +// TableName overrides the table name used by Contact. +func (Contact) TableName() string { + return "contacts" +} diff --git a/api/pkg/entities/contact_test.go b/api/pkg/entities/contact_test.go new file mode 100644 index 000000000..fa921840f --- /dev/null +++ b/api/pkg/entities/contact_test.go @@ -0,0 +1,71 @@ +package entities + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContact_BSONFieldNames(t *testing.T) { + contactType := reflect.TypeOf(Contact{}) + expected := map[string]string{ + "ID": "_id", + "UserID": "user_id", + "Name": "name", + "Emails": "emails", + "PhoneNumbers": "phone_numbers", + "Properties": "properties", + "CreatedAt": "created_at", + "UpdatedAt": "updated_at", + } + + for fieldName, bsonName := range expected { + field, found := contactType.FieldByName(fieldName) + assert.True(t, found) + assert.Equal(t, bsonName, field.Tag.Get("bson")) + } +} + +func TestContactProperties_ValueScanRoundTrip(t *testing.T) { + cases := []ContactProperties{ + nil, + {}, + {"company": "Acme", "role": "CTO"}, + } + + for _, original := range cases { + value, err := original.Value() + assert.Nil(t, err) + + var scanned ContactProperties + assert.Nil(t, scanned.Scan(value)) + + if len(original) == 0 { + assert.Equal(t, 0, len(scanned)) + continue + } + assert.Equal(t, original, scanned) + } +} + +func TestContactProperties_ScanFromString(t *testing.T) { + var scanned ContactProperties + assert.Nil(t, scanned.Scan(`{"k":"v"}`)) + assert.Equal(t, ContactProperties{"k": "v"}, scanned) +} + +func TestContactProperties_ScanNil(t *testing.T) { + var scanned ContactProperties + assert.Nil(t, scanned.Scan(nil)) + assert.Equal(t, 0, len(scanned)) +} + +func TestContactProperties_ScanUnsupportedType(t *testing.T) { + var scanned ContactProperties + + err := scanned.Scan(123) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported type [int] for ContactProperties") +} diff --git a/api/pkg/entities/message_thread.go b/api/pkg/entities/message_thread.go index 2eb1e2d33..e8f1f687f 100644 --- a/api/pkg/entities/message_thread.go +++ b/api/pkg/entities/message_thread.go @@ -9,10 +9,11 @@ import ( // MessageThread represents a message thread between 2 phone numbers type MessageThread struct { ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703ca"` - Owner string `json:"owner" example:"+18005550199"` - Contact string `json:"contact" example:"+18005550100"` + Owner string `json:"owner" gorm:"uniqueIndex:idx_message_threads_user_owner_contact,priority:2" example:"+18005550199"` + Contact string `json:"contact" gorm:"uniqueIndex:idx_message_threads_user_owner_contact,priority:3" example:"+18005550100"` IsArchived bool `json:"is_archived" example:"false"` - UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` + UnreadCount uint `json:"unread_count" gorm:"not null;default:0" example:"0"` + UserID UserID `json:"user_id" gorm:"uniqueIndex:idx_message_threads_user_owner_contact,priority:1" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` Color string `json:"color" example:"indigo"` Status MessageStatus `json:"status" example:"PENDING"` LastMessageContent *string `json:"last_message_content" example:"This is a sample message content"` @@ -20,6 +21,8 @@ type MessageThread struct { CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:09.527976+03:00"` UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:09.527976+03:00"` OrderTimestamp time.Time `json:"order_timestamp" example:"2022-06-05T14:26:09.527976+03:00"` + // ContactDetails is resolved at read time and never persisted. + ContactDetails *Contact `json:"contact_details,omitempty" gorm:"-"` } // Update a message thread after a message event diff --git a/api/pkg/entities/message_thread_test.go b/api/pkg/entities/message_thread_test.go new file mode 100644 index 000000000..47eaf2778 --- /dev/null +++ b/api/pkg/entities/message_thread_test.go @@ -0,0 +1,52 @@ +package entities + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMessageThreadUnreadCountHasDefault(t *testing.T) { + threadType := reflect.TypeOf(MessageThread{}) + + unreadCount, ok := threadType.FieldByName("UnreadCount") + require.True(t, ok) + assert.Contains(t, unreadCount.Tag.Get("gorm"), "not null") + assert.Contains(t, unreadCount.Tag.Get("gorm"), "default:0") + assert.Equal(t, "unread_count", unreadCount.Tag.Get("json")) +} + +func TestMessageThreadHasUniqueOwnerContactPerUser(t *testing.T) { + threadType := reflect.TypeOf(MessageThread{}) + + expectedTags := map[string]string{ + "UserID": "uniqueIndex:idx_message_threads_user_owner_contact,priority:1", + "Owner": "uniqueIndex:idx_message_threads_user_owner_contact,priority:2", + "Contact": "uniqueIndex:idx_message_threads_user_owner_contact,priority:3", + } + for fieldName, expectedTag := range expectedTags { + field, ok := threadType.FieldByName(fieldName) + require.True(t, ok) + assert.Contains(t, field.Tag.Get("gorm"), expectedTag) + } +} + +func TestMessageThreadContactDetailsAreTransientAndOmittedWhenNil(t *testing.T) { + threadType := reflect.TypeOf(MessageThread{}) + + contactDetails, ok := threadType.FieldByName("ContactDetails") + require.True(t, ok) + assert.Equal(t, "*entities.Contact", contactDetails.Type.String()) + assert.Equal(t, "contact_details,omitempty", contactDetails.Tag.Get("json")) + assert.Equal(t, "-", contactDetails.Tag.Get("gorm")) + + data, err := json.Marshal(MessageThread{}) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(data, &payload)) + assert.NotContains(t, payload, "contact_details") +} diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 4f3c33f28..36be28aeb 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -1,8 +1,11 @@ package entities import ( + "net/url" + "strings" "time" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" ) @@ -31,6 +34,25 @@ type Phone struct { UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` } +// NotificationTransport identifies how a phone receives wake-up notifications. +type NotificationTransport string + +const ( + // NotificationTransportFCM sends notifications through Firebase. + NotificationTransportFCM NotificationTransport = "fcm" + // NotificationTransportHTTP sends notifications to an HTTPS endpoint. + NotificationTransportHTTP NotificationTransport = "http" +) + +func isNotificationURLCandidate(token string) bool { + lower := strings.ToLower(token) + + return strings.Contains(token, "://") || + strings.HasPrefix(lower, "http:") || + strings.HasPrefix(lower, "https:") || + strings.HasPrefix(lower, "ftp:") +} + // MessageExpirationDuration returns the message expiration as time.Duration func (phone *Phone) MessageExpirationDuration() time.Duration { return time.Duration(int(phone.MessageExpirationSecondsSanitized())) * time.Second @@ -51,3 +73,52 @@ func (phone *Phone) MaxSendAttemptsSanitized() uint { } return phone.MaxSendAttempts } + +// NotificationTransport returns the transport encoded by FcmToken. +func (phone *Phone) NotificationTransport() (NotificationTransport, error) { + if phone == nil || phone.FcmToken == nil { + return "", stacktrace.NewErrorf("phone has no notification token") + } + + token := strings.TrimSpace(*phone.FcmToken) + if token == "" { + return "", stacktrace.NewErrorf("phone has no notification token") + } + + if !isNotificationURLCandidate(token) { + return NotificationTransportFCM, nil + } + + endpoint, err := url.Parse(token) + if err != nil { + return "", stacktrace.NewError("invalid notification URL") + } + + if !strings.EqualFold(endpoint.Scheme, "https") { + return "", stacktrace.NewErrorf("notification URL must use https") + } + if endpoint.Hostname() == "" { + return "", stacktrace.NewErrorf("notification URL must include a hostname") + } + + return NotificationTransportHTTP, nil +} + +// NotificationURL returns the parsed endpoint for an HTTP notification token. +func (phone *Phone) NotificationURL() (*url.URL, error) { + transport, err := phone.NotificationTransport() + if err != nil { + return nil, err + } + + if transport != NotificationTransportHTTP { + return nil, stacktrace.NewErrorf("phone notification transport is [%s], not HTTP", transport) + } + + endpoint, err := url.Parse(strings.TrimSpace(*phone.FcmToken)) + if err != nil { + return nil, stacktrace.NewError("cannot parse notification URL") + } + + return endpoint, nil +} diff --git a/api/pkg/entities/phone_notification.go b/api/pkg/entities/phone_notification.go index 8720579ae..ab3fc5908 100644 --- a/api/pkg/entities/phone_notification.go +++ b/api/pkg/entities/phone_notification.go @@ -18,7 +18,7 @@ const ( // PhoneNotificationStatus is the status of a phone notification type PhoneNotificationStatus string -// PhoneNotification represents an FCM notification to a mobile phone +// PhoneNotification represents a scheduled wake-up notification for a phone gateway. type PhoneNotification struct { ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;"` MessageID uuid.UUID `json:"message_id"` diff --git a/api/pkg/entities/phone_test.go b/api/pkg/entities/phone_test.go new file mode 100644 index 000000000..886d31f6d --- /dev/null +++ b/api/pkg/entities/phone_test.go @@ -0,0 +1,85 @@ +package entities + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func stringPointer(value string) *string { + return &value +} + +func TestPhoneNotificationTransport(t *testing.T) { + tests := []struct { + name string + token *string + transport NotificationTransport + hasError bool + }{ + {name: "firebase token with colon", token: stringPointer("fcm-token:value"), transport: NotificationTransportFCM}, + {name: "opaque token with slash", token: stringPointer("projects/alpha/messages/123"), transport: NotificationTransportFCM}, + {name: "public https url", token: stringPointer("https://adapter.example.com/notify"), transport: NotificationTransportHTTP}, + {name: "missing token", token: nil, hasError: true}, + {name: "empty token", token: stringPointer(" "), hasError: true}, + {name: "http url", token: stringPointer("http://adapter.example.com/notify"), hasError: true}, + {name: "ftp url", token: stringPointer("ftp://adapter.example.com/notify"), hasError: true}, + {name: "scheme-like https token", token: stringPointer("https:adapter.example.com"), hasError: true}, + {name: "scheme-like http token", token: stringPointer("http:foo"), hasError: true}, + {name: "scheme-like ftp token", token: stringPointer("ftp:foo"), hasError: true}, + {name: "missing host", token: stringPointer("https:///notify"), hasError: true}, + {name: "embedded user information", token: stringPointer("https://user:password@adapter.example.com/notify"), transport: NotificationTransportHTTP}, + {name: "malformed url", token: stringPointer("https://[::1"), hasError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + phone := &Phone{FcmToken: test.token} + + transport, err := phone.NotificationTransport() + + if test.hasError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.transport, transport) + }) + } +} + +func TestPhoneNotificationURL(t *testing.T) { + phone := &Phone{FcmToken: stringPointer("https://user:password@adapter.example.com/notify?tenant=42")} + + endpoint, err := phone.NotificationURL() + + require.NoError(t, err) + assert.Equal(t, "https", endpoint.Scheme) + assert.Equal(t, "user", endpoint.User.Username()) + password, hasPassword := endpoint.User.Password() + assert.True(t, hasPassword) + assert.Equal(t, "password", password) + assert.Equal(t, "adapter.example.com", endpoint.Hostname()) + assert.Equal(t, "/notify", endpoint.Path) + assert.Equal(t, "tenant=42", endpoint.RawQuery) +} + +func TestPhoneNotificationURLRejectsFCMToken(t *testing.T) { + phone := &Phone{FcmToken: stringPointer("fcm-token:value")} + + _, err := phone.NotificationURL() + + require.Error(t, err) +} + +func TestPhoneNotificationTransportDoesNotExposeMalformedToken(t *testing.T) { + token := "https://[::1/secret?token=customer-secret" + phone := &Phone{FcmToken: &token} + + _, err := phone.NotificationTransport() + + require.Error(t, err) + assert.NotContains(t, err.Error(), token) + assert.NotContains(t, err.Error(), "customer-secret") +} diff --git a/api/pkg/handlers/attachment_handler.go b/api/pkg/handlers/attachment_handler.go index 0b058f505..696521b61 100644 --- a/api/pkg/handlers/attachment_handler.go +++ b/api/pkg/handlers/attachment_handler.go @@ -6,8 +6,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // AttachmentHandler handles attachment download requests @@ -66,7 +66,7 @@ func (h *AttachmentHandler) GetAttachment(c fiber.Ctx) error { data, err := h.storage.Download(ctx, path) if err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot download attachment from path [%s]", path)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot download attachment from path [%s]", path)) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { return h.responseNotFound(c, "attachment not found") } diff --git a/api/pkg/handlers/billing_handler.go b/api/pkg/handlers/billing_handler.go index 33f0b2f0d..bcbdef4ee 100644 --- a/api/pkg/handlers/billing_handler.go +++ b/api/pkg/handlers/billing_handler.go @@ -7,9 +7,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // BillingHandler handles billing http requests. @@ -65,18 +65,18 @@ func (h *BillingHandler) UsageHistory(c fiber.Ctx) error { var request requests.BillingUsageHistory if err := c.Bind().Query(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.Body(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateHistory(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while fetching heartbeats [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while fetching heartbeats [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while fetching usage history") } heartbeats, err := h.service.GetUsageHistory(ctx, h.userIDFomContext(c), request.ToIndexParams()) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get billing usage history with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get billing usage history with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -104,7 +104,7 @@ func (h *BillingHandler) Usage(c fiber.Ctx) error { billingUsage, err := h.service.GetCurrentUsage(ctx, h.userIDFomContext(c)) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get current usage record for user [%s]", h.userFromContext(c))) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get current usage record for user [%s]", h.userFromContext(c))) return h.responseInternalServerError(c) } diff --git a/api/pkg/handlers/bulk_message_handler.go b/api/pkg/handlers/bulk_message_handler.go index 01e451469..ef087afbd 100644 --- a/api/pkg/handlers/bulk_message_handler.go +++ b/api/pkg/handlers/bulk_message_handler.go @@ -12,9 +12,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // BulkMessageHandler handles bulk SMS http requests @@ -67,7 +67,7 @@ func (h *BulkMessageHandler) Index(c fiber.Ctx) error { orders, err := h.messageService.GetBulkMessages(ctx, h.userIDFomContext(c)) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot fetch bulk messages for user [%s]", h.userIDFomContext(c))) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot fetch bulk messages for user [%s]", h.userIDFomContext(c))) return h.responseInternalServerError(c) } @@ -94,18 +94,18 @@ func (h *BulkMessageHandler) Store(c fiber.Ctx) error { file, err := c.FormFile("document") if err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot fetch file with name [%s] from request", "document")) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot fetch file with name [%s] from request", "document")) return h.responseBadRequest(c, err) } messages, userLocation, validationErrors := h.validator.ValidateStore(ctx, h.userIDFomContext(c), file) if len(validationErrors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while sending bulk sms from CSV file [%s] for [%s]", spew.Sdump(validationErrors), file.Filename, h.userIDFomContext(c))) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while sending bulk sms from CSV file [%s] for [%s]", spew.Sdump(validationErrors), file.Filename, h.userIDFomContext(c))) return h.responseUnprocessableEntity(c, validationErrors, "validation errors while sending bulk SMS") } if msg := h.billingService.IsEntitledWithCount(ctx, h.userIDFomContext(c), uint(len(messages))); msg != nil { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] is not entitled to send [%d] messages", h.userIDFomContext(c), len(messages))) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] is not entitled to send [%d] messages", h.userIDFomContext(c), len(messages))) return h.responsePaymentRequired(c, *msg) } @@ -133,7 +133,7 @@ func (h *BulkMessageHandler) Store(c fiber.Ctx) error { if err != nil { count.Add(-1) - ctxLogger.Error(stacktrace.Propagate(err, "cannot send message with payload [%s] at index [%d]", spew.Sdump(message), index)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot send message with payload [%s] at index [%d]", spew.Sdump(message), index)) } wg.Done() }(message, perPhoneIndex) diff --git a/api/pkg/handlers/contact_handler.go b/api/pkg/handlers/contact_handler.go new file mode 100644 index 000000000..096a802e7 --- /dev/null +++ b/api/pkg/handlers/contact_handler.go @@ -0,0 +1,322 @@ +package handlers + +import ( + "context" + "fmt" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" + "github.com/davecgh/go-spew/spew" + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" +) + +// ContactHandler handles contact http requests. +type ContactHandler struct { + handler + logger telemetry.Logger + tracer telemetry.Tracer + validator *validators.ContactHandlerValidator + service *services.ContactService + entitlementService *services.EntitlementService +} + +// NewContactHandler creates a new ContactHandler. +func NewContactHandler( + logger telemetry.Logger, + tracer telemetry.Tracer, + validator *validators.ContactHandlerValidator, + service *services.ContactService, + entitlementService *services.EntitlementService, +) (h *ContactHandler) { + return &ContactHandler{ + logger: logger.WithService(fmt.Sprintf("%T", h)), + tracer: tracer, + validator: validator, + service: service, + entitlementService: entitlementService, + } +} + +// RegisterRoutes registers the routes for the ContactHandler. +func (h *ContactHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { + h.register(router, fiber.MethodGet, "/v1/contacts", middlewares, h.Index) + h.register(router, fiber.MethodPost, "/v1/contacts", middlewares, h.Store) + h.register(router, fiber.MethodPost, "/v1/contacts/upload", middlewares, h.Upload) + h.register(router, fiber.MethodPut, "/v1/contacts/:contactID", middlewares, h.Update) + h.register(router, fiber.MethodDelete, "/v1/contacts/:contactID", middlewares, h.Delete) +} + +// Index lists contacts for the authenticated user. +// @Summary List contacts +// @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. +// @Security ApiKeyAuth +// @Tags Contacts +// @Accept json +// @Produce json +// @Param skip query int false "number of contacts to skip" minimum(0) +// @Param query query string false "filter contacts containing query" +// @Param sort_by query string false "field to sort contacts by" Enums(name, updated_at) +// @Param sort_descending query bool false "sort contacts in descending order" +// @Param limit query int false "number of contacts to return" minimum(1) maximum(100) +// @Success 200 {object} responses.ContactsResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /contacts [get] +func (h *ContactHandler) Index(c fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + var request requests.ContactIndex + if err := c.Bind().Query(&request); err != nil { + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + return h.responseBadRequest(c, err) + } + + sanitized := request.Sanitize() + if errors := h.validator.ValidateIndex(ctx, sanitized); len(errors) != 0 { + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while listing contacts [%+#v]", spew.Sdump(errors), sanitized)) + return h.responseUnprocessableEntity(c, errors, "validation errors while listing contacts") + } + + userID := h.userIDFomContext(c) + params := sanitized.ToIndexParams() + contacts, err := h.service.Index(ctx, userID, params) + if err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot list contacts for user [%s]", userID)) + return h.responseInternalServerError(c) + } + + total, err := h.service.Count(ctx, userID, params) + if err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot count contacts for user [%s]", userID)) + return h.responseInternalServerError(c) + } + + return h.responseOKWithTotal(c, fmt.Sprintf("fetched %d %s", len(*contacts), h.pluralize("contact", len(*contacts))), contacts, total) +} + +// Store creates one or many contacts. +// @Summary Create one or many contacts +// @Description Creates a single contact or a batch of contacts. Accepts a JSON array or an object with a "contacts" array. +// @Security ApiKeyAuth +// @Tags Contacts +// @Accept json +// @Produce json +// @Param payload body requests.ContactStoreRequest true "Contact(s) to create" +// @Success 201 {object} responses.ContactsCreatedResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 402 {object} responses.PaymentRequired +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /contacts [post] +func (h *ContactHandler) Store(c fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + var request requests.ContactStoreRequest + if err := c.Bind().Body(&request); err != nil { + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall body [%s] into %T", c.Body(), request)) + return h.responseBadRequest(c, err) + } + + sanitized := request.Sanitize() + if errors := h.validator.ValidateStore(ctx, sanitized); len(errors) != 0 { + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while creating contacts", spew.Sdump(errors))) + return h.responseUnprocessableEntity(c, errors, "validation errors while creating contacts") + } + + userID := h.userIDFomContext(c) + contacts := sanitized.ToContacts(userID) + result, err := h.checkCreateEntitlement(ctx, userID, len(contacts)) + if err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot check contact entitlement for user [%s]", userID)) + return h.responseInternalServerError(c) + } + if !result.Allowed { + return h.responsePaymentRequired(c, result.Message) + } + + if err := h.service.CreateMany(ctx, userID, contacts); err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot create [%d] contacts for user [%s]", len(contacts), userID)) + return h.responseInternalServerError(c) + } + + return h.responseCreated(c, fmt.Sprintf("created %d %s", len(contacts), h.pluralize("contact", len(contacts))), contacts) +} + +// Upload imports contacts from a CSV file. +// @Summary Import contacts from CSV +// @Description Uploads a CSV file (multipart field "document") of contacts. Columns: Name, Emails, PhoneNumbers (multi-values separated by ";"). +// @Security ApiKeyAuth +// @Tags Contacts +// @Accept multipart/form-data +// @Produce json +// @Param document formData file true "CSV file of contacts" +// @Success 201 {object} responses.ContactsCreatedResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 402 {object} responses.PaymentRequired +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /contacts/upload [post] +func (h *ContactHandler) Upload(c fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + file, err := c.FormFile("document") + if err != nil { + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot fetch file with name [%s] from request", "document")) + return h.responseBadRequest(c, err) + } + + userID := h.userIDFomContext(c) + items, errors := h.validator.ValidateUpload(ctx, userID, file) + if len(errors) != 0 { + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while importing contacts from CSV [%s]", spew.Sdump(errors), file.Filename)) + return h.responseUnprocessableEntity(c, errors, "validation errors while importing contacts") + } + + // items are already sanitized by ValidateUpload (SanitizeContactItem), so + // build the persistable records directly without re-sanitizing. + request := requests.ContactStoreRequest{Contacts: items} + contacts := request.ToContacts(userID) + result, err := h.checkCreateEntitlement(ctx, userID, len(contacts)) + if err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot check contact entitlement for user [%s]", userID)) + return h.responseInternalServerError(c) + } + if !result.Allowed { + return h.responsePaymentRequired(c, result.Message) + } + + if err = h.service.CreateMany(ctx, userID, contacts); err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot import [%d] contacts for user [%s]", len(contacts), userID)) + return h.responseInternalServerError(c) + } + + return h.responseCreated(c, fmt.Sprintf("imported %d %s", len(contacts), h.pluralize("contact", len(contacts))), contacts) +} + +// Update updates a single contact. +// @Summary Update a contact +// @Description Updates the details of a single contact. +// @Security ApiKeyAuth +// @Tags Contacts +// @Accept json +// @Produce json +// @Param contactID path string true "ID of the contact" +// @Param payload body requests.ContactUpdateRequest true "Contact details to update" +// @Success 200 {object} responses.ContactResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /contacts/{contactID} [put] +func (h *ContactHandler) Update(c fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + contactID := c.Params("contactID") + if errors := h.validator.ValidateUUID(contactID, "contactID"); len(errors) != 0 { + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating contact [%s]", spew.Sdump(errors), contactID)) + return h.responseUnprocessableEntity(c, errors, "validation errors while updating contact") + } + + var request requests.ContactUpdateRequest + if err := c.Bind().Body(&request); err != nil { + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall body into %T", request)) + return h.responseBadRequest(c, err) + } + + sanitized := request.Sanitize() + if errors := h.validator.ValidateUpdate(ctx, sanitized); len(errors) != 0 { + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating contact [%s]", spew.Sdump(errors), contactID)) + return h.responseUnprocessableEntity(c, errors, "validation errors while updating contact") + } + + userID := h.userIDFomContext(c) + contact, err := h.service.Get(ctx, userID, uuid.MustParse(contactID)) + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, fmt.Sprintf("cannot find contact with ID [%s]", contactID)) + } + if err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot load contact [%s] for user [%s]", contactID, userID)) + return h.responseInternalServerError(c) + } + + previousPhoneNumbers := append([]string{}, contact.PhoneNumbers...) + sanitized.ApplyTo(contact) + if err = h.service.Update(ctx, contact, previousPhoneNumbers); err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot update contact [%s] for user [%s]", contactID, userID)) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, "contact updated successfully", contact) +} + +// Delete removes a single contact. +// @Summary Delete a contact +// @Description Deletes a single contact from the database. +// @Security ApiKeyAuth +// @Tags Contacts +// @Accept json +// @Produce json +// @Param contactID path string true "ID of the contact" +// @Success 204 {object} responses.NoContent +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /contacts/{contactID} [delete] +func (h *ContactHandler) Delete(c fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + contactID := c.Params("contactID") + if errors := h.validator.ValidateUUID(contactID, "contactID"); len(errors) != 0 { + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while deleting contact [%s]", spew.Sdump(errors), contactID)) + return h.responseUnprocessableEntity(c, errors, "validation errors while deleting contact") + } + + userID := h.userIDFomContext(c) + + // Load first so a missing contact for the authenticated user returns 404 + // instead of silently succeeding via a 0-row DELETE. This also prevents + // leaking whether another user's contact exists. + if _, err := h.service.Get(ctx, userID, uuid.MustParse(contactID)); err != nil { + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, fmt.Sprintf("cannot find contact with ID [%s]", contactID)) + } + ctxLogger.Error(stacktrace.Propagatef(err, "cannot load contact [%s] for user [%s]", contactID, userID)) + return h.responseInternalServerError(c) + } + + if err := h.service.Delete(ctx, userID, uuid.MustParse(contactID)); err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot delete contact [%s] for user [%s]", contactID, userID)) + return h.responseInternalServerError(c) + } + + return h.responseNoContent(c, "contact deleted successfully") +} + +func (h *ContactHandler) checkCreateEntitlement( + ctx context.Context, + userID entities.UserID, + additionalCount int, +) (*services.EntitlementCheckResult, error) { + return h.entitlementService.CheckAdditional(ctx, userID, entities.EntityNameContact, additionalCount, func() (int, error) { + count, err := h.service.Count(ctx, userID, repositories.IndexParams{}) + return int(count), err + }) +} diff --git a/api/pkg/handlers/contact_handler_test.go b/api/pkg/handlers/contact_handler_test.go new file mode 100644 index 000000000..5229cfadf --- /dev/null +++ b/api/pkg/handlers/contact_handler_test.go @@ -0,0 +1,658 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "net/url" + "sync" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/middlewares" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" + "github.com/dgraph-io/ristretto/v2" + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// contactHandlerFakeRepo is a shared, thread-safe ContactRepository stub that +// records the effects of Store/Update/Delete and returns configurable Load/Index +// results so handler tests can assert real service→repository behaviour. +type contactHandlerFakeRepo struct { + mu sync.Mutex + + stored [][]*entities.Contact + updated []*entities.Contact + deleted []deletedContact + indexParams []repositories.IndexParams + countParams []repositories.IndexParams + loadCalls []loadedContact + + loadResult *entities.Contact + loadErr error + indexResult []entities.Contact + indexErr error + countResult int64 + countErr error + storeErr error + updateErr error + deleteErr error +} + +type deletedContact struct { + userID entities.UserID + id uuid.UUID +} + +type loadedContact struct { + userID entities.UserID + id uuid.UUID +} + +func (r *contactHandlerFakeRepo) Store(_ context.Context, contacts []*entities.Contact) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.storeErr != nil { + return r.storeErr + } + r.stored = append(r.stored, contacts) + return nil +} + +func (r *contactHandlerFakeRepo) Update(_ context.Context, contact *entities.Contact) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.updateErr != nil { + return r.updateErr + } + r.updated = append(r.updated, contact) + return nil +} + +func (r *contactHandlerFakeRepo) Load(_ context.Context, userID entities.UserID, id uuid.UUID) (*entities.Contact, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.loadCalls = append(r.loadCalls, loadedContact{userID: userID, id: id}) + if r.loadErr != nil { + return nil, r.loadErr + } + if r.loadResult == nil { + return nil, nil + } + // Return a copy so handler mutations don't corrupt the fixture. + clone := *r.loadResult + return &clone, nil +} + +func (r *contactHandlerFakeRepo) Index(_ context.Context, _ entities.UserID, params repositories.IndexParams) (*[]entities.Contact, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.indexParams = append(r.indexParams, params) + if r.indexErr != nil { + return nil, r.indexErr + } + out := make([]entities.Contact, len(r.indexResult)) + copy(out, r.indexResult) + return &out, nil +} + +func (r *contactHandlerFakeRepo) Count(_ context.Context, _ entities.UserID, params repositories.IndexParams) (int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.countParams = append(r.countParams, params) + if r.countErr != nil { + return 0, r.countErr + } + return r.countResult, nil +} + +func (r *contactHandlerFakeRepo) FetchByPhoneNumbers(context.Context, entities.UserID, []string) (*[]entities.Contact, error) { + out := []entities.Contact{} + return &out, nil +} + +func (r *contactHandlerFakeRepo) Delete(_ context.Context, userID entities.UserID, id uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.deleteErr != nil { + return r.deleteErr + } + r.deleted = append(r.deleted, deletedContact{userID: userID, id: id}) + return nil +} + +func (r *contactHandlerFakeRepo) DeleteAllForUser(context.Context, entities.UserID) error { + return nil +} + +// snapshot returns a safe read of the recorded effects. +func (r *contactHandlerFakeRepo) snapshot() ([][]*entities.Contact, []*entities.Contact, []deletedContact, []repositories.IndexParams) { + r.mu.Lock() + defer r.mu.Unlock() + return r.stored, r.updated, r.deleted, r.indexParams +} + +const contactHandlerTestUserID = entities.UserID("user-id") + +type contactHandlerEntitlementUserRepo struct { + repositories.UserRepository + subscriptionName entities.SubscriptionName +} + +func (repository *contactHandlerEntitlementUserRepo) Load(_ context.Context, userID entities.UserID) (*entities.User, error) { + return &entities.User{ID: userID, SubscriptionName: repository.subscriptionName}, nil +} + +func newContactHandlerTestApp(repo repositories.ContactRepository) *fiber.App { + return newContactHandlerTestAppWithEntitlements(repo, false, entities.SubscriptionNameFree) +} + +func newContactHandlerTestAppWithEntitlements( + repo repositories.ContactRepository, + entitlementsEnabled bool, + subscriptionName entities.SubscriptionName, +) *fiber.App { + logger := &messageThreadHandlerNoopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + contactCache, err := ristretto.NewCache[string, services.ContactCacheEntry](&ristretto.Config[string, services.ContactCacheEntry]{ + MaxCost: 100, NumCounters: 1_000, BufferItems: 64, + }) + if err != nil { + panic(err) + } + service := services.NewContactService(logger, tracer, repo, contactCache) + entitlementService := services.NewEntitlementService( + logger, + tracer, + entitlementsEnabled, + &contactHandlerEntitlementUserRepo{subscriptionName: subscriptionName}, + ) + handler := NewContactHandler(logger, tracer, validators.NewContactHandlerValidator(logger, tracer), service, entitlementService) + + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals(middlewares.ContextKeyAuthUserID, entities.AuthContext{ID: contactHandlerTestUserID, Email: "user@example.com"}) + return c.Next() + }) + handler.RegisterRoutes(app) + return app +} + +type contactHandlerPayload struct { + Status string `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + Total int64 `json:"total"` +} + +func decodeContactHandlerPayload(t *testing.T, resp *http.Response) contactHandlerPayload { + t.Helper() + var payload contactHandlerPayload + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) + return payload +} + +func TestContactHandler_Store_CreatesSingleContact(t *testing.T) { + repo := &contactHandlerFakeRepo{} + app := newContactHandlerTestApp(repo) + + body := `[{"name":"Alice","phone_numbers":["+18005550199"],"emails":["alice@example.com"]}]` + req := httptest.NewRequest(http.MethodPost, "/v1/contacts", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + stored, _, _, _ := repo.snapshot() + require.Len(t, stored, 1) + require.Len(t, stored[0], 1) + require.Equal(t, "Alice", stored[0][0].Name) + require.Equal(t, contactHandlerTestUserID, stored[0][0].UserID) + require.Equal(t, pq.StringArray{"+18005550199"}, stored[0][0].PhoneNumbers) +} + +func TestContactHandler_Store_CreatesManyContactsFromObjectShape(t *testing.T) { + repo := &contactHandlerFakeRepo{} + app := newContactHandlerTestApp(repo) + + body := `{"contacts":[ + {"name":"Alice","phone_numbers":["+18005550199"]}, + {"name":"Bob","phone_numbers":["+18005550100"]} + ]}` + req := httptest.NewRequest(http.MethodPost, "/v1/contacts", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + stored, _, _, _ := repo.snapshot() + require.Len(t, stored, 1) + require.Len(t, stored[0], 2) + assert.Equal(t, "Alice", stored[0][0].Name) + assert.Equal(t, "Bob", stored[0][1].Name) +} + +func TestContactHandler_Store_RejectsBatchExceedingContactLimit(t *testing.T) { + repo := &contactHandlerFakeRepo{countResult: 199} + app := newContactHandlerTestAppWithEntitlements(repo, true, entities.SubscriptionNameFree) + + body := `{"contacts":[ + {"name":"Alice","phone_numbers":["+18005550199"]}, + {"name":"Bob","phone_numbers":["+18005550100"]} + ]}` + req := httptest.NewRequest(http.MethodPost, "/v1/contacts", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusPaymentRequired, resp.StatusCode) + + stored, _, _, _ := repo.snapshot() + assert.Empty(t, stored) +} + +func TestContactHandler_Store_DisabledEntitlementsDoNotLimitContacts(t *testing.T) { + repo := &contactHandlerFakeRepo{countResult: 200} + app := newContactHandlerTestAppWithEntitlements(repo, false, entities.SubscriptionNameFree) + + body := `[{"name":"Alice","phone_numbers":["+18005550199"]}]` + req := httptest.NewRequest(http.MethodPost, "/v1/contacts", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + stored, _, _, _ := repo.snapshot() + require.Len(t, stored, 1) +} + +func TestContactHandler_Store_ValidationError_ReturnsUnprocessableEntity(t *testing.T) { + repo := &contactHandlerFakeRepo{} + app := newContactHandlerTestApp(repo) + + body := `[{"name":"","phone_numbers":[]}]` + req := httptest.NewRequest(http.MethodPost, "/v1/contacts", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) + + stored, _, _, _ := repo.snapshot() + require.Empty(t, stored, "no contact should be stored on validation failure") +} + +func TestContactHandler_Store_MalformedJSON_ReturnsBadRequest(t *testing.T) { + app := newContactHandlerTestApp(&contactHandlerFakeRepo{}) + + req := httptest.NewRequest(http.MethodPost, "/v1/contacts", bytes.NewBufferString("{not json")) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func buildContactCSVUpload(t *testing.T, filename string, contentType string, body string) (*bytes.Buffer, string) { + t.Helper() + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="document"; filename="%s"`, filename)) + header.Set("Content-Type", contentType) + part, err := writer.CreatePart(header) + require.NoError(t, err) + _, err = part.Write([]byte(body)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + return &buf, writer.FormDataContentType() +} + +func TestContactHandler_Upload_CSVSuccess(t *testing.T) { + repo := &contactHandlerFakeRepo{} + app := newContactHandlerTestApp(repo) + + csv := "Name,Emails,PhoneNumbers\nAlice,alice@example.com,+18005550199\nBob,,+18005550100\n" + body, contentType := buildContactCSVUpload(t, "contacts.csv", "text/csv", csv) + + req := httptest.NewRequest(http.MethodPost, "/v1/contacts/upload", body) + req.Header.Set("Content-Type", contentType) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + stored, _, _, _ := repo.snapshot() + require.Len(t, stored, 1) + require.Len(t, stored[0], 2) + assert.Equal(t, "Alice", stored[0][0].Name) + assert.Equal(t, "Bob", stored[0][1].Name) + for _, c := range stored[0] { + assert.Equal(t, contactHandlerTestUserID, c.UserID) + } +} + +func TestContactHandler_Upload_RejectsBatchExceedingContactLimit(t *testing.T) { + repo := &contactHandlerFakeRepo{countResult: 199} + app := newContactHandlerTestAppWithEntitlements(repo, true, entities.SubscriptionNameFree) + + csv := "Name,Emails,PhoneNumbers\nAlice,alice@example.com,+18005550199\nBob,,+18005550100\n" + body, contentType := buildContactCSVUpload(t, "contacts.csv", "text/csv", csv) + req := httptest.NewRequest(http.MethodPost, "/v1/contacts/upload", body) + req.Header.Set("Content-Type", contentType) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusPaymentRequired, resp.StatusCode) + + stored, _, _, _ := repo.snapshot() + assert.Empty(t, stored) +} + +func TestContactHandler_Upload_NonCSVFile_ReturnsUnprocessableEntity(t *testing.T) { + repo := &contactHandlerFakeRepo{} + app := newContactHandlerTestApp(repo) + + body, contentType := buildContactCSVUpload(t, "contacts.txt", "text/plain", "junk") + req := httptest.NewRequest(http.MethodPost, "/v1/contacts/upload", body) + req.Header.Set("Content-Type", contentType) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) + + stored, _, _, _ := repo.snapshot() + require.Empty(t, stored) +} + +func TestContactHandler_Upload_MissingDocument_ReturnsBadRequest(t *testing.T) { + app := newContactHandlerTestApp(&contactHandlerFakeRepo{}) + + // multipart body without the "document" field. + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.WriteField("other", "value")) + require.NoError(t, writer.Close()) + + req := httptest.NewRequest(http.MethodPost, "/v1/contacts/upload", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestContactHandler_Update_Success(t *testing.T) { + contactID := uuid.New() + repo := &contactHandlerFakeRepo{ + loadResult: &entities.Contact{ + ID: contactID, + UserID: contactHandlerTestUserID, + Name: "Old Name", + PhoneNumbers: pq.StringArray{"+18005550100"}, + }, + } + app := newContactHandlerTestApp(repo) + + body := `{"name":"New Name","phone_numbers":["+18005550199"],"emails":["new@example.com"]}` + req := httptest.NewRequest(http.MethodPut, "/v1/contacts/"+contactID.String(), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + _, updated, _, _ := repo.snapshot() + require.Len(t, updated, 1) + assert.Equal(t, "New Name", updated[0].Name) + assert.Equal(t, contactID, updated[0].ID) + assert.Equal(t, contactHandlerTestUserID, updated[0].UserID) + assert.Equal(t, pq.StringArray{"+18005550199"}, updated[0].PhoneNumbers) + + // The repo Load call must have been user-scoped. + require.Len(t, repo.loadCalls, 1) + assert.Equal(t, contactHandlerTestUserID, repo.loadCalls[0].userID) + assert.Equal(t, contactID, repo.loadCalls[0].id) +} + +func TestContactHandler_Update_NotFound_ReturnsNotFound(t *testing.T) { + contactID := uuid.New() + repo := &contactHandlerFakeRepo{ + loadErr: stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found"), + } + app := newContactHandlerTestApp(repo) + + body := `{"name":"New Name","phone_numbers":["+18005550199"]}` + req := httptest.NewRequest(http.MethodPut, "/v1/contacts/"+contactID.String(), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + + payload := decodeContactHandlerPayload(t, resp) + assert.Contains(t, payload.Message, contactID.String()) + + _, updated, _, _ := repo.snapshot() + assert.Empty(t, updated) +} + +func TestContactHandler_Update_InvalidID_ReturnsUnprocessableEntity(t *testing.T) { + app := newContactHandlerTestApp(&contactHandlerFakeRepo{}) + + body := `{"name":"Alice","phone_numbers":["+18005550199"]}` + req := httptest.NewRequest(http.MethodPut, "/v1/contacts/not-a-uuid", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) +} + +func TestContactHandler_Update_ValidationError_ReturnsUnprocessableEntity(t *testing.T) { + contactID := uuid.New() + app := newContactHandlerTestApp(&contactHandlerFakeRepo{}) + + body := `{"name":"","phone_numbers":[]}` + req := httptest.NewRequest(http.MethodPut, "/v1/contacts/"+contactID.String(), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) +} + +func TestContactHandler_Delete_Success(t *testing.T) { + contactID := uuid.New() + repo := &contactHandlerFakeRepo{ + loadResult: &entities.Contact{ + ID: contactID, + UserID: contactHandlerTestUserID, + Name: "Alice", + }, + } + app := newContactHandlerTestApp(repo) + + req := httptest.NewRequest(http.MethodDelete, "/v1/contacts/"+contactID.String(), nil) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, resp.StatusCode) + + _, _, deleted, _ := repo.snapshot() + require.Len(t, deleted, 1) + assert.Equal(t, contactHandlerTestUserID, deleted[0].userID) + assert.Equal(t, contactID, deleted[0].id) +} + +func TestContactHandler_Delete_NotFound_ReturnsNotFound(t *testing.T) { + contactID := uuid.New() + repo := &contactHandlerFakeRepo{ + loadErr: stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found"), + } + app := newContactHandlerTestApp(repo) + + req := httptest.NewRequest(http.MethodDelete, "/v1/contacts/"+contactID.String(), nil) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + + // Ensure we did not delete another user's contact silently. + _, _, deleted, _ := repo.snapshot() + assert.Empty(t, deleted) +} + +func TestContactHandler_Delete_InvalidID_ReturnsUnprocessableEntity(t *testing.T) { + app := newContactHandlerTestApp(&contactHandlerFakeRepo{}) + + req := httptest.NewRequest(http.MethodDelete, "/v1/contacts/not-a-uuid", nil) + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) +} + +func TestContactHandler_Index_ConvertsQueryAndScopesToUser(t *testing.T) { + repo := &contactHandlerFakeRepo{ + indexResult: []entities.Contact{ + {ID: uuid.New(), UserID: contactHandlerTestUserID, Name: "Alice", PhoneNumbers: pq.StringArray{"+18005550199"}}, + }, + } + app := newContactHandlerTestApp(repo) + + values := url.Values{} + values.Set("skip", "5") + values.Set("limit", "25") + values.Set("query", "ali") + values.Set("sort_by", "name") + values.Set("sort_descending", "true") + req := httptest.NewRequest(http.MethodGet, "/v1/contacts?"+values.Encode(), nil) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + _, _, _, indexParams := repo.snapshot() + require.Len(t, indexParams, 1) + assert.Equal(t, 5, indexParams[0].Skip) + assert.Equal(t, 25, indexParams[0].Limit) + assert.Equal(t, "ali", indexParams[0].Query) + assert.Equal(t, "name", indexParams[0].SortBy) + assert.True(t, indexParams[0].SortDescending) + + payload := decodeContactHandlerPayload(t, resp) + assert.Equal(t, "success", payload.Status) + assert.Contains(t, payload.Message, "1") +} + +func TestContactHandler_Index_ReturnsServerTotalIndependentOfPageLength(t *testing.T) { + repo := &contactHandlerFakeRepo{ + indexResult: []entities.Contact{ + {ID: uuid.New(), UserID: contactHandlerTestUserID, Name: "Alice", PhoneNumbers: pq.StringArray{"+18005550199"}}, + {ID: uuid.New(), UserID: contactHandlerTestUserID, Name: "Bob", PhoneNumbers: pq.StringArray{"+18005550100"}}, + }, + countResult: 57, + } + app := newContactHandlerTestApp(repo) + + values := url.Values{} + values.Set("skip", "5") + values.Set("limit", "25") + values.Set("query", "ali") + req := httptest.NewRequest(http.MethodGet, "/v1/contacts?"+values.Encode(), nil) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + payload := decodeContactHandlerPayload(t, resp) + assert.Equal(t, "success", payload.Status) + // total must be the server count, not the length of the returned page. + assert.Equal(t, int64(57), payload.Total) + + var data []entities.Contact + require.NoError(t, json.Unmarshal(payload.Data, &data)) + assert.Len(t, data, 2) + + // Count must run with the exact same filter/pagination params as Index. + require.Len(t, repo.indexParams, 1) + require.Len(t, repo.countParams, 1) + assert.Equal(t, repo.indexParams[0], repo.countParams[0]) + assert.Equal(t, 5, repo.countParams[0].Skip) + assert.Equal(t, 25, repo.countParams[0].Limit) + assert.Equal(t, "ali", repo.countParams[0].Query) +} + +func TestContactHandler_Index_CountErrorReturnsInternalServerError(t *testing.T) { + repo := &contactHandlerFakeRepo{countErr: assert.AnError} + app := newContactHandlerTestApp(repo) + + req := httptest.NewRequest(http.MethodGet, "/v1/contacts", nil) + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestContactHandler_Index_DefaultsAppliedWhenParamsMissing(t *testing.T) { + repo := &contactHandlerFakeRepo{} + app := newContactHandlerTestApp(repo) + + req := httptest.NewRequest(http.MethodGet, "/v1/contacts", nil) + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + _, _, _, indexParams := repo.snapshot() + require.Len(t, indexParams, 1) + assert.Equal(t, 0, indexParams[0].Skip) + assert.Equal(t, 20, indexParams[0].Limit) + assert.Equal(t, "", indexParams[0].Query) +} + +func TestContactHandler_Index_InvalidLimit_ReturnsUnprocessableEntity(t *testing.T) { + app := newContactHandlerTestApp(&contactHandlerFakeRepo{}) + + req := httptest.NewRequest(http.MethodGet, "/v1/contacts?limit=notanumber", nil) + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + require.NoError(t, err) + require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) +} + +// TestContactService_WiresIntoMessageThreadService is a compile-time guard that +// pins the DI wiring change: ContactService must satisfy the contactMapProvider +// interface consumed by MessageThreadService, so container.MessageThreadService() +// can be constructed with container.ContactService() instead of nil. +func TestContactService_WiresIntoMessageThreadService(t *testing.T) { + logger := &messageThreadHandlerNoopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + contactCache, err := ristretto.NewCache[string, services.ContactCacheEntry](&ristretto.Config[string, services.ContactCacheEntry]{ + MaxCost: 100, NumCounters: 1_000, BufferItems: 64, + }) + require.NoError(t, err) + t.Cleanup(contactCache.Close) + contactService := services.NewContactService(logger, tracer, &contactHandlerFakeRepo{}, contactCache) + + // If this compiles and runs, the ContactService satisfies the + // contactMapProvider interface expected by NewMessageThreadService. + threadService := services.NewMessageThreadService(logger, tracer, nil, nil, nil, contactService) + require.NotNil(t, threadService) +} diff --git a/api/pkg/handlers/discord_handler.go b/api/pkg/handlers/discord_handler.go index 29da67a07..6f7403f14 100644 --- a/api/pkg/handlers/discord_handler.go +++ b/api/pkg/handlers/discord_handler.go @@ -16,9 +16,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // DiscordHandler handles discord events @@ -88,18 +88,18 @@ func (h *DiscordHandler) Index(c fiber.Ctx) error { var request requests.DiscordIndex if err := c.Bind().Query(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall URL [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall URL [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateIndex(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while fetching discord integrations [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while fetching discord integrations [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while fetching discord integrations") } discordIntegrations, err := h.service.Index(ctx, h.userIDFomContext(c), request.ToIndexParams()) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get discord integrations with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get discord integrations with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -126,13 +126,13 @@ func (h *DiscordHandler) Delete(c fiber.Ctx) error { discordID := c.Params("discordID") if errors := h.validator.ValidateUUID(discordID, "discordID"); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while deleting discord integration with ID [%s]", spew.Sdump(errors), discordID)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while deleting discord integration with ID [%s]", spew.Sdump(errors), discordID)) return h.responseUnprocessableEntity(c, errors, "validation errors while deleting discord integration") } err := h.service.Delete(ctx, h.userIDFomContext(c), uuid.MustParse(discordID)) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot delete discord integration with ID [%+#v]", discordID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot delete discord integration with ID [%+#v]", discordID)) return h.responseInternalServerError(c) } @@ -160,19 +160,19 @@ func (h *DiscordHandler) Update(c fiber.Ctx) error { var request requests.DiscordUpdate if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into [%T]", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into [%T]", c.Body(), request)) return h.responseBadRequest(c, err) } request.DiscordID = c.Params("discordID") if errors := h.validator.ValidateUpdate(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while updating user [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating user [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while updating discord integration") } user, err := h.service.Update(ctx, request.ToUpdateParams(h.userFromContext(c))) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot update discord integration with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot update discord integration with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -201,29 +201,29 @@ func (h *DiscordHandler) Store(c fiber.Ctx) error { var request requests.DiscordStore if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall body [%s] into [%T]", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall body [%s] into [%T]", c.Body(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateStore(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while storing discord integration [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while storing discord integration [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while storing discord integration") } discordIntegrations, err := h.service.Index(ctx, h.userIDFomContext(c), repositories.IndexParams{Skip: 0, Limit: 1}) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot index discord integrations for user [%s]", h.userIDFomContext(c))) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot index discord integrations for user [%s]", h.userIDFomContext(c))) return h.responseInternalServerError(c) } if len(discordIntegrations) > 0 { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] wants to create more than 1 discord integration", h.userIDFomContext(c))) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] wants to create more than 1 discord integration", h.userIDFomContext(c))) return h.responsePaymentRequired(c, "You can't create more than 1 discord integration contact us to upgrade your account.") } discordIntegration, err := h.service.Store(ctx, request.ToStoreParams(h.userFromContext(c))) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot store discord integration with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot store discord integration with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -252,7 +252,7 @@ func (h *DiscordHandler) Event(c fiber.Ctx) error { var payload map[string]any if err := json.Unmarshal(c.Body(), &payload); err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot unmarshall [%s] to [%T]", string(c.Body()), payload))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot unmarshall [%s] to [%T]", string(c.Body()), payload))) return h.responseBadRequest(c, err) } @@ -266,7 +266,7 @@ func (h *DiscordHandler) Event(c fiber.Ctx) error { return h.sendSMS(ctx, c, payload) } - return h.responseBadRequest(c, stacktrace.NewError("unknown type [%d]", payload["type"])) + return h.responseBadRequest(c, stacktrace.NewErrorf("unknown type [%d]", payload["type"])) } func (h *DiscordHandler) createRequest(payload map[string]any) requests.MessageSend { @@ -291,7 +291,7 @@ func (h *DiscordHandler) sendSMS(ctx context.Context, c fiber.Ctx, payload map[s discord, err := h.service.GetByServerID(ctx, payload["guild_id"].(string)) if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot get discord integration by server ID [%s]", payload["guild_id"].(string)))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot get discord integration by server ID [%s]", payload["guild_id"].(string)))) return c.JSON( fiber.Map{ "type": 4, @@ -329,7 +329,7 @@ func (h *DiscordHandler) sendSMS(ctx context.Context, c fiber.Ctx, payload map[s } if errors := h.messageValidator.ValidateMessageSend(ctx, discord.UserID, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body())) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body())) var embeds []fiber.Map for _, value := range errors { @@ -351,7 +351,7 @@ func (h *DiscordHandler) sendSMS(ctx context.Context, c fiber.Ctx, payload map[s } if msg := h.billingService.IsEntitled(ctx, discord.UserID); msg != nil { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] can't send a message", discord.UserID)) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] can't send a message", discord.UserID)) return c.JSON( fiber.Map{ "type": 4, @@ -370,7 +370,7 @@ func (h *DiscordHandler) sendSMS(ctx context.Context, c fiber.Ctx, payload map[s message, err := h.messageService.SendMessage(ctx, request.ToMessageSendParams(discord.UserID, c.OriginalURL())) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot send message with paylod [%s] from discord server [%s]", c.Body(), discord.ServerID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot send message with paylod [%s] from discord server [%s]", c.Body(), discord.ServerID)) return c.JSON( fiber.Map{ "type": 4, @@ -436,7 +436,7 @@ func (h *DiscordHandler) verifyInteraction(ctxLogger telemetry.Logger, c fiber.C key, err := hex.DecodeString(os.Getenv("DISCORD_PUBLIC_KEY")) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot decode DISCORD_PUBLIC_KEY env variable [%s]", os.Getenv("DISCORD_PUBLIC_KEY"))) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot decode DISCORD_PUBLIC_KEY env variable [%s]", os.Getenv("DISCORD_PUBLIC_KEY"))) return false } diff --git a/api/pkg/handlers/events_handler.go b/api/pkg/handlers/events_handler.go index b2586ed21..5e856cc70 100644 --- a/api/pkg/handlers/events_handler.go +++ b/api/pkg/handlers/events_handler.go @@ -7,9 +7,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // EventsHandler handles heartbeat http requests. @@ -49,24 +49,24 @@ func (h *EventsHandler) Dispatch(c fiber.Ctx) error { var request cloudevents.Event if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if err := request.Validate(); err != nil { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while dispatching event [%+#v]", spew.Sdump(err.Error()), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while dispatching event [%+#v]", spew.Sdump(err.Error()), request)) return h.responseUnprocessableEntity(c, map[string][]string{"event": {err.Error()}}, "validation errors while dispatching event") } if h.userIDFomContext(c) != h.queueConfig.UserID { - ctxLogger.Error(stacktrace.NewError("user with ID [%s], cannot dispatch event [%+#v]", h.userIDFomContext(c), request)) + ctxLogger.Error(stacktrace.NewErrorf("user with ID [%s], cannot dispatch event [%+#v]", h.userIDFomContext(c), request)) return h.responseForbidden(c) } ctxLogger.Info(fmt.Sprintf("handling [%s] event with ID [%s]", request.Type(), request.ID())) err := h.service.DispatchSync(ctx, request) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot dispatch [%s] event with ID [%s]", request.Type(), request.ID())) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot dispatch [%s] event with ID [%s]", request.Type(), request.ID())) return h.responseInternalServerError(c) } diff --git a/api/pkg/handlers/handler.go b/api/pkg/handlers/handler.go index 070e2db71..1f655b447 100644 --- a/api/pkg/handlers/handler.go +++ b/api/pkg/handlers/handler.go @@ -97,6 +97,15 @@ func (h *handler) responseOK(c fiber.Ctx, message string, data interface{}) erro }) } +func (h *handler) responseOKWithTotal(c fiber.Ctx, message string, data interface{}, total int64) error { + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "status": "success", + "message": message, + "data": data, + "total": total, + }) +} + func (h *handler) responseCreated(c fiber.Ctx, message string, data interface{}) error { return c.Status(fiber.StatusCreated).JSON(fiber.Map{ "status": "success", diff --git a/api/pkg/handlers/heartbeat_handler.go b/api/pkg/handlers/heartbeat_handler.go index eb9dec405..20554b049 100644 --- a/api/pkg/handlers/heartbeat_handler.go +++ b/api/pkg/handlers/heartbeat_handler.go @@ -10,9 +10,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // HeartbeatHandler handles heartbeat http requests. @@ -74,18 +74,18 @@ func (h *HeartbeatHandler) Index(c fiber.Ctx) error { var request requests.HeartbeatIndex if err := c.Bind().Query(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateIndex(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while fetching heartbeats [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while fetching heartbeats [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while fetching heartbeats") } heartbeats, err := h.service.Index(ctx, h.userIDFomContext(c), request.Owner, request.ToIndexParams()) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get messgaes with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get heartbeats with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -114,18 +114,18 @@ func (h *HeartbeatHandler) Store(c fiber.Ctx) error { var request requests.HeartbeatStore if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateStore(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while storing heartbeat [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while storing heartbeat [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while storing heartbeat") } for _, phoneNumber := range request.PhoneNumbers { if !h.authorizePhoneAPIKey(c, phoneNumber) { - ctxLogger.Warn(stacktrace.NewError("phone API Key ID [%s] is not authorized to store heartbeat for phone number [%s]", h.userFromContext(c).PhoneAPIKeyID, phoneNumber)) + ctxLogger.Warn(stacktrace.NewErrorf("phone API Key ID [%s] is not authorized to store heartbeat for phone number [%s]", h.userFromContext(c).PhoneAPIKeyID, phoneNumber)) return h.responsePhoneAPIKeyUnauthorized(c, phoneNumber, h.userFromContext(c)) } } @@ -139,7 +139,7 @@ func (h *HeartbeatHandler) Store(c fiber.Ctx) error { go func(input services.HeartbeatStoreParams, index int) { response, err := h.service.Store(ctx, input) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot store heartbeat with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot store heartbeat with params [%+#v]", request)) } responses[index] = response wg.Done() diff --git a/api/pkg/handlers/integration_3cx_handler.go b/api/pkg/handlers/integration_3cx_handler.go index e53213f42..63c88bffe 100644 --- a/api/pkg/handlers/integration_3cx_handler.go +++ b/api/pkg/handlers/integration_3cx_handler.go @@ -8,8 +8,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // Integration3CXHandler handles 3CX events @@ -62,19 +62,19 @@ func (h *Integration3CXHandler) Messages(c fiber.Ctx) error { var request requests.Integration3CXMessage if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall [%s] into %T", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall [%s] into %T", c.Body(), request)) return h.responseBadRequest(c, err) } if msg := h.billingService.IsEntitled(ctx, h.userIDFomContext(c)); msg != nil { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] can't send a [3cx] message", h.userIDFomContext(c))) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] can't send a [3cx] message", h.userIDFomContext(c))) return h.responsePaymentRequired(c, *msg) } request.Sanitize() message, err := h.messageService.SendMessage(ctx, request.ToMessageSendParams(h.userIDFomContext(c), c.OriginalURL())) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot send [3cx] message with payload [%s]", c.Body())) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot send [3cx] message with payload [%s]", c.Body())) return h.responseInternalServerError(c) } diff --git a/api/pkg/handlers/lemonsqueezy_handler.go b/api/pkg/handlers/lemonsqueezy_handler.go index ce3ac5aac..9147cd311 100644 --- a/api/pkg/handlers/lemonsqueezy_handler.go +++ b/api/pkg/handlers/lemonsqueezy_handler.go @@ -9,9 +9,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/httpsms/pkg/validators" lemonsqueezy "github.com/NdoleStudio/lemonsqueezy-go" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // LemonsqueezyHandler handles lemonsqueezy events @@ -51,12 +51,12 @@ func (h *LemonsqueezyHandler) Event(c fiber.Ctx) error { signature := c.Get("X-Signature") if errors := h.validator.ValidateEvent(ctx, signature, c.Body()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while storing request [%s] and signature [%s]", spew.Sdump(errors), c.Body(), signature)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while storing request [%s] and signature [%s]", spew.Sdump(errors), c.Body(), signature)) return h.responseUnprocessableEntity(c, errors, "validation errors while storing lemonsqueezy event") } if err := h.handleRequest(ctx, c); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot handle lemonsqueezy event [%s]", c.Body())) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot handle lemonsqueezy event [%s]", c.Body())) return h.responseInternalServerError(c) } @@ -70,31 +70,31 @@ func (h *LemonsqueezyHandler) handleRequest(ctx context.Context, c fiber.Ctx) er var request lemonsqueezy.WebhookRequestSubscription err := json.Unmarshal(c.Body(), &request) if err != nil { - return stacktrace.Propagate(err, "cannot marshall [%s] to [%T]", c.Body(), request) + return stacktrace.Propagatef(err, "cannot marshall [%s] to [%T]", c.Body(), request) } return h.service.HandleSubscriptionCreatedEvent(ctx, c.OriginalURL(), &request) case "subscription_cancelled": var request lemonsqueezy.WebhookRequestSubscription err := json.Unmarshal(c.Body(), &request) if err != nil { - return stacktrace.Propagate(err, "cannot marshall [%s] to [%T]", c.Body(), request) + return stacktrace.Propagatef(err, "cannot marshall [%s] to [%T]", c.Body(), request) } return h.service.HandleSubscriptionCanceledEvent(ctx, c.OriginalURL(), &request) case "subscription_expired": var request lemonsqueezy.WebhookRequestSubscription err := json.Unmarshal(c.Body(), &request) if err != nil { - return stacktrace.Propagate(err, "cannot marshall [%s] to [%T]", c.Body(), request) + return stacktrace.Propagatef(err, "cannot marshall [%s] to [%T]", c.Body(), request) } return h.service.HandleSubscriptionExpiredEvent(ctx, c.OriginalURL(), &request) case "subscription_updated": var request lemonsqueezy.WebhookRequestSubscription err := json.Unmarshal(c.Body(), &request) if err != nil { - return stacktrace.Propagate(err, "cannot marshall [%s] to [%T]", c.Body(), request) + return stacktrace.Propagatef(err, "cannot marshall [%s] to [%T]", c.Body(), request) } return h.service.HandleSubscriptionUpdatedEvent(ctx, c.OriginalURL(), &request) default: - return stacktrace.NewError("invalid event [%s] received with request [%s]", eventName, c.Body()) + return stacktrace.NewErrorf("invalid event [%s] received with request [%s]", eventName, c.Body()) } } diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index 542fc7d04..8c7a1420a 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -16,9 +16,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // MessageHandler handles message http requests. @@ -88,23 +88,23 @@ func (h *MessageHandler) PostSend(c fiber.Ctx) error { var request requests.MessageSend if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall [%s] into %T", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall [%s] into %T", c.Body(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateMessageSend(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body())) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body())) return h.responseUnprocessableEntity(c, errors, "validation errors while sending message") } if msg := h.billingService.IsEntitled(ctx, h.userIDFomContext(c)); msg != nil { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] can't send a message", h.userIDFomContext(c))) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] can't send a message", h.userIDFomContext(c))) return h.responsePaymentRequired(c, *msg) } message, err := h.service.SendMessage(ctx, request.ToMessageSendParams(h.userIDFomContext(c), c.OriginalURL())) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot send message with paylod [%s]", c.Body())) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot send message with paylod [%s]", c.Body())) return h.responseInternalServerError(c) } @@ -133,17 +133,17 @@ func (h *MessageHandler) BulkSend(c fiber.Ctx) error { var request requests.MessageBulkSend if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall [%s] into %T", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall [%s] into %T", c.Body(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateMessageBulkSend(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body())) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body())) return h.responseUnprocessableEntity(c, errors, "validation errors while sending messages") } if msg := h.billingService.IsEntitledWithCount(ctx, h.userIDFomContext(c), uint(len(request.To))); msg != nil { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] is not entitled to send [%d] messages", h.userIDFomContext(c), len(request.To))) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] is not entitled to send [%d] messages", h.userIDFomContext(c), len(request.To))) return h.responsePaymentRequired(c, *msg) } @@ -160,7 +160,7 @@ func (h *MessageHandler) BulkSend(c fiber.Ctx) error { if err != nil { count.Add(-1) - ctxLogger.Error(stacktrace.Propagate(err, "cannot send message with paylod [%s] at index [%d]", spew.Sdump(message), index)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot send message with paylod [%s] at index [%d]", spew.Sdump(message), index)) } responses[index] = response wg.Done() @@ -194,24 +194,24 @@ func (h *MessageHandler) GetOutstanding(c fiber.Ctx) error { var request requests.MessageOutstanding if err := c.Bind().Query(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateMessageOutstanding(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while fetching outstanding messages [%s]", spew.Sdump(errors), c.OriginalURL())) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while fetching outstanding messages [%s]", spew.Sdump(errors), c.OriginalURL())) return h.responseUnprocessableEntity(c, errors, "validation errors while fetching outstanding messages") } message, err := h.service.GetOutstanding(ctx, request.ToGetOutstandingParams(c.Path(), h.userFromContext(c), timestamp)) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { msg := fmt.Sprintf("Cannot find outstanding message with ID [%s]", request.MessageID) - ctxLogger.Warn(stacktrace.Propagate(err, "Cannot find outstanding message with ID [%s]", request.MessageID)) + ctxLogger.Warn(stacktrace.Propagatef(err, "Cannot find outstanding message with ID [%s]", request.MessageID)) return h.responseNotFound(c, msg) } if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get outstanding messgage with ID [%s]", request.MessageID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get outstanding messgage with ID [%s]", request.MessageID)) return h.responseInternalServerError(c) } @@ -244,18 +244,18 @@ func (h *MessageHandler) Index(c fiber.Ctx) error { var request requests.MessageIndex if err := c.Bind().Query(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateMessageIndex(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while fetching messages [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while fetching messages [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while fetching messages") } messages, err := h.service.GetMessages(ctx, request.ToGetParams(h.userIDFomContext(c))) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get messages with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get messages with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -286,7 +286,7 @@ func (h *MessageHandler) PostEvent(c fiber.Ctx) error { var request requests.MessageEvent if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall [%s] into %T", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall [%s] into %T", c.Body(), request)) return h.responseBadRequest(c, err) } @@ -296,7 +296,7 @@ func (h *MessageHandler) PostEvent(c fiber.Ctx) error { } if errors := h.validator.ValidateMessageEvent(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while storing event [%s] for message [%s]", spew.Sdump(errors), c.Body(), request.MessageID)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while storing event [%s] for message [%s]", spew.Sdump(errors), c.Body(), request.MessageID)) return h.responseUnprocessableEntity(c, errors, "validation errors while storing event") } @@ -306,18 +306,18 @@ func (h *MessageHandler) PostEvent(c fiber.Ctx) error { } if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find message with id [%s]", request.MessageID))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", request.MessageID))) return h.responseInternalServerError(c) } if !h.authorizePhoneAPIKey(c, message.Owner) { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] is not authorized to send event for message with ID [%s]", h.userIDFomContext(c), request.MessageID)) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] is not authorized to send event for message with ID [%s]", h.userIDFomContext(c), request.MessageID)) return h.responsePhoneAPIKeyUnauthorized(c, message.Owner, h.userFromContext(c)) } message, err = h.service.StoreEvent(ctx, message, request.ToMessageStoreEventParams(c.OriginalURL())) if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot store event for message [%s] with paylod [%s]", request.MessageID, c.Body()))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store event for message [%s] with paylod [%s]", request.MessageID, c.Body()))) return h.responseInternalServerError(c) } @@ -345,28 +345,28 @@ func (h *MessageHandler) PostReceive(c fiber.Ctx) error { var request requests.MessageReceive if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall [%s] into %T", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall [%s] into %T", c.Body(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateMessageReceive(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body())) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body())) return h.responseUnprocessableEntity(c, errors, "validation errors while receiving message") } if msg := h.billingService.IsEntitled(ctx, h.userIDFomContext(c)); msg != nil { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] can't receive a message becasuse they have exceeded the limit", h.userIDFomContext(c))) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] can't receive a message becasuse they have exceeded the limit", h.userIDFomContext(c))) return h.responsePaymentRequired(c, *msg) } if !h.authorizePhoneAPIKey(c, request.To) { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] is not authorized to receive message to phone number [%s]", h.userIDFomContext(c), request.To)) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] is not authorized to receive message to phone number [%s]", h.userIDFomContext(c), request.To)) return h.responsePhoneAPIKeyUnauthorized(c, request.To, h.userFromContext(c)) } message, err := h.service.ReceiveMessage(ctx, request.ToMessageReceiveParams(h.userIDFomContext(c), c.OriginalURL())) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot receive message with payload [%s]", c.Body())) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot receive message with payload [%s]", c.Body())) return h.responseInternalServerError(c) } @@ -396,7 +396,7 @@ func (h *MessageHandler) Delete(c fiber.Ctx) error { messageID := c.Params("messageID") if errors := h.validator.ValidateUUID(messageID, "messageID"); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while deleting a message with ID [%s]", spew.Sdump(errors), messageID)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while deleting a message with ID [%s]", spew.Sdump(errors), messageID)) return h.responseUnprocessableEntity(c, errors, "validation errors while storing event") } @@ -406,12 +406,12 @@ func (h *MessageHandler) Delete(c fiber.Ctx) error { } if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find message with id [%s]", messageID))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", messageID))) return h.responseInternalServerError(c) } if err = h.service.DeleteMessage(ctx, c.OriginalURL(), message); err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete message with ID [%s] for user with ID [%s]", messageID, message.UserID))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete message with ID [%s] for user with ID [%s]", messageID, message.UserID))) return h.responseInternalServerError(c) } @@ -441,7 +441,7 @@ func (h *MessageHandler) Get(c fiber.Ctx) error { messageID := c.Params("messageID") if errors := h.validator.ValidateUUID(messageID, "messageID"); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while deleting a message with ID [%s]", spew.Sdump(errors), messageID)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while deleting a message with ID [%s]", spew.Sdump(errors), messageID)) return h.responseUnprocessableEntity(c, errors, "validation errors while storing event") } @@ -451,7 +451,7 @@ func (h *MessageHandler) Get(c fiber.Ctx) error { } if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find message with id [%s]", messageID))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", messageID))) return h.responseInternalServerError(c) } @@ -481,23 +481,23 @@ func (h *MessageHandler) PostCallMissed(c fiber.Ctx) error { var request requests.MessageCallMissed if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall [%s] into %T", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall [%s] into %T", c.Body(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateCallMissed(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], for missed call event [%s]", spew.Sdump(errors), c.Body())) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], for missed call event [%s]", spew.Sdump(errors), c.Body())) return h.responseUnprocessableEntity(c, errors, "validation errors while storing missed call event") } if !h.authorizePhoneAPIKey(c, request.To) { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] is not authorized to register missed phone call for phone number [%s]", h.userIDFomContext(c), request.To)) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] is not authorized to register missed phone call for phone number [%s]", h.userIDFomContext(c), request.To)) return h.responsePhoneAPIKeyUnauthorized(c, request.To, h.userFromContext(c)) } message, err := h.service.RegisterMissedCall(ctx, request.ToCallMissedParams(h.userIDFomContext(c), c.OriginalURL())) if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot store missed call event for user [%s] with paylod [%s]", h.userIDFomContext(c), c.Body()))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store missed call event for user [%s] with paylod [%s]", h.userIDFomContext(c), c.Body()))) return h.responseInternalServerError(c) } @@ -528,7 +528,7 @@ func (h *MessageHandler) Search(c fiber.Ctx) error { var request requests.MessageSearch if err := c.Bind().Query(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params in [%s] into [%T]", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params in [%s] into [%T]", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } @@ -536,13 +536,13 @@ func (h *MessageHandler) Search(c fiber.Ctx) error { request.Token = c.Get("token") if errors := h.validator.ValidateMessageSearch(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while searching messages [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while searching messages [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while searching messages") } messages, err := h.service.SearchMessages(ctx, request.ToSearchParams(h.userIDFomContext(c))) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot search messages with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot search messages with params [%+#v]", request)) return h.responseInternalServerError(c) } diff --git a/api/pkg/handlers/message_send_schedule_handler.go b/api/pkg/handlers/message_send_schedule_handler.go index 90d65cca9..1847bb713 100644 --- a/api/pkg/handlers/message_send_schedule_handler.go +++ b/api/pkg/handlers/message_send_schedule_handler.go @@ -9,10 +9,10 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" "github.com/google/uuid" - "github.com/palantir/stacktrace" ) // MessageSendScheduleHandler handles HTTP requests for message send schedules. @@ -69,7 +69,7 @@ func (h *MessageSendScheduleHandler) Index(c fiber.Ctx) error { schedules, err := h.service.Index(ctx, userID) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot list send schedules for user [%s]", userID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot list send schedules for user [%s]", userID)) return h.responseInternalServerError(c) } @@ -102,7 +102,7 @@ func (h *MessageSendScheduleHandler) Store(c fiber.Ctx) error { return h.service.CountByUser(ctx, userID) }) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot check entitlement for send schedules for user [%s]", userID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot check entitlement for send schedules for user [%s]", userID)) return h.responseInternalServerError(c) } if !result.Allowed { @@ -116,7 +116,7 @@ func (h *MessageSendScheduleHandler) Store(c fiber.Ctx) error { request = request.Sanitize() if errors := h.validator.ValidateStore(ctx, request); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError( + ctxLogger.Warn(stacktrace.NewErrorf( "validation errors [%s], while storing send schedule [%+#v]", spew.Sdump(errors), request, @@ -126,7 +126,7 @@ func (h *MessageSendScheduleHandler) Store(c fiber.Ctx) error { schedule, err := h.service.Store(ctx, request.ToParams(h.userFromContext(c))) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot create send schedule for user [%s]", userID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot create send schedule for user [%s]", userID)) return h.responseInternalServerError(c) } @@ -173,7 +173,7 @@ func (h *MessageSendScheduleHandler) Update(c fiber.Ctx) error { schedule, err := h.service.Update(ctx, userID, scheduleID, request.ToParams(h.userFromContext(c))) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot update send schedule for user [%s] and schedule [%s]", userID, scheduleID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot update send schedule for user [%s] and schedule [%s]", userID, scheduleID)) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { return h.responseNotFound(c, err.Error()) } @@ -209,7 +209,7 @@ func (h *MessageSendScheduleHandler) Delete(c fiber.Ctx) error { userID := h.userIDFomContext(c) if _, err = h.service.Load(ctx, userID, scheduleID); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot load send schedule for deletion for user [%s] and schedule [%s]", userID, scheduleID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot load send schedule for deletion for user [%s] and schedule [%s]", userID, scheduleID)) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { return h.responseNotFound(c, err.Error()) } @@ -217,7 +217,7 @@ func (h *MessageSendScheduleHandler) Delete(c fiber.Ctx) error { } if err = h.service.Delete(ctx, userID, scheduleID); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot delete send schedule for user [%s] and schedule [%s]", userID, scheduleID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot delete send schedule for user [%s] and schedule [%s]", userID, scheduleID)) return h.responseInternalServerError(c) } diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index 1a00f7737..72e0b543d 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -10,9 +10,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // MessageThreadHandler handles message-thead http requests. @@ -57,6 +57,7 @@ func (h *MessageThreadHandler) RegisterRoutes(router fiber.Router, middlewares . // @Param skip query int false "number of messages to skip" minimum(0) // @Param query query string false "filter message threads containing query" // @Param limit query int false "number of messages to return" minimum(1) maximum(20) +// @Param contacts query bool false "include matching contact details" // @Success 200 {object} responses.MessageThreadsResponse // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized @@ -73,18 +74,18 @@ func (h *MessageThreadHandler) Index(c fiber.Ctx) error { var request requests.MessageThreadIndex if err := c.Bind().Query(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateMessageThreadIndex(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while fetching message threads [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while fetching message threads [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while fetching message threads") } threads, err := h.service.GetThreads(ctx, request.ToGetParams(h.userIDFomContext(c))) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get message threads with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get message threads with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -100,9 +101,10 @@ func (h *MessageThreadHandler) Index(c fiber.Ctx) error { // @Produce json // @Param messageThreadID path string true "ID of the message thread" default(32343a19-da5e-4b1b-a767-3298a73703ca) // @Param payload body requests.MessageThreadUpdate true "Payload of message thread details to update" -// @Success 200 {object} responses.PhoneResponse +// @Success 200 {object} responses.MessageThreadResponse // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound // @Failure 422 {object} responses.UnprocessableEntity // @Failure 500 {object} responses.InternalServerError // @Router /message-threads/{messageThreadID} [put] @@ -112,19 +114,22 @@ func (h *MessageThreadHandler) Update(c fiber.Ctx) error { var request requests.MessageThreadUpdate if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } request.MessageThreadID = c.Params("messageThreadID") if errors := h.validator.ValidateUpdate(ctx, request); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while updating message thread [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating message thread [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while updating message thread") } thread, err := h.service.UpdateStatus(ctx, request.ToUpdateParams(h.userIDFomContext(c))) + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, fmt.Sprintf("cannot find message thread with ID [%s]", request.MessageThreadID)) + } if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot update message thread with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot update message thread with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -152,7 +157,7 @@ func (h *MessageThreadHandler) Delete(c fiber.Ctx) error { messageThreadID := c.Params("messageThreadID") if errors := h.validator.ValidateUUID(messageThreadID, "messageThreadID"); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while deleting a thread thread with ID [%s]", spew.Sdump(errors), messageThreadID)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while deleting a thread thread with ID [%s]", spew.Sdump(errors), messageThreadID)) return h.responseUnprocessableEntity(c, errors, "validation errors while deleting a thread thread") } @@ -162,12 +167,12 @@ func (h *MessageThreadHandler) Delete(c fiber.Ctx) error { } if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find thread thread with id [%s]", messageThreadID))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find thread thread with id [%s]", messageThreadID))) return h.responseInternalServerError(c) } if err = h.service.DeleteThread(ctx, c.OriginalURL(), thread); err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete thread thread with ID [%s] for user with ID [%s]", messageThreadID, thread.UserID))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete thread thread with ID [%s] for user with ID [%s]", messageThreadID, thread.UserID))) return h.responseInternalServerError(c) } diff --git a/api/pkg/handlers/message_thread_handler_contacts_test.go b/api/pkg/handlers/message_thread_handler_contacts_test.go new file mode 100644 index 000000000..f6681fbd7 --- /dev/null +++ b/api/pkg/handlers/message_thread_handler_contacts_test.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/middlewares" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type messageThreadHandlerIndexRepositoryStub struct { + repositories.MessageThreadRepository + threads []entities.MessageThread +} + +func (stub *messageThreadHandlerIndexRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { + threads := make([]entities.MessageThread, len(stub.threads)) + copy(threads, stub.threads) + return &threads, nil +} + +type messageThreadHandlerContactProviderStub struct { + contacts map[string]*entities.Contact + calls int +} + +func (stub *messageThreadHandlerContactProviderStub) GetContactMap(context.Context, entities.UserID, []string) (map[string]*entities.Contact, error) { + stub.calls++ + return stub.contacts, nil +} + +func TestMessageThreadHandlerIndex_ParsesContactsQuery(t *testing.T) { + contact := &entities.Contact{ID: uuid.New(), Name: "Alice", PhoneNumbers: []string{"+18005550100"}} + repository := &messageThreadHandlerIndexRepositoryStub{threads: []entities.MessageThread{{Contact: "+18005550100"}}} + provider := &messageThreadHandlerContactProviderStub{contacts: map[string]*entities.Contact{"+18005550100": contact}} + logger := &messageThreadHandlerNoopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + service := services.NewMessageThreadService(logger, tracer, repository, nil, nil, provider) + handler := NewMessageThreadHandler(logger, tracer, validators.NewMessageThreadHandlerValidator(logger, tracer), service) + + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals(middlewares.ContextKeyAuthUserID, entities.AuthContext{ID: entities.UserID("user-id"), Email: "user@example.com"}) + return c.Next() + }) + handler.RegisterRoutes(app) + + req := httptest.NewRequest(http.MethodGet, "/v1/message-threads?owner=%2B18005550199&contacts=true", nil) + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 1, provider.calls) + + var payload struct { + Data []entities.MessageThread `json:"data"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) + require.Len(t, payload.Data, 1) + require.NotNil(t, payload.Data[0].ContactDetails) + assert.Equal(t, contact.ID, payload.Data[0].ContactDetails.ID) + assert.Equal(t, "Alice", payload.Data[0].ContactDetails.Name) +} diff --git a/api/pkg/handlers/message_thread_handler_test.go b/api/pkg/handlers/message_thread_handler_test.go new file mode 100644 index 000000000..a304f4ad3 --- /dev/null +++ b/api/pkg/handlers/message_thread_handler_test.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/middlewares" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + "gorm.io/gorm" +) + +type messageThreadHandlerRepositoryStub struct{} + +func (stub *messageThreadHandlerRepositoryStub) Store(context.Context, *entities.MessageThread) error { + return nil +} + +func (stub *messageThreadHandlerRepositoryStub) UpdateActivity(context.Context, repositories.MessageThreadActivityUpdate) error { + return nil +} + +func (stub *messageThreadHandlerRepositoryStub) UpdateStatus(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) { + return nil, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") +} + +func (stub *messageThreadHandlerRepositoryStub) LoadByOwnerContact(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return nil, nil +} + +func (stub *messageThreadHandlerRepositoryStub) Load(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { + return nil, nil +} + +func (stub *messageThreadHandlerRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { + return nil, nil +} + +func (stub *messageThreadHandlerRepositoryStub) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { + return nil +} + +func (stub *messageThreadHandlerRepositoryStub) Delete(context.Context, entities.UserID, uuid.UUID) error { + return nil +} + +func (stub *messageThreadHandlerRepositoryStub) DeleteAllForUser(context.Context, entities.UserID) error { + return nil +} + +func TestMessageThreadHandlerUpdate_ReturnsNotFoundWhenThreadIsMissing(t *testing.T) { + logger := &messageThreadHandlerNoopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + service := services.NewMessageThreadService(logger, tracer, &messageThreadHandlerRepositoryStub{}, nil, nil, nil) + handler := NewMessageThreadHandler(logger, tracer, validators.NewMessageThreadHandlerValidator(logger, tracer), service) + + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals(middlewares.ContextKeyAuthUserID, entities.AuthContext{ID: entities.UserID("user-id"), Email: "user@example.com"}) + return c.Next() + }) + handler.RegisterRoutes(app) + + messageThreadID := uuid.New() + req := httptest.NewRequest(http.MethodPut, "/v1/message-threads/"+messageThreadID.String(), bytes.NewBufferString(`{"unread_count":0}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) + require.Equal(t, "cannot find message thread with ID ["+messageThreadID.String()+"]", payload.Message) +} + +type messageThreadHandlerNoopLogger struct{} + +var _ telemetry.Logger = (*messageThreadHandlerNoopLogger)(nil) + +func (logger *messageThreadHandlerNoopLogger) Error(_ error) {} +func (logger *messageThreadHandlerNoopLogger) WithService(_ string) telemetry.Logger { return logger } + +func (logger *messageThreadHandlerNoopLogger) WithString(_, _ string) telemetry.Logger { return logger } + +func (logger *messageThreadHandlerNoopLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { + return logger +} +func (logger *messageThreadHandlerNoopLogger) Trace(_ string) {} +func (logger *messageThreadHandlerNoopLogger) Info(_ string) {} +func (logger *messageThreadHandlerNoopLogger) Warn(_ error) {} +func (logger *messageThreadHandlerNoopLogger) Debug(_ string) {} +func (logger *messageThreadHandlerNoopLogger) Fatal(_ error) {} +func (logger *messageThreadHandlerNoopLogger) Printf(_ string, _ ...interface{}) {} diff --git a/api/pkg/handlers/phone_api_key_handler.go b/api/pkg/handlers/phone_api_key_handler.go index 835ad893a..09f893404 100644 --- a/api/pkg/handlers/phone_api_key_handler.go +++ b/api/pkg/handlers/phone_api_key_handler.go @@ -9,10 +9,10 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" "github.com/google/uuid" - "github.com/palantir/stacktrace" ) // PhoneAPIKeyHandler handles phone API key http requests @@ -72,18 +72,18 @@ func (h *PhoneAPIKeyHandler) index(c fiber.Ctx) error { var request requests.PhoneAPIKeyIndex if err := c.Bind().Query(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateIndex(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while fetching phone API keys [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while fetching phone API keys [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while fetching phone API keys") } apiKeys, err := h.service.Index(ctx, h.userIDFomContext(c), request.ToIndexParams()) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot index phone API keys with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot index phone API keys with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -114,7 +114,7 @@ func (h *PhoneAPIKeyHandler) store(c fiber.Ctx) error { return h.service.CountByUser(ctx, userID) }) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot check entitlement for phone API keys for user [%s]", userID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot check entitlement for phone API keys for user [%s]", userID)) return h.responseInternalServerError(c) } if !result.Allowed { @@ -123,18 +123,18 @@ func (h *PhoneAPIKeyHandler) store(c fiber.Ctx) error { var request requests.PhoneAPIKeyStoreRequest if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateStore(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") } phoneAPIKey, err := h.service.Create(ctx, h.userFromContext(c), request.Name) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot update phones with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot update phones with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -161,7 +161,7 @@ func (h *PhoneAPIKeyHandler) delete(c fiber.Ctx) error { phoneAPIKeyID := c.Params("phoneAPIKeyID") if errors := h.validator.ValidateUUID(phoneAPIKeyID, "phoneAPIKeyID"); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while deleting a phone API key with ID [%s]", spew.Sdump(errors), phoneAPIKeyID)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while deleting a phone API key with ID [%s]", spew.Sdump(errors), phoneAPIKeyID)) return h.responseUnprocessableEntity(c, errors, "validation errors while storing event") } @@ -171,7 +171,7 @@ func (h *PhoneAPIKeyHandler) delete(c fiber.Ctx) error { } if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete phone API key with ID [%s] for user with ID [%s]", phoneAPIKeyID, h.userIDFomContext(c)))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete phone API key with ID [%s] for user with ID [%s]", phoneAPIKeyID, h.userIDFomContext(c)))) return h.responseInternalServerError(c) } @@ -200,7 +200,7 @@ func (h *PhoneAPIKeyHandler) deletePhone(c fiber.Ctx) error { phoneAPIKeyID := c.Params("phoneAPIKeyID") phoneID := c.Params("phoneID") if errors := h.mergeErrors(h.validator.ValidateUUID(phoneAPIKeyID, "phoneAPIKeyID"), h.validator.ValidateUUID(phoneID, "phoneID")); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while deleting a phone API key with ID [%s]", spew.Sdump(errors), phoneAPIKeyID)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while deleting a phone API key with ID [%s]", spew.Sdump(errors), phoneAPIKeyID)) return h.responseUnprocessableEntity(c, errors, "validation errors while storing event") } @@ -210,7 +210,7 @@ func (h *PhoneAPIKeyHandler) deletePhone(c fiber.Ctx) error { } if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot remove phone with ID [%s] from phone API key with ID [%s] for user with ID [%s]", phoneID, phoneAPIKeyID, h.userIDFomContext(c)))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot remove phone with ID [%s] from phone API key with ID [%s] for user with ID [%s]", phoneID, phoneAPIKeyID, h.userIDFomContext(c)))) return h.responseInternalServerError(c) } diff --git a/api/pkg/handlers/phone_handler.go b/api/pkg/handlers/phone_handler.go index 291c071ad..c81ef4ddd 100644 --- a/api/pkg/handlers/phone_handler.go +++ b/api/pkg/handlers/phone_handler.go @@ -10,8 +10,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // PhoneHandler handles phone http requests. @@ -74,18 +74,18 @@ func (h *PhoneHandler) Index(c fiber.Ctx) error { var request requests.PhoneIndex if err := c.Bind().Query(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateIndex(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while fetching phones [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while fetching phones [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while fetching phones") } phones, err := h.service.Index(ctx, h.userFromContext(c), request.ToIndexParams()) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot index phones with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot index phones with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -94,7 +94,7 @@ func (h *PhoneHandler) Index(c fiber.Ctx) error { // Upsert a phone // @Summary Upsert Phone -// @Description Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' +// @Description Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups. // @Security ApiKeyAuth // @Tags Phones // @Accept json @@ -114,18 +114,18 @@ func (h *PhoneHandler) Upsert(c fiber.Ctx) error { var request requests.PhoneUpsert if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateUpsert(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") } phone, err := h.service.Upsert(ctx, request.ToUpsertParams(h.userFromContext(c), c.OriginalURL(), c.Body())) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot update phones with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot update phones with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -154,7 +154,7 @@ func (h *PhoneHandler) Delete(c fiber.Ctx) error { request := requests.PhoneDelete{PhoneID: c.Params("phoneID")} if errors := h.validator.ValidateDelete(ctx, request); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while deleting phone [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while deleting phone [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while deleting phone") } @@ -163,7 +163,7 @@ func (h *PhoneHandler) Delete(c fiber.Ctx) error { return h.responseNotFound(c, fmt.Sprintf("cannot find phone with ID [%s]", request.PhoneID)) } if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot delete phones with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot delete phones with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -172,7 +172,7 @@ func (h *PhoneHandler) Delete(c fiber.Ctx) error { // UpsertFCMToken upserts the FCM token of a phone // @Summary Upserts the FCM token of a phone -// @Description Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' +// @Description Updates the FCM token or adapter callback URL of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups. // @Security ApiKeyAuth // @Tags Phones // @Accept json @@ -192,18 +192,18 @@ func (h *PhoneHandler) UpsertFCMToken(c fiber.Ctx) error { var request requests.PhoneFCMToken if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateFCMToken(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") } phone, err := h.service.UpsertFCMToken(ctx, request.ToPhoneFCMTokenParams(h.userFromContext(c), c.OriginalURL())) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot delete phones with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot delete phones with params [%+#v]", request)) return h.responseInternalServerError(c) } diff --git a/api/pkg/handlers/user_handler.go b/api/pkg/handlers/user_handler.go index b7066aa3f..673879b7f 100644 --- a/api/pkg/handlers/user_handler.go +++ b/api/pkg/handlers/user_handler.go @@ -10,8 +10,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // UserHandler handles user http requests. @@ -71,7 +71,7 @@ func (h *UserHandler) Show(c fiber.Ctx) error { authUser := h.userFromContext(c) user, err := h.service.Get(ctx, c.OriginalURL(), authUser) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get user with ID [%s]", authUser.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get user with ID [%s]", authUser.ID)) return h.responseInternalServerError(c) } @@ -100,18 +100,18 @@ func (h *UserHandler) Update(c fiber.Ctx) error { var request requests.UserUpdate if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateUpdate(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while updating user [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating user [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while updating user") } user, err := h.service.Update(ctx, c.OriginalURL(), h.userFromContext(c), request.ToUpdateParams()) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot update user with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot update user with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -134,7 +134,7 @@ func (h *UserHandler) Delete(c fiber.Ctx) error { defer span.End() if err := h.service.Delete(ctx, c.OriginalURL(), h.userIDFomContext(c)); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot delete user user with ID [%s]", h.userIDFomContext(c))) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot delete user user with ID [%s]", h.userIDFomContext(c))) return h.responseInternalServerError(c) } @@ -162,13 +162,13 @@ func (h *UserHandler) UpdateNotifications(c fiber.Ctx) error { var request requests.UserNotificationUpdate if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } user, err := h.service.UpdateNotificationSettings(ctx, h.userIDFomContext(c), request.ToUserNotificationUpdateParams()) if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update notification for [%T] with ID [%s]", user, h.userIDFomContext(c)))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update notification for [%T] with ID [%s]", user, h.userIDFomContext(c)))) return h.responseInternalServerError(c) } @@ -196,7 +196,7 @@ func (h *UserHandler) subscriptionUpdateURL(c fiber.Ctx) error { url, err := h.service.GetSubscriptionUpdateURL(ctx, authUser.ID) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get user with ID [%s]", authUser.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get subscription update URL for user with ID [%s]", authUser.ID)) return h.responseInternalServerError(c) } @@ -224,7 +224,7 @@ func (h *UserHandler) cancelSubscription(c fiber.Ctx) error { err := h.service.InitiateSubscriptionCancel(ctx, authUser.ID) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get user with ID [%s]", authUser.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get user with ID [%s]", authUser.ID)) return h.responseInternalServerError(c) } @@ -257,7 +257,7 @@ func (h *UserHandler) DeleteAPIKey(c fiber.Ctx) error { user, err := h.service.RotateAPIKey(ctx, c.OriginalURL(), h.userIDFomContext(c)) if err != nil { - ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot rotate the api key for [%T] with ID [%s]", user, h.userIDFomContext(c)))) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot rotate the api key for [%T] with ID [%s]", user, h.userIDFomContext(c)))) return h.responseInternalServerError(c) } @@ -283,7 +283,7 @@ func (h *UserHandler) subscriptionPayments(c fiber.Ctx) error { invoices, err := h.service.GetSubscriptionPayments(ctx, h.userIDFomContext(c)) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get current subscription invoices for user [%s]", h.userFromContext(c))) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get current subscription invoices for user [%s]", h.userFromContext(c))) return h.responseInternalServerError(c) } @@ -311,19 +311,19 @@ func (h *UserHandler) subscriptionInvoice(c fiber.Ctx) error { var request requests.UserPaymentInvoice if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.Body(), request)) return h.responseBadRequest(c, err) } request.SubscriptionInvoiceID = c.Params("subscriptionInvoiceID") if errors := h.validator.ValidatePaymentInvoice(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while validating subscription payment invoice request [%s]", spew.Sdump(errors), c.Body())) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while validating subscription payment invoice request [%s]", spew.Sdump(errors), c.Body())) return h.responseUnprocessableEntity(c, errors, "validation errors while generating payment invoice") } reader, err := h.service.GenerateReceipt(ctx, request.UserInvoiceGenerateParams(h.userIDFomContext(c))) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot generate receipt for invoice ID [%s] and user [%s]", request.SubscriptionInvoiceID, h.userFromContext(c))) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot generate receipt for invoice ID [%s] and user [%s]", request.SubscriptionInvoiceID, h.userFromContext(c))) return h.responseInternalServerError(c) } @@ -332,7 +332,7 @@ func (h *UserHandler) subscriptionInvoice(c fiber.Ctx) error { data, err := io.ReadAll(reader) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot read invoice data with ID [%s] for user with ID [%s]", request.SubscriptionInvoiceID, h.userIDFomContext(c))) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot read invoice data with ID [%s] for user with ID [%s]", request.SubscriptionInvoiceID, h.userIDFomContext(c))) return h.responseInternalServerError(c) } diff --git a/api/pkg/handlers/webhook_handler.go b/api/pkg/handlers/webhook_handler.go index 656d59ea0..6866da6bc 100644 --- a/api/pkg/handlers/webhook_handler.go +++ b/api/pkg/handlers/webhook_handler.go @@ -11,9 +11,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/NdoleStudio/stacktrace" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // WebhookHandler handles webhook requests @@ -70,18 +70,18 @@ func (h *WebhookHandler) Index(c fiber.Ctx) error { var request requests.WebhookIndex if err := c.Bind().Query(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall URL [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall URL [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateIndex(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while fetching webhooks [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while fetching webhooks [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while fetching webhooks") } webhooks, err := h.service.Index(ctx, h.userIDFomContext(c), request.ToIndexParams()) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get webhooks with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get webhooks with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -108,13 +108,13 @@ func (h *WebhookHandler) Delete(c fiber.Ctx) error { webhookID := c.Params("webhookID") if errors := h.validator.ValidateUUID(webhookID, "webhookID"); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while deleting webhook with ID [%s]", spew.Sdump(errors), webhookID)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while deleting webhook with ID [%s]", spew.Sdump(errors), webhookID)) return h.responseUnprocessableEntity(c, errors, "validation errors while deleting webhook") } err := h.service.Delete(ctx, h.userIDFomContext(c), uuid.MustParse(webhookID)) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot delete webhook with ID [%+#v]", webhookID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot delete webhook with ID [%+#v]", webhookID)) return h.responseInternalServerError(c) } @@ -143,29 +143,29 @@ func (h *WebhookHandler) Store(c fiber.Ctx) error { var request requests.WebhookStore if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall body [%s] into [%T]", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall body [%s] into [%T]", c.Body(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateStore(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while storing webhook [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while storing webhook [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while storing webhook") } webhooks, err := h.service.Index(ctx, h.userIDFomContext(c), repositories.IndexParams{Skip: 0, Limit: 10}) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot index webhooks for user [%s]", h.userIDFomContext(c))) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot index webhooks for user [%s]", h.userIDFomContext(c))) return h.responseInternalServerError(c) } if len(webhooks) == 10 { - ctxLogger.Warn(stacktrace.NewError("user with ID [%s] wants to create more than 10 webhooks", h.userIDFomContext(c))) + ctxLogger.Warn(stacktrace.NewErrorf("user with ID [%s] wants to create more than 10 webhooks", h.userIDFomContext(c))) return h.responsePaymentRequired(c, "You can't create more than 10 webhooks contact us to upgrade to our enterprise plan.") } webhook, err := h.service.Store(ctx, request.ToStoreParams(h.userFromContext(c))) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot store webhoook with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot store webhoook with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -193,19 +193,19 @@ func (h *WebhookHandler) Update(c fiber.Ctx) error { var request requests.WebhookUpdate if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into [%T]", c.Body(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into [%T]", c.Body(), request)) return h.responseBadRequest(c, err) } request.WebhookID = c.Params("webhookID") if errors := h.validator.ValidateUpdate(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while updating user [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating user [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while updating webhook") } user, err := h.service.Update(ctx, request.ToUpdateParams(h.userFromContext(c))) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot update user with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot update user with params [%+#v]", request)) return h.responseInternalServerError(c) } diff --git a/api/pkg/listeners/billing_listener.go b/api/pkg/listeners/billing_listener.go index 59f166f10..08badc22d 100644 --- a/api/pkg/listeners/billing_listener.go +++ b/api/pkg/listeners/billing_listener.go @@ -9,8 +9,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // BillingListener handles cloud events which affect billing @@ -46,11 +46,11 @@ func (listener *BillingListener) OnMessageAPISent(ctx context.Context, event clo var payload events.MessageAPISentPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.RegisterSentMessage(ctx, payload.MessageID, payload.RequestReceivedAt, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot register sent message for event [%s] for event with ID [%s]", spew.Sdump(payload), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot register sent message for event [%s] for event with ID [%s]", spew.Sdump(payload), event.ID())) } return nil @@ -63,11 +63,11 @@ func (listener *BillingListener) OnMessagePhoneReceived(ctx context.Context, eve var payload events.MessagePhoneReceivedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.RegisterReceivedMessage(ctx, payload.MessageID, payload.Timestamp, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot register received message for event [%s] for event with ID [%s]", spew.Sdump(payload), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot register received message for event [%s] for event with ID [%s]", spew.Sdump(payload), event.ID())) } return nil @@ -79,11 +79,11 @@ func (listener *BillingListener) onUserAccountDeleted(ctx context.Context, event var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.BillingUsage] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.BillingUsage] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/contact_listener.go b/api/pkg/listeners/contact_listener.go new file mode 100644 index 000000000..c2b7d4d9f --- /dev/null +++ b/api/pkg/listeners/contact_listener.go @@ -0,0 +1,57 @@ +package listeners + +import ( + "context" + "fmt" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +// ContactDeletionService removes contacts belonging to a deleted user. +type ContactDeletionService interface { + DeleteAllForUser(ctx context.Context, userID entities.UserID) error +} + +// ContactListener handles contact-related cloud events. +type ContactListener struct { + logger telemetry.Logger + tracer telemetry.Tracer + service ContactDeletionService +} + +// NewContactListener creates a new ContactListener. +func NewContactListener( + logger telemetry.Logger, + tracer telemetry.Tracer, + service ContactDeletionService, +) (l *ContactListener, routes map[string]events.EventListener) { + l = &ContactListener{ + logger: logger.WithService(fmt.Sprintf("%T", l)), + tracer: tracer, + service: service, + } + + return l, map[string]events.EventListener{ + events.UserAccountDeleted: l.onUserAccountDeleted, + } +} + +func (listener *ContactListener) onUserAccountDeleted(ctx context.Context, event cloudevents.Event) error { + ctx, span := listener.tracer.Start(ctx) + defer span.End() + + var payload events.UserAccountDeletedPayload + if err := event.DataAs(&payload); err != nil { + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + } + + if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.Contact] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + } + + return nil +} diff --git a/api/pkg/listeners/contact_listener_test.go b/api/pkg/listeners/contact_listener_test.go new file mode 100644 index 000000000..2296f3994 --- /dev/null +++ b/api/pkg/listeners/contact_listener_test.go @@ -0,0 +1,70 @@ +package listeners + +import ( + "context" + "errors" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type contactDeletionServiceStub struct { + userIDs []entities.UserID + err error +} + +func (service *contactDeletionServiceStub) DeleteAllForUser(_ context.Context, userID entities.UserID) error { + service.userIDs = append(service.userIDs, userID) + return service.err +} + +func TestContactListenerDeletesContactsForDeletedUser(t *testing.T) { + service := &contactDeletionServiceStub{} + routes := newContactListenerRoutes(t, service) + event := deletedUserContactEvent(t, entities.UserID("user-id")) + + err := routes[events.UserAccountDeleted](context.Background(), event) + + require.NoError(t, err) + assert.Equal(t, []entities.UserID{"user-id"}, service.userIDs) +} + +func TestContactListenerWrapsDeleteError(t *testing.T) { + service := &contactDeletionServiceStub{err: errors.New("delete contacts boom")} + routes := newContactListenerRoutes(t, service) + event := deletedUserContactEvent(t, entities.UserID("user-id")) + + err := routes[events.UserAccountDeleted](context.Background(), event) + + require.Error(t, err) + assert.Contains(t, err.Error(), "delete contacts boom") +} + +func newContactListenerRoutes(t *testing.T, service *contactDeletionServiceStub) map[string]events.EventListener { + t.Helper() + + logger := &noopListenerLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + _, routes := NewContactListener(logger, tracer, service) + require.Contains(t, routes, events.UserAccountDeleted) + return routes +} + +func deletedUserContactEvent(t *testing.T, userID entities.UserID) cloudevents.Event { + t.Helper() + + event := cloudevents.NewEvent() + event.SetID(uuid.NewString()) + event.SetSource("/v1/users") + event.SetType(events.UserAccountDeleted) + require.NoError(t, event.SetData(cloudevents.ApplicationJSON, events.UserAccountDeletedPayload{ + UserID: userID, + })) + return event +} diff --git a/api/pkg/listeners/discord_listener.go b/api/pkg/listeners/discord_listener.go index 531aa2080..e0514177e 100644 --- a/api/pkg/listeners/discord_listener.go +++ b/api/pkg/listeners/discord_listener.go @@ -7,8 +7,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // DiscordListener sends messages to discord @@ -43,11 +43,11 @@ func (listener *DiscordListener) OnMessagePhoneReceived(ctx context.Context, eve var payload events.MessagePhoneReceivedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.HandleMessageReceived(ctx, payload.UserID, event); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -59,11 +59,11 @@ func (listener *DiscordListener) onUserAccountDeleted(ctx context.Context, event var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.Discord] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.Discord] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/email_notification_listener.go b/api/pkg/listeners/email_notification_listener.go index 1f7662855..bc78f993e 100644 --- a/api/pkg/listeners/email_notification_listener.go +++ b/api/pkg/listeners/email_notification_listener.go @@ -7,8 +7,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // EmailNotificationListener listens for events about failed and expired messages @@ -45,11 +45,11 @@ func (listener *EmailNotificationListener) OnMessageSendExpired(ctx context.Cont payload := new(events.MessageSendExpiredPayload) if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.NotifyMessageExpired(ctx, payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -62,11 +62,11 @@ func (listener *EmailNotificationListener) OnMessageSendFailed(ctx context.Conte payload := new(events.MessageSendFailedPayload) if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.NotifyMessageFailed(ctx, payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -79,11 +79,11 @@ func (listener *EmailNotificationListener) OnWebhookSendFailed(ctx context.Conte payload := new(events.WebhookSendFailedPayload) if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.NotifyWebhookSendFailed(ctx, payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -96,11 +96,11 @@ func (listener *EmailNotificationListener) OnDiscordSendFailed(ctx context.Conte payload := new(events.DiscordSendFailedPayload) if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.NotifyDiscordSendFailed(ctx, payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/heartbeat_listener.go b/api/pkg/listeners/heartbeat_listener.go index 00767a955..4e0cdc0d7 100644 --- a/api/pkg/listeners/heartbeat_listener.go +++ b/api/pkg/listeners/heartbeat_listener.go @@ -4,9 +4,9 @@ import ( "context" "fmt" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/davecgh/go-spew/spew" - "github.com/palantir/stacktrace" "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" @@ -48,7 +48,7 @@ func (listener *HeartbeatListener) onPhoneUpdated(ctx context.Context, event clo var payload events.PhoneUpdatedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } storeParams := &services.HeartbeatMonitorStoreParams{ @@ -59,7 +59,7 @@ func (listener *HeartbeatListener) onPhoneUpdated(ctx context.Context, event clo } if _, err := listener.service.StoreMonitor(ctx, storeParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot store heartbeat monitor with params [%s] for event with ID [%s]", spew.Sdump(storeParams), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store heartbeat monitor with params [%s] for event with ID [%s]", spew.Sdump(storeParams), event.ID())) } return nil @@ -72,11 +72,11 @@ func (listener *HeartbeatListener) onPhoneDeleted(ctx context.Context, event clo var payload events.PhoneDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteMonitor(ctx, payload.UserID, payload.Owner); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete heartbeat monitor with userID [%s] and owner [%s] for event with ID [%s]", payload.UserID, payload.Owner, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete heartbeat monitor with userID [%s] and owner [%s] for event with ID [%s]", payload.UserID, payload.Owner, event.ID())) } return nil @@ -89,7 +89,7 @@ func (listener *HeartbeatListener) onPhoneHeartbeatCheck(ctx context.Context, ev var payload events.PhoneHeartbeatCheckPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } monitorParams := &services.HeartbeatMonitorParams{ @@ -101,7 +101,7 @@ func (listener *HeartbeatListener) onPhoneHeartbeatCheck(ctx context.Context, ev } if err := listener.service.Monitor(ctx, monitorParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot monitor heartbeats for userID [%s] and owner [%s] for event with ID [%s]", payload.UserID, payload.Owner, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot monitor heartbeats for userID [%s] and owner [%s] for event with ID [%s]", payload.UserID, payload.Owner, event.ID())) } return nil @@ -114,11 +114,11 @@ func (listener *HeartbeatListener) onPhoneHeartbeatOffline(ctx context.Context, var payload events.PhoneHeartbeatOfflinePayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.UpdatePhoneOnline(ctx, payload.UserID, payload.MonitorID, false); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete heartbeat monitor with userID [%s] and owner [%s] for event with ID [%s]", payload.UserID, payload.Owner, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete heartbeat monitor with userID [%s] and owner [%s] for event with ID [%s]", payload.UserID, payload.Owner, event.ID())) } return nil @@ -130,11 +130,11 @@ func (listener *HeartbeatListener) onUserAccountDeleted(ctx context.Context, eve var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.Heartbeat] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.Heartbeat] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/integration_3cx_listener.go b/api/pkg/listeners/integration_3cx_listener.go index 2aafebf9f..314d6a291 100644 --- a/api/pkg/listeners/integration_3cx_listener.go +++ b/api/pkg/listeners/integration_3cx_listener.go @@ -7,8 +7,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // Integration3CXListener sends 3CX events to users @@ -46,11 +46,11 @@ func (listener *Integration3CXListener) OnMessagePhoneReceived(ctx context.Conte var payload events.MessagePhoneReceivedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -63,11 +63,11 @@ func (listener *Integration3CXListener) OnMessageSendFailed(ctx context.Context, var payload events.MessageSendFailedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -80,11 +80,11 @@ func (listener *Integration3CXListener) OnMessagePhoneSent(ctx context.Context, var payload events.MessagePhoneSentPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -97,11 +97,11 @@ func (listener *Integration3CXListener) OnMessagePhoneDelivered(ctx context.Cont var payload events.MessagePhoneDeliveredPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -113,11 +113,11 @@ func (listener *Integration3CXListener) onUserAccountDeleted(ctx context.Context var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.Integration3CX] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.Integration3CX] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/marketing_listener.go b/api/pkg/listeners/marketing_listener.go index c2bc0a20a..fd2272a07 100644 --- a/api/pkg/listeners/marketing_listener.go +++ b/api/pkg/listeners/marketing_listener.go @@ -7,8 +7,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // MarketingListener handled marketing events @@ -42,11 +42,11 @@ func (listener *MarketingListener) onUserAccountCreated(ctx context.Context, eve var payload events.UserAccountCreatedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.CreateContact(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [contact] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [contact] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil @@ -58,11 +58,11 @@ func (listener *MarketingListener) onUserAccountDeleted(ctx context.Context, eve var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteContact(ctx, payload.UserEmail); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [contact] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [contact] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/message_listener.go b/api/pkg/listeners/message_listener.go index b6193626c..254bccf82 100644 --- a/api/pkg/listeners/message_listener.go +++ b/api/pkg/listeners/message_listener.go @@ -8,8 +8,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // MessageListener handles cloud events which need to update entities.Message @@ -54,7 +54,7 @@ func (listener *MessageListener) OnMessagePhoneSending(ctx context.Context, even var payload events.MessagePhoneSendingPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } handleParams := services.HandleMessageParams{ @@ -65,7 +65,7 @@ func (listener *MessageListener) OnMessagePhoneSending(ctx context.Context, even } if err := listener.service.HandleMessageSending(ctx, handleParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot handle sending for message with ID [%s] for event with ID [%s]", handleParams.ID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot handle sending for message with ID [%s] for event with ID [%s]", handleParams.ID, event.ID())) } return nil @@ -78,7 +78,7 @@ func (listener *MessageListener) OnMessagePhoneSent(ctx context.Context, event c var payload events.MessagePhoneSentPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } handleParams := services.HandleMessageParams{ @@ -89,7 +89,7 @@ func (listener *MessageListener) OnMessagePhoneSent(ctx context.Context, event c } if err := listener.service.HandleMessageSent(ctx, handleParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot handle [%s] for message with ID [%s] for event with ID [%s]", event.Type(), handleParams.ID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot handle [%s] for message with ID [%s] for event with ID [%s]", event.Type(), handleParams.ID, event.ID())) } return nil } @@ -101,7 +101,7 @@ func (listener *MessageListener) OnMessagePhoneDelivered(ctx context.Context, ev var payload events.MessagePhoneDeliveredPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } handleParams := services.HandleMessageParams{ @@ -111,7 +111,7 @@ func (listener *MessageListener) OnMessagePhoneDelivered(ctx context.Context, ev } if err := listener.service.HandleMessageDelivered(ctx, handleParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot handle [%s] for message with ID [%s] for event with ID [%s]", event.Type(), handleParams.ID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot handle [%s] for message with ID [%s] for event with ID [%s]", event.Type(), handleParams.ID, event.ID())) } return nil @@ -124,7 +124,7 @@ func (listener *MessageListener) OnMessagePhoneFailed(ctx context.Context, event var payload events.MessageSendFailedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } handleParams := services.HandleMessageFailedParams{ @@ -135,7 +135,7 @@ func (listener *MessageListener) OnMessagePhoneFailed(ctx context.Context, event } if err := listener.service.HandleMessageFailed(ctx, handleParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot handle [%s] for message with ID [%s] for event with ID [%s]", event.Type(), handleParams.ID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot handle [%s] for message with ID [%s] for event with ID [%s]", event.Type(), handleParams.ID, event.ID())) } return nil @@ -148,12 +148,12 @@ func (listener *MessageListener) onMessageNotificationFailed(ctx context.Context var payload events.MessageNotificationFailedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } message, err := listener.service.GetMessage(ctx, payload.UserID, payload.MessageID) if err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load message with id [%s] and user id [%s]", payload.MessageID, payload.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load message with id [%s] and user id [%s]", payload.MessageID, payload.UserID)) } storeParams := services.MessageStoreEventParams{ @@ -164,7 +164,7 @@ func (listener *MessageListener) onMessageNotificationFailed(ctx context.Context Source: event.Source(), } if _, err = listener.service.StoreEvent(ctx, message, storeParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot store message event [%s] for message with ID [%s]", storeParams.EventName, storeParams.MessageID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store message event [%s] for message with ID [%s]", storeParams.EventName, storeParams.MessageID)) } return nil @@ -177,7 +177,7 @@ func (listener *MessageListener) onMessageNotificationSent(ctx context.Context, var payload events.MessageNotificationSentPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } checkParams := services.MessageScheduleExpirationParams{ @@ -189,7 +189,7 @@ func (listener *MessageListener) onMessageNotificationSent(ctx context.Context, MessageExpirationDuration: payload.MessageExpirationDuration, } if err := listener.service.ScheduleExpirationCheck(ctx, checkParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot exchedule expiration check for ID [%s] and userID [%s]", checkParams.MessageID, checkParams.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot exchedule expiration check for ID [%s] and userID [%s]", checkParams.MessageID, checkParams.UserID)) } handleParams := services.HandleMessageParams{ @@ -199,7 +199,7 @@ func (listener *MessageListener) onMessageNotificationSent(ctx context.Context, Timestamp: payload.NotificationSentAt, } if err := listener.service.HandleMessageNotificationSent(ctx, handleParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot handle event [%s] for message [%s] and userID [%s]", event.Type(), checkParams.MessageID, checkParams.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot handle event [%s] for message [%s] and userID [%s]", event.Type(), checkParams.MessageID, checkParams.UserID)) } return nil @@ -212,7 +212,7 @@ func (listener *MessageListener) onMessageSendExpiredCheck(ctx context.Context, var payload events.MessageSendExpiredCheckPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } checkParams := services.MessageCheckExpired{ @@ -221,7 +221,7 @@ func (listener *MessageListener) onMessageSendExpiredCheck(ctx context.Context, Source: event.Source(), } if err := listener.service.CheckExpired(ctx, checkParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot check expiration for message with ID [%s] and userID [%s]", checkParams.MessageID, checkParams.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot check expiration for message with ID [%s] and userID [%s]", checkParams.MessageID, checkParams.UserID)) } return nil @@ -234,7 +234,7 @@ func (listener *MessageListener) onMessageSendExpired(ctx context.Context, event var payload events.MessageSendExpiredPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } expiredParams := services.HandleMessageParams{ @@ -244,7 +244,7 @@ func (listener *MessageListener) onMessageSendExpired(ctx context.Context, event Timestamp: payload.Timestamp, } if err := listener.service.HandleMessageExpired(ctx, expiredParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot handle event [%s] for ID [%s] and userID [%s]", event.Type(), expiredParams.ID, expiredParams.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot handle event [%s] for ID [%s] and userID [%s]", event.Type(), expiredParams.ID, expiredParams.UserID)) } return nil @@ -257,7 +257,7 @@ func (listener *MessageListener) onMessageNotificationScheduled(ctx context.Cont var payload events.MessageNotificationScheduledPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } expiredParams := services.HandleMessageParams{ @@ -267,7 +267,7 @@ func (listener *MessageListener) onMessageNotificationScheduled(ctx context.Cont Timestamp: payload.ScheduledAt, } if err := listener.service.HandleMessageNotificationScheduled(ctx, expiredParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot handle event [%s] for ID [%s] and userID [%s]", event.Type(), expiredParams.ID, expiredParams.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot handle event [%s] for ID [%s] and userID [%s]", event.Type(), expiredParams.ID, expiredParams.UserID)) } return nil @@ -280,11 +280,11 @@ func (listener *MessageListener) onMessageThreadAPIDeleted(ctx context.Context, var payload events.MessageThreadAPIDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteByOwnerAndContact(ctx, payload.UserID, payload.Owner, payload.Contact); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot handle [%s] event with ID [%s] and userID [%s]", event.Type(), event.ID(), payload.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot handle [%s] event with ID [%s] and userID [%s]", event.Type(), event.ID(), payload.UserID)) } return nil @@ -297,11 +297,11 @@ func (listener *MessageListener) onMessageCallMissed(ctx context.Context, event payload := new(events.MessageCallMissedPayload) if err := event.DataAs(payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.RespondToMissedCall(ctx, event.Source(), payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot handle [%s] event with ID [%s] and userID [%s]", event.Type(), event.ID(), payload.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot handle [%s] event with ID [%s] and userID [%s]", event.Type(), event.ID(), payload.UserID)) } return nil @@ -313,11 +313,11 @@ func (listener *MessageListener) onUserAccountDeleted(ctx context.Context, event var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.Message] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.Message] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/message_send_schedule_listener.go b/api/pkg/listeners/message_send_schedule_listener.go index 0fb1f00c0..8e2838c72 100644 --- a/api/pkg/listeners/message_send_schedule_listener.go +++ b/api/pkg/listeners/message_send_schedule_listener.go @@ -7,8 +7,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // MessageSendScheduleListener handles cloud events related to message send schedules. @@ -45,11 +45,11 @@ func (listener *MessageSendScheduleListener) onUserAccountDeleted( var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.MessageSendSchedule] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.MessageSendSchedule] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/message_thread_listener.go b/api/pkg/listeners/message_thread_listener.go index e5f90ca39..f6d2c52c6 100644 --- a/api/pkg/listeners/message_thread_listener.go +++ b/api/pkg/listeners/message_thread_listener.go @@ -9,8 +9,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // MessageThreadListener handles cloud events which need to update entities.MessageThread @@ -40,6 +40,7 @@ func NewMessageThreadListener( events.EventTypeMessagePhoneDelivered: l.OnMessagePhoneDelivered, events.EventTypeMessageSendFailed: l.OnMessagePhoneFailed, events.EventTypeMessagePhoneReceived: l.OnMessagePhoneReceived, + events.MessageCallMissed: l.OnMessageCallMissed, events.EventTypeMessageNotificationScheduled: l.onMessageNotificationScheduled, events.EventTypeMessageSendExpired: l.onMessageExpired, events.UserAccountDeleted: l.onUserAccountDeleted, @@ -53,7 +54,7 @@ func (listener *MessageThreadListener) OnMessageAPISent(ctx context.Context, eve var payload events.MessageAPISentPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } updateParams := services.MessageThreadUpdateParams{ @@ -67,7 +68,7 @@ func (listener *MessageThreadListener) OnMessageAPISent(ctx context.Context, eve } if err := listener.service.UpdateThread(ctx, updateParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) } return nil @@ -80,11 +81,11 @@ func (listener *MessageThreadListener) onMessageDeleted(ctx context.Context, eve payload := new(events.MessageAPIDeletedPayload) if err := event.DataAs(payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.UpdateAfterDeletedMessage(ctx, payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread for message with ID [%s] for event with ID [%s]", payload.MessageID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread for message with ID [%s] for event with ID [%s]", payload.MessageID, event.ID())) } return nil @@ -97,7 +98,7 @@ func (listener *MessageThreadListener) OnMessagePhoneSending(ctx context.Context var payload events.MessagePhoneSendingPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } updateParams := services.MessageThreadUpdateParams{ @@ -111,7 +112,7 @@ func (listener *MessageThreadListener) OnMessagePhoneSending(ctx context.Context } if err := listener.service.UpdateThread(ctx, updateParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) } return nil @@ -124,7 +125,7 @@ func (listener *MessageThreadListener) OnMessagePhoneSent(ctx context.Context, e var payload events.MessagePhoneSentPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } updateParams := services.MessageThreadUpdateParams{ @@ -138,7 +139,7 @@ func (listener *MessageThreadListener) OnMessagePhoneSent(ctx context.Context, e } if err := listener.service.UpdateThread(ctx, updateParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) } return nil @@ -151,7 +152,7 @@ func (listener *MessageThreadListener) OnMessagePhoneDelivered(ctx context.Conte var payload events.MessagePhoneDeliveredPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } updateParams := services.MessageThreadUpdateParams{ @@ -165,7 +166,7 @@ func (listener *MessageThreadListener) OnMessagePhoneDelivered(ctx context.Conte } if err := listener.service.UpdateThread(ctx, updateParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) } return nil @@ -178,7 +179,7 @@ func (listener *MessageThreadListener) OnMessagePhoneFailed(ctx context.Context, var payload events.MessageSendFailedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T] for event [%s]", event.Data(), payload, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T] for event [%s]", event.Data(), payload, event.ID())) } updateParams := services.MessageThreadUpdateParams{ @@ -192,7 +193,7 @@ func (listener *MessageThreadListener) OnMessagePhoneFailed(ctx context.Context, } if err := listener.service.UpdateThread(ctx, updateParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) } return nil @@ -205,26 +206,55 @@ func (listener *MessageThreadListener) OnMessagePhoneReceived(ctx context.Contex var payload events.MessagePhoneReceivedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } updateParams := services.MessageThreadUpdateParams{ - Owner: payload.Owner, - Contact: payload.Contact, - Timestamp: payload.Timestamp, - UserID: payload.UserID, - Status: entities.MessageStatusReceived, - Content: payload.Content, - MessageID: payload.MessageID, + Owner: payload.Owner, + Contact: payload.Contact, + Timestamp: payload.Timestamp, + UserID: payload.UserID, + Status: entities.MessageStatusReceived, + Content: payload.Content, + MessageID: payload.MessageID, + MarkAsUnread: true, + EventTimestamp: event.Time(), } if err := listener.service.UpdateThread(ctx, updateParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) } return nil } +// OnMessageCallMissed handles the events.MessageCallMissed event +func (listener *MessageThreadListener) OnMessageCallMissed(ctx context.Context, event cloudevents.Event) error { + ctx, span := listener.tracer.Start(ctx) + defer span.End() + + var payload events.MessageCallMissedPayload + if err := event.DataAs(&payload); err != nil { + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + } + + params := services.MessageThreadUpdateParams{ + Owner: payload.Owner, + Contact: payload.Contact, + UserID: payload.UserID, + Status: entities.MessageStatusReceived, + Timestamp: payload.Timestamp, + Content: "Missed phone call", + MessageID: payload.MessageID, + MarkAsUnread: true, + EventTimestamp: event.Time(), + } + if err := listener.service.UpdateThread(ctx, params); err != nil { + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread for missed call [%s] on event [%s]", payload.MessageID, event.ID())) + } + return nil +} + // onMessageNotificationScheduled handles the events.EventTypeMessageNotificationScheduled event func (listener *MessageThreadListener) onMessageNotificationScheduled(ctx context.Context, event cloudevents.Event) error { ctx, span := listener.tracer.Start(ctx) @@ -232,7 +262,7 @@ func (listener *MessageThreadListener) onMessageNotificationScheduled(ctx contex var payload events.MessageNotificationScheduledPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } updateParams := services.MessageThreadUpdateParams{ @@ -246,7 +276,7 @@ func (listener *MessageThreadListener) onMessageNotificationScheduled(ctx contex } if err := listener.service.UpdateThread(ctx, updateParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) } return nil @@ -259,7 +289,7 @@ func (listener *MessageThreadListener) onMessageExpired(ctx context.Context, eve var payload events.MessageSendExpiredPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } updateParams := services.MessageThreadUpdateParams{ @@ -273,7 +303,7 @@ func (listener *MessageThreadListener) onMessageExpired(ctx context.Context, eve } if err := listener.service.UpdateThread(ctx, updateParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread for message with ID [%s] for event with ID [%s]", updateParams.MessageID, event.ID())) } return nil @@ -285,11 +315,11 @@ func (listener *MessageThreadListener) onUserAccountDeleted(ctx context.Context, var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.MessageThread] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.MessageThread] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/message_thread_listener_test.go b/api/pkg/listeners/message_thread_listener_test.go new file mode 100644 index 000000000..f0410dfaa --- /dev/null +++ b/api/pkg/listeners/message_thread_listener_test.go @@ -0,0 +1,72 @@ +package listeners + +import ( + "context" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMessageThreadListenerMarksInboundMessageUnread(t *testing.T) { + repository, routes := newMessageThreadListenerForTest() + event := cloudevents.NewEvent() + event.SetID(uuid.NewString()) + event.SetSource("/v1/messages/phone-received") + event.SetType(events.EventTypeMessagePhoneReceived) + event.SetTime(time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC)) + require.NoError(t, event.SetData(cloudevents.ApplicationJSON, events.MessagePhoneReceivedPayload{ + MessageID: uuid.New(), + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + Content: "hello", + Timestamp: time.Date(2026, 7, 18, 6, 59, 0, 0, time.UTC), + })) + + err := routes[events.EventTypeMessagePhoneReceived](context.Background(), event) + + require.NoError(t, err) + assert.True(t, repository.activity.MarkAsUnread) + assert.Equal(t, event.Time(), repository.activity.EventTimestamp) +} + +func TestMessageThreadListenerMarksMissedCallUnread(t *testing.T) { + repository, routes := newMessageThreadListenerForTest() + event := cloudevents.NewEvent() + event.SetID(uuid.NewString()) + event.SetSource("/v1/messages/call-missed") + event.SetType(events.MessageCallMissed) + event.SetTime(time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC)) + require.NoError(t, event.SetData(cloudevents.ApplicationJSON, events.MessageCallMissedPayload{ + MessageID: uuid.New(), + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + Timestamp: time.Date(2026, 7, 18, 6, 59, 0, 0, time.UTC), + })) + require.Contains(t, routes, events.MessageCallMissed) + + err := routes[events.MessageCallMissed](context.Background(), event) + + require.NoError(t, err) + assert.True(t, repository.activity.MarkAsUnread) + assert.Equal(t, "Missed phone call", repository.activity.Content) + assert.Equal(t, event.Time(), repository.activity.EventTimestamp) +} + +func newMessageThreadListenerForTest() (*listenerMessageThreadRepository, map[string]events.EventListener) { + repository := &listenerMessageThreadRepository{} + logger := &noopListenerLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + service := services.NewMessageThreadService(logger, tracer, repository, nil, nil, nil) + _, routes := NewMessageThreadListener(logger, tracer, service) + return repository, routes +} diff --git a/api/pkg/listeners/phone_api_key_listener.go b/api/pkg/listeners/phone_api_key_listener.go index 04fc0ce2e..8eb3621e0 100644 --- a/api/pkg/listeners/phone_api_key_listener.go +++ b/api/pkg/listeners/phone_api_key_listener.go @@ -6,9 +6,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/davecgh/go-spew/spew" - "github.com/palantir/stacktrace" "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" @@ -48,7 +48,7 @@ func (listener *PhoneAPIKeyListener) onPhoneUpdated(ctx context.Context, event c var payload events.PhoneUpdatedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if payload.PhoneAPIKeyID == nil { @@ -57,7 +57,7 @@ func (listener *PhoneAPIKeyListener) onPhoneUpdated(ctx context.Context, event c } if err := listener.service.AddPhone(ctx, payload.UserID, *payload.PhoneAPIKeyID, payload.PhoneID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot store heartbeat monitor with params [%s] for event with ID [%s]", spew.Sdump(payload), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store heartbeat monitor with params [%s] for event with ID [%s]", spew.Sdump(payload), event.ID())) } return nil @@ -70,11 +70,11 @@ func (listener *PhoneAPIKeyListener) onPhoneDeleted(ctx context.Context, event c var payload events.PhoneDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.RemovePhoneByID(ctx, payload.UserID, payload.PhoneID, payload.Owner); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot remove phone with ID [%s] from phone api key for [%s] event with ID [%s]", payload.PhoneID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot remove phone with ID [%s] from phone api key for [%s] event with ID [%s]", payload.PhoneID, event.Type(), event.ID())) } return nil @@ -87,11 +87,11 @@ func (listener *PhoneAPIKeyListener) onUserAccountDeleted(ctx context.Context, e var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s] for [%s] event with ID [%s]", entities.PhoneAPIKey{}, payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s] for [%s] event with ID [%s]", entities.PhoneAPIKey{}, payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/phone_listener.go b/api/pkg/listeners/phone_listener.go index 08d1990d8..6f3418c80 100644 --- a/api/pkg/listeners/phone_listener.go +++ b/api/pkg/listeners/phone_listener.go @@ -4,8 +4,8 @@ import ( "context" "fmt" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" @@ -44,11 +44,11 @@ func (listener *PhoneListener) onMessageSendScheduleDeleted(ctx context.Context, var payload events.MessageSendScheduleDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.NullifyScheduleID(ctx, payload.UserID, payload.ScheduleID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot nullify schedule ID [%s] for user [%s] on [%s] event with ID [%s]", payload.ScheduleID, payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot nullify schedule ID [%s] for user [%s] on [%s] event with ID [%s]", payload.ScheduleID, payload.UserID, event.Type(), event.ID())) } return nil @@ -61,11 +61,11 @@ func (listener *PhoneListener) onUserAccountDeleted(ctx context.Context, event c var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [entities.Phone] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [entities.Phone] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/phone_notification_listener.go b/api/pkg/listeners/phone_notification_listener.go index d397cdda7..5bac8d73e 100644 --- a/api/pkg/listeners/phone_notification_listener.go +++ b/api/pkg/listeners/phone_notification_listener.go @@ -9,8 +9,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // PhoneNotificationListener handles cloud events which sends notifications @@ -49,7 +49,7 @@ func (listener *PhoneNotificationListener) onMessageAPISent(ctx context.Context, var payload events.MessageAPISentPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } sendParams := &services.PhoneNotificationScheduleParams{ @@ -66,7 +66,7 @@ func (listener *PhoneNotificationListener) onMessageAPISent(ctx context.Context, } if err := listener.service.Schedule(ctx, sendParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send notification with params [%s] for event with ID [%s]", spew.Sdump(sendParams), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send notification with params [%s] for event with ID [%s]", spew.Sdump(sendParams), event.ID())) } return nil @@ -79,7 +79,7 @@ func (listener *PhoneNotificationListener) onMessageSendRetry(ctx context.Contex var payload events.MessageSendRetryPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } sendParams := &services.PhoneNotificationScheduleParams{ @@ -94,7 +94,7 @@ func (listener *PhoneNotificationListener) onMessageSendRetry(ctx context.Contex } if err := listener.service.Schedule(ctx, sendParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send notification with params [%s] for event with ID [%s]", spew.Sdump(sendParams), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send notification with params [%s] for event with ID [%s]", spew.Sdump(sendParams), event.ID())) } return nil @@ -107,11 +107,11 @@ func (listener *PhoneNotificationListener) onPhoneHeartbeatMissed(ctx context.Co payload := new(events.PhoneHeartbeatMissedPayload) if err := event.DataAs(payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.SendHeartbeatFCM(ctx, payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot schedule send heartbeat FCM with params [%s] for event with ID [%s]", spew.Sdump(payload), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot schedule send heartbeat FCM with params [%s] for event with ID [%s]", spew.Sdump(payload), event.ID())) } return nil @@ -124,7 +124,7 @@ func (listener *PhoneNotificationListener) onMessageNotificationSend(ctx context var payload events.MessageNotificationSendPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } scheduleParams := &services.PhoneNotificationSendParams{ @@ -137,7 +137,7 @@ func (listener *PhoneNotificationListener) onMessageNotificationSend(ctx context } if err := listener.service.Send(ctx, scheduleParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot schedule notification with params [%s] for event with ID [%s]", spew.Sdump(scheduleParams), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot schedule notification with params [%s] for event with ID [%s]", spew.Sdump(scheduleParams), event.ID())) } return nil @@ -149,11 +149,11 @@ func (listener *PhoneNotificationListener) onUserAccountDeleted(ctx context.Cont var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.Phone] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.Phone] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil @@ -166,11 +166,11 @@ func (listener *PhoneNotificationListener) onMessageAPIDeleted(ctx context.Conte var payload events.MessageAPIDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteByMessageID(ctx, payload.UserID, payload.MessageID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.PhoneNotification] for user [%s] and message [%s] on [%s] event with ID [%s]", payload.UserID, payload.MessageID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.PhoneNotification] for user [%s] and message [%s] on [%s] event with ID [%s]", payload.UserID, payload.MessageID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/read_receipts_test_helpers_test.go b/api/pkg/listeners/read_receipts_test_helpers_test.go new file mode 100644 index 000000000..60cd27e65 --- /dev/null +++ b/api/pkg/listeners/read_receipts_test_helpers_test.go @@ -0,0 +1,66 @@ +package listeners + +import ( + "context" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "go.opentelemetry.io/otel/trace" +) + +type noopListenerLogger struct{} + +func (logger *noopListenerLogger) Error(error) {} +func (logger *noopListenerLogger) WithService(string) telemetry.Logger { return logger } +func (logger *noopListenerLogger) WithString(string, string) telemetry.Logger { return logger } +func (logger *noopListenerLogger) WithSpan(trace.SpanContext) telemetry.Logger { return logger } +func (logger *noopListenerLogger) Trace(string) {} +func (logger *noopListenerLogger) Info(string) {} +func (logger *noopListenerLogger) Warn(error) {} +func (logger *noopListenerLogger) Debug(string) {} +func (logger *noopListenerLogger) Fatal(error) {} +func (logger *noopListenerLogger) Printf(string, ...interface{}) {} + +type listenerMessageThreadRepository struct { + activity repositories.MessageThreadActivityUpdate +} + +func (repository *listenerMessageThreadRepository) Store(context.Context, *entities.MessageThread) error { + return nil +} + +func (repository *listenerMessageThreadRepository) UpdateActivity(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + repository.activity = params + return nil +} + +func (repository *listenerMessageThreadRepository) UpdateStatus(_ context.Context, _ entities.UserID, threadID uuid.UUID, _ repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: threadID}, nil +} + +func (repository *listenerMessageThreadRepository) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { + return nil +} + +func (repository *listenerMessageThreadRepository) LoadByOwnerContact(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: uuid.New()}, nil +} + +func (repository *listenerMessageThreadRepository) Load(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { + return &entities.MessageThread{}, nil +} + +func (repository *listenerMessageThreadRepository) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { + threads := []entities.MessageThread{} + return &threads, nil +} + +func (repository *listenerMessageThreadRepository) Delete(context.Context, entities.UserID, uuid.UUID) error { + return nil +} + +func (repository *listenerMessageThreadRepository) DeleteAllForUser(context.Context, entities.UserID) error { + return nil +} diff --git a/api/pkg/listeners/user_listener.go b/api/pkg/listeners/user_listener.go index 960ce6c90..f681cb1ca 100644 --- a/api/pkg/listeners/user_listener.go +++ b/api/pkg/listeners/user_listener.go @@ -9,8 +9,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // UserListener handles cloud events which sends notifications @@ -50,7 +50,7 @@ func (listener *UserListener) onPhoneHeartbeatDead(ctx context.Context, event cl var payload events.PhoneHeartbeatOfflinePayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } sendParams := &services.UserSendPhoneDeadEmailParams{ @@ -61,7 +61,7 @@ func (listener *UserListener) onPhoneHeartbeatDead(ctx context.Context, event cl } if err := listener.service.SendPhoneDeadEmail(ctx, sendParams); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send notification with params [%s] for event with ID [%s]", spew.Sdump(sendParams), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send notification with params [%s] for event with ID [%s]", spew.Sdump(sendParams), event.ID())) } return nil @@ -74,11 +74,11 @@ func (listener *UserListener) onUserAPIKeyRotated(ctx context.Context, event clo payload := new(events.UserAPIKeyRotatedPayload) if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.SendAPIKeyRotatedEmail(ctx, payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send notification with params [%s] for event with ID [%s]", spew.Sdump(payload), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send notification with params [%s] for event with ID [%s]", spew.Sdump(payload), event.ID())) } return nil @@ -91,11 +91,11 @@ func (listener *UserListener) OnUserSubscriptionCreated(ctx context.Context, eve var payload events.UserSubscriptionCreatedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.StartSubscription(ctx, &payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot start subscription for user with ID [%s] for event with ID [%s]", payload.UserID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot start subscription for user with ID [%s] for event with ID [%s]", payload.UserID, event.ID())) } return nil @@ -108,11 +108,11 @@ func (listener *UserListener) OnUserSubscriptionCancelled(ctx context.Context, e var payload events.UserSubscriptionCancelledPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.CancelSubscription(ctx, &payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot cancell subscription for user with ID [%s] for event with ID [%s]", payload.UserID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot cancell subscription for user with ID [%s] for event with ID [%s]", payload.UserID, event.ID())) } return nil @@ -125,11 +125,11 @@ func (listener *UserListener) OnUserSubscriptionExpired(ctx context.Context, eve var payload events.UserSubscriptionExpiredPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.ExpireSubscription(ctx, &payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot expire subscription for user with ID [%s] for event with ID [%s]", payload.UserID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot expire subscription for user with ID [%s] for event with ID [%s]", payload.UserID, event.ID())) } return nil @@ -142,11 +142,11 @@ func (listener *UserListener) OnUserSubscriptionUpdated(ctx context.Context, eve var payload events.UserSubscriptionUpdatedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.UpdateSubscription(ctx, &payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot expire subscription for user with ID [%s] for event with ID [%s]", payload.UserID, event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot expire subscription for user with ID [%s] for event with ID [%s]", payload.UserID, event.ID())) } return nil @@ -158,11 +158,11 @@ func (listener *UserListener) onUserAccountDeleted(ctx context.Context, event cl var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAuthUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.AuthUser] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.AuthUser] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/webhook_listener.go b/api/pkg/listeners/webhook_listener.go index 5ff410467..d14008b13 100644 --- a/api/pkg/listeners/webhook_listener.go +++ b/api/pkg/listeners/webhook_listener.go @@ -7,8 +7,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // WebhookListener sends webhook events to users @@ -50,11 +50,11 @@ func (listener *WebhookListener) OnMessagePhoneReceived(ctx context.Context, eve var payload events.MessagePhoneReceivedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event, payload.Owner); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -67,11 +67,11 @@ func (listener *WebhookListener) OnMessageSendExpired(ctx context.Context, event var payload events.MessageSendExpiredPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event, payload.Owner); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -84,11 +84,11 @@ func (listener *WebhookListener) OnMessageSendFailed(ctx context.Context, event var payload events.MessageSendFailedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event, payload.Owner); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -101,11 +101,11 @@ func (listener *WebhookListener) OnMessagePhoneSent(ctx context.Context, event c var payload events.MessagePhoneSentPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event, payload.Owner); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -118,11 +118,11 @@ func (listener *WebhookListener) OnMessagePhoneDelivered(ctx context.Context, ev var payload events.MessagePhoneDeliveredPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event, payload.Owner); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -135,11 +135,11 @@ func (listener *WebhookListener) onPhoneHeartbeatOffline(ctx context.Context, ev var payload events.PhoneHeartbeatOfflinePayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event, payload.Owner); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -152,11 +152,11 @@ func (listener *WebhookListener) onPhoneHeartbeatOnline(ctx context.Context, eve var payload events.PhoneHeartbeatOnlinePayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event, payload.Owner); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -169,11 +169,11 @@ func (listener *WebhookListener) onMessageCallMissed(ctx context.Context, event var payload events.MessageCallMissedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.Send(ctx, payload.UserID, event, payload.Owner); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot process [%s] event with ID [%s]", event.Type(), event.ID())) } return nil @@ -185,11 +185,11 @@ func (listener *WebhookListener) onUserAccountDeleted(ctx context.Context, event var payload events.UserAccountDeletedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [entities.Webhook] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [entities.Webhook] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())) } return nil diff --git a/api/pkg/listeners/websocket_listener.go b/api/pkg/listeners/websocket_listener.go index 3108ed671..bdc43f938 100644 --- a/api/pkg/listeners/websocket_listener.go +++ b/api/pkg/listeners/websocket_listener.go @@ -4,8 +4,8 @@ import ( "context" "fmt" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -36,9 +36,25 @@ func NewWebsocketListener( events.EventTypeMessagePhoneSent: l.onMessagePhoneSent, events.EventTypeMessageSendFailed: l.onMessagePhoneFailed, events.EventTypeMessagePhoneReceived: l.onMessagePhoneReceived, + events.MessageCallMissed: l.onMessageCallMissed, } } +func (listener *WebsocketListener) onMessageCallMissed(ctx context.Context, event cloudevents.Event) error { + ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) + defer span.End() + + var payload events.MessageCallMissedPayload + if err := event.DataAs(&payload); err != nil { + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + } + + if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil { + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)) + } + return nil +} + // onMessagePhoneSent handles the events.EventTypeMessagePhoneSent event func (listener *WebsocketListener) onMessagePhoneSent(ctx context.Context, event cloudevents.Event) error { ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) @@ -46,11 +62,11 @@ func (listener *WebsocketListener) onMessagePhoneSent(ctx context.Context, event var payload events.MessagePhoneSentPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)) } return nil @@ -63,11 +79,11 @@ func (listener *WebsocketListener) onMessagePhoneReceived(ctx context.Context, e var payload events.MessagePhoneReceivedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)) } return nil @@ -80,11 +96,11 @@ func (listener *WebsocketListener) onMessagePhoneFailed(ctx context.Context, eve var payload events.MessageSendFailedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)) } return nil @@ -97,11 +113,11 @@ func (listener *WebsocketListener) onPhoneUpdated(ctx context.Context, event clo var payload events.PhoneUpdatedPayload if err := event.DataAs(&payload); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode [%s] into [%T]", event.Data(), payload)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) } if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil { - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)) } return nil diff --git a/api/pkg/listeners/websocket_listener_test.go b/api/pkg/listeners/websocket_listener_test.go new file mode 100644 index 000000000..fdaf1c6d5 --- /dev/null +++ b/api/pkg/listeners/websocket_listener_test.go @@ -0,0 +1,18 @@ +package listeners + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/pusher/pusher-http-go/v5" + "github.com/stretchr/testify/assert" +) + +func TestWebsocketListenerRegistersMissedCalls(t *testing.T) { + logger := &noopListenerLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + _, routes := NewWebsocketListener(logger, tracer, &pusher.Client{}) + + assert.Contains(t, routes, events.MessageCallMissed) +} diff --git a/api/pkg/middlewares/api_key_auth_middleware.go b/api/pkg/middlewares/api_key_auth_middleware.go index 0d65be307..cec70976e 100644 --- a/api/pkg/middlewares/api_key_auth_middleware.go +++ b/api/pkg/middlewares/api_key_auth_middleware.go @@ -6,8 +6,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // APIKeyAuth authenticates a user from the X-API-Key header @@ -28,7 +28,7 @@ func APIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepository authUser, err := userRepository.LoadAuthContext(ctx, apiKey) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot load user with api key [%s]", apiKey)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot load user with api key [%s]", apiKey)) return c.Next() } diff --git a/api/pkg/middlewares/bearer_api_key_auth_middleware.go b/api/pkg/middlewares/bearer_api_key_auth_middleware.go index 7806aa7d0..6125dca87 100644 --- a/api/pkg/middlewares/bearer_api_key_auth_middleware.go +++ b/api/pkg/middlewares/bearer_api_key_auth_middleware.go @@ -6,8 +6,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // BearerAPIKeyAuth authenticates an API key using the Bearer header @@ -26,7 +26,7 @@ func BearerAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepo authUser, err := userRepository.LoadAuthContext(ctx, apiKey) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot load user with api key [%s] using header [%s]", apiKey, c.Get(authHeaderBearer))) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot load user with api key [%s] using header [%s]", apiKey, c.Get(authHeaderBearer))) return c.Next() } diff --git a/api/pkg/middlewares/bearer_auth_middleware.go b/api/pkg/middlewares/bearer_auth_middleware.go index 1573413c3..3391e8759 100644 --- a/api/pkg/middlewares/bearer_auth_middleware.go +++ b/api/pkg/middlewares/bearer_auth_middleware.go @@ -8,8 +8,8 @@ import ( "firebase.google.com/go/auth" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // BearerAuth authenticates a user based on the bearer token @@ -33,7 +33,7 @@ func BearerAuth(logger telemetry.Logger, tracer telemetry.Tracer, authClient *au token, err := authClient.VerifyIDToken(context.Background(), authToken) if err != nil { - ctxLogger.Warn(tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "invalid firebase id token [%s]", authToken))) + ctxLogger.Warn(tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "invalid firebase id token [%s]", authToken))) return c.Next() } diff --git a/api/pkg/middlewares/http_request_logger_middleware.go b/api/pkg/middlewares/http_request_logger_middleware.go index 388c02917..cd11c6009 100644 --- a/api/pkg/middlewares/http_request_logger_middleware.go +++ b/api/pkg/middlewares/http_request_logger_middleware.go @@ -5,8 +5,8 @@ import ( "slices" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) const ( @@ -24,7 +24,7 @@ func HTTPRequestLogger(tracer telemetry.Tracer, logger telemetry.Logger) fiber.H statusCode := c.Response().StatusCode() span.AddEvent(fmt.Sprintf("finished handling request with traceID: [%s], statusCode: [%d]", span.SpanContext().TraceID().String(), statusCode)) if statusCode >= 300 && len(c.Request().Body()) > 0 && !slices.Contains([]int{401, 402}, statusCode) { - ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewError("http.status [%d], body [%s]", statusCode, string(c.Request().Body()))) + ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewErrorf("http.status [%d], body [%s]", statusCode, c.Request().Body())) } return response diff --git a/api/pkg/middlewares/phone_api_key_auth_middleware.go b/api/pkg/middlewares/phone_api_key_auth_middleware.go index 5fb01c5ba..22ae1f5f4 100644 --- a/api/pkg/middlewares/phone_api_key_auth_middleware.go +++ b/api/pkg/middlewares/phone_api_key_auth_middleware.go @@ -6,8 +6,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // PhoneAPIKeyAuth authenticates a user from the X-API-Key header @@ -26,7 +26,7 @@ func PhoneAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, repositor authUser, err := repository.LoadAuthContext(ctx, apiKey) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot load user with phone api key [%s]", apiKey)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot load user with phone api key [%s]", apiKey)) return c.Next() } diff --git a/api/pkg/repositories/contact_repository.go b/api/pkg/repositories/contact_repository.go new file mode 100644 index 000000000..87c89a6a4 --- /dev/null +++ b/api/pkg/repositories/contact_repository.go @@ -0,0 +1,37 @@ +package repositories + +import ( + "context" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" +) + +// ContactRepository loads and persists an entities.Contact. +type ContactRepository interface { + // Store one or many new entities.Contact. + Store(ctx context.Context, contacts []*entities.Contact) error + + // Update an existing entities.Contact. + Update(ctx context.Context, contact *entities.Contact) error + + // Load a contact by ID for a user. + Load(ctx context.Context, userID entities.UserID, contactID uuid.UUID) (*entities.Contact, error) + + // Index contacts for a user with optional search. + Index(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.Contact, error) + + // Count returns the number of contacts for a user matching the same + // name/emails/phone_numbers filter as Index, ignoring pagination. + Count(ctx context.Context, userID entities.UserID, params IndexParams) (int64, error) + + // FetchByPhoneNumbers returns contacts containing at least one requested + // phone number, ordered by updated_at ascending. + FetchByPhoneNumbers(ctx context.Context, userID entities.UserID, phoneNumbers []string) (*[]entities.Contact, error) + + // Delete a contact by ID for a user. + Delete(ctx context.Context, userID entities.UserID, contactID uuid.UUID) error + + // DeleteAllForUser deletes all contacts for a user. + DeleteAllForUser(ctx context.Context, userID entities.UserID) error +} diff --git a/api/pkg/repositories/google_cloud_storage_attachment_repository.go b/api/pkg/repositories/google_cloud_storage_attachment_repository.go index 34d389eb7..3c8b9e8c2 100644 --- a/api/pkg/repositories/google_cloud_storage_attachment_repository.go +++ b/api/pkg/repositories/google_cloud_storage_attachment_repository.go @@ -8,7 +8,7 @@ import ( "cloud.google.com/go/storage" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" ) // GoogleCloudStorageAttachmentRepository stores attachments in Google Cloud Storage @@ -43,11 +43,11 @@ func (s *GoogleCloudStorageAttachmentRepository) Upload(ctx context.Context, pat writer.ContentType = contentType if _, err := writer.Write(data); err != nil { - return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot write attachment to GCS path [%s]", path)) + return s.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot write attachment to GCS path [%s]", path)) } if err := writer.Close(); err != nil { - return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot close GCS writer for path [%s]", path)) + return s.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot close GCS writer for path [%s]", path)) } ctxLogger.Info(fmt.Sprintf("uploaded attachment to GCS path [%s/%s] with size [%d]", s.bucket, path, len(data))) @@ -62,15 +62,15 @@ func (s *GoogleCloudStorageAttachmentRepository) Download(ctx context.Context, p reader, err := s.client.Bucket(s.bucket).Object(path).NewReader(ctx) if err != nil { if errors.Is(err, storage.ErrObjectNotExist) { - return nil, s.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "cannot open GCS reader for path [%s]", path)) + return nil, s.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "cannot open GCS reader for path [%s]", path)) } - return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot open GCS reader for path [%s]", path)) + return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot open GCS reader for path [%s]", path)) } defer reader.Close() data, err := io.ReadAll(reader) if err != nil { - return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot read attachment from GCS path [%s]", path)) + return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot read attachment from GCS path [%s]", path)) } ctxLogger.Info(fmt.Sprintf("downloaded attachment from GCS path [%s/%s] with size [%d]", s.bucket, path, len(data))) @@ -83,7 +83,7 @@ func (s *GoogleCloudStorageAttachmentRepository) Delete(ctx context.Context, pat defer span.End() if err := s.client.Bucket(s.bucket).Object(path).Delete(ctx); err != nil { - return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete GCS object at path [%s]", path)) + return s.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete GCS object at path [%s]", path)) } ctxLogger.Info(fmt.Sprintf("deleted attachment from GCS path [%s/%s]", s.bucket, path)) diff --git a/api/pkg/repositories/gorm_billing_usage_repository.go b/api/pkg/repositories/gorm_billing_usage_repository.go index 47c7fadb6..740cd0f6e 100644 --- a/api/pkg/repositories/gorm_billing_usage_repository.go +++ b/api/pkg/repositories/gorm_billing_usage_repository.go @@ -8,9 +8,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/cockroachdb/cockroach-go/v2/crdb/crdbgorm" "github.com/google/uuid" - "github.com/palantir/stacktrace" "gorm.io/gorm" ) @@ -40,7 +40,7 @@ func (repository *gormBillingUsageRepository) DeleteAllForUser(ctx context.Conte defer span.End() if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.BillingUsage{}).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.BillingUsage{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.BillingUsage{}, userID)) } return nil @@ -133,7 +133,7 @@ func (repository *gormBillingUsageRepository) GetCurrent(ctx context.Context, us }, ) if err != nil { - return &usage, stacktrace.Propagate(err, "cannot load billing usage for user [%s]", userID) + return &usage, stacktrace.Propagatef(err, "cannot load billing usage for user [%s]", userID) } return &usage, nil @@ -156,7 +156,7 @@ func (repository *gormBillingUsageRepository) GetHistory(ctx context.Context, us Find(&usages). Error if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch billing usage history for userID [%s] and params [%+#v]", userID, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch billing usage history for userID [%s] and params [%+#v]", userID, params)) } return usages, nil @@ -167,7 +167,7 @@ func (repository *gormBillingUsageRepository) GetHistory(ctx context.Context, us func (repository *gormBillingUsageRepository) createBillingUsageForUser(ctx context.Context, tx *gorm.DB, userID entities.UserID, timestamp time.Time, sent uint, received uint) (*entities.BillingUsage, error) { user := new(entities.User) if err := tx.WithContext(ctx).First(user, userID).Error; err != nil { - return nil, stacktrace.Propagate(err, "cannot load user [%s] to compute billing cycle", userID) + return nil, stacktrace.Propagatef(err, "cannot load user [%s] to compute billing cycle", userID) } start, end := computeBillingCycle(timestamp, user.GetBillingAnchorDay()) diff --git a/api/pkg/repositories/gorm_contact_repository.go b/api/pkg/repositories/gorm_contact_repository.go new file mode 100644 index 000000000..2ecee4de4 --- /dev/null +++ b/api/pkg/repositories/gorm_contact_repository.go @@ -0,0 +1,187 @@ +package repositories + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" + "github.com/lib/pq" + "gorm.io/gorm" +) + +// gormContactRepository is responsible for persisting entities.Contact. +type gormContactRepository struct { + logger telemetry.Logger + tracer telemetry.Tracer + db *gorm.DB +} + +// NewGormContactRepository creates the GORM version of the ContactRepository. +func NewGormContactRepository( + logger telemetry.Logger, + tracer telemetry.Tracer, + db *gorm.DB, +) ContactRepository { + return &gormContactRepository{ + logger: logger.WithService(fmt.Sprintf("%T", &gormContactRepository{})), + tracer: tracer, + db: db, + } +} + +func (repository *gormContactRepository) Store(ctx context.Context, contacts []*entities.Contact) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + if len(contacts) == 0 { + return nil + } + + if err := repository.db.WithContext(ctx).Create(&contacts).Error; err != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store [%d] contacts", len(contacts))) + } + + return nil +} + +func (repository *gormContactRepository) Update(ctx context.Context, contact *entities.Contact) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + if err := repository.db.WithContext(ctx).Save(contact).Error; err != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update contact with ID [%s]", contact.ID)) + } + + return nil +} + +func (repository *gormContactRepository) Load(ctx context.Context, userID entities.UserID, contactID uuid.UUID) (*entities.Contact, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + contact := new(entities.Contact) + err := repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Where("id = ?", contactID). + First(contact).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "contact with ID [%s] for user [%s] does not exist", contactID, userID)) + } + + if err != nil { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load contact with ID [%s] for user [%s]", contactID, userID)) + } + + return contact, nil +} + +// scopedContactQuery builds the shared query that scopes contacts to a user and +// applies the optional name/emails/phone_numbers search filter. Index and Count +// both build on it so their filters can never drift apart. It sets the model so +// callers can chain Find or Count without repeating the table. +func (repository *gormContactRepository) scopedContactQuery(ctx context.Context, userID entities.UserID, query string) *gorm.DB { + scoped := repository.db.WithContext(ctx).Model(&entities.Contact{}).Where("user_id = ?", userID) + if len(query) > 0 { + escaped := strings.NewReplacer(`%`, `\%`, `_`, `\_`).Replace(query) + queryPattern := "%" + escaped + "%" + scoped = scoped.Where( + repository.db.WithContext(ctx).Where("name ILIKE ?", queryPattern). + Or("array_to_string(emails, ',') ILIKE ?", queryPattern). + Or("array_to_string(phone_numbers, ',') ILIKE ?", queryPattern), + ) + } + return scoped +} + +func (repository *gormContactRepository) Index(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.Contact, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + contacts := new([]entities.Contact) + if err := repository.scopedContactQuery(ctx, userID, params.Query). + Order(repository.contactOrder(params)). + Limit(params.Limit). + Offset(params.Skip). + Find(contacts).Error; err != nil { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot index contacts for user [%s] with params [%+#v]", userID, params)) + } + + return contacts, nil +} + +func (repository *gormContactRepository) contactOrder(params IndexParams) string { + if params.SortBy == "" { + return "updated_at DESC, id DESC" + } + + sortBy := "updated_at" + if params.SortBy == "name" { + sortBy = "name" + } + + direction := "ASC" + if params.SortDescending { + direction = "DESC" + } + + return fmt.Sprintf("%s %s, id %s", sortBy, direction, direction) +} + +func (repository *gormContactRepository) Count(ctx context.Context, userID entities.UserID, params IndexParams) (int64, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + var count int64 + if err := repository.scopedContactQuery(ctx, userID, params.Query).Count(&count).Error; err != nil { + return 0, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot count contacts for user [%s] with query [%s]", userID, params.Query)) + } + + return count, nil +} + +func (repository *gormContactRepository) FetchByPhoneNumbers(ctx context.Context, userID entities.UserID, phoneNumbers []string) (*[]entities.Contact, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + contacts := new([]entities.Contact) + if err := repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Where("phone_numbers && ?", pq.Array(phoneNumbers)). + Order("updated_at ASC, id ASC"). + Find(contacts).Error; err != nil { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch contacts for user [%s] by phone numbers [%v]", userID, phoneNumbers)) + } + + return contacts, nil +} + +func (repository *gormContactRepository) Delete(ctx context.Context, userID entities.UserID, contactID uuid.UUID) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + err := repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Where("id = ?", contactID). + Delete(&entities.Contact{}).Error + if err != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete contact with ID [%s] for user [%s]", contactID, userID)) + } + + return nil +} + +func (repository *gormContactRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Contact{}).Error; err != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all contacts for user [%s]", userID)) + } + + return nil +} diff --git a/api/pkg/repositories/gorm_contact_repository_test.go b/api/pkg/repositories/gorm_contact_repository_test.go new file mode 100644 index 000000000..166161023 --- /dev/null +++ b/api/pkg/repositories/gorm_contact_repository_test.go @@ -0,0 +1,365 @@ +package repositories + +import ( + "context" + "database/sql" + "database/sql/driver" + "io" + "regexp" + "strings" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func normalizeContactSQL(query string) string { + return strings.Join(strings.Fields(query), " ") +} + +type contactTestConnPool struct { + messageThreadTestConnPool +} + +func (pool *contactTestConnPool) QueryContext(_ context.Context, query string, args ...any) (*sql.Rows, error) { + pool.statements = append(pool.statements, messageThreadTestStatement{ + query: query, + args: append([]any(nil), args...), + }) + + db := sql.OpenDB(contactTestRowsConnector{}) + return db.QueryContext(context.Background(), "SELECT 1") +} + +func (pool *contactTestConnPool) BeginTx(context.Context, *sql.TxOptions) (gorm.ConnPool, error) { + return pool, nil +} + +type contactTestRowsConnector struct{} + +func (contactTestRowsConnector) Connect(context.Context) (driver.Conn, error) { + return contactTestRowsConn{}, nil +} + +func (contactTestRowsConnector) Driver() driver.Driver { + return contactTestRowsDriver{} +} + +type contactTestRowsDriver struct{} + +func (contactTestRowsDriver) Open(string) (driver.Conn, error) { + return contactTestRowsConn{}, nil +} + +type contactTestRowsConn struct{} + +func (contactTestRowsConn) Prepare(string) (driver.Stmt, error) { + return nil, assert.AnError +} + +func (contactTestRowsConn) Close() error { + return nil +} + +func (contactTestRowsConn) Begin() (driver.Tx, error) { + return nil, assert.AnError +} + +func (contactTestRowsConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { + return contactTestRows{}, nil +} + +type contactTestRows struct{} + +func (contactTestRows) Columns() []string { + return []string{"id", "user_id", "name", "emails", "phone_numbers", "properties", "created_at", "updated_at"} +} + +func (contactTestRows) Close() error { + return nil +} + +func (contactTestRows) Next([]driver.Value) error { + return io.EOF +} + +func lastContactStatement(t *testing.T, pool *contactTestConnPool) messageThreadTestStatement { + t.Helper() + require.NotEmpty(t, pool.statements) + + statement := pool.statements[len(pool.statements)-1] + statement.query = normalizeContactSQL(statement.query) + return statement +} + +func newContactTestRepo(t *testing.T) (ContactRepository, *contactTestConnPool) { + t.Helper() + + pool := &contactTestConnPool{} + db, err := gorm.Open( + postgres.New(postgres.Config{ + Conn: pool, + WithoutReturning: true, + }), + &gorm.Config{ + DisableAutomaticPing: true, + NowFunc: func() time.Time { + return time.Date(2026, 7, 19, 18, 0, 0, 0, time.UTC) + }, + }, + ) + require.NoError(t, err) + + logger := &messageThreadTestLogger{} + return NewGormContactRepository(logger, telemetry.NewOtelLogger("test", logger), db), pool +} + +func TestGormContactRepository_Store_BatchInsertsContacts(t *testing.T) { + repository, recorder := newContactTestRepo(t) + firstID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + secondID := uuid.MustParse("22222222-2222-2222-2222-222222222222") + + err := repository.Store(context.Background(), []*entities.Contact{ + { + ID: firstID, + UserID: entities.UserID("user-1"), + Name: "Alice", + Emails: pq.StringArray{"alice@example.com"}, + PhoneNumbers: pq.StringArray{"+18005550199"}, + Properties: entities.ContactProperties{"source": "phone"}, + }, + { + ID: secondID, + UserID: entities.UserID("user-1"), + Name: "Bob", + Emails: pq.StringArray{"bob@example.com"}, + PhoneNumbers: pq.StringArray{"+18005550200"}, + Properties: entities.ContactProperties{"source": "import"}, + }, + }) + + require.NoError(t, err) + require.Len(t, recorder.statements, 1) + statement := lastContactStatement(t, recorder) + assert.True(t, strings.HasPrefix(statement.query, `INSERT INTO "contacts"`)) + assert.Contains(t, statement.query, `("id","user_id","name","emails","phone_numbers","properties","created_at","updated_at")`) + assert.Regexp(t, regexp.MustCompile(`VALUES \(\$1,\$2,\$3,\$4,\$5,\$6,\$7,\$8\),\(\$9,\$10,\$11,\$12,\$13,\$14,\$15,\$16\)`), statement.query) + require.Len(t, statement.args, 16) + assert.Equal(t, firstID, statement.args[0]) + assert.Equal(t, entities.UserID("user-1"), statement.args[1]) + assert.Equal(t, "Alice", statement.args[2]) + assert.Equal(t, pq.StringArray{"alice@example.com"}, statement.args[3]) + assert.Equal(t, pq.StringArray{"+18005550199"}, statement.args[4]) + assert.Equal(t, entities.ContactProperties{"source": "phone"}, statement.args[5]) + assert.Equal(t, secondID, statement.args[8]) + assert.Equal(t, entities.UserID("user-1"), statement.args[9]) + assert.Equal(t, "Bob", statement.args[10]) + assert.Equal(t, pq.StringArray{"bob@example.com"}, statement.args[11]) + assert.Equal(t, pq.StringArray{"+18005550200"}, statement.args[12]) + assert.Equal(t, entities.ContactProperties{"source": "import"}, statement.args[13]) +} + +func TestGormContactRepository_Update_SavesContact(t *testing.T) { + repository, recorder := newContactTestRepo(t) + contactID := uuid.MustParse("33333333-3333-3333-3333-333333333333") + + err := repository.Update(context.Background(), &entities.Contact{ + ID: contactID, + UserID: entities.UserID("user-1"), + Name: "Updated Alice", + Emails: pq.StringArray{"updated@example.com"}, + PhoneNumbers: pq.StringArray{"+18005550199"}, + Properties: entities.ContactProperties{"group": "friends"}, + }) + + require.NoError(t, err) + statement := lastContactStatement(t, recorder) + assert.True(t, strings.HasPrefix(statement.query, `UPDATE "contacts"`)) + assert.Contains(t, statement.query, `"user_id"=$1`) + assert.Contains(t, statement.query, `"name"=$2`) + assert.Contains(t, statement.query, `"emails"=$3`) + assert.Contains(t, statement.query, `"phone_numbers"=$4`) + assert.Contains(t, statement.query, `"properties"=$5`) + assert.Contains(t, statement.query, `"created_at"=$6`) + assert.Contains(t, statement.query, `"updated_at"=$7`) + assert.Contains(t, statement.query, `WHERE "id" = $8`) + require.Len(t, statement.args, 8) + assert.Equal(t, entities.UserID("user-1"), statement.args[0]) + assert.Equal(t, "Updated Alice", statement.args[1]) + assert.Equal(t, pq.StringArray{"updated@example.com"}, statement.args[2]) + assert.Equal(t, pq.StringArray{"+18005550199"}, statement.args[3]) + assert.Equal(t, entities.ContactProperties{"group": "friends"}, statement.args[4]) + assert.Equal(t, contactID, statement.args[7]) +} + +func TestGormContactRepository_Load_ScopesByUserAndContactID(t *testing.T) { + repository, recorder := newContactTestRepo(t) + contactID := uuid.MustParse("44444444-4444-4444-4444-444444444444") + + _, err := repository.Load(context.Background(), entities.UserID("user-1"), contactID) + + require.Error(t, err) + assert.Equal(t, ErrCodeNotFound, stacktrace.GetCode(err)) + statement := lastContactStatement(t, recorder) + assert.True(t, strings.HasPrefix(statement.query, `SELECT * FROM "contacts"`)) + assert.Contains(t, statement.query, `WHERE user_id = $1 AND id = $2`) + assert.Contains(t, statement.query, `ORDER BY "contacts"."id" LIMIT $3`) + require.Len(t, statement.args, 3) + assert.Equal(t, entities.UserID("user-1"), statement.args[0]) + assert.Equal(t, contactID, statement.args[1]) + assert.Equal(t, 1, statement.args[2]) +} + +func TestGormContactRepository_Count_ReusesIndexFilterWithoutPagination(t *testing.T) { + repository, recorder := newContactTestRepo(t) + + total, err := repository.Count(context.Background(), entities.UserID("user-1"), IndexParams{ + Query: "alice", + // Limit/Skip must be ignored by Count. + Limit: 20, + Skip: 40, + }) + + require.NoError(t, err) + assert.Equal(t, int64(0), total) + + statement := lastContactStatement(t, recorder) + assert.True(t, strings.HasPrefix(statement.query, `SELECT count(*) FROM "contacts"`)) + // Count must apply the exact same user + name/emails/phone_numbers filter as Index. + assert.Contains(t, statement.query, `WHERE user_id = $1 AND (name ILIKE $2 OR array_to_string(emails, ',') ILIKE $3 OR array_to_string(phone_numbers, ',') ILIKE $4)`) + // Count must ignore pagination. + assert.NotContains(t, statement.query, "LIMIT") + assert.NotContains(t, statement.query, "OFFSET") + require.Len(t, statement.args, 4) + assert.Equal(t, entities.UserID("user-1"), statement.args[0]) + assert.Equal(t, "%alice%", statement.args[1]) + assert.Equal(t, "%alice%", statement.args[2]) + assert.Equal(t, "%alice%", statement.args[3]) +} + +func TestGormContactRepository_Count_ScopesByUserWithoutQuery(t *testing.T) { + repository, recorder := newContactTestRepo(t) + + _, err := repository.Count(context.Background(), entities.UserID("user-1"), IndexParams{}) + + require.NoError(t, err) + statement := lastContactStatement(t, recorder) + assert.Equal(t, `SELECT count(*) FROM "contacts" WHERE user_id = $1`, statement.query) + require.Len(t, statement.args, 1) + assert.Equal(t, entities.UserID("user-1"), statement.args[0]) +} + +func TestGormContactRepository_Index_FiltersByUserAndQueryAcrossContactFields(t *testing.T) { + repository, recorder := newContactTestRepo(t) + + _, err := repository.Index(context.Background(), entities.UserID("user-1"), IndexParams{ + Query: "alice", + Limit: 20, + Skip: 40, + }) + + require.NoError(t, err) + statement := lastContactStatement(t, recorder) + assert.True(t, strings.HasPrefix(statement.query, `SELECT * FROM "contacts"`)) + assert.Contains(t, statement.query, `WHERE user_id = $1 AND (name ILIKE $2 OR array_to_string(emails, ',') ILIKE $3 OR array_to_string(phone_numbers, ',') ILIKE $4)`) + assert.Contains(t, statement.query, `ORDER BY updated_at DESC, id DESC LIMIT $5 OFFSET $6`) + require.Len(t, statement.args, 6) + assert.Equal(t, entities.UserID("user-1"), statement.args[0]) + assert.Equal(t, "%alice%", statement.args[1]) + assert.Equal(t, "%alice%", statement.args[2]) + assert.Equal(t, "%alice%", statement.args[3]) + assert.Equal(t, 20, statement.args[4]) + assert.Equal(t, 40, statement.args[5]) +} + +func TestGormContactRepository_Index_OrdersByRequestedFieldAndDirection(t *testing.T) { + tests := []struct { + name string + params IndexParams + expectedOrderBy string + }{ + { + name: "name ascending", + params: IndexParams{SortBy: "name"}, + expectedOrderBy: "ORDER BY name ASC, id ASC", + }, + { + name: "name descending", + params: IndexParams{SortBy: "name", SortDescending: true}, + expectedOrderBy: "ORDER BY name DESC, id DESC", + }, + { + name: "updated descending", + params: IndexParams{SortBy: "updated_at", SortDescending: true}, + expectedOrderBy: "ORDER BY updated_at DESC, id DESC", + }, + { + name: "default", + params: IndexParams{}, + expectedOrderBy: "ORDER BY updated_at DESC, id DESC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repository, recorder := newContactTestRepo(t) + + _, err := repository.Index(context.Background(), entities.UserID("user-1"), tt.params) + + require.NoError(t, err) + statement := lastContactStatement(t, recorder) + assert.Contains(t, statement.query, tt.expectedOrderBy) + }) + } +} + +func TestGormContactRepository_FetchByPhoneNumbers_ScopesByUserAndRequestedNumbers(t *testing.T) { + repository, recorder := newContactTestRepo(t) + + _, err := repository.FetchByPhoneNumbers( + context.Background(), + entities.UserID("user-1"), + []string{"+18005550199", "+18005550100"}, + ) + + require.NoError(t, err) + statement := lastContactStatement(t, recorder) + assert.Equal(t, `SELECT * FROM "contacts" WHERE user_id = $1 AND phone_numbers && $2 ORDER BY updated_at ASC, id ASC`, statement.query) + require.Len(t, statement.args, 2) + assert.Equal(t, entities.UserID("user-1"), statement.args[0]) + assert.Equal(t, &pq.StringArray{"+18005550199", "+18005550100"}, statement.args[1]) +} + +func TestGormContactRepository_Delete_ScopesByUserAndContactID(t *testing.T) { + repository, recorder := newContactTestRepo(t) + contactID := uuid.MustParse("55555555-5555-5555-5555-555555555555") + + err := repository.Delete(context.Background(), entities.UserID("user-1"), contactID) + + require.NoError(t, err) + statement := lastContactStatement(t, recorder) + assert.Equal(t, `DELETE FROM "contacts" WHERE user_id = $1 AND id = $2`, statement.query) + require.Len(t, statement.args, 2) + assert.Equal(t, entities.UserID("user-1"), statement.args[0]) + assert.Equal(t, contactID, statement.args[1]) +} + +func TestGormContactRepository_DeleteAllForUser_ScopesByUserOnly(t *testing.T) { + repository, recorder := newContactTestRepo(t) + + err := repository.DeleteAllForUser(context.Background(), entities.UserID("user-1")) + + require.NoError(t, err) + statement := lastContactStatement(t, recorder) + assert.Equal(t, `DELETE FROM "contacts" WHERE user_id = $1`, statement.query) + require.Len(t, statement.args, 1) + assert.Equal(t, entities.UserID("user-1"), statement.args[0]) +} diff --git a/api/pkg/repositories/gorm_discord_repository.go b/api/pkg/repositories/gorm_discord_repository.go index a1d70faa4..9102b382a 100644 --- a/api/pkg/repositories/gorm_discord_repository.go +++ b/api/pkg/repositories/gorm_discord_repository.go @@ -7,8 +7,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" "gorm.io/gorm" ) @@ -37,7 +37,7 @@ func (repository *gormDiscordRepository) DeleteAllForUser(ctx context.Context, u defer span.End() if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Discord{}).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.Discord{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.Discord{}, userID)) } return nil @@ -48,7 +48,7 @@ func (repository *gormDiscordRepository) Save(ctx context.Context, Discord *enti defer span.End() if err := repository.db.WithContext(ctx).Save(Discord).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update discord integration with ID [%s]", Discord.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update discord integration with ID [%s]", Discord.ID)) } return nil @@ -67,7 +67,7 @@ func (repository *gormDiscordRepository) Index(ctx context.Context, userID entit discords := make([]*entities.Discord, 0) if err := query.Order("created_at DESC").Limit(params.Limit).Offset(params.Skip).Find(&discords).Error; err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch discord integrations for user [%s] and params [%+#v]", userID, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch discord integrations for user [%s] and params [%+#v]", userID, params)) } return discords, nil @@ -85,7 +85,7 @@ func (repository *gormDiscordRepository) FetchHavingIncomingChannel(ctx context. Where("incoming_channel_id != ?", ""). Find(&discords).Error if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load discord integrations for user with ID [%s] having a valid [incoming_channel_id]", userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load discord integrations for user with ID [%s] having a valid [incoming_channel_id]", userID)) } return discords, nil @@ -98,11 +98,11 @@ func (repository *gormDiscordRepository) Load(ctx context.Context, userID entiti discord := new(entities.Discord) err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("id = ?", discordID).First(&discord).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "discord integration with ID [%s] for user [%s] does not exist", discordID, userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "discord integration with ID [%s] for user [%s] does not exist", discordID, userID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load discord integration with ID [%s] for user [%s]", discordID, userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load discord integration with ID [%s] for user [%s]", discordID, userID)) } return discord, nil @@ -115,11 +115,11 @@ func (repository *gormDiscordRepository) FindByServerID(ctx context.Context, ser discord := new(entities.Discord) err := repository.db.WithContext(ctx).Where("server_id = ?", serverID).First(&discord).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "discord integration with server ID [%s] does not exist", serverID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "discord integration with server ID [%s] does not exist", serverID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load discord integration with serverID [%s]", serverID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load discord integration with serverID [%s]", serverID)) } return discord, nil @@ -134,7 +134,7 @@ func (repository *gormDiscordRepository) Delete(ctx context.Context, userID enti Where("id = ?", discordID). Delete(&entities.Discord{}).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete discord integration with ID [%s] and userID [%s]", discordID, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete discord integration with ID [%s] and userID [%s]", discordID, userID)) } return nil diff --git a/api/pkg/repositories/gorm_heartbeat_monitor_repository.go b/api/pkg/repositories/gorm_heartbeat_monitor_repository.go index c53e2368b..50a2cefb4 100644 --- a/api/pkg/repositories/gorm_heartbeat_monitor_repository.go +++ b/api/pkg/repositories/gorm_heartbeat_monitor_repository.go @@ -11,7 +11,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "gorm.io/gorm" ) @@ -40,7 +40,7 @@ func (repository *gormHeartbeatMonitorRepository) DeleteAllForUser(ctx context.C defer span.End() if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.HeartbeatMonitor{}).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.HeartbeatMonitor{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.HeartbeatMonitor{}, userID)) } return nil } @@ -62,7 +62,7 @@ func (repository *gormHeartbeatMonitorRepository) UpdatePhoneOnline(ctx context. "updated_at": time.Now().UTC(), }).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update heartbeat monitor ID [%s] for user [%s]", monitorID, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update heartbeat monitor ID [%s] for user [%s]", monitorID, userID)) } return nil } @@ -83,7 +83,7 @@ func (repository *gormHeartbeatMonitorRepository) UpdateQueueID(ctx context.Cont "updated_at": time.Now().UTC(), }).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update heartbeat monitor ID [%s]", monitorID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update heartbeat monitor ID [%s]", monitorID)) } return nil } @@ -100,7 +100,7 @@ func (repository *gormHeartbeatMonitorRepository) Delete(ctx context.Context, us Where("owner = ?", owner). Delete(&entities.HeartbeatMonitor{}).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete heartbeat monitor with owner [%s] and userID [%s]", owner, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete heartbeat monitor with owner [%s] and userID [%s]", owner, userID)) } return nil @@ -117,7 +117,7 @@ func (repository *gormHeartbeatMonitorRepository) Index(ctx context.Context, use query := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("owner = ?", owner) heartbeats := new([]entities.Heartbeat) if err := query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error; err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params)) } return heartbeats, nil @@ -132,7 +132,7 @@ func (repository *gormHeartbeatMonitorRepository) Store(ctx context.Context, hea defer cancel() if err := repository.db.WithContext(ctx).Create(heartbeatMonitor).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save heartbeatMonitor monitor with ID [%s]", heartbeatMonitor.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save heartbeatMonitor monitor with ID [%s]", heartbeatMonitor.ID)) } return nil @@ -152,11 +152,11 @@ func (repository *gormHeartbeatMonitorRepository) Load(ctx context.Context, user Where("owner = ?", owner). First(&phone).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "heartbeat monitor with userID [%s] and owner [%s] does not exist", userID, owner)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "heartbeat monitor with userID [%s] and owner [%s] does not exist", userID, owner)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load heartbeat monitor with userID [%s] and owner [%s]", userID, owner)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load heartbeat monitor with userID [%s] and owner [%s]", userID, owner)) } return phone, nil @@ -178,7 +178,7 @@ func (repository *gormHeartbeatMonitorRepository) Exists(ctx context.Context, us Where("id = ?", monitorID). Find(&exists).Error if err != nil { - return exists, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot check if heartbeat monitor exists with userID [%s] and montior ID [%s]", userID, monitorID)) + return exists, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot check if heartbeat monitor exists with userID [%s] and montior ID [%s]", userID, monitorID)) } return exists, nil diff --git a/api/pkg/repositories/gorm_heartbeat_repository.go b/api/pkg/repositories/gorm_heartbeat_repository.go index 8f77ef21e..940718171 100644 --- a/api/pkg/repositories/gorm_heartbeat_repository.go +++ b/api/pkg/repositories/gorm_heartbeat_repository.go @@ -8,7 +8,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "gorm.io/gorm" ) @@ -38,7 +38,7 @@ func (repository *gormHeartbeatRepository) DeleteAllForUser(ctx context.Context, err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Heartbeat{}).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.Heartbeat{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.Heartbeat{}, userID)) } return nil @@ -58,11 +58,11 @@ func (repository *gormHeartbeatRepository) Last(ctx context.Context, userID enti Order("timestamp DESC"). First(&heartbeat).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "heartbeat with userID [%s] and owner [%s] does not exist", userID, owner)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "heartbeat with userID [%s] and owner [%s] does not exist", userID, owner)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load heartbeat with userID [%s] and owner [%s]", userID, owner)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load heartbeat with userID [%s] and owner [%s]", userID, owner)) } return heartbeat, nil @@ -85,7 +85,7 @@ func (repository *gormHeartbeatRepository) Index(ctx context.Context, userID ent heartbeats := new([]entities.Heartbeat) err := query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params)) } return heartbeats, nil @@ -100,7 +100,7 @@ func (repository *gormHeartbeatRepository) Store(ctx context.Context, heartbeat defer cancel() if err := repository.db.WithContext(ctx).Create(heartbeat).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save heartbeat with ID [%s]", heartbeat.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save heartbeat with ID [%s]", heartbeat.ID)) } return nil diff --git a/api/pkg/repositories/gorm_integration_3cx_repository.go b/api/pkg/repositories/gorm_integration_3cx_repository.go index f6bbf0f7c..233f316c1 100644 --- a/api/pkg/repositories/gorm_integration_3cx_repository.go +++ b/api/pkg/repositories/gorm_integration_3cx_repository.go @@ -7,7 +7,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "gorm.io/gorm" ) @@ -36,7 +36,7 @@ func (repository *gormIntegration3CxRepository) DeleteAllForUser(ctx context.Con defer span.End() if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Integration3CX{}).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.Integration3CX{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.Integration3CX{}, userID)) } return nil @@ -50,11 +50,11 @@ func (repository *gormIntegration3CxRepository) Load(ctx context.Context, userID integration := new(entities.Integration3CX) err := repository.db.WithContext(ctx).Where("user_id = ?", userID).First(&integration).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "[3cx] integration for user [%s] does not exist", userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "[3cx] integration for user [%s] does not exist", userID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load [3cx] integration for user [%s]", userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load [3cx] integration for user [%s]", userID)) } return integration, nil @@ -66,7 +66,7 @@ func (repository *gormIntegration3CxRepository) Save(ctx context.Context, integr defer span.End() if err := repository.db.WithContext(ctx).Save(integration).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save [%T] with ID [%s]", integration, integration.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save [%T] with ID [%s]", integration, integration.ID)) } return nil diff --git a/api/pkg/repositories/gorm_message_repository.go b/api/pkg/repositories/gorm_message_repository.go index 0eea42d1d..a4bf309f5 100644 --- a/api/pkg/repositories/gorm_message_repository.go +++ b/api/pkg/repositories/gorm_message_repository.go @@ -9,9 +9,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/cockroachdb/cockroach-go/v2/crdb/crdbgorm" "github.com/google/uuid" - "github.com/palantir/stacktrace" "gorm.io/gorm" ) @@ -40,7 +40,7 @@ func (repository *gormMessageRepository) DeleteAllForUser(ctx context.Context, u defer span.End() if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Message{}).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.Message{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.Message{}, userID)) } return nil @@ -58,7 +58,7 @@ func (repository *gormMessageRepository) DeleteByOwnerAndContact(ctx context.Con Delete(&entities.Message{}). Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete messages between owner [%s] and contact [%s] for user with ID [%s]", owner, contact, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete messages between owner [%s] and contact [%s] for user with ID [%s]", owner, contact, userID)) } return nil @@ -71,7 +71,7 @@ func (repository *gormMessageRepository) Delete(ctx context.Context, userID enti err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("id = ?", messageID).Delete(&entities.Message{}).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete message with ID [%s] for user with ID [%s]", messageID, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete message with ID [%s] for user with ID [%s]", messageID, userID)) } return nil @@ -94,7 +94,7 @@ func (repository *gormMessageRepository) Index(ctx context.Context, userID entit messages := new([]entities.Message) if err := query.Order("order_timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&messages).Error; err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch messges with owner [%s] and contact [%s] and params [%+#v]", owner, contact, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch messges with owner [%s] and contact [%s] and params [%+#v]", owner, contact, params)) } return messages, nil @@ -114,11 +114,11 @@ func (repository *gormMessageRepository) LastMessage(ctx context.Context, userID err := query.Order("order_timestamp DESC").First(&message).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "cannot get last message for [%s] with owner [%s] and contact [%s]", userID, owner, contact)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "cannot get last message for [%s] with owner [%s] and contact [%s]", userID, owner, contact)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot get last message for [%s] with owner [%s] and contact [%s]", userID, owner, contact)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot get last message for [%s] with owner [%s] and contact [%s]", userID, owner, contact)) } return message, nil @@ -163,7 +163,7 @@ func (repository *gormMessageRepository) Search(ctx context.Context, userID enti Find(&messages). Error if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot search messages with for user [%s] params [%+#v]", userID, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot search messages with for user [%s] params [%+#v]", userID, params)) } return messages, nil @@ -193,7 +193,7 @@ func (repository *gormMessageRepository) GetBulkMessages(ctx context.Context, us LIMIT ? `, userID, limit).Scan(&orders).Error if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch bulk message orders for user [%s]", userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch bulk message orders for user [%s]", userID)) } return orders, nil @@ -205,7 +205,7 @@ func (repository *gormMessageRepository) Store(ctx context.Context, message *ent defer span.End() if err := repository.db.WithContext(ctx).Create(message).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save message with ID [%s]", message.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save message with ID [%s]", message.ID)) } return nil @@ -219,11 +219,11 @@ func (repository *gormMessageRepository) Load(ctx context.Context, userID entiti message := new(entities.Message) err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("id = ?", messageID).First(message).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "message with ID [%s] and userID [%s] does not exist", messageID, userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "message with ID [%s] and userID [%s] does not exist", messageID, userID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load message with ID [%s]", messageID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load message with ID [%s]", messageID)) } return message, nil @@ -235,7 +235,7 @@ func (repository *gormMessageRepository) Update(ctx context.Context, message *en defer span.End() if err := repository.db.WithContext(ctx).Save(message).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message with ID [%s]", message.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update message with ID [%s]", message.ID)) } return nil @@ -264,15 +264,15 @@ func (repository *gormMessageRepository) GetOutstanding(ctx context.Context, use }, ) if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "outstanding message with ID [%s] and userID [%s] does not exist", messageID, userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "outstanding message with ID [%s] and userID [%s] does not exist", messageID, userID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch outstanding message with userID [%s] and messageID [%s]", userID, messageID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch outstanding message with userID [%s] and messageID [%s]", userID, messageID)) } if message == nil || message.ID == uuid.Nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.NewErrorWithCode(ErrCodeNotFound, "outstanding message with ID [%s] and userID [%s] does not exist", messageID, userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.NewErrorWithCodef(ErrCodeNotFound, "outstanding message with ID [%s] and userID [%s] does not exist", messageID, userID)) } return message, nil diff --git a/api/pkg/repositories/gorm_message_send_schedule_repository.go b/api/pkg/repositories/gorm_message_send_schedule_repository.go index 54e588431..a3d0be6c1 100644 --- a/api/pkg/repositories/gorm_message_send_schedule_repository.go +++ b/api/pkg/repositories/gorm_message_send_schedule_repository.go @@ -7,8 +7,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" "gorm.io/gorm" ) @@ -41,7 +41,7 @@ func (r *gormMessageSendScheduleRepository) Store( defer span.End() if err := r.db.WithContext(ctx).Create(schedule).Error; err != nil { - return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot store send schedule [%s]", schedule.ID)) + return r.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store send schedule [%s]", schedule.ID)) } return nil @@ -56,7 +56,7 @@ func (r *gormMessageSendScheduleRepository) Update( defer span.End() if err := r.db.WithContext(ctx).Save(schedule).Error; err != nil { - return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update send schedule [%s]", schedule.ID)) + return r.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update send schedule [%s]", schedule.ID)) } return nil @@ -79,11 +79,11 @@ func (r *gormMessageSendScheduleRepository) Load( if errors.Is(err, gorm.ErrRecordNotFound) { return nil, r.tracer.WrapErrorSpan( span, - stacktrace.PropagateWithCode(err, ErrCodeNotFound, "send schedule [%s] not found for user with ID [%s]", scheduleID, userID), + stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "send schedule [%s] not found for user with ID [%s]", scheduleID, userID), ) } if err != nil { - return nil, r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load send schedule [%s]", scheduleID)) + return nil, r.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load send schedule [%s]", scheduleID)) } return item, nil @@ -103,7 +103,7 @@ func (r *gormMessageSendScheduleRepository) Index( Order("created_at DESC"). Find(&items).Error if err != nil { - return nil, r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot index send schedules for user [%s]", userID)) + return nil, r.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot index send schedules for user [%s]", userID)) } return items, nil @@ -123,7 +123,7 @@ func (r *gormMessageSendScheduleRepository) Delete( Where("id = ?", scheduleID). Delete(&entities.MessageSendSchedule{}).Error if err != nil { - return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete send schedule [%s]", scheduleID)) + return r.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete send schedule [%s]", scheduleID)) } return nil @@ -141,7 +141,7 @@ func (r *gormMessageSendScheduleRepository) DeleteAllForUser( Where("user_id = ?", userID). Delete(&entities.MessageSendSchedule{}).Error if err != nil { - return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete send schedules for user [%s]", userID)) + return r.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete send schedules for user [%s]", userID)) } return nil @@ -161,7 +161,7 @@ func (r *gormMessageSendScheduleRepository) CountByUser( Where("user_id = ?", userID). Count(&count).Error if err != nil { - return 0, r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot count send schedules for user [%s]", userID)) + return 0, r.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot count send schedules for user [%s]", userID)) } return int(count), nil diff --git a/api/pkg/repositories/gorm_message_thread_repository.go b/api/pkg/repositories/gorm_message_thread_repository.go index 7bc07f7b3..c30091b9b 100644 --- a/api/pkg/repositories/gorm_message_thread_repository.go +++ b/api/pkg/repositories/gorm_message_thread_repository.go @@ -11,7 +11,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "gorm.io/gorm" ) @@ -35,12 +35,47 @@ func NewGormMessageThreadRepository( } } +func messageThreadActivityUpdates(params MessageThreadActivityUpdate) map[string]any { + updates := map[string]any{ + "order_timestamp": params.Timestamp, + "last_message_id": params.MessageID, + "last_message_content": params.Content, + "status": params.Status, + } + if params.Unarchive { + updates["is_archived"] = false + } + if params.MarkAsUnread { + updates["unread_count"] = gorm.Expr("unread_count + ?", 1) + } + return updates +} + +func messageThreadDeletedUpdates(params MessageThreadDeletedUpdate) map[string]any { + return map[string]any{ + "last_message_id": params.LastMessageID, + "last_message_content": params.LastMessageContent, + "status": params.LastMessageStatus, + } +} + +func messageThreadStatusUpdates(params MessageThreadStatusUpdate) map[string]any { + updates := make(map[string]any) + if params.IsArchived != nil { + updates["is_archived"] = *params.IsArchived + } + if params.UnreadCount != nil { + updates["unread_count"] = *params.UnreadCount + } + return updates +} + func (repository *gormMessageThreadRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { ctx, span := repository.tracer.Start(ctx) defer span.End() if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.MessageThread{}).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.MessageThread{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.MessageThread{}, userID)) } return nil @@ -53,27 +88,24 @@ func (repository *gormMessageThreadRepository) Delete(ctx context.Context, userI err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("id = ?", messageThreadID).Delete(&entities.MessageThread{}).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete message thread with ID [%s] for user with ID [%s]", messageThreadID, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete message thread with ID [%s] for user with ID [%s]", messageThreadID, userID)) } return nil } // UpdateAfterDeletedMessage updates a thread after the original message has been deleted -func (repository *gormMessageThreadRepository) UpdateAfterDeletedMessage(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error { +func (repository *gormMessageThreadRepository) UpdateAfterDeletedMessage(ctx context.Context, params MessageThreadDeletedUpdate) error { ctx, span := repository.tracer.Start(ctx) defer span.End() - err := repository.db.WithContext(ctx).Model(&entities.MessageThread{}). - Where("user_id = ?", userID). - Where("last_message_id = ?", messageID). - Updates(map[string]any{ - "last_message_id": nil, - "last_message_content": nil, - "status": entities.MessageStatusDeleted, - }).Error - if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread after message is deleted with userID [%s] and messageID [%s]", userID, messageID)) + result := repository.db.WithContext(ctx). + Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID). + Updates(messageThreadDeletedUpdates(params)) + if result.Error != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(result.Error, "cannot update deleted-message metadata for thread [%s]", params.MessageThreadID)) } return nil @@ -84,25 +116,74 @@ func (repository *gormMessageThreadRepository) Store(ctx context.Context, thread ctx, span := repository.tracer.Start(ctx) defer span.End() - if err := repository.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(thread).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save message thread with ID [%s]", thread.ID)) + db := repository.db.WithContext(ctx).Session(&gorm.Session{SkipDefaultTransaction: true}) + onConflict := clause.OnConflict{ + Columns: []clause.Column{ + {Name: "user_id"}, + {Name: "owner"}, + {Name: "contact"}, + }, + DoNothing: thread.UnreadCount == 0, + } + if thread.UnreadCount > 0 { + onConflict.DoUpdates = clause.Assignments(map[string]any{ + "unread_count": gorm.Expr("message_threads.unread_count + ?", 1), + }) } + if result := db.Clauses(onConflict).Create(thread); result.Error != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(result.Error, "cannot insert message thread with ID [%s]", thread.ID)) + } return nil } -// Update a new entities.MessageThread -func (repository *gormMessageThreadRepository) Update(ctx context.Context, thread *entities.MessageThread) error { +// UpdateActivity persists the last-message activity fields for a thread +func (repository *gormMessageThreadRepository) UpdateActivity(ctx context.Context, params MessageThreadActivityUpdate) error { ctx, span := repository.tracer.Start(ctx) defer span.End() - if err := repository.db.WithContext(ctx).Save(thread).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message thread thread with ID [%s]", thread.ID)) + result := repository.db.WithContext(ctx). + Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID). + Updates(messageThreadActivityUpdates(params)) + if result.Error != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(result.Error, "cannot update message activity for thread [%s]", params.MessageThreadID)) + } + if result.RowsAffected == 0 { + return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, ErrCodeNotFound, "thread with id [%s] not found", params.MessageThreadID)) } return nil } +// UpdateStatus persists archive/read status fields for a thread +func (repository *gormMessageThreadRepository) UpdateStatus( + ctx context.Context, + userID entities.UserID, + messageThreadID uuid.UUID, + params MessageThreadStatusUpdate, +) (*entities.MessageThread, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + thread := new(entities.MessageThread) + result := repository.db.WithContext(ctx). + Model(thread). + Clauses(clause.Returning{}). + Where("user_id = ?", userID). + Where("id = ?", messageThreadID). + Updates(messageThreadStatusUpdates(params)) + if result.Error != nil { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(result.Error, "cannot update status for thread [%s] and user [%s]", messageThreadID, userID)) + } + if result.RowsAffected == 0 { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, ErrCodeNotFound, "thread with id [%s] not found for user with ID [%s]", messageThreadID, userID)) + } + + return thread, nil +} + // LoadByOwnerContact a thread between 2 users func (repository *gormMessageThreadRepository) LoadByOwnerContact(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.MessageThread, error) { ctx, span := repository.tracer.Start(ctx) @@ -118,11 +199,11 @@ func (repository *gormMessageThreadRepository) LoadByOwnerContact(ctx context.Co First(thread). Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "thread with owner [%s] and contact [%s] does not exist", owner, contact)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "thread with owner [%s] and contact [%s] does not exist", owner, contact)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load thread with owner [%s] and contact [%s]", owner, contact)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load thread with owner [%s] and contact [%s]", owner, contact)) } return thread, nil @@ -142,11 +223,11 @@ func (repository *gormMessageThreadRepository) Load(ctx context.Context, userID First(thread). Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "thread with id [%s] not found", ID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "thread with id [%s] not found", ID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "thread with id [%s]", ID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "thread with id [%s]", ID)) } return thread, nil @@ -179,7 +260,7 @@ func (repository *gormMessageThreadRepository) Index(ctx context.Context, userID threads := new([]entities.MessageThread) if err := query.Order("order_timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&threads).Error; err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch message threads with owner [%s] and params [%+#v]", owner, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch message threads with owner [%s] and params [%+#v]", owner, params)) } return threads, nil diff --git a/api/pkg/repositories/gorm_message_thread_repository_test.go b/api/pkg/repositories/gorm_message_thread_repository_test.go new file mode 100644 index 000000000..5ca7d4a3f --- /dev/null +++ b/api/pkg/repositories/gorm_message_thread_repository_test.go @@ -0,0 +1,195 @@ +package repositories + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "strings" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +type messageThreadTestStatement struct { + query string + args []any +} + +type messageThreadTestConnPool struct { + statements []messageThreadTestStatement +} + +func (messageThreadTestConnPool) PrepareContext(context.Context, string) (*sql.Stmt, error) { + return nil, errors.New("unexpected PrepareContext") +} + +func (pool *messageThreadTestConnPool) ExecContext(_ context.Context, query string, args ...any) (sql.Result, error) { + pool.statements = append(pool.statements, messageThreadTestStatement{ + query: query, + args: append([]any(nil), args...), + }) + return driver.RowsAffected(1), nil +} + +func (messageThreadTestConnPool) QueryContext(context.Context, string, ...any) (*sql.Rows, error) { + return nil, errors.New("unexpected QueryContext") +} + +func (messageThreadTestConnPool) QueryRowContext(context.Context, string, ...any) *sql.Row { + return &sql.Row{} +} + +func (pool *messageThreadTestConnPool) BeginTx(context.Context, *sql.TxOptions) (gorm.ConnPool, error) { + return pool, nil +} + +func (*messageThreadTestConnPool) Commit() error { + return nil +} + +func (*messageThreadTestConnPool) Rollback() error { + return nil +} + +type messageThreadTestLogger struct{} + +func (logger *messageThreadTestLogger) Error(error) {} +func (logger *messageThreadTestLogger) WithService(string) telemetry.Logger { return logger } + +func (logger *messageThreadTestLogger) WithString(string, string) telemetry.Logger { return logger } + +func (logger *messageThreadTestLogger) WithSpan(trace.SpanContext) telemetry.Logger { return logger } +func (logger *messageThreadTestLogger) Trace(string) {} +func (logger *messageThreadTestLogger) Info(string) {} +func (logger *messageThreadTestLogger) Warn(error) {} +func (logger *messageThreadTestLogger) Debug(string) {} +func (logger *messageThreadTestLogger) Fatal(error) {} +func (logger *messageThreadTestLogger) Printf(string, ...interface{}) {} + +func TestMessageThreadStoreIncrementsUnreadCountOnConflict(t *testing.T) { + pool := &messageThreadTestConnPool{} + db, err := gorm.Open( + postgres.New(postgres.Config{ + Conn: pool, + WithoutReturning: true, + }), + &gorm.Config{DisableAutomaticPing: true}, + ) + require.NoError(t, err) + + logger := &messageThreadTestLogger{} + repository := NewGormMessageThreadRepository(logger, telemetry.NewOtelLogger("test", logger), db) + thread := &entities.MessageThread{ + ID: uuid.New(), + UnreadCount: 1, + } + + require.NoError(t, repository.Store(context.Background(), thread)) + assert.Equal(t, uint(1), thread.UnreadCount) + + require.NotEmpty(t, pool.statements) + insert := pool.statements[len(pool.statements)-1] + assert.True(t, strings.HasPrefix(insert.query, `INSERT INTO "message_threads"`)) + assert.Contains(t, insert.query, `ON CONFLICT ("user_id","owner","contact") DO UPDATE SET "unread_count"=message_threads.unread_count + $`) +} + +func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) { + messageID := uuid.New() + updates := messageThreadActivityUpdates(MessageThreadActivityUpdate{ + Timestamp: time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), + MessageID: messageID, + Content: "hello", + Status: entities.MessageStatusReceived, + }) + + assert.Equal(t, map[string]any{ + "order_timestamp": time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), + "last_message_id": messageID, + "last_message_content": "hello", + "status": entities.MessageStatus(entities.MessageStatusReceived), + }, updates) + assert.NotContains(t, updates, "unread_count") + assert.NotContains(t, updates, "is_archived") +} + +func TestUpdateActivityMarksUnreadWithOneQuery(t *testing.T) { + pool := &messageThreadTestConnPool{} + db, err := gorm.Open( + postgres.New(postgres.Config{ + Conn: pool, + WithoutReturning: true, + }), + &gorm.Config{DisableAutomaticPing: true}, + ) + require.NoError(t, err) + + logger := &messageThreadTestLogger{} + repository := NewGormMessageThreadRepository(logger, telemetry.NewOtelLogger("test", logger), db) + + err = repository.UpdateActivity(context.Background(), MessageThreadActivityUpdate{ + MessageThreadID: uuid.New(), + UserID: entities.UserID("user-id"), + Timestamp: time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC), + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + MarkAsUnread: true, + EventTimestamp: time.Date(2026, 7, 19, 10, 0, 1, 0, time.UTC), + }) + + require.NoError(t, err) + var updates []messageThreadTestStatement + for _, statement := range pool.statements { + if strings.HasPrefix(statement.query, `UPDATE "message_threads"`) { + updates = append(updates, statement) + } + } + require.Len(t, updates, 1) + assert.Contains(t, updates[0].query, `"unread_count"=unread_count + $`) +} + +func TestMessageThreadDeletedUpdatesPreserveStatusType(t *testing.T) { + messageID := uuid.New() + content := "previous message" + updates := messageThreadDeletedUpdates(MessageThreadDeletedUpdate{ + LastMessageID: &messageID, + LastMessageContent: &content, + LastMessageStatus: entities.MessageStatusDelivered, + }) + + assert.Equal(t, map[string]any{ + "last_message_id": &messageID, + "last_message_content": &content, + "status": entities.MessageStatus(entities.MessageStatusDelivered), + }, updates) +} + +func TestMessageThreadStatusUpdatesUnreadCountOnly(t *testing.T) { + unreadCount := uint(0) + + updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ + UnreadCount: &unreadCount, + }) + + assert.Equal(t, map[string]any{"unread_count": uint(0)}, updates) + assert.NotContains(t, updates, "is_archived") +} + +func TestMessageThreadStatusUpdatesArchiveOnly(t *testing.T) { + isArchived := true + + updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ + IsArchived: &isArchived, + }) + assert.Equal(t, map[string]any{"is_archived": true}, updates) + assert.NotContains(t, updates, "unread_count") +} diff --git a/api/pkg/repositories/gorm_phone_api_key_repository.go b/api/pkg/repositories/gorm_phone_api_key_repository.go index 7f912b28b..aa64436c6 100644 --- a/api/pkg/repositories/gorm_phone_api_key_repository.go +++ b/api/pkg/repositories/gorm_phone_api_key_repository.go @@ -10,9 +10,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/dgraph-io/ristretto/v2" "github.com/google/uuid" - "github.com/palantir/stacktrace" "gorm.io/gorm" ) @@ -53,7 +53,7 @@ WHERE user_id = ? AND array_position(phone_ids, ?) IS NOT NULL; Exec(query, phoneID, phoneNumber, userID, phoneID). Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot remove phone with ID [%s] and number [%s] for user with ID [%s] ", phoneID, phoneNumber, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot remove phone with ID [%s] and number [%s] for user with ID [%s] ", phoneID, phoneNumber, userID)) } repository.cache.Clear() @@ -71,7 +71,7 @@ func (repository *gormPhoneAPIKeyRepository) CountByUser(ctx context.Context, us Where("user_id = ?", userID). Count(&count).Error if err != nil { - return 0, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot count phone API keys for user [%s]", userID)) + return 0, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot count phone API keys for user [%s]", userID)) } return int(count), nil @@ -85,11 +85,11 @@ func (repository *gormPhoneAPIKeyRepository) Load(ctx context.Context, userID en phoneAPIKey := new(entities.PhoneAPIKey) err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("id = ?", phoneAPIKeyID).First(&phoneAPIKey).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "[%T] with ID [%s] for user with ID [%s] does not exist", phoneAPIKey, phoneAPIKeyID, userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "[%T] with ID [%s] for user with ID [%s] does not exist", phoneAPIKey, phoneAPIKeyID, userID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load [%T] with ID [%s] for user with ID [%s]", phoneAPIKey, phoneAPIKeyID, userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load [%T] with ID [%s] for user with ID [%s]", phoneAPIKey, phoneAPIKeyID, userID)) } return phoneAPIKey, nil @@ -100,7 +100,7 @@ func (repository *gormPhoneAPIKeyRepository) Create(ctx context.Context, phoneAP defer span.End() if err := repository.db.WithContext(ctx).Create(phoneAPIKey).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save phone API key with ID [%s] for user with ID [%s]", phoneAPIKey.ID, phoneAPIKey.UserID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save phone API key with ID [%s] for user with ID [%s]", phoneAPIKey.ID, phoneAPIKey.UserID)) } return nil @@ -117,11 +117,11 @@ func (repository *gormPhoneAPIKeyRepository) LoadAuthContext(ctx context.Context phoneAPIKey := new(entities.PhoneAPIKey) err := repository.db.WithContext(ctx).Where("api_key = ?", apiKey).First(phoneAPIKey).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "phone api key [%s] does not exist", apiKey)) + return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "phone api key [%s] does not exist", apiKey)) } if err != nil { - return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load phone api key [%s]", apiKey)) + return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load phone api key [%s]", apiKey)) } authUser := entities.AuthContext{ @@ -132,7 +132,7 @@ func (repository *gormPhoneAPIKeyRepository) LoadAuthContext(ctx context.Context } if result := repository.cache.SetWithTTL(apiKey, authUser, 1, 15*time.Second); !result { - ctxLogger.Error(repository.tracer.WrapErrorSpan(span, stacktrace.NewError("cannot cache [%T] with ID [%s] and result [%t]", authUser, phoneAPIKey.ID, result))) + ctxLogger.Error(repository.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("cannot cache [%T] with ID [%s] and result [%t]", authUser, phoneAPIKey.ID, result))) } return authUser, nil @@ -150,7 +150,7 @@ func (repository *gormPhoneAPIKeyRepository) Index(ctx context.Context, userID e phoneAPIKeys := new([]*entities.PhoneAPIKey) if err := query.Order("created_at DESC").Limit(params.Limit).Offset(params.Skip).Find(phoneAPIKeys).Error; err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch phone API Keys with userID [%s] and params [%+#v]", userID, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch phone API Keys with userID [%s] and params [%+#v]", userID, params)) } return *phoneAPIKeys, nil @@ -162,7 +162,7 @@ func (repository *gormPhoneAPIKeyRepository) Delete(ctx context.Context, phoneAP err := repository.db.WithContext(ctx).Delete(phoneAPIKey).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete phone API key with ID [%s] and userID [%s]", phoneAPIKey.ID, phoneAPIKey.UserID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete phone API key with ID [%s] and userID [%s]", phoneAPIKey.ID, phoneAPIKey.UserID)) } repository.cache.Del(phoneAPIKey.APIKey) @@ -184,7 +184,7 @@ WHERE user_id = ?; Exec(query, phone.ID, phone.PhoneNumber, phone.UserID). Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot remove phone with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone.ID, phoneAPIKey.ID, phone.UserID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot remove phone with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone.ID, phoneAPIKey.ID, phone.UserID)) } query = ` @@ -197,13 +197,13 @@ WHERE array_position(phone_ids, ?) IS NULL AND id = ?; Exec(query, phone.ID, phone.PhoneNumber, phone.ID, phoneAPIKey.ID). Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot add [%T] with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone, phone.ID, phoneAPIKey.ID, phone.UserID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot add [%T] with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone, phone.ID, phoneAPIKey.ID, phone.UserID)) } return nil }) if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot add [%T] with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone, phone.ID, phoneAPIKey.ID, phone.UserID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot add [%T] with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone, phone.ID, phoneAPIKey.ID, phone.UserID)) } repository.cache.Clear() return nil @@ -223,7 +223,7 @@ WHERE id = ?; Exec(query, phone.ID, phone.PhoneNumber, phoneAPIKey.ID). Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot remove phone with ID [%s] from phone API key with ID [%s]", phone.ID, phoneAPIKey.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot remove phone with ID [%s] from phone API key with ID [%s]", phone.ID, phoneAPIKey.ID)) } return nil @@ -234,7 +234,7 @@ func (repository *gormPhoneAPIKeyRepository) DeleteAllForUser(ctx context.Contex defer span.End() if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.PhoneAPIKey{}).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.PhoneAPIKey{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.PhoneAPIKey{}, userID)) } return nil diff --git a/api/pkg/repositories/gorm_phone_notification_repository.go b/api/pkg/repositories/gorm_phone_notification_repository.go index ffe9352e9..c83aaf380 100644 --- a/api/pkg/repositories/gorm_phone_notification_repository.go +++ b/api/pkg/repositories/gorm_phone_notification_repository.go @@ -11,7 +11,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "gorm.io/gorm" ) @@ -48,7 +48,7 @@ func (repository *gormPhoneNotificationRepository) DeleteAllForUser( Delete(&entities.PhoneNotification{}).Error; err != nil { return repository.tracer.WrapErrorSpan( span, - stacktrace.Propagate( + stacktrace.Propagatef( err, "cannot delete all [%T] for user with ID [%s]", &entities.PhoneNotification{}, @@ -71,7 +71,7 @@ func (repository *gormPhoneNotificationRepository) DeleteByMessageID(ctx context if err != nil { return repository.tracer.WrapErrorSpan( span, - stacktrace.Propagate(err, "cannot delete [%T] for user [%s] and message with ID [%s]", &entities.PhoneNotification{}, userID, messageID), + stacktrace.Propagatef(err, "cannot delete [%T] for user [%s] and message with ID [%s]", &entities.PhoneNotification{}, userID, messageID), ) } @@ -91,7 +91,7 @@ func (repository *gormPhoneNotificationRepository) UpdateStatus(ctx context.Cont if err != nil { return repository.tracer.WrapErrorSpan( span, - stacktrace.Propagate( + stacktrace.Propagatef( err, "cannot update notification [%s] with status [%s]", notificationID, @@ -132,7 +132,7 @@ func (repository *gormPhoneNotificationRepository) Schedule( First(lastNotification). Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return stacktrace.Propagate( + return stacktrace.Propagatef( err, "cannot fetch last notification with phone ID [%s]", notification.PhoneID, @@ -151,7 +151,7 @@ func (repository *gormPhoneNotificationRepository) Schedule( } if err = tx.WithContext(ctx).Create(notification).Error; err != nil { - return stacktrace.Propagate( + return stacktrace.Propagatef( err, "cannot create new notification with id [%s] and schedule [%s]", notification.ID, @@ -164,7 +164,7 @@ func (repository *gormPhoneNotificationRepository) Schedule( if err != nil { return repository.tracer.WrapErrorSpan( span, - stacktrace.Propagate( + stacktrace.Propagatef( err, "cannot schedule phone notification with ID [%s]", notification.ID, @@ -207,7 +207,7 @@ func (repository *gormPhoneNotificationRepository) insert( if err := repository.db.WithContext(ctx).Create(notification).Error; err != nil { return repository.tracer.WrapErrorSpan( span, - stacktrace.Propagate( + stacktrace.Propagatef( err, "cannot store notification with id [%s]", notification.ID, @@ -235,7 +235,7 @@ func (repository *gormPhoneNotificationRepository) ScheduleExact( Count(&count).Error; err != nil { return repository.tracer.WrapErrorSpan( span, - stacktrace.Propagate(err, "cannot check for existing notification for message [%s]", notification.MessageID), + stacktrace.Propagatef(err, "cannot check for existing notification for message [%s]", notification.MessageID), ) } @@ -246,7 +246,7 @@ func (repository *gormPhoneNotificationRepository) ScheduleExact( if err := repository.db.WithContext(ctx).Create(notification).Error; err != nil { return repository.tracer.WrapErrorSpan( span, - stacktrace.Propagate(err, "cannot create exact-time notification with id [%s]", notification.ID), + stacktrace.Propagatef(err, "cannot create exact-time notification with id [%s]", notification.ID), ) } diff --git a/api/pkg/repositories/gorm_phone_repository.go b/api/pkg/repositories/gorm_phone_repository.go index f57cdbe10..bf6d69e43 100644 --- a/api/pkg/repositories/gorm_phone_repository.go +++ b/api/pkg/repositories/gorm_phone_repository.go @@ -8,9 +8,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/dgraph-io/ristretto/v2" "github.com/google/uuid" - "github.com/palantir/stacktrace" "gorm.io/gorm" ) @@ -42,7 +42,7 @@ func (repository *gormPhoneRepository) DeleteAllForUser(ctx context.Context, use defer span.End() if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Phone{}).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.Phone{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.Phone{}, userID)) } repository.cache.Clear() @@ -60,7 +60,7 @@ func (repository *gormPhoneRepository) NullifyScheduleID(ctx context.Context, us Where("message_send_schedule_id = ?", scheduleID). Update("message_send_schedule_id", nil).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot nullify message_send_schedule_id [%s] for user [%s]", scheduleID, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot nullify message_send_schedule_id [%s] for user [%s]", scheduleID, userID)) } repository.cache.Clear() @@ -78,11 +78,11 @@ func (repository *gormPhoneRepository) LoadByID(ctx context.Context, userID enti Where("id = ?", phoneID). First(&phone).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "phone with ID [%s] does not exist", phoneID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "phone with ID [%s] does not exist", phoneID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load phone with ID [%s]", phoneID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load phone with ID [%s]", phoneID)) } return phone, nil @@ -98,7 +98,7 @@ func (repository *gormPhoneRepository) Delete(ctx context.Context, userID entiti Where("id = ?", phoneID). Delete(&entities.Phone{}).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete phone with ID [%s] and userID [%s]", phoneID, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete phone with ID [%s] and userID [%s]", phoneID, userID)) } repository.cache.Clear() @@ -115,14 +115,14 @@ func (repository *gormPhoneRepository) Save(ctx context.Context, phone *entities ctxLogger.Info(fmt.Sprintf("phone with user [%s] and number[%s] already exists", phone.UserID, phone.PhoneNumber)) loadedPhone, err := repository.Load(ctx, phone.UserID, phone.PhoneNumber) if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load phone for user [%s] and number [%s]", phone.UserID, phone.PhoneNumber)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load phone for user [%s] and number [%s]", phone.UserID, phone.PhoneNumber)) } *phone = *loadedPhone return nil } if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save phone with ID [%s]", phone.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save phone with ID [%s]", phone.ID)) } repository.cache.Del(repository.getCacheKey(phone.UserID, phone.PhoneNumber)) @@ -141,15 +141,15 @@ func (repository *gormPhoneRepository) Load(ctx context.Context, userID entities phone := new(entities.Phone) err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("phone_number = ?", phoneNumber).First(phone).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "phone with userID [%s] and phoneNumber [%s] does not exist", userID, phoneNumber)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "phone with userID [%s] and phoneNumber [%s] does not exist", userID, phoneNumber)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load phone phone with userID [%s] and phoneNumber [%s]", userID, phoneNumber)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load phone phone with userID [%s] and phoneNumber [%s]", userID, phoneNumber)) } if result := repository.cache.SetWithTTL(repository.getCacheKey(userID, phoneNumber), phone, 1, 30*time.Minute); !result { - ctxLogger.Error(repository.tracer.WrapErrorSpan(span, stacktrace.NewError("cannot cache [%T] with ID [%s] and result [%t]", phone, phone.ID, result))) + ctxLogger.Error(repository.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("cannot cache [%T] with ID [%s] and result [%t]", phone, phone.ID, result))) } return phone, nil @@ -167,7 +167,7 @@ func (repository *gormPhoneRepository) Index(ctx context.Context, userID entitie phones := new([]entities.Phone) if err := query.Order("created_at DESC").Limit(params.Limit).Offset(params.Skip).Find(&phones).Error; err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch phones with userID [%s] and params [%+#v]", userID, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch phones with userID [%s] and params [%+#v]", userID, params)) } return phones, nil diff --git a/api/pkg/repositories/gorm_user_repository.go b/api/pkg/repositories/gorm_user_repository.go index a2c95e5ef..f7c74f318 100644 --- a/api/pkg/repositories/gorm_user_repository.go +++ b/api/pkg/repositories/gorm_user_repository.go @@ -15,7 +15,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "gorm.io/gorm" ) @@ -48,7 +48,7 @@ func (repository *gormUserRepository) Delete(ctx context.Context, user *entities defer span.End() if err := repository.db.WithContext(ctx).Delete(user).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete user with ID [%s]", user.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete user with ID [%s]", user.ID)) } return nil @@ -60,7 +60,7 @@ func (repository *gormUserRepository) RotateAPIKey(ctx context.Context, userID e apiKey, err := repository.generateAPIKey(64) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate apiKey for user [%s]", userID) + return nil, stacktrace.Propagatef(err, "cannot generate apiKey for user [%s]", userID) } user := new(entities.User) @@ -79,7 +79,7 @@ func (repository *gormUserRepository) RotateAPIKey(ctx context.Context, userID e }, ) if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "user with ID [%s] does not exist", userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "user with ID [%s] does not exist", userID)) } if err == nil && oldAPIKey != "" { @@ -102,11 +102,11 @@ func (repository *gormUserRepository) LoadBySubscriptionID(ctx context.Context, First(user). Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "user with subscriptionID [%s] does not exist", subscriptionID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "user with subscriptionID [%s] does not exist", subscriptionID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load user with subscription ID [%s]", subscriptionID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with subscription ID [%s]", subscriptionID)) } return user, nil @@ -122,11 +122,11 @@ func (repository *gormUserRepository) LoadByEmail(ctx context.Context, email str First(user). Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "user with email [%s] does not exist", email)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "user with email [%s] does not exist", email)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load user with email ID [%s]", email)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with email ID [%s]", email)) } return user, nil @@ -137,7 +137,7 @@ func (repository *gormUserRepository) Store(ctx context.Context, user *entities. defer span.End() if err := repository.db.WithContext(ctx).Create(user).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save user with ID [%s]", user.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save user with ID [%s]", user.ID)) } return nil @@ -148,7 +148,7 @@ func (repository *gormUserRepository) Update(ctx context.Context, user *entities defer span.End() if err := repository.db.WithContext(ctx).Save(user).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update user with ID [%s]", user.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update user with ID [%s]", user.ID)) } return nil @@ -160,7 +160,7 @@ func (repository *gormUserRepository) LoadAuthContext(ctx context.Context, apiKe if authUser, found := repository.cache.Get(apiKey); found { if authUser.IsNoop() { - return authUser, repository.tracer.WrapErrorSpan(span, stacktrace.NewError("user with api key [%s] does not exist", apiKey)) + return authUser, repository.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("user with api key [%s] does not exist", apiKey)) } return authUser, nil } @@ -170,11 +170,11 @@ func (repository *gormUserRepository) LoadAuthContext(ctx context.Context, apiKe if errors.Is(err, gorm.ErrRecordNotFound) { repository.cache.SetWithTTL(apiKey, entities.AuthContext{}, 1, 2*time.Hour) - return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "user with api key [%s] does not exist", apiKey)) + return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "user with api key [%s] does not exist", apiKey)) } if err != nil { - return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load user with api key [%s]", apiKey)) + return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with api key [%s]", apiKey)) } authUser := entities.AuthContext{ @@ -183,7 +183,7 @@ func (repository *gormUserRepository) LoadAuthContext(ctx context.Context, apiKe } if result := repository.cache.SetWithTTL(apiKey, authUser, 1, 2*time.Hour); !result { - ctxLogger.Error(repository.tracer.WrapErrorSpan(span, stacktrace.NewError("cannot cache [%T] with ID [%s] and result [%t]", authUser, user.ID, result))) + ctxLogger.Error(repository.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("cannot cache [%T] with ID [%s] and result [%t]", authUser, user.ID, result))) } return authUser, nil @@ -196,11 +196,11 @@ func (repository *gormUserRepository) Load(ctx context.Context, userID entities. user := new(entities.User) err := repository.db.WithContext(ctx).First(user, userID).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "user with ID [%s] does not exist", userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "user with ID [%s] does not exist", userID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load user with ID [%s]", userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with ID [%s]", userID)) } return user, nil @@ -217,7 +217,7 @@ func (repository *gormUserRepository) LoadOrStore(ctx context.Context, authUser apiKey, err := repository.generateAPIKey(64) if err != nil { - return nil, false, stacktrace.Propagate(err, "cannot generate apiKey for user [%s]", authUser.ID) + return nil, false, stacktrace.Propagatef(err, "cannot generate apiKey for user [%s]", authUser.ID) } user = &entities.User{ @@ -241,7 +241,7 @@ func (repository *gormUserRepository) LoadOrStore(ctx context.Context, authUser return result.Error }) if err != nil { - return user, isNew, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create user from auth user [%+#v]", authUser)) + return user, isNew, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create user from auth user [%+#v]", authUser)) } return user, isNew, nil @@ -255,7 +255,7 @@ func (repository *gormUserRepository) generateRandomBytes(n int) ([]byte, error) b := make([]byte, n) // Note that err == nil only if we read len(b) bytes. if _, err := rand.Read(b); err != nil { - return nil, stacktrace.Propagate(err, "cannot generate [%d] random bytes", n) + return nil, stacktrace.Propagatef(err, "cannot generate [%d] random bytes", n) } return b, nil @@ -268,5 +268,5 @@ func (repository *gormUserRepository) generateRandomBytes(n int) ([]byte, error) // case the caller should not continue. func (repository *gormUserRepository) generateAPIKey(n int) (string, error) { b, err := repository.generateRandomBytes(n) - return base64.URLEncoding.EncodeToString(b)[0:n], stacktrace.Propagate(err, "cannot generate random bytes") + return base64.URLEncoding.EncodeToString(b)[0:n], stacktrace.Propagatef(err, "cannot generate random bytes") } diff --git a/api/pkg/repositories/gorm_webhook_repository.go b/api/pkg/repositories/gorm_webhook_repository.go index 86b2dd1d9..5cf23b077 100644 --- a/api/pkg/repositories/gorm_webhook_repository.go +++ b/api/pkg/repositories/gorm_webhook_repository.go @@ -7,8 +7,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" "gorm.io/gorm" ) @@ -37,7 +37,7 @@ func (repository *gormWebhookRepository) DeleteAllForUser(ctx context.Context, u defer span.End() if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Webhook{}).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.Webhook{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.Webhook{}, userID)) } return nil @@ -48,7 +48,7 @@ func (repository *gormWebhookRepository) Save(ctx context.Context, webhook *enti defer span.End() if err := repository.db.WithContext(ctx).Save(webhook).Error; err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update webhook with ID [%s]", webhook.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update webhook with ID [%s]", webhook.ID)) } return nil @@ -67,7 +67,7 @@ func (repository *gormWebhookRepository) Index(ctx context.Context, userID entit webhooks := make([]*entities.Webhook, 0) if err := query.Order("created_at DESC").Limit(params.Limit).Offset(params.Skip).Find(&webhooks).Error; err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch webhooks for user [%s] and params [%+#v]", userID, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch webhooks for user [%s] and params [%+#v]", userID, params)) } return webhooks, nil @@ -83,7 +83,7 @@ func (repository *gormWebhookRepository) LoadByEvent(ctx context.Context, userID Scan(&webhooks). Error if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load webhooks for user with ID [%s] and event [%s]", userID, event)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load webhooks for user with ID [%s] and event [%s]", userID, event)) } return webhooks, nil @@ -96,11 +96,11 @@ func (repository *gormWebhookRepository) Load(ctx context.Context, userID entiti webhook := new(entities.Webhook) err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("id = ?", webhookID).First(&webhook).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "webhook with ID [%s] for user [%s] does not exist", webhookID, userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "webhook with ID [%s] for user [%s] does not exist", webhookID, userID)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load webhook with ID [%s] for user [%s]", webhookID, userID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load webhook with ID [%s] for user [%s]", webhookID, userID)) } return webhook, nil @@ -115,7 +115,7 @@ func (repository *gormWebhookRepository) Delete(ctx context.Context, userID enti Where("id = ?", webhookID). Delete(&entities.Webhook{}).Error if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete webhook with ID [%s] and userID [%s]", webhookID, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete webhook with ID [%s] and userID [%s]", webhookID, userID)) } return nil diff --git a/api/pkg/repositories/memory_attachment_repository.go b/api/pkg/repositories/memory_attachment_repository.go index c640d5f8d..d6070d65d 100644 --- a/api/pkg/repositories/memory_attachment_repository.go +++ b/api/pkg/repositories/memory_attachment_repository.go @@ -6,7 +6,7 @@ import ( "sync" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" ) // MemoryAttachmentRepository stores attachments in memory @@ -44,7 +44,7 @@ func (s *MemoryAttachmentRepository) Download(ctx context.Context, path string) value, ok := s.data.Load(path) if !ok { - return nil, s.tracer.WrapErrorSpan(span, stacktrace.NewErrorWithCode(ErrCodeNotFound, "attachment not found at path [%s]", path)) + return nil, s.tracer.WrapErrorSpan(span, stacktrace.NewErrorWithCodef(ErrCodeNotFound, "attachment not found at path [%s]", path)) } return value.([]byte), nil } diff --git a/api/pkg/repositories/message_thread_repository.go b/api/pkg/repositories/message_thread_repository.go index 542fd519b..28efc11ee 100644 --- a/api/pkg/repositories/message_thread_repository.go +++ b/api/pkg/repositories/message_thread_repository.go @@ -2,19 +2,49 @@ package repositories import ( "context" + "time" "github.com/google/uuid" "github.com/NdoleStudio/httpsms/pkg/entities" ) +type MessageThreadActivityUpdate struct { + MessageThreadID uuid.UUID + UserID entities.UserID + // Timestamp controls thread activity ordering; EventTimestamp is the server-side unread watermark. + Timestamp time.Time + MessageID uuid.UUID + Content string + Status entities.MessageStatus + MarkAsUnread bool + EventTimestamp time.Time + Unarchive bool +} + +type MessageThreadStatusUpdate struct { + IsArchived *bool + UnreadCount *uint +} + +type MessageThreadDeletedUpdate struct { + MessageThreadID uuid.UUID + UserID entities.UserID + LastMessageID *uuid.UUID + LastMessageContent *string + LastMessageStatus entities.MessageStatus +} + // MessageThreadRepository loads and persists an entities.MessageThread type MessageThreadRepository interface { // Store a new entities.MessageThread Store(ctx context.Context, thread *entities.MessageThread) error - // Update a new entities.MessageThread - Update(ctx context.Context, thread *entities.MessageThread) error + // UpdateActivity persists the last-message activity fields for a thread + UpdateActivity(ctx context.Context, params MessageThreadActivityUpdate) error + + // UpdateStatus persists archive/read status fields for a thread + UpdateStatus(ctx context.Context, userID entities.UserID, messageThreadID uuid.UUID, params MessageThreadStatusUpdate) (*entities.MessageThread, error) // LoadByOwnerContact fetches a thread between owner and contact LoadByOwnerContact(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.MessageThread, error) @@ -26,7 +56,7 @@ type MessageThreadRepository interface { Index(ctx context.Context, userID entities.UserID, owner string, archived bool, params IndexParams) (*[]entities.MessageThread, error) // UpdateAfterDeletedMessage updates a thread after the original message has been deleted - UpdateAfterDeletedMessage(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error + UpdateAfterDeletedMessage(ctx context.Context, params MessageThreadDeletedUpdate) error // Delete an entities.MessageThread by ID Delete(ctx context.Context, userID entities.UserID, messageThreadID uuid.UUID) error diff --git a/api/pkg/repositories/mongo_contact_repository.go b/api/pkg/repositories/mongo_contact_repository.go new file mode 100644 index 000000000..23c783577 --- /dev/null +++ b/api/pkg/repositories/mongo_contact_repository.go @@ -0,0 +1,230 @@ +package repositories + +import ( + "context" + "fmt" + "regexp" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// mongoContactRepository is responsible for persisting entities.Contact in MongoDB. +type mongoContactRepository struct { + logger telemetry.Logger + tracer telemetry.Tracer + collection *mongo.Collection +} + +// NewMongoContactRepository creates the MongoDB version of the ContactRepository. +func NewMongoContactRepository( + logger telemetry.Logger, + tracer telemetry.Tracer, + db *mongo.Database, +) ContactRepository { + return &mongoContactRepository{ + logger: logger.WithService(fmt.Sprintf("%T", &mongoContactRepository{})), + tracer: tracer, + collection: db.Collection(collectionContacts), + } +} + +func (repository *mongoContactRepository) Store(ctx context.Context, contacts []*entities.Contact) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + if len(contacts) == 0 { + return nil + } + + ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) + defer cancel() + + documents := make([]any, len(contacts)) + for index, contact := range contacts { + documents[index] = contact + } + + if _, err := repository.collection.InsertMany(ctx, documents); err != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store [%d] contacts", len(contacts))) + } + + return nil +} + +func (repository *mongoContactRepository) Update(ctx context.Context, contact *entities.Contact) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) + defer cancel() + + filter := mongoContactIDFilter(contact.UserID, contact.ID) + if _, err := repository.collection.ReplaceOne(ctx, filter, contact); err != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update contact with ID [%s]", contact.ID)) + } + + return nil +} + +func (repository *mongoContactRepository) Load(ctx context.Context, userID entities.UserID, contactID uuid.UUID) (*entities.Contact, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) + defer cancel() + + contact := new(entities.Contact) + err := repository.collection.FindOne(ctx, mongoContactIDFilter(userID, contactID)).Decode(contact) + if err == mongo.ErrNoDocuments { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "contact with ID [%s] for user [%s] does not exist", contactID, userID)) + } + if err != nil { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load contact with ID [%s] for user [%s]", contactID, userID)) + } + + return contact, nil +} + +func (repository *mongoContactRepository) Index(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.Contact, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) + defer cancel() + + findOptions := options.Find(). + SetSort(mongoContactSort(params)). + SetSkip(int64(params.Skip)). + SetLimit(int64(params.Limit)) + cursor, err := repository.collection.Find(ctx, mongoContactFilter(userID, params.Query), findOptions) + if err != nil { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot index contacts for user [%s] with params [%+#v]", userID, params)) + } + defer cursor.Close(ctx) + + contacts := make([]entities.Contact, 0) + if err = cursor.All(ctx, &contacts); err != nil { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode contacts for user [%s]", userID)) + } + + return &contacts, nil +} + +func (repository *mongoContactRepository) Count(ctx context.Context, userID entities.UserID, params IndexParams) (int64, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) + defer cancel() + + count, err := repository.collection.CountDocuments(ctx, mongoContactFilter(userID, params.Query)) + if err != nil { + return 0, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot count contacts for user [%s] with query [%s]", userID, params.Query)) + } + + return count, nil +} + +func (repository *mongoContactRepository) FetchByPhoneNumbers(ctx context.Context, userID entities.UserID, phoneNumbers []string) (*[]entities.Contact, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) + defer cancel() + + findOptions := options.Find().SetSort(bson.D{ + {Key: "updated_at", Value: 1}, + {Key: "_id", Value: 1}, + }) + cursor, err := repository.collection.Find(ctx, mongoContactPhoneNumbersFilter(userID, phoneNumbers), findOptions) + if err != nil { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch contacts for user [%s] by phone numbers [%v]", userID, phoneNumbers)) + } + defer cursor.Close(ctx) + + contacts := make([]entities.Contact, 0) + if err = cursor.All(ctx, &contacts); err != nil { + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode contacts for user [%s] by phone numbers", userID)) + } + + return &contacts, nil +} + +func (repository *mongoContactRepository) Delete(ctx context.Context, userID entities.UserID, contactID uuid.UUID) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) + defer cancel() + + if _, err := repository.collection.DeleteOne(ctx, mongoContactIDFilter(userID, contactID)); err != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete contact with ID [%s] for user [%s]", contactID, userID)) + } + + return nil +} + +func (repository *mongoContactRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) + defer cancel() + + if _, err := repository.collection.DeleteMany(ctx, bson.D{{Key: "user_id", Value: string(userID)}}); err != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all contacts for user [%s]", userID)) + } + + return nil +} + +func mongoContactFilter(userID entities.UserID, query string) bson.D { + filter := bson.D{{Key: "user_id", Value: string(userID)}} + if query == "" { + return filter + } + + expression := bson.Regex{Pattern: regexp.QuoteMeta(query), Options: "i"} + return append(filter, bson.E{Key: "$or", Value: bson.A{ + bson.D{{Key: "name", Value: expression}}, + bson.D{{Key: "emails", Value: expression}}, + bson.D{{Key: "phone_numbers", Value: expression}}, + }}) +} + +func mongoContactSort(params IndexParams) bson.D { + sortBy := "updated_at" + if params.SortBy == "name" { + sortBy = "name" + } + + direction := 1 + if params.SortBy == "" || params.SortDescending { + direction = -1 + } + + return bson.D{ + {Key: sortBy, Value: direction}, + {Key: "_id", Value: direction}, + } +} + +func mongoContactPhoneNumbersFilter(userID entities.UserID, phoneNumbers []string) bson.D { + return bson.D{ + {Key: "user_id", Value: string(userID)}, + {Key: "phone_numbers", Value: bson.D{{Key: "$in", Value: phoneNumbers}}}, + } +} + +func mongoContactIDFilter(userID entities.UserID, contactID uuid.UUID) bson.D { + return bson.D{ + {Key: "user_id", Value: string(userID)}, + {Key: "_id", Value: contactID.String()}, + } +} diff --git a/api/pkg/repositories/mongo_contact_repository_test.go b/api/pkg/repositories/mongo_contact_repository_test.go new file mode 100644 index 000000000..4f6ba1c73 --- /dev/null +++ b/api/pkg/repositories/mongo_contact_repository_test.go @@ -0,0 +1,124 @@ +package repositories + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestMongoContactFilter_ScopesByUserAndSearchesContactFields(t *testing.T) { + filter := mongoContactFilter(entities.UserID("user-1"), "Alice") + + assert.Equal(t, bson.D{ + {Key: "user_id", Value: "user-1"}, + {Key: "$or", Value: bson.A{ + bson.D{{Key: "name", Value: bson.Regex{Pattern: "Alice", Options: "i"}}}, + bson.D{{Key: "emails", Value: bson.Regex{Pattern: "Alice", Options: "i"}}}, + bson.D{{Key: "phone_numbers", Value: bson.Regex{Pattern: "Alice", Options: "i"}}}, + }}, + }, filter) +} + +func TestMongoContactFilter_WithoutSearchOnlyScopesByUser(t *testing.T) { + filter := mongoContactFilter(entities.UserID("user-1"), "") + + assert.Equal(t, bson.D{{Key: "user_id", Value: "user-1"}}, filter) +} + +func TestMongoContactFilter_TreatsSearchAsLiteralText(t *testing.T) { + filter := mongoContactFilter(entities.UserID("user-1"), "Alice.*") + + expectedExpression := bson.Regex{Pattern: `Alice\.\*`, Options: "i"} + assert.Equal(t, bson.D{ + {Key: "user_id", Value: "user-1"}, + {Key: "$or", Value: bson.A{ + bson.D{{Key: "name", Value: expectedExpression}}, + bson.D{{Key: "emails", Value: expectedExpression}}, + bson.D{{Key: "phone_numbers", Value: expectedExpression}}, + }}, + }, filter) +} + +func TestMongoContactSort_UsesStableRequestedOrdering(t *testing.T) { + tests := []struct { + name string + params IndexParams + expected bson.D + }{ + { + name: "default", + params: IndexParams{}, + expected: bson.D{{Key: "updated_at", Value: -1}, {Key: "_id", Value: -1}}, + }, + { + name: "name ascending", + params: IndexParams{SortBy: "name"}, + expected: bson.D{{Key: "name", Value: 1}, {Key: "_id", Value: 1}}, + }, + { + name: "name descending", + params: IndexParams{SortBy: "name", SortDescending: true}, + expected: bson.D{{Key: "name", Value: -1}, {Key: "_id", Value: -1}}, + }, + { + name: "updated ascending", + params: IndexParams{SortBy: "updated_at"}, + expected: bson.D{{Key: "updated_at", Value: 1}, {Key: "_id", Value: 1}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, mongoContactSort(tt.params)) + }) + } +} + +func TestMongoContactPhoneNumbersFilter_ScopesByUserAndRequestedNumbers(t *testing.T) { + filter := mongoContactPhoneNumbersFilter( + entities.UserID("user-1"), + []string{"+18005550199", "+18005550100"}, + ) + + assert.Equal(t, bson.D{ + {Key: "user_id", Value: "user-1"}, + {Key: "phone_numbers", Value: bson.D{{Key: "$in", Value: []string{"+18005550199", "+18005550100"}}}}, + }, filter) +} + +func TestMongoContactIDFilter_ScopesByUserAndContactID(t *testing.T) { + contactID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + + filter := mongoContactIDFilter(entities.UserID("user-1"), contactID) + + assert.Equal(t, bson.D{ + {Key: "user_id", Value: "user-1"}, + {Key: "_id", Value: contactID.String()}, + }, filter) +} + +func TestContactMongoIndexModels_CoverListingAndPhoneLookup(t *testing.T) { + indexes := contactMongoIndexModels() + + require.Len(t, indexes, 3) + assert.Equal(t, bson.D{ + {Key: "user_id", Value: 1}, + {Key: "updated_at", Value: -1}, + {Key: "_id", Value: -1}, + }, indexes[0].Keys) + assert.Equal(t, bson.D{ + {Key: "user_id", Value: 1}, + {Key: "name", Value: 1}, + {Key: "_id", Value: 1}, + }, indexes[1].Keys) + assert.Equal(t, bson.D{ + {Key: "user_id", Value: 1}, + {Key: "phone_numbers", Value: 1}, + {Key: "updated_at", Value: 1}, + {Key: "_id", Value: 1}, + }, indexes[2].Keys) +} diff --git a/api/pkg/repositories/mongo_heartbeat_monitor_repository.go b/api/pkg/repositories/mongo_heartbeat_monitor_repository.go index c6f44dc89..fea05e993 100644 --- a/api/pkg/repositories/mongo_heartbeat_monitor_repository.go +++ b/api/pkg/repositories/mongo_heartbeat_monitor_repository.go @@ -11,7 +11,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" ) // mongoHeartbeatMonitorRepository is responsible for persisting entities.HeartbeatMonitor in MongoDB @@ -43,7 +43,7 @@ func (repository *mongoHeartbeatMonitorRepository) Store(ctx context.Context, mo _, err := repository.collection.InsertOne(ctx, monitor) if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save heartbeat monitor with ID [%s]", monitor.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save heartbeat monitor with ID [%s]", monitor.ID)) } return nil @@ -64,10 +64,10 @@ func (repository *mongoHeartbeatMonitorRepository) Load(ctx context.Context, use var monitor entities.HeartbeatMonitor err := repository.collection.FindOne(ctx, filter).Decode(&monitor) if err == mongo.ErrNoDocuments { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "heartbeat monitor with userID [%s] and owner [%s] does not exist", userID, phoneNumber)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "heartbeat monitor with userID [%s] and owner [%s] does not exist", userID, phoneNumber)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load heartbeat monitor with userID [%s] and owner [%s]", userID, phoneNumber)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load heartbeat monitor with userID [%s] and owner [%s]", userID, phoneNumber)) } return &monitor, nil @@ -87,7 +87,7 @@ func (repository *mongoHeartbeatMonitorRepository) Exists(ctx context.Context, u count, err := repository.collection.CountDocuments(ctx, filter) if err != nil { - return false, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot check if heartbeat monitor exists with userID [%s] and monitor ID [%s]", userID, monitorID)) + return false, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot check if heartbeat monitor exists with userID [%s] and monitor ID [%s]", userID, monitorID)) } return count > 0, nil @@ -108,7 +108,7 @@ func (repository *mongoHeartbeatMonitorRepository) UpdateQueueID(ctx context.Con _, err := repository.collection.UpdateOne(ctx, filter, update) if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update heartbeat monitor ID [%s]", monitorID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update heartbeat monitor ID [%s]", monitorID)) } return nil @@ -128,7 +128,7 @@ func (repository *mongoHeartbeatMonitorRepository) Delete(ctx context.Context, u _, err := repository.collection.DeleteMany(ctx, filter) if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete heartbeat monitor with owner [%s] and userID [%s]", phoneNumber, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete heartbeat monitor with owner [%s] and userID [%s]", phoneNumber, userID)) } return nil @@ -152,7 +152,7 @@ func (repository *mongoHeartbeatMonitorRepository) UpdatePhoneOnline(ctx context _, err := repository.collection.UpdateOne(ctx, filter, update) if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update heartbeat monitor ID [%s] for user [%s]", monitorID, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update heartbeat monitor ID [%s] for user [%s]", monitorID, userID)) } return nil @@ -167,7 +167,7 @@ func (repository *mongoHeartbeatMonitorRepository) DeleteAllForUser(ctx context. _, err := repository.collection.DeleteMany(ctx, bson.D{{Key: "user_id", Value: string(userID)}}) if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.HeartbeatMonitor{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.HeartbeatMonitor{}, userID)) } return nil diff --git a/api/pkg/repositories/mongo_heartbeat_repository.go b/api/pkg/repositories/mongo_heartbeat_repository.go index 72bef3b67..44ed280db 100644 --- a/api/pkg/repositories/mongo_heartbeat_repository.go +++ b/api/pkg/repositories/mongo_heartbeat_repository.go @@ -10,7 +10,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" ) // mongoHeartbeatRepository is responsible for persisting entities.Heartbeat in MongoDB @@ -42,7 +42,7 @@ func (repository *mongoHeartbeatRepository) Store(ctx context.Context, heartbeat _, err := repository.collection.InsertOne(ctx, heartbeat) if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save heartbeat with ID [%s]", heartbeat.ID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save heartbeat with ID [%s]", heartbeat.ID)) } return nil @@ -71,13 +71,13 @@ func (repository *mongoHeartbeatRepository) Index(ctx context.Context, userID en cursor, err := repository.collection.Find(ctx, filter, opts) if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params)) } defer cursor.Close(ctx) var heartbeats []entities.Heartbeat if err = cursor.All(ctx, &heartbeats); err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot decode heartbeats for owner [%s]", owner)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode heartbeats for owner [%s]", owner)) } if heartbeats == nil { @@ -104,10 +104,10 @@ func (repository *mongoHeartbeatRepository) Last(ctx context.Context, userID ent var heartbeat entities.Heartbeat err := repository.collection.FindOne(ctx, filter, opts).Decode(&heartbeat) if err == mongo.ErrNoDocuments { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, "heartbeat with userID [%s] and owner [%s] does not exist", userID, owner)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, ErrCodeNotFound, "heartbeat with userID [%s] and owner [%s] does not exist", userID, owner)) } if err != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load heartbeat with userID [%s] and owner [%s]", userID, owner)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load heartbeat with userID [%s] and owner [%s]", userID, owner)) } return &heartbeat, nil @@ -122,7 +122,7 @@ func (repository *mongoHeartbeatRepository) DeleteAllForUser(ctx context.Context _, err := repository.collection.DeleteMany(ctx, bson.D{{Key: "user_id", Value: string(userID)}}) if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete all [%T] for user with ID [%s]", &entities.Heartbeat{}, userID)) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all [%T] for user with ID [%s]", &entities.Heartbeat{}, userID)) } return nil diff --git a/api/pkg/repositories/mongodb.go b/api/pkg/repositories/mongodb.go index 5f68f9c9b..44f35bd4b 100644 --- a/api/pkg/repositories/mongodb.go +++ b/api/pkg/repositories/mongodb.go @@ -6,8 +6,8 @@ import ( "reflect" "time" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" @@ -17,6 +17,7 @@ import ( const ( collectionHeartbeats = "heartbeats" collectionHeartbeatMonitors = "heartbeat_monitors" + collectionContacts = "contacts" ) // uuidEncodeValue encodes uuid.UUID as a BSON string @@ -52,7 +53,7 @@ func newMongoRegistry() *bson.Registry { func NewMongoDB(uri string) (*mongo.Database, error) { dbName, err := parseMongoDBName(uri) if err != nil { - return nil, stacktrace.Propagate(err, "cannot parse database name from MongoDB URI") + return nil, stacktrace.Propagatef(err, "cannot parse database name from MongoDB URI") } pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -68,11 +69,11 @@ func NewMongoDB(uri string) (*mongo.Database, error) { client, err := mongo.Connect(opts) if err != nil { - return nil, stacktrace.Propagate(err, "cannot connect to MongoDB Atlas") + return nil, stacktrace.Propagatef(err, "cannot connect to MongoDB Atlas") } if err = client.Ping(pingCtx, nil); err != nil { - return nil, stacktrace.Propagate(err, "cannot ping MongoDB Atlas") + return nil, stacktrace.Propagatef(err, "cannot ping MongoDB Atlas") } db := client.Database(dbName) @@ -81,7 +82,7 @@ func NewMongoDB(uri string) (*mongo.Database, error) { defer indexCancel() if err = createMongoIndexes(indexCtx, db); err != nil { - return nil, stacktrace.Propagate(err, "cannot create MongoDB indexes") + return nil, stacktrace.Propagatef(err, "cannot create MongoDB indexes") } return db, nil @@ -91,12 +92,12 @@ func NewMongoDB(uri string) (*mongo.Database, error) { func parseMongoDBName(uri string) (string, error) { parsed, err := url.Parse(uri) if err != nil { - return "", stacktrace.Propagate(err, "cannot parse MongoDB URI [%s]", uri) + return "", stacktrace.Propagatef(err, "cannot parse MongoDB URI [%s]", uri) } appName := parsed.Query().Get("appName") if appName == "" { - return "", stacktrace.NewError("MongoDB URI is missing the 'appName' query parameter which is used as the database name") + return "", stacktrace.NewErrorf("MongoDB URI is missing the 'appName' query parameter which is used as the database name") } return appName, nil @@ -110,7 +111,7 @@ func createMongoIndexes(ctx context.Context, db *mongo.Database) error { {Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "owner", Value: 1}, {Key: "timestamp", Value: -1}}}, }) if err != nil { - return stacktrace.Propagate(err, "cannot create indexes on heartbeats collection") + return stacktrace.Propagatef(err, "cannot create indexes on heartbeats collection") } // Heartbeat monitors indexes @@ -119,8 +120,35 @@ func createMongoIndexes(ctx context.Context, db *mongo.Database) error { {Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "owner", Value: 1}}}, }) if err != nil { - return stacktrace.Propagate(err, "cannot create indexes on heartbeat_monitors collection") + return stacktrace.Propagatef(err, "cannot create indexes on heartbeat_monitors collection") + } + + contactsCol := db.Collection(collectionContacts) + _, err = contactsCol.Indexes().CreateMany(ctx, contactMongoIndexModels()) + if err != nil { + return stacktrace.Propagatef(err, "cannot create indexes on contacts collection") } return nil } + +func contactMongoIndexModels() []mongo.IndexModel { + return []mongo.IndexModel{ + {Keys: bson.D{ + {Key: "user_id", Value: 1}, + {Key: "updated_at", Value: -1}, + {Key: "_id", Value: -1}, + }}, + {Keys: bson.D{ + {Key: "user_id", Value: 1}, + {Key: "name", Value: 1}, + {Key: "_id", Value: 1}, + }}, + {Keys: bson.D{ + {Key: "user_id", Value: 1}, + {Key: "phone_numbers", Value: 1}, + {Key: "updated_at", Value: 1}, + {Key: "_id", Value: 1}, + }}, + } +} diff --git a/api/pkg/repositories/repository.go b/api/pkg/repositories/repository.go index 32ba4337d..5d2541fd4 100644 --- a/api/pkg/repositories/repository.go +++ b/api/pkg/repositories/repository.go @@ -3,7 +3,7 @@ package repositories import ( "time" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" ) // IndexParams parameters for indexing a database table diff --git a/api/pkg/requests/contact_index.go b/api/pkg/requests/contact_index.go new file mode 100644 index 000000000..6940384a7 --- /dev/null +++ b/api/pkg/requests/contact_index.go @@ -0,0 +1,44 @@ +package requests + +import ( + "strings" + + "github.com/NdoleStudio/httpsms/pkg/repositories" +) + +// ContactIndex lists contacts for a user. +type ContactIndex struct { + request + Skip string `json:"skip" query:"skip"` + Query string `json:"query" query:"query"` + SortBy string `json:"sort_by" query:"sort_by"` + SortDescending bool `json:"sort_descending" query:"sort_descending"` + Limit string `json:"limit" query:"limit"` +} + +// Sanitize sets defaults for the list request. +func (input *ContactIndex) Sanitize() ContactIndex { + input.Query = strings.TrimSpace(input.Query) + input.SortBy = strings.TrimSpace(input.SortBy) + input.Skip = strings.TrimSpace(input.Skip) + input.Limit = strings.TrimSpace(input.Limit) + + if input.Skip == "" { + input.Skip = "0" + } + if input.Limit == "" { + input.Limit = "20" + } + return *input +} + +// ToIndexParams converts the request into repositories.IndexParams. +func (input *ContactIndex) ToIndexParams() repositories.IndexParams { + return repositories.IndexParams{ + Skip: input.getInt(input.Skip), + Query: input.Query, + SortBy: input.SortBy, + SortDescending: input.SortDescending, + Limit: input.getInt(input.Limit), + } +} diff --git a/api/pkg/requests/contact_store.go b/api/pkg/requests/contact_store.go new file mode 100644 index 000000000..7ddf147b0 --- /dev/null +++ b/api/pkg/requests/contact_store.go @@ -0,0 +1,97 @@ +package requests + +import ( + "encoding/json" + "strings" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/lib/pq" +) + +// ContactItem is a single contact in a create request. +type ContactItem struct { + Name string `json:"name" example:"Alice Smith"` + Emails []string `json:"emails,omitempty"` + PhoneNumbers []string `json:"phone_numbers"` + Properties map[string]string `json:"properties,omitempty"` +} + +// ContactStoreRequest creates one or many contacts. +type ContactStoreRequest struct { + request + Contacts []ContactItem `json:"contacts"` +} + +// UnmarshalJSON accepts either a JSON array of contacts or {"contacts":[...]}. +func (input *ContactStoreRequest) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + if strings.HasPrefix(trimmed, "[") { + var items []ContactItem + if err := json.Unmarshal(data, &items); err != nil { + return err + } + input.Contacts = items + return nil + } + + type alias ContactStoreRequest + var wrapper alias + if err := json.Unmarshal(data, &wrapper); err != nil { + return err + } + + input.Contacts = wrapper.Contacts + return nil +} + +// Sanitize trims and normalizes each contact item. +func (input ContactStoreRequest) Sanitize() ContactStoreRequest { + for index := range input.Contacts { + input.Contacts[index] = SanitizeContactItem(input.Contacts[index]) + } + return input +} + +// ToContacts converts the request into persistable entities.Contact records. +func (input *ContactStoreRequest) ToContacts(userID entities.UserID) []*entities.Contact { + now := time.Now().UTC() + contacts := make([]*entities.Contact, 0, len(input.Contacts)) + for _, item := range input.Contacts { + properties := item.Properties + if properties == nil { + properties = map[string]string{} + } + + contacts = append(contacts, &entities.Contact{ + ID: uuid.New(), + UserID: userID, + Name: item.Name, + Emails: pq.StringArray(item.Emails), + PhoneNumbers: pq.StringArray(item.PhoneNumbers), + Properties: entities.ContactProperties(properties), + CreatedAt: now, + UpdatedAt: now, + }) + } + return contacts +} + +// SanitizeContactItem trims and normalizes a single contact item so the CSV +// upload and JSON create paths accept and normalize identical values (e.g. a +// phone number without a leading "+" or an email with mixed case/whitespace). +func SanitizeContactItem(item ContactItem) ContactItem { + item.Name = strings.TrimSpace(item.Name) + item.Emails = sanitizeUniqueStrings(item.Emails, func(value string) string { + return strings.ToLower(strings.TrimSpace(value)) + }) + item.PhoneNumbers = sanitizeUniqueStrings(item.PhoneNumbers, func(value string) string { + var base request + return base.sanitizeAddress(value) + }) + if item.Properties == nil { + item.Properties = map[string]string{} + } + return item +} diff --git a/api/pkg/requests/contact_store_test.go b/api/pkg/requests/contact_store_test.go new file mode 100644 index 000000000..85d20c073 --- /dev/null +++ b/api/pkg/requests/contact_store_test.go @@ -0,0 +1,208 @@ +package requests + +import ( + "encoding/json" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContactStoreRequest_UnmarshalArrayForm(t *testing.T) { + var request ContactStoreRequest + + require.NoError(t, json.Unmarshal([]byte(`[{"name":"Alice","phone_numbers":["+18005550199"]}]`), &request)) + + require.Len(t, request.Contacts, 1) + assert.Equal(t, "Alice", request.Contacts[0].Name) + assert.Equal(t, []string{"+18005550199"}, request.Contacts[0].PhoneNumbers) +} + +func TestContactStoreRequest_UnmarshalObjectForm(t *testing.T) { + var request ContactStoreRequest + + require.NoError(t, json.Unmarshal([]byte(`{"contacts":[{"name":"Bob","phone_numbers":["+18005550100"]}]}`), &request)) + + require.Len(t, request.Contacts, 1) + assert.Equal(t, "Bob", request.Contacts[0].Name) + assert.Equal(t, []string{"+18005550100"}, request.Contacts[0].PhoneNumbers) +} + +func TestContactStoreRequest_UnmarshalMissingOptionalFields(t *testing.T) { + var request ContactStoreRequest + + require.NoError(t, json.Unmarshal([]byte(`[{"name":"Alice","phone_numbers":["+18005550199"]}]`), &request)) + + require.Len(t, request.Contacts, 1) + assert.Nil(t, request.Contacts[0].Emails) + assert.Nil(t, request.Contacts[0].Properties) + + sanitized := request.Sanitize() + require.Len(t, sanitized.Contacts, 1) + assert.NotNil(t, sanitized.Contacts[0].Properties) + assert.Empty(t, sanitized.Contacts[0].Properties) +} + +func TestContactStoreRequest_UnmarshalMalformedJSON(t *testing.T) { + var request ContactStoreRequest + + err := json.Unmarshal([]byte(`{"contacts":[{"name":"Alice"`), &request) + + require.Error(t, err) +} + +func TestContactStoreRequest_SanitizeNormalizesAndDeduplicates(t *testing.T) { + request := ContactStoreRequest{Contacts: []ContactItem{{ + Name: " Alice ", + PhoneNumbers: []string{"18005550199", "+18005550199", " ", "+18005550100"}, + Emails: []string{" Alice@Example.com ", "", "alice@example.com", "B@example.com", "b@example.com"}, + Properties: map[string]string{"company": "Acme", "role": "CTO"}, + }}} + + request = request.Sanitize() + + require.Len(t, request.Contacts, 1) + assert.Equal(t, "Alice", request.Contacts[0].Name) + assert.Equal(t, []string{"+18005550199", "+18005550100"}, request.Contacts[0].PhoneNumbers) + assert.Equal(t, []string{"alice@example.com", "b@example.com"}, request.Contacts[0].Emails) + assert.Equal(t, map[string]string{"company": "Acme", "role": "CTO"}, request.Contacts[0].Properties) +} + +func TestContactStoreRequest_SanitizeInitializesNilProperties(t *testing.T) { + request := ContactStoreRequest{Contacts: []ContactItem{{ + Name: "Alice", + PhoneNumbers: []string{"+18005550199"}, + }}} + + request = request.Sanitize() + + require.Len(t, request.Contacts, 1) + assert.NotNil(t, request.Contacts[0].Properties) + assert.Empty(t, request.Contacts[0].Properties) +} + +func TestContactStoreRequest_ToContactsPreservesPropertiesAndOwnership(t *testing.T) { + request := ContactStoreRequest{Contacts: []ContactItem{{ + Name: "Alice", + Emails: []string{"alice@example.com"}, + PhoneNumbers: []string{"+18005550199"}, + Properties: map[string]string{"company": "Acme"}, + }}}.Sanitize() + + contacts := request.ToContacts(entities.UserID("user-1")) + + require.Len(t, contacts, 1) + assert.NotZero(t, contacts[0].ID) + assert.Equal(t, entities.UserID("user-1"), contacts[0].UserID) + assert.Equal(t, "Alice", contacts[0].Name) + assert.Equal(t, []string{"alice@example.com"}, []string(contacts[0].Emails)) + assert.Equal(t, []string{"+18005550199"}, []string(contacts[0].PhoneNumbers)) + assert.Equal(t, entities.ContactProperties{"company": "Acme"}, contacts[0].Properties) + assert.False(t, contacts[0].CreatedAt.IsZero()) + assert.False(t, contacts[0].UpdatedAt.IsZero()) + assert.True(t, contacts[0].CreatedAt.Equal(contacts[0].UpdatedAt)) +} + +func TestContactStoreRequest_ToContactsInitializesNilProperties(t *testing.T) { + request := ContactStoreRequest{Contacts: []ContactItem{{ + Name: "Alice", + PhoneNumbers: []string{"+18005550199"}, + }}} + + contacts := request.ToContacts(entities.UserID("user-1")) + + require.Len(t, contacts, 1) + assert.NotNil(t, contacts[0].Properties) + assert.Empty(t, contacts[0].Properties) +} + +func TestContactUpdateRequest_SanitizeAndApplyTo(t *testing.T) { + request := ContactUpdateRequest{ + Name: " Alice ", + Emails: []string{" Alice@Example.com ", "", "alice@example.com"}, + PhoneNumbers: []string{"18005550199", "+18005550199"}, + Properties: map[string]string{"nickname": "Al"}, + }.Sanitize() + + contact := &entities.Contact{ + ID: uuid.MustParse("32343a19-da5e-4b1b-a767-3298a73703cb"), + UserID: entities.UserID("user-1"), + Name: "Before", + Emails: nil, + PhoneNumbers: nil, + Properties: entities.ContactProperties{"old": "value"}, + CreatedAt: time.Unix(1, 0).UTC(), + UpdatedAt: time.Unix(2, 0).UTC(), + } + + request.ApplyTo(contact) + + assert.Equal(t, "Alice", contact.Name) + assert.Equal(t, []string{"alice@example.com"}, []string(contact.Emails)) + assert.Equal(t, []string{"+18005550199"}, []string(contact.PhoneNumbers)) + assert.Equal(t, entities.ContactProperties{"nickname": "Al"}, contact.Properties) + assert.Equal(t, time.Unix(1, 0).UTC(), contact.CreatedAt) + assert.True(t, contact.UpdatedAt.After(time.Unix(2, 0).UTC())) +} + +func TestContactUpdateRequest_InitializesNilProperties(t *testing.T) { + request := ContactUpdateRequest{Name: "Alice", PhoneNumbers: []string{"+18005550199"}}.Sanitize() + + assert.NotNil(t, request.Properties) + assert.Empty(t, request.Properties) +} + +func TestContactUpdateRequest_UnmarshalMissingOptionalFields(t *testing.T) { + var request ContactUpdateRequest + + require.NoError(t, json.Unmarshal([]byte(`{"name":"Alice","phone_numbers":["+18005550199"]}`), &request)) + + assert.Nil(t, request.Emails) + assert.Nil(t, request.Properties) + + sanitized := request.Sanitize() + assert.NotNil(t, sanitized.Properties) + assert.Empty(t, sanitized.Properties) +} + +func TestContactIndex_SanitizeUsesDefaultsAndToIndexParams(t *testing.T) { + request := ContactIndex{} + + sanitized := request.Sanitize() + params := sanitized.ToIndexParams() + + assert.Equal(t, "0", sanitized.Skip) + assert.Equal(t, "20", sanitized.Limit) + assert.Equal(t, "", sanitized.Query) + assert.Equal(t, 0, params.Skip) + assert.Equal(t, 20, params.Limit) + assert.Equal(t, "", params.Query) + assert.Equal(t, "", params.SortBy) + assert.False(t, params.SortDescending) +} + +func TestContactIndex_SanitizeTrimsAndConverts(t *testing.T) { + request := ContactIndex{ + Skip: " 15 ", + Limit: " 50 ", + Query: " alice ", + SortBy: " name ", + SortDescending: true, + } + + sanitized := request.Sanitize() + params := sanitized.ToIndexParams() + + assert.Equal(t, "15", sanitized.Skip) + assert.Equal(t, "50", sanitized.Limit) + assert.Equal(t, "alice", sanitized.Query) + assert.Equal(t, "name", sanitized.SortBy) + assert.Equal(t, 15, params.Skip) + assert.Equal(t, 50, params.Limit) + assert.Equal(t, "alice", params.Query) + assert.Equal(t, "name", params.SortBy) + assert.True(t, params.SortDescending) +} diff --git a/api/pkg/requests/contact_update.go b/api/pkg/requests/contact_update.go new file mode 100644 index 000000000..55e34d604 --- /dev/null +++ b/api/pkg/requests/contact_update.go @@ -0,0 +1,43 @@ +package requests + +import ( + "strings" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/lib/pq" +) + +// ContactUpdateRequest updates an existing contact. +type ContactUpdateRequest struct { + request + Name string `json:"name" example:"Alice Smith"` + Emails []string `json:"emails,omitempty"` + PhoneNumbers []string `json:"phone_numbers"` + Properties map[string]string `json:"properties,omitempty"` +} + +// Sanitize trims and normalizes the update request. +func (input ContactUpdateRequest) Sanitize() ContactUpdateRequest { + input.Name = strings.TrimSpace(input.Name) + input.Emails = sanitizeUniqueStrings(input.Emails, func(value string) string { + return strings.ToLower(strings.TrimSpace(value)) + }) + input.PhoneNumbers = sanitizeUniqueStrings(input.PhoneNumbers, func(value string) string { + var base request + return base.sanitizeAddress(value) + }) + if input.Properties == nil { + input.Properties = map[string]string{} + } + return input +} + +// ApplyTo mutates an existing contact with the update values. +func (input *ContactUpdateRequest) ApplyTo(contact *entities.Contact) { + contact.Name = input.Name + contact.Emails = pq.StringArray(input.Emails) + contact.PhoneNumbers = pq.StringArray(input.PhoneNumbers) + contact.Properties = entities.ContactProperties(input.Properties) + contact.UpdatedAt = time.Now().UTC() +} diff --git a/api/pkg/requests/message_thread_index_request.go b/api/pkg/requests/message_thread_index_request.go index 53157df55..053f90003 100644 --- a/api/pkg/requests/message_thread_index_request.go +++ b/api/pkg/requests/message_thread_index_request.go @@ -18,6 +18,7 @@ type MessageThreadIndex struct { Query string `json:"query" query:"query"` Limit string `json:"limit" query:"limit"` Owner string `json:"owner" query:"owner"` + Contacts string `json:"contacts" query:"contacts" example:"false"` } // Sanitize sets defaults to MessageOutstanding @@ -30,7 +31,16 @@ func (input *MessageThreadIndex) Sanitize() MessageThreadIndex { input.IsArchived = "false" } + if strings.TrimSpace(input.Contacts) == "" { + input.Contacts = "false" + } + input.IsArchived = input.sanitizeBool(input.IsArchived) + input.Contacts = input.sanitizeBool(input.Contacts) + if input.Contacts != "true" && input.Contacts != "false" { + input.Contacts = "false" + } + input.Query = strings.TrimSpace(input.Query) input.Owner = input.sanitizeAddress(input.Owner) @@ -50,8 +60,9 @@ func (input *MessageThreadIndex) ToGetParams(userID entities.UserID) services.Me Query: input.Query, Limit: input.getInt(input.Limit), }, - UserID: userID, - IsArchived: input.getBool(input.IsArchived), - Owner: input.Owner, + UserID: userID, + IsArchived: input.getBool(input.IsArchived), + WithContacts: input.getBool(input.Contacts), + Owner: input.Owner, } } diff --git a/api/pkg/requests/message_thread_index_request_test.go b/api/pkg/requests/message_thread_index_request_test.go new file mode 100644 index 000000000..fc7f7b0ed --- /dev/null +++ b/api/pkg/requests/message_thread_index_request_test.go @@ -0,0 +1,40 @@ +package requests + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func TestMessageThreadIndex_ToGetParams_WithContactsTrue(t *testing.T) { + input := (&MessageThreadIndex{Owner: "+18005550199", Contacts: " true "}).Sanitize() + params := input.ToGetParams(entities.UserID("user-id")) + + assert.Equal(t, "true", input.Contacts) + assert.True(t, params.WithContacts) +} + +func TestMessageThreadIndex_ToGetParams_WithContactsFalse(t *testing.T) { + input := (&MessageThreadIndex{Owner: "+18005550199", Contacts: "0"}).Sanitize() + params := input.ToGetParams(entities.UserID("user-id")) + + assert.Equal(t, "false", input.Contacts) + assert.False(t, params.WithContacts) +} + +func TestMessageThreadIndex_ToGetParams_WithContactsDefaultsFalse(t *testing.T) { + input := (&MessageThreadIndex{Owner: "+18005550199"}).Sanitize() + params := input.ToGetParams(entities.UserID("user-id")) + + assert.Equal(t, "false", input.Contacts) + assert.False(t, params.WithContacts) +} + +func TestMessageThreadIndex_ToGetParams_WithContactsInvalidNormalizesFalse(t *testing.T) { + input := (&MessageThreadIndex{Owner: "+18005550199", Contacts: "definitely"}).Sanitize() + params := input.ToGetParams(entities.UserID("user-id")) + + assert.Equal(t, "false", input.Contacts) + assert.False(t, params.WithContacts) +} diff --git a/api/pkg/requests/message_thread_update_request.go b/api/pkg/requests/message_thread_update_request.go index d64d75c59..0832ad819 100644 --- a/api/pkg/requests/message_thread_update_request.go +++ b/api/pkg/requests/message_thread_update_request.go @@ -10,7 +10,8 @@ import ( // MessageThreadUpdate is the payload for updating a message thread type MessageThreadUpdate struct { request - IsArchived bool `json:"is_archived" example:"true"` + IsArchived *bool `json:"is_archived,omitempty" example:"true"` + UnreadCount *uint `json:"unread_count,omitempty" example:"0"` MessageThreadID string `json:"messageThreadID" swaggerignore:"true"` // used internally for validation } @@ -21,5 +22,6 @@ func (input *MessageThreadUpdate) ToUpdateParams(userID entities.UserID) service UserID: userID, MessageThreadID: uuid.MustParse(input.MessageThreadID), IsArchived: input.IsArchived, + UnreadCount: input.UnreadCount, } } diff --git a/api/pkg/requests/message_thread_update_request_test.go b/api/pkg/requests/message_thread_update_request_test.go new file mode 100644 index 000000000..58ba7e4f7 --- /dev/null +++ b/api/pkg/requests/message_thread_update_request_test.go @@ -0,0 +1,25 @@ +package requests + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestMessageThreadUpdateToUpdateParamsPreservesOptionalFields(t *testing.T) { + threadID := uuid.New() + unreadCount := uint(0) + input := MessageThreadUpdate{ + MessageThreadID: threadID.String(), + UnreadCount: &unreadCount, + } + + params := input.ToUpdateParams(entities.UserID("user-id")) + + assert.Equal(t, threadID, params.MessageThreadID) + assert.Equal(t, entities.UserID("user-id"), params.UserID) + assert.Nil(t, params.IsArchived) + assert.Same(t, &unreadCount, params.UnreadCount) +} diff --git a/api/pkg/requests/phone_fcm_token_request.go b/api/pkg/requests/phone_fcm_token_request.go index dde935b53..bdd3ab1a6 100644 --- a/api/pkg/requests/phone_fcm_token_request.go +++ b/api/pkg/requests/phone_fcm_token_request.go @@ -13,7 +13,8 @@ import ( type PhoneFCMToken struct { request PhoneNumber string `json:"phone_number" example:"[+18005550199]"` - FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` + // FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. + FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot SIM string `json:"sim" example:"SIM1"` } diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go index 96b2882e1..cedd51a0d 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -25,6 +25,7 @@ type PhoneUpsert struct { // MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline. MaxSendAttempts uint `json:"max_send_attempts" example:"2"` + // FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"e.g. This phone cannot receive calls. Please send an SMS instead."` diff --git a/api/pkg/requests/request.go b/api/pkg/requests/request.go index 1db278614..af2066835 100644 --- a/api/pkg/requests/request.go +++ b/api/pkg/requests/request.go @@ -120,6 +120,24 @@ func (input *request) removeEmptyStrings(values []string) []string { return result } +func sanitizeUniqueStrings(values []string, normalize func(string) string) []string { + seen := map[string]struct{}{} + result := make([]string, 0, len(values)) + for _, value := range values { + value = normalize(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + + return result +} + func (input *request) sanitizeMessageID(value string) string { id := strings.Builder{} for _, char := range value { diff --git a/api/pkg/responses/contact_responses.go b/api/pkg/responses/contact_responses.go new file mode 100644 index 000000000..09db7884b --- /dev/null +++ b/api/pkg/responses/contact_responses.go @@ -0,0 +1,24 @@ +package responses + +import "github.com/NdoleStudio/httpsms/pkg/entities" + +// ContactResponse is the payload containing entities.Contact. +type ContactResponse struct { + response + Data entities.Contact `json:"data"` +} + +// ContactsCreatedResponse is the payload returned after creating contacts. +type ContactsCreatedResponse struct { + response + Data []entities.Contact `json:"data"` +} + +// ContactsResponse is the payload containing []entities.Contact. +type ContactsResponse struct { + response + Data []entities.Contact `json:"data"` + // Total is the number of contacts matching the request filter for the + // user, independent of the pagination skip/limit applied to Data. + Total int64 `json:"total" example:"57"` +} diff --git a/api/pkg/responses/message_thead_responses.go b/api/pkg/responses/message_thead_responses.go index 917706a47..e47fec287 100644 --- a/api/pkg/responses/message_thead_responses.go +++ b/api/pkg/responses/message_thead_responses.go @@ -7,3 +7,9 @@ type MessageThreadsResponse struct { response Data []entities.MessageThread `json:"data"` } + +// MessageThreadResponse is the payload containing entities.MessageThread +type MessageThreadResponse struct { + response + Data entities.MessageThread `json:"data"` +} diff --git a/api/pkg/services/billing_service.go b/api/pkg/services/billing_service.go index 262067130..e94002d1d 100644 --- a/api/pkg/services/billing_service.go +++ b/api/pkg/services/billing_service.go @@ -11,8 +11,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" ) // BillingService is responsible for tracking usages and billing users @@ -55,13 +55,13 @@ func (service *BillingService) IsEntitledWithCount(ctx context.Context, userID e user, err := service.userRepository.Load(ctx, userID) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load user with ID [%s], entitlement successful", userID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with ID [%s], entitlement successful", userID))) return nil } usage, err := service.billingUsageRepository.GetCurrent(ctx, userID) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load billing usage for user with ID [%s], entitlement successful", userID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load billing usage for user with ID [%s], entitlement successful", userID))) return nil } @@ -102,18 +102,18 @@ func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user email, err := service.emailFactory.UsageLimitExceeded(user, usage) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot create usage limit email for user [%s]", user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot create usage limit email for user [%s]", user.ID)) return } if err = service.mailer.Send(ctx, email); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "canot send usage limit exceeded notification to user [%s]", user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "canot send usage limit exceeded notification to user [%s]", user.ID)) return } ctxLogger.Info(fmt.Sprintf("usage limit exceeded email sent to user [%s]", user.ID)) if err = service.cache.Set(ctx, key, "", time.Hour*12); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot set item in redis with key [%s]", key)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot set item in redis with key [%s]", key)) } } @@ -141,7 +141,7 @@ func (service *BillingService) RegisterSentMessage(ctx context.Context, messageI ctxLogger := service.tracer.CtxLogger(service.logger, span) if err := service.billingUsageRepository.RegisterSentMessage(ctx, timestamp, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not register [sent] message with ID [%s] for user with ID [%s]", messageID, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not register [sent] message with ID [%s] for user with ID [%s]", messageID, userID)) } ctxLogger.Info(fmt.Sprintf("registered [sent] message with ID [%s] for user [%s]", messageID, userID)) @@ -157,7 +157,7 @@ func (service *BillingService) RegisterReceivedMessage(ctx context.Context, mess ctxLogger := service.tracer.CtxLogger(service.logger, span) if err := service.billingUsageRepository.RegisterReceivedMessage(ctx, timestamp, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not register [received] message with ID [%s] for user with ID [%s]", messageID, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not register [received] message with ID [%s] for user with ID [%s]", messageID, userID)) } ctxLogger.Info(fmt.Sprintf("registered [received] message with ID [%s] for user [%s]", messageID, userID)) @@ -171,7 +171,7 @@ func (service *BillingService) DeleteAllForUser(ctx context.Context, userID enti defer span.End() if err := service.billingUsageRepository.DeleteAllForUser(ctx, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete [entities.BillingUsage] for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete [entities.BillingUsage] for user with ID [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.BillingUsage] for user with ID [%s]", userID)) @@ -184,13 +184,13 @@ func (service *BillingService) sendUsageAlert(ctx context.Context, userID entiti user, err := service.userRepository.Load(ctx, userID) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load user with ID [%s]", userID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with ID [%s]", userID))) return } billingUsage, err := service.billingUsageRepository.GetCurrent(ctx, userID) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load billing usage for user with ID [%s]", userID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load billing usage for user with ID [%s]", userID))) return } @@ -200,12 +200,12 @@ func (service *BillingService) sendUsageAlert(ctx context.Context, userID entiti email, err := service.emailFactory.UsageLimitAlert(user, billingUsage) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot create usage alert email for user [%s]", user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot create usage alert email for user [%s]", user.ID)) return } if err = service.mailer.Send(ctx, email); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "canot send usage alert notification to user [%s]", user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "canot send usage alert notification to user [%s]", user.ID)) } ctxLogger.Info(fmt.Sprintf("usage alert email sent to user [%s]", user.ID)) diff --git a/api/pkg/services/contact_service.go b/api/pkg/services/contact_service.go new file mode 100644 index 000000000..fd7df59ea --- /dev/null +++ b/api/pkg/services/contact_service.go @@ -0,0 +1,291 @@ +package services + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/dgraph-io/ristretto/v2" + "github.com/google/uuid" +) + +// contactMapCacheTTL bounds cross-instance staleness because invalidation is process-local. +const ( + contactMapCacheTTL = 5 * time.Minute + contactGenerationCleanupInterval = time.Hour +) + +// ContactCacheEntry stores a contact together with its user's cache generation. +type ContactCacheEntry struct { + contact *entities.Contact + generation uint64 +} + +type contactGeneration struct { + value uint64 + lastAccess time.Time +} + +// ContactService owns contact CRUD and phone-number contact lookups. +type ContactService struct { + service + logger telemetry.Logger + tracer telemetry.Tracer + repository repositories.ContactRepository + cache *ristretto.Cache[string, ContactCacheEntry] + generationMu sync.Mutex + generations map[entities.UserID]contactGeneration + nextGeneration uint64 + lastGenerationCleanup time.Time +} + +// NewContactService creates a new ContactService. +func NewContactService( + logger telemetry.Logger, + tracer telemetry.Tracer, + repository repositories.ContactRepository, + contactCache *ristretto.Cache[string, ContactCacheEntry], +) (s *ContactService) { + return &ContactService{ + logger: logger.WithService(fmt.Sprintf("%T", s)), + tracer: tracer, + repository: repository, + cache: contactCache, + generations: make(map[entities.UserID]contactGeneration), + } +} + +func (service *ContactService) cacheKey(userID entities.UserID, phoneNumber string) string { + return fmt.Sprintf("%s|%s", userID, phoneNumber) +} + +func (service *ContactService) generation(userID entities.UserID) uint64 { + now := time.Now() + service.generationMu.Lock() + defer service.generationMu.Unlock() + + service.expireGenerationsLocked(now) + if generation, ok := service.generations[userID]; ok { + generation.lastAccess = now + service.generations[userID] = generation + return generation.value + } + + service.nextGeneration++ + service.generations[userID] = contactGeneration{ + value: service.nextGeneration, + lastAccess: now, + } + return service.nextGeneration +} + +func (service *ContactService) advanceGeneration(userID entities.UserID) { + now := time.Now() + service.generationMu.Lock() + defer service.generationMu.Unlock() + + service.expireGenerationsLocked(now) + service.nextGeneration++ + service.generations[userID] = contactGeneration{ + value: service.nextGeneration, + lastAccess: now, + } +} + +func (service *ContactService) expireGenerations(now time.Time) { + service.generationMu.Lock() + defer service.generationMu.Unlock() + service.expireGenerationsLocked(now) +} + +func (service *ContactService) expireGenerationsLocked(now time.Time) { + if !service.lastGenerationCleanup.IsZero() && + now.Sub(service.lastGenerationCleanup) < contactGenerationCleanupInterval { + return + } + for userID, generation := range service.generations { + if now.Sub(generation.lastAccess) >= contactMapCacheTTL { + delete(service.generations, userID) + } + } + service.lastGenerationCleanup = now +} + +// CreateMany persists one or many contacts in a single batch. +func (service *ContactService) CreateMany(ctx context.Context, userID entities.UserID, contacts []*entities.Contact) error { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + if err := service.repository.Store(ctx, contacts); err != nil { + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store [%d] contacts for user [%s]", len(contacts), userID)) + } + + phoneNumbers := make([]string, 0) + for _, contact := range contacts { + phoneNumbers = append(phoneNumbers, contact.PhoneNumbers...) + } + service.invalidate(userID, phoneNumbers) + return nil +} + +// Get returns a single contact scoped to the user. +func (service *ContactService) Get(ctx context.Context, userID entities.UserID, contactID uuid.UUID) (*entities.Contact, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + contact, err := service.repository.Load(ctx, userID, contactID) + if err != nil { + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load contact [%s] for user [%s]", contactID, userID)) + } + return contact, nil +} + +// Index lists contacts for a user with the provided search/pagination params. +func (service *ContactService) Index(ctx context.Context, userID entities.UserID, params repositories.IndexParams) (*[]entities.Contact, error) { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + contacts, err := service.repository.Index(ctx, userID, params) + if err != nil { + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot index contacts for user [%s]", userID)) + } + + ctxLogger.Info(fmt.Sprintf("fetched [%d] contacts with params [%+#v]", len(*contacts), params)) + return contacts, nil +} + +// Count returns the total number of contacts for a user matching the same +// search filter as Index, ignoring pagination. It lets callers report an +// accurate total independent of the current page's skip/limit. +func (service *ContactService) Count(ctx context.Context, userID entities.UserID, params repositories.IndexParams) (int64, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + total, err := service.repository.Count(ctx, userID, params) + if err != nil { + return 0, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot count contacts for user [%s]", userID)) + } + return total, nil +} + +// Update persists changes to a contact and invalidates its old and new numbers. +func (service *ContactService) Update(ctx context.Context, contact *entities.Contact, previousPhoneNumbers []string) error { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + if err := service.repository.Update(ctx, contact); err != nil { + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update contact [%s] for user [%s]", contact.ID, contact.UserID)) + } + + phoneNumbers := make([]string, 0, len(previousPhoneNumbers)+len(contact.PhoneNumbers)) + phoneNumbers = append(phoneNumbers, previousPhoneNumbers...) + phoneNumbers = append(phoneNumbers, contact.PhoneNumbers...) + service.invalidate(contact.UserID, phoneNumbers) + return nil +} + +// Delete removes a contact scoped to the user and invalidates its phone numbers. +func (service *ContactService) Delete(ctx context.Context, userID entities.UserID, contactID uuid.UUID) error { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + contact, err := service.repository.Load(ctx, userID, contactID) + if err != nil { + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load contact [%s] for user [%s] before delete", contactID, userID)) + } + + if err := service.repository.Delete(ctx, userID, contactID); err != nil { + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete contact [%s] for user [%s]", contactID, userID)) + } + + service.invalidate(userID, contact.PhoneNumbers) + return nil +} + +// DeleteAllForUser removes every contact owned by a user and clears cached contacts. +func (service *ContactService) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete all contacts for user [%s]", userID)) + } + + service.invalidate(userID, nil) + return nil +} + +// GetContactMap resolves only the requested phone numbers. Each contact is +// cached independently by user and phone number. +func (service *ContactService) GetContactMap(ctx context.Context, userID entities.UserID, phoneNumbers []string) (map[string]*entities.Contact, error) { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + generation := service.generation(userID) + result := make(map[string]*entities.Contact, len(phoneNumbers)) + missing := make([]string, 0, len(phoneNumbers)) + missingSet := make(map[string]struct{}, len(phoneNumbers)) + requested := make(map[string]struct{}, len(phoneNumbers)) + for _, phoneNumber := range phoneNumbers { + if _, seen := requested[phoneNumber]; seen { + continue + } + requested[phoneNumber] = struct{}{} + key := service.cacheKey(userID, phoneNumber) + if entry, found := service.cache.Get(key); found { + if entry.generation == generation { + if entry.contact != nil { + result[phoneNumber] = entry.contact + } + continue + } + service.cache.Del(key) + } + missing = append(missing, phoneNumber) + missingSet[phoneNumber] = struct{}{} + } + + if len(missing) == 0 { + return result, nil + } + + ctxLogger.Info(fmt.Sprintf("fetching [%d] missing contacts by phone numbers for user [%s]", len(missing), userID)) + contacts, err := service.repository.FetchByPhoneNumbers(ctx, userID, missing) + if err != nil { + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch contacts by phone numbers for user [%s]", userID)) + } + + for index := range *contacts { + contact := (*contacts)[index] + for _, phoneNumber := range contact.PhoneNumbers { + if _, requested := missingSet[phoneNumber]; requested { + result[phoneNumber] = &contact + } + } + } + + if service.generation(userID) == generation { + for _, phoneNumber := range missing { + contact := result[phoneNumber] + entry := ContactCacheEntry{contact: contact, generation: generation} + if accepted := service.cache.SetWithTTL(service.cacheKey(userID, phoneNumber), entry, 1, contactMapCacheTTL); !accepted { + ctxLogger.Error(stacktrace.NewErrorf("cannot cache contact lookup for user [%s] and phone number [%s]", userID, phoneNumber)) + } + } + } + + return result, nil +} + +func (service *ContactService) invalidate(userID entities.UserID, phoneNumbers []string) { + service.advanceGeneration(userID) + service.cache.Wait() + for _, phoneNumber := range phoneNumbers { + service.cache.Del(service.cacheKey(userID, phoneNumber)) + } +} diff --git a/api/pkg/services/contact_service_test.go b/api/pkg/services/contact_service_test.go new file mode 100644 index 000000000..2c5498039 --- /dev/null +++ b/api/pkg/services/contact_service_test.go @@ -0,0 +1,598 @@ +package services + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/dgraph-io/ristretto/v2" + "github.com/google/uuid" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +// --- fake repository -------------------------------------------------------- + +type fakeContactRepo struct { + mu sync.Mutex + + contacts []*entities.Contact + + storeCalls [][]*entities.Contact + updateCalls []*entities.Contact + loadCalls []loadCall + indexCalls []indexCall + countCalls []indexCall + deleteCalls []deleteCall + deleteAllCalls []entities.UserID + fetchCalls []fetchCall + + storeErr error + updateErr error + loadErr error + indexErr error + countErr error + deleteErr error + deleteAllErr error + fetchErr error + + indexResult []entities.Contact + countResult int64 +} + +type loadCall struct { + userID entities.UserID + id uuid.UUID +} + +type indexCall struct { + userID entities.UserID + params repositories.IndexParams +} + +type deleteCall struct { + userID entities.UserID + id uuid.UUID +} + +type fetchCall struct { + userID entities.UserID + phoneNumbers []string +} + +type blockingContactRepo struct { + *fakeContactRepo + fetchStarted chan struct{} + releaseFetch chan struct{} + fetchOnce sync.Once +} + +func (r *blockingContactRepo) FetchByPhoneNumbers(ctx context.Context, userID entities.UserID, phoneNumbers []string) (*[]entities.Contact, error) { + var captured []entities.Contact + r.fetchOnce.Do(func() { + r.fakeContactRepo.mu.Lock() + captured = append(captured, *r.fakeContactRepo.contacts[0]) + r.fakeContactRepo.mu.Unlock() + close(r.fetchStarted) + <-r.releaseFetch + }) + if captured != nil { + return &captured, nil + } + return r.fakeContactRepo.FetchByPhoneNumbers(ctx, userID, phoneNumbers) +} + +func (r *fakeContactRepo) Store(_ context.Context, contacts []*entities.Contact) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.storeCalls = append(r.storeCalls, contacts) + if r.storeErr != nil { + return r.storeErr + } + r.contacts = append(r.contacts, contacts...) + return nil +} + +func (r *fakeContactRepo) Update(_ context.Context, contact *entities.Contact) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.updateCalls = append(r.updateCalls, contact) + if r.updateErr != nil { + return r.updateErr + } + for index := range r.contacts { + if r.contacts[index].ID == contact.ID && r.contacts[index].UserID == contact.UserID { + clone := *contact + r.contacts[index] = &clone + break + } + } + return nil +} + +func (r *fakeContactRepo) Load(_ context.Context, userID entities.UserID, id uuid.UUID) (*entities.Contact, error) { + r.mu.Lock() + defer r.mu.Unlock() + + r.loadCalls = append(r.loadCalls, loadCall{userID: userID, id: id}) + if r.loadErr != nil { + return nil, r.loadErr + } + for _, c := range r.contacts { + if c.ID == id && c.UserID == userID { + return c, nil + } + } + return nil, stacktrace.NewErrorWithCodef(repositories.ErrCodeNotFound, "contact [%s] not found", id) +} + +func (r *fakeContactRepo) Index(_ context.Context, userID entities.UserID, params repositories.IndexParams) (*[]entities.Contact, error) { + r.mu.Lock() + defer r.mu.Unlock() + + r.indexCalls = append(r.indexCalls, indexCall{userID: userID, params: params}) + if r.indexErr != nil { + return nil, r.indexErr + } + out := append([]entities.Contact{}, r.indexResult...) + return &out, nil +} + +func (r *fakeContactRepo) Count(_ context.Context, userID entities.UserID, params repositories.IndexParams) (int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + + r.countCalls = append(r.countCalls, indexCall{userID: userID, params: params}) + if r.countErr != nil { + return 0, r.countErr + } + return r.countResult, nil +} + +func (r *fakeContactRepo) FetchByPhoneNumbers(_ context.Context, userID entities.UserID, phoneNumbers []string) (*[]entities.Contact, error) { + r.mu.Lock() + defer r.mu.Unlock() + + r.fetchCalls = append(r.fetchCalls, fetchCall{ + userID: userID, + phoneNumbers: append([]string{}, phoneNumbers...), + }) + if r.fetchErr != nil { + return nil, r.fetchErr + } + requested := make(map[string]struct{}, len(phoneNumbers)) + for _, phoneNumber := range phoneNumbers { + requested[phoneNumber] = struct{}{} + } + + out := make([]entities.Contact, 0) + for _, c := range r.contacts { + if c.UserID != userID { + continue + } + for _, phoneNumber := range c.PhoneNumbers { + if _, ok := requested[phoneNumber]; ok { + out = append(out, *c) + break + } + } + } + return &out, nil +} + +func (r *fakeContactRepo) Delete(_ context.Context, userID entities.UserID, id uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.deleteCalls = append(r.deleteCalls, deleteCall{userID: userID, id: id}) + return r.deleteErr +} + +func (r *fakeContactRepo) DeleteAllForUser(_ context.Context, userID entities.UserID) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.deleteAllCalls = append(r.deleteAllCalls, userID) + if r.deleteAllErr != nil { + return r.deleteAllErr + } + remaining := r.contacts[:0] + for _, contact := range r.contacts { + if contact.UserID != userID { + remaining = append(remaining, contact) + } + } + r.contacts = remaining + return nil +} + +type recordingLogger struct { + *noopLogger + mu sync.Mutex + errors []error + warns []error + infos []string + debugs []string +} + +func newRecordingLogger() *recordingLogger { + return &recordingLogger{noopLogger: &noopLogger{}} +} + +func (l *recordingLogger) Error(err error) { + l.mu.Lock() + defer l.mu.Unlock() + l.errors = append(l.errors, err) +} + +func (l *recordingLogger) Warn(err error) { + l.mu.Lock() + defer l.mu.Unlock() + l.warns = append(l.warns, err) +} + +func (l *recordingLogger) Info(v string) { + l.mu.Lock() + defer l.mu.Unlock() + l.infos = append(l.infos, v) +} + +func (l *recordingLogger) Debug(v string) { + l.mu.Lock() + defer l.mu.Unlock() + l.debugs = append(l.debugs, v) +} + +func (l *recordingLogger) WithService(_ string) telemetry.Logger { return l } +func (l *recordingLogger) WithString(_, _ string) telemetry.Logger { return l } +func (l *recordingLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { + return l +} + +// --- helpers --------------------------------------------------------------- + +func newContactCache(t *testing.T) *ristretto.Cache[string, ContactCacheEntry] { + t.Helper() + + contactCache, err := ristretto.NewCache[string, ContactCacheEntry](&ristretto.Config[string, ContactCacheEntry]{ + MaxCost: 100, + NumCounters: 1_000, + BufferItems: 64, + }) + require.NoError(t, err) + t.Cleanup(contactCache.Close) + return contactCache +} + +func newContactServiceForTest(t *testing.T, repo repositories.ContactRepository, logger telemetry.Logger) *ContactService { + t.Helper() + if logger == nil { + logger = &noopLogger{} + } + tracer := telemetry.NewOtelLogger("test", logger) + return NewContactService(logger, tracer, repo, newContactCache(t)) +} + +// --- tests ----------------------------------------------------------------- + +func TestContactService_GetContactMap_FetchesOnlyUncachedPhoneNumbers(t *testing.T) { + repo := &fakeContactRepo{contacts: []*entities.Contact{ + {ID: uuid.New(), UserID: "u1", Name: "Alice", PhoneNumbers: pq.StringArray{"+18005550199"}}, + {ID: uuid.New(), UserID: "u1", Name: "Bob", PhoneNumbers: pq.StringArray{"+18005550100"}}, + {ID: uuid.New(), UserID: "u1", Name: "Carol", PhoneNumbers: pq.StringArray{"+18005550111"}}, + }} + service := newContactServiceForTest(t, repo, nil) + + first, err := service.GetContactMap(context.Background(), entities.UserID("u1"), []string{"+18005550199"}) + require.NoError(t, err) + service.cache.Wait() + require.Len(t, first, 1) + + second, err := service.GetContactMap(context.Background(), entities.UserID("u1"), []string{"+18005550199", "+18005550111"}) + require.NoError(t, err) + + require.Len(t, repo.fetchCalls, 2) + assert.Equal(t, []string{"+18005550199"}, repo.fetchCalls[0].phoneNumbers) + assert.Equal(t, []string{"+18005550111"}, repo.fetchCalls[1].phoneNumbers) + assert.Equal(t, "Carol", second["+18005550111"].Name) +} + +func TestContactService_GetContactMap_CacheKeyIncludesUserAndPhoneNumber(t *testing.T) { + number := "+18005550199" + repo := &fakeContactRepo{contacts: []*entities.Contact{ + {ID: uuid.New(), UserID: "u1", Name: "Alice", PhoneNumbers: pq.StringArray{number}}, + {ID: uuid.New(), UserID: "u2", Name: "Bob", PhoneNumbers: pq.StringArray{number}}, + }} + service := newContactServiceForTest(t, repo, nil) + + first, err := service.GetContactMap(context.Background(), entities.UserID("u1"), []string{number}) + require.NoError(t, err) + service.cache.Wait() + second, err := service.GetContactMap(context.Background(), entities.UserID("u2"), []string{number}) + require.NoError(t, err) + + assert.Equal(t, "Alice", first[number].Name) + assert.Equal(t, "Bob", second[number].Name) + require.Len(t, repo.fetchCalls, 2) +} + +func TestContactService_GetContactMap_CachesMissingPhoneNumbers(t *testing.T) { + repo := &fakeContactRepo{} + service := newContactServiceForTest(t, repo, nil) + number := "+18005550199" + + first, err := service.GetContactMap(context.Background(), entities.UserID("u1"), []string{number}) + require.NoError(t, err) + service.cache.Wait() + second, err := service.GetContactMap(context.Background(), entities.UserID("u1"), []string{number}) + require.NoError(t, err) + + assert.Empty(t, first) + assert.Empty(t, second) + require.Len(t, repo.fetchCalls, 1) +} + +func TestContactService_ExpiresInactiveGenerationStateWithoutReusingEpoch(t *testing.T) { + service := newContactServiceForTest(t, &fakeContactRepo{}, nil) + + first := service.generation(entities.UserID("u1")) + service.expireGenerations(time.Now().Add(contactMapCacheTTL + time.Hour)) + second := service.generation(entities.UserID("u1")) + + assert.NotEqual(t, first, second) +} + +func TestContactService_GetContactMap_TieBreakMostRecentlyUpdatedWins(t *testing.T) { + number := "+18005550199" + older := &entities.Contact{ID: uuid.New(), UserID: "u1", Name: "Old", PhoneNumbers: pq.StringArray{number}, UpdatedAt: time.Now().Add(-time.Hour)} + newer := &entities.Contact{ID: uuid.New(), UserID: "u1", Name: "New", PhoneNumbers: pq.StringArray{number}, UpdatedAt: time.Now()} + repo := &fakeContactRepo{contacts: []*entities.Contact{older, newer}} + service := newContactServiceForTest(t, repo, nil) + + result, err := service.GetContactMap(context.Background(), entities.UserID("u1"), []string{number}) + require.NoError(t, err) + + require.NotNil(t, result[number]) + assert.Equal(t, newer.ID, result[number].ID) +} + +func TestContactService_Update_InvalidatesOldAndNewPhoneNumbers(t *testing.T) { + oldNumber := "+18005550199" + newNumber := "+18005550100" + id := uuid.New() + repo := &fakeContactRepo{contacts: []*entities.Contact{{ + ID: id, UserID: "u1", Name: "Alice", PhoneNumbers: pq.StringArray{oldNumber}, + }}} + contactCache := newContactCache(t) + stale := &entities.Contact{ID: id, UserID: "u1", Name: "Stale"} + require.True(t, contactCache.Set("u1|"+oldNumber, ContactCacheEntry{contact: stale}, 1)) + require.True(t, contactCache.Set("u1|"+newNumber, ContactCacheEntry{contact: stale}, 1)) + contactCache.Wait() + logger := &noopLogger{} + service := NewContactService(logger, telemetry.NewOtelLogger("test", logger), repo, contactCache) + + err := service.Update(context.Background(), &entities.Contact{ + ID: id, UserID: "u1", Name: "Alice", PhoneNumbers: pq.StringArray{newNumber}, + }, []string{oldNumber}) + require.NoError(t, err) + contactCache.Wait() + + _, oldFound := contactCache.Get("u1|" + oldNumber) + _, newFound := contactCache.Get("u1|" + newNumber) + assert.False(t, oldFound) + assert.False(t, newFound) +} + +func TestContactService_GetContactMap_DoesNotReuseResultFetchedDuringUpdate(t *testing.T) { + number := "+18005550199" + id := uuid.New() + repo := &blockingContactRepo{ + fakeContactRepo: &fakeContactRepo{contacts: []*entities.Contact{{ + ID: id, UserID: "u1", Name: "Old", PhoneNumbers: pq.StringArray{number}, + }}}, + fetchStarted: make(chan struct{}), + releaseFetch: make(chan struct{}), + } + service := newContactServiceForTest(t, repo, nil) + + firstResult := make(chan map[string]*entities.Contact, 1) + go func() { + result, _ := service.GetContactMap(context.Background(), entities.UserID("u1"), []string{number}) + firstResult <- result + }() + <-repo.fetchStarted + + require.NoError(t, service.Update(context.Background(), &entities.Contact{ + ID: id, UserID: "u1", Name: "New", PhoneNumbers: pq.StringArray{number}, + }, []string{number})) + close(repo.releaseFetch) + assert.Equal(t, "Old", (<-firstResult)[number].Name) + service.cache.Wait() + + second, err := service.GetContactMap(context.Background(), entities.UserID("u1"), []string{number}) + require.NoError(t, err) + assert.Equal(t, "New", second[number].Name) +} + +func TestContactService_DeleteAllForUser_DoesNotEvictOtherUsers(t *testing.T) { + repo := &fakeContactRepo{contacts: []*entities.Contact{ + {UserID: "u1", Name: "Alice", PhoneNumbers: pq.StringArray{"+18005550199"}}, + {UserID: "u2", Name: "Bob", PhoneNumbers: pq.StringArray{"+18005550100"}}, + }} + contactCache := newContactCache(t) + logger := &noopLogger{} + service := NewContactService(logger, telemetry.NewOtelLogger("test", logger), repo, contactCache) + require.True(t, contactCache.Set("u1|+18005550199", ContactCacheEntry{ + contact: repo.contacts[0], generation: service.generation("u1"), + }, 1)) + require.True(t, contactCache.Set("u2|+18005550100", ContactCacheEntry{ + contact: repo.contacts[1], generation: service.generation("u2"), + }, 1)) + contactCache.Wait() + + require.NoError(t, service.DeleteAllForUser(context.Background(), entities.UserID("u1"))) + + first, err := service.GetContactMap(context.Background(), entities.UserID("u1"), []string{"+18005550199"}) + require.NoError(t, err) + second, err := service.GetContactMap(context.Background(), entities.UserID("u2"), []string{"+18005550100"}) + require.NoError(t, err) + assert.Empty(t, first) + assert.Equal(t, "Bob", second["+18005550100"].Name) + require.Len(t, repo.fetchCalls, 1) + assert.Equal(t, entities.UserID("u1"), repo.fetchCalls[0].userID) +} + +// --- CRUD delegation and user scope tests --------------------------------- + +func TestContactService_CreateMany_PersistsBatchInSingleCall(t *testing.T) { + repo := &fakeContactRepo{} + service := newContactServiceForTest(t, repo, nil) + + batch := []*entities.Contact{ + {ID: uuid.New(), UserID: "u1", Name: "A", PhoneNumbers: pq.StringArray{"+18005550100"}}, + {ID: uuid.New(), UserID: "u1", Name: "B", PhoneNumbers: pq.StringArray{"+18005550111"}}, + } + require.NoError(t, service.CreateMany(context.Background(), entities.UserID("u1"), batch)) + + require.Len(t, repo.storeCalls, 1) + assert.Equal(t, batch, repo.storeCalls[0]) +} + +func TestContactService_CreateMany_RepositoryErrorIsWrapped(t *testing.T) { + repo := &fakeContactRepo{storeErr: errors.New("db down")} + service := newContactServiceForTest(t, repo, nil) + + err := service.CreateMany(context.Background(), entities.UserID("u1"), []*entities.Contact{{ + ID: uuid.New(), UserID: "u1", Name: "A", PhoneNumbers: pq.StringArray{"+18005550100"}, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "db down") +} + +func TestContactService_Get_DelegatesWithUserScope(t *testing.T) { + id := uuid.New() + other := uuid.New() + repo := &fakeContactRepo{contacts: []*entities.Contact{ + {ID: id, UserID: "u1", Name: "Alice", PhoneNumbers: pq.StringArray{"+18005550199"}}, + {ID: other, UserID: "u2", Name: "Bob", PhoneNumbers: pq.StringArray{"+18005550100"}}, + }} + service := newContactServiceForTest(t, repo, nil) + + got, err := service.Get(context.Background(), entities.UserID("u1"), id) + require.NoError(t, err) + assert.Equal(t, "Alice", got.Name) + + // Wrong user scope must not resolve the contact. + _, err = service.Get(context.Background(), entities.UserID("u1"), other) + require.Error(t, err) + assert.Equal(t, repositories.ErrCodeNotFound, stacktrace.GetCode(err)) +} + +func TestContactService_Index_DelegatesParams(t *testing.T) { + want := []entities.Contact{{ID: uuid.New(), UserID: "u1", Name: "Alice", PhoneNumbers: pq.StringArray{"+18005550199"}}} + repo := &fakeContactRepo{indexResult: want} + service := newContactServiceForTest(t, repo, nil) + + params := repositories.IndexParams{Skip: 5, Limit: 10, SortBy: "name", Query: "Ali"} + got, err := service.Index(context.Background(), entities.UserID("u1"), params) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, want, *got) + + require.Len(t, repo.indexCalls, 1) + assert.Equal(t, entities.UserID("u1"), repo.indexCalls[0].userID) + assert.Equal(t, params, repo.indexCalls[0].params) +} + +func TestContactService_Index_RepositoryErrorIsWrapped(t *testing.T) { + repo := &fakeContactRepo{indexErr: errors.New("index boom")} + service := newContactServiceForTest(t, repo, nil) + + _, err := service.Index(context.Background(), entities.UserID("u1"), repositories.IndexParams{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "index boom") +} + +func TestContactService_Count_DelegatesParamsAndReturnsTotal(t *testing.T) { + repo := &fakeContactRepo{countResult: 57} + service := newContactServiceForTest(t, repo, nil) + + params := repositories.IndexParams{Skip: 5, Limit: 10, Query: "Ali"} + total, err := service.Count(context.Background(), entities.UserID("u1"), params) + require.NoError(t, err) + assert.Equal(t, int64(57), total) + + require.Len(t, repo.countCalls, 1) + assert.Equal(t, entities.UserID("u1"), repo.countCalls[0].userID) + assert.Equal(t, params, repo.countCalls[0].params) +} + +func TestContactService_Count_RepositoryErrorIsWrapped(t *testing.T) { + repo := &fakeContactRepo{countErr: errors.New("count boom")} + service := newContactServiceForTest(t, repo, nil) + + _, err := service.Count(context.Background(), entities.UserID("u1"), repositories.IndexParams{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "count boom") +} + +func TestContactService_Update_RepositoryErrorIsWrappedAndSkipsInvalidation(t *testing.T) { + id := uuid.New() + repo := &fakeContactRepo{ + contacts: []*entities.Contact{{ID: id, UserID: "u1", PhoneNumbers: pq.StringArray{"+18005550100"}}}, + updateErr: errors.New("update boom"), + } + contactCache := newContactCache(t) + require.True(t, contactCache.Set("u1|+18005550100", ContactCacheEntry{contact: &entities.Contact{Name: "Cached"}}, 1)) + contactCache.Wait() + logger := &noopLogger{} + service := NewContactService(logger, telemetry.NewOtelLogger("test", logger), repo, contactCache) + + err := service.Update( + context.Background(), + &entities.Contact{ID: id, UserID: "u1", Name: "A", PhoneNumbers: pq.StringArray{"+18005550100"}}, + []string{"+18005550100"}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "update boom") + _, found := contactCache.Get("u1|+18005550100") + assert.True(t, found, "invalidation must not run when the write fails") +} + +func TestContactService_Delete_RepositoryErrorIsWrappedAndSkipsInvalidation(t *testing.T) { + id := uuid.New() + repo := &fakeContactRepo{ + contacts: []*entities.Contact{{ID: id, UserID: "u1", PhoneNumbers: pq.StringArray{"+18005550100"}}}, + deleteErr: errors.New("delete boom"), + } + contactCache := newContactCache(t) + require.True(t, contactCache.Set("u1|+18005550100", ContactCacheEntry{contact: &entities.Contact{Name: "Cached"}}, 1)) + contactCache.Wait() + logger := &noopLogger{} + service := NewContactService(logger, telemetry.NewOtelLogger("test", logger), repo, contactCache) + + err := service.Delete(context.Background(), entities.UserID("u1"), id) + require.Error(t, err) + assert.Contains(t, err.Error(), "delete boom") + _, found := contactCache.Get("u1|+18005550100") + assert.True(t, found, "invalidation must not run when the write fails") +} + +func TestContactService_GetContactMap_FetchErrorIsWrapped(t *testing.T) { + repo := &fakeContactRepo{fetchErr: errors.New("fetch boom")} + service := newContactServiceForTest(t, repo, nil) + + _, err := service.GetContactMap(context.Background(), entities.UserID("u1"), []string{"+18005550100"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "fetch boom") +} diff --git a/api/pkg/services/discord_service.go b/api/pkg/services/discord_service.go index 9d5f2680f..68bbbdc7b 100644 --- a/api/pkg/services/discord_service.go +++ b/api/pkg/services/discord_service.go @@ -15,8 +15,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" ) // DiscordService is responsible for handling discordIntegrations @@ -59,7 +59,7 @@ func (service *DiscordService) DeleteAllForUser(ctx context.Context, userID enti defer span.End() if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete all [entities.Discord] for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete all [entities.Discord] for user with ID [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.Discord] for user with ID [%s]", userID)) @@ -75,7 +75,7 @@ func (service *DiscordService) Index(ctx context.Context, userID entities.UserID discordIntegrations, err := service.repository.Index(ctx, userID, params) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not fetch discord integrations with params [%+#v]", params)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not fetch discord integrations with params [%+#v]", params)) } ctxLogger.Info(fmt.Sprintf("fetched [%d] discord integrations with prams [%+#v]", len(discordIntegrations), params)) @@ -90,11 +90,11 @@ func (service *DiscordService) Delete(ctx context.Context, userID entities.UserI ctxLogger := service.tracer.CtxLogger(service.logger, span) if _, err := service.repository.Load(ctx, userID, discordID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load discord integration with userID [%s] and discordID [%s]", userID, discordID)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load discord integration with userID [%s] and discordID [%s]", userID, discordID)) } if err := service.repository.Delete(ctx, userID, discordID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete discord integration with id [%s] and discordID [%s]", discordID, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete discord integration with id [%s] and discordID [%s]", discordID, userID)) } ctxLogger.Info(fmt.Sprintf("deleted discord integration with id [%s] and user id [%s]", discordID, userID)) @@ -115,7 +115,7 @@ func (service *DiscordService) Store(ctx context.Context, params *DiscordStorePa defer span.End() if err := service.createSlashCommand(ctx, params.ServerID); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create slash command for server [%s]", params.ServerID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create slash command for server [%s]", params.ServerID)) } discordIntegration := &entities.Discord{ @@ -129,7 +129,7 @@ func (service *DiscordService) Store(ctx context.Context, params *DiscordStorePa } if err := service.repository.Save(ctx, discordIntegration); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save discord integration with id [%s]", discordIntegration.ID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save discord integration with id [%s]", discordIntegration.ID)) } ctxLogger.Info(fmt.Sprintf("discord integration saved with id [%s] in the [%T]", discordIntegration.ID, service.repository)) @@ -172,7 +172,7 @@ func (service *DiscordService) createSlashCommand(ctx context.Context, serverID }, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create slash command for server [%s]", serverID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create slash command for server [%s]", serverID)) } ctxLogger.Info(fmt.Sprintf("upserted a slash command with ID [%s] for discord server [%s] and applicationID [%s]", command.ID, serverID, command.ApplicationID)) @@ -195,11 +195,11 @@ func (service *DiscordService) Update(ctx context.Context, params *DiscordUpdate discordIntegration, err := service.repository.Load(ctx, params.UserID, params.DiscordID) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load discord integration with userID [%s] and discordID [%s]", params.UserID, params.DiscordID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load discord integration with userID [%s] and discordID [%s]", params.UserID, params.DiscordID)) } if err = service.createSlashCommand(ctx, params.ServerID); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create slash command for server [%s]", params.ServerID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create slash command for server [%s]", params.ServerID)) } discordIntegration.Name = params.Name @@ -207,7 +207,7 @@ func (service *DiscordService) Update(ctx context.Context, params *DiscordUpdate discordIntegration.IncomingChannelID = params.IncomingChannelID if err = service.repository.Save(ctx, discordIntegration); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save discord integration with id [%s] after update", discordIntegration.ID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save discord integration with id [%s] after update", discordIntegration.ID)) } ctxLogger.Info(fmt.Sprintf("discord integration updated with id [%s] in the [%T]", discordIntegration.ID, service.repository)) @@ -221,7 +221,7 @@ func (service *DiscordService) HandleMessageReceived(ctx context.Context, userID discordIntegrations, err := service.repository.FetchHavingIncomingChannel(ctx, userID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load discord integrations for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load discord integrations for user with ID [%s]", userID)) } if len(discordIntegrations) == 0 { @@ -248,14 +248,14 @@ func (service *DiscordService) sendMessage(ctx context.Context, event cloudevent payload := new(events.MessagePhoneReceivedPayload) if err := event.DataAs(payload); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload)) return } request := service.createDiscordMessage(ctxLogger, payload) message, response, err := service.client.Channel.CreateMessage(ctx, discord.IncomingChannelID, request) if err != nil { - ctxLogger.Warn(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send [%s] event to discord channel [%s] for user [%s]", event.Type(), discord.IncomingChannelID, discord.UserID))) + ctxLogger.Warn(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send [%s] event to discord channel [%s] for user [%s]", event.Type(), discord.IncomingChannelID, discord.UserID))) eventPayload := &events.DiscordSendFailedPayload{ DiscordID: discord.ID, @@ -315,12 +315,12 @@ func (service *DiscordService) handleDiscordMessageFailed(ctx context.Context, s event, err := service.createEvent(events.EventTypeDiscordSendFailed, source, payload) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event [%s] for user with id [%s]", events.EventTypeDiscordSendFailed, payload.UserID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event [%s] for user with id [%s]", events.EventTypeDiscordSendFailed, payload.UserID))) return } if err = service.dispatcher.Dispatch(ctx, event); err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] for user with id [%s]", event.Type(), payload.UserID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] for user with id [%s]", event.Type(), payload.UserID))) return } diff --git a/api/pkg/services/email_notification_service.go b/api/pkg/services/email_notification_service.go index ef6d7d853..d72b3ee0c 100644 --- a/api/pkg/services/email_notification_service.go +++ b/api/pkg/services/email_notification_service.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/emails" @@ -66,7 +66,7 @@ func (service *EmailNotificationService) NotifyMessageExpired(ctx context.Contex user, err := service.userRepository.Load(ctx, payload.UserID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load user with ID [%s] and for expired message with ID [%s]", payload.UserID, payload.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load user with ID [%s] and for expired message with ID [%s]", payload.UserID, payload.MessageID)) } if !user.NotificationMessageStatusEnabled { @@ -76,11 +76,11 @@ func (service *EmailNotificationService) NotifyMessageExpired(ctx context.Contex email, err := service.factory.MessageExpired(user, payload) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create email for user with ID [%s] and for expired message with ID [%s]", payload.UserID, payload.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create email for user with ID [%s] and for expired message with ID [%s]", payload.UserID, payload.MessageID)) } if err = service.mailer.Send(ctx, email); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send email for user with ID [%s] and for expired message with ID [%s]", payload.UserID, payload.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send email for user with ID [%s] and for expired message with ID [%s]", payload.UserID, payload.MessageID)) } ctxLogger.Info(fmt.Sprintf("[%s] email sent to [%s] for message with ID [%s]", events.EventTypeMessageSendExpired, user.ID, payload.MessageID)) @@ -101,7 +101,7 @@ func (service *EmailNotificationService) NotifyMessageFailed(ctx context.Context user, err := service.userRepository.Load(ctx, payload.UserID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load user with ID [%s] for [%s] message with ID [%s]", payload.UserID, events.EventTypeMessageSendFailed, payload.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load user with ID [%s] for [%s] message with ID [%s]", payload.UserID, events.EventTypeMessageSendFailed, payload.ID)) } if !user.NotificationMessageStatusEnabled { @@ -111,11 +111,11 @@ func (service *EmailNotificationService) NotifyMessageFailed(ctx context.Context email, err := service.factory.MessageFailed(user, payload) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create email for user with ID [%s] for [%s] message with ID [%s]", payload.UserID, events.EventTypeMessageSendFailed, payload.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create email for user with ID [%s] for [%s] message with ID [%s]", payload.UserID, events.EventTypeMessageSendFailed, payload.ID)) } if err = service.mailer.Send(ctx, email); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send email for user with ID [%s] for [%s] message with ID [%s]", payload.UserID, events.EventTypeMessageSendFailed, payload.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send email for user with ID [%s] for [%s] message with ID [%s]", payload.UserID, events.EventTypeMessageSendFailed, payload.ID)) } ctxLogger.Info(fmt.Sprintf("[%s] email sent to [%s] for message with ID [%s]", events.EventTypeMessageSendFailed, user.ID, payload.ID)) @@ -136,7 +136,7 @@ func (service *EmailNotificationService) NotifyWebhookSendFailed(ctx context.Con user, err := service.userRepository.Load(ctx, payload.UserID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load user with ID [%s] for [%s] event with ID [%s]", payload.UserID, events.EventTypeWebhookSendFailed, payload.EventID)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load user with ID [%s] for [%s] event with ID [%s]", payload.UserID, events.EventTypeWebhookSendFailed, payload.EventID)) } if !user.NotificationWebhookEnabled { @@ -146,11 +146,11 @@ func (service *EmailNotificationService) NotifyWebhookSendFailed(ctx context.Con email, err := service.factory.WebhookSendFailed(user, payload) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [%s] email for user with ID [%s] for [%s] event with ID [%s]", events.EventTypeWebhookSendFailed, payload.UserID, payload.EventType, payload.EventID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [%s] email for user with ID [%s] for [%s] event with ID [%s]", events.EventTypeWebhookSendFailed, payload.UserID, payload.EventType, payload.EventID)) } if err = service.mailer.Send(ctx, email); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send [%s] email for user with ID [%s] for [%s] event with ID [%s]", events.EventTypeWebhookSendFailed, payload.UserID, payload.EventType, payload.EventID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send [%s] email for user with ID [%s] for [%s] event with ID [%s]", events.EventTypeWebhookSendFailed, payload.UserID, payload.EventType, payload.EventID)) } ctxLogger.Info(fmt.Sprintf("[%s] email sent to [%s] for [%s] event with ID [%s]", events.EventTypeWebhookSendFailed, user.ID, payload.EventType, payload.EventID)) @@ -171,7 +171,7 @@ func (service *EmailNotificationService) NotifyDiscordSendFailed(ctx context.Con user, err := service.userRepository.Load(ctx, payload.UserID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load user with ID [%s] for [%s] event for message with ID [%s]", payload.UserID, payload.EventType, payload.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load user with ID [%s] for [%s] event for message with ID [%s]", payload.UserID, payload.EventType, payload.MessageID)) } if !user.NotificationWebhookEnabled { @@ -181,11 +181,11 @@ func (service *EmailNotificationService) NotifyDiscordSendFailed(ctx context.Con email, err := service.factory.DiscordSendFailed(user, payload) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create email for user with ID [%s] for [%s] event and message with ID [%s]", payload.UserID, payload.EventType, payload.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create email for user with ID [%s] for [%s] event and message with ID [%s]", payload.UserID, payload.EventType, payload.MessageID)) } if err = service.mailer.Send(ctx, email); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send email for user with ID [%s] for [%s] message with ID [%s]", payload.UserID, events.EventTypeMessageSendFailed, payload.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send email for user with ID [%s] for [%s] message with ID [%s]", payload.UserID, events.EventTypeMessageSendFailed, payload.MessageID)) } ctxLogger.Info(fmt.Sprintf("[%s] email sent to [%s] for [%s] event with ID [%s]", payload.EventType, user.ID, payload.EventType, payload.MessageID)) @@ -209,6 +209,6 @@ func (service *EmailNotificationService) addToCache(ctx context.Context, timeout cacheKey := service.getCacheKey(event, owner) if err := service.cache.Set(ctx, cacheKey, "", timeout); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot set item in redis with key [%s] for owner [%s]", cacheKey, owner)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot set item in redis with key [%s] for owner [%s]", cacheKey, owner)) } } diff --git a/api/pkg/services/emulator_fcm_client.go b/api/pkg/services/emulator_fcm_client.go index a83767753..09dce8e54 100644 --- a/api/pkg/services/emulator_fcm_client.go +++ b/api/pkg/services/emulator_fcm_client.go @@ -10,7 +10,8 @@ import ( "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" ) // EmulatorFCMClient sends FCM messages to the phone emulator via HTTP. @@ -50,7 +51,7 @@ type emulatorFCMResponse struct { } // Send sends a message to the emulator's FCM endpoint. -func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { +func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message, _ uuid.UUID) (string, error) { payload := &emulatorFCMRequest{ Message: &emulatorFCMMessage{ Token: message.Token, @@ -65,34 +66,34 @@ func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message body, err := json.Marshal(payload) if err != nil { - return "", stacktrace.Propagate(err, "cannot marshal FCM request for emulator") + return "", stacktrace.Propagatef(err, "cannot marshal FCM request for emulator") } url := fmt.Sprintf("%s/v1/projects/httpsms-test/messages:send", c.endpoint) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { - return "", stacktrace.Propagate(err, "cannot create HTTP request for emulator FCM") + return "", stacktrace.Propagatef(err, "cannot create HTTP request for emulator FCM") } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { - return "", stacktrace.Propagate(err, "cannot send FCM to emulator at [%s]", url) + return "", stacktrace.Propagatef(err, "cannot send FCM to emulator at [%s]", url) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return "", stacktrace.Propagate(err, "cannot read emulator FCM response body") + return "", stacktrace.Propagatef(err, "cannot read emulator FCM response body") } if resp.StatusCode != http.StatusOK { - return "", stacktrace.NewError("emulator FCM returned status %d: %s", resp.StatusCode, string(respBody)) + return "", stacktrace.NewErrorf("emulator FCM returned status %d: %s", resp.StatusCode, string(respBody)) } var result emulatorFCMResponse if err = json.Unmarshal(respBody, &result); err != nil { - return "", stacktrace.Propagate(err, "cannot decode emulator FCM response") + return "", stacktrace.Propagatef(err, "cannot decode emulator FCM response") } c.logger.Info(fmt.Sprintf("emulator FCM sent successfully: %s", result.Name)) diff --git a/api/pkg/services/emulator_push_queue.go b/api/pkg/services/emulator_push_queue.go index f861cebfb..634689f1e 100644 --- a/api/pkg/services/emulator_push_queue.go +++ b/api/pkg/services/emulator_push_queue.go @@ -6,8 +6,8 @@ import ( "net/http" "time" + "github.com/NdoleStudio/stacktrace" "github.com/carlmjohnson/requests" - "github.com/palantir/stacktrace" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/google/uuid" @@ -74,7 +74,7 @@ func (queue *emulatorPushQueue) push(task PushQueueTask, queueID string) func() request.Header("Content-Type", "application/json") if err := request.Fetch(ctx); err != nil { - queue.logger.Error(stacktrace.Propagate(err, "cannot send http request to [%s] for queue task [%s]", task.URL, queueID)) + queue.logger.Error(stacktrace.Propagatef(err, "cannot send http request to [%s] for queue task [%s]", task.URL, queueID)) return } diff --git a/api/pkg/services/entitlement_service.go b/api/pkg/services/entitlement_service.go index 875b27915..01ddf4411 100644 --- a/api/pkg/services/entitlement_service.go +++ b/api/pkg/services/entitlement_service.go @@ -9,8 +9,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" pluralize "github.com/gertd/go-pluralize" - "github.com/palantir/stacktrace" ) // entityLimits maps entity name → subscription plan → max count. @@ -62,6 +62,18 @@ func (service *EntitlementService) Check( userID entities.UserID, entityName string, countFunc func() (int, error), +) (*EntitlementCheckResult, error) { + return service.CheckAdditional(ctx, userID, entityName, 1, countFunc) +} + +// CheckAdditional verifies if the user can create additionalCount instances of +// the given entity without exceeding their subscription plan limit. +func (service *EntitlementService) CheckAdditional( + ctx context.Context, + userID entities.UserID, + entityName string, + additionalCount int, + countFunc func() (int, error), ) (*EntitlementCheckResult, error) { ctx, span := service.tracer.Start(ctx) defer span.End() @@ -70,8 +82,8 @@ func (service *EntitlementService) Check( return &EntitlementCheckResult{Allowed: true}, nil } - limits, exists := entityLimits[entityName] - if !exists { + limits, hasConfiguredLimits := entityLimits[entityName] + if !hasConfiguredLimits && entityName != entities.EntityNameContact { return &EntitlementCheckResult{Allowed: true}, nil } @@ -79,28 +91,32 @@ func (service *EntitlementService) Check( if err != nil { return nil, service.tracer.WrapErrorSpan( span, - stacktrace.Propagate(err, "cannot load user [%s] for entitlement check", userID), + stacktrace.Propagatef(err, "cannot load user [%s] for entitlement check", userID), ) } - limit, hasLimit := limits[user.SubscriptionName] - if !hasLimit || limit == 0 { - return &EntitlementCheckResult{Allowed: true}, nil + limit := int(user.SubscriptionName.Limit()) + if entityName != entities.EntityNameContact { + var hasLimit bool + limit, hasLimit = limits[user.SubscriptionName] + if !hasLimit || limit == 0 { + return &EntitlementCheckResult{Allowed: true}, nil + } } currentCount, err := countFunc() if err != nil { return nil, service.tracer.WrapErrorSpan( span, - stacktrace.Propagate(err, "cannot count entities [%s] for user [%s]", entityName, userID), + stacktrace.Propagatef(err, "cannot count entities [%s] for user [%s]", entityName, userID), ) } - if currentCount >= limit { + if currentCount+additionalCount > limit { return &EntitlementCheckResult{ Allowed: false, Message: fmt.Sprintf( - "Upgrade to a paid plan to create more than [%d] %s. Visit https://httpsms.com/pricing for details.", + "Upgrade your plan to create more than [%d] %s. Visit https://httpsms.com/pricing for details.", limit, formatEntityName(entityName, true), ), diff --git a/api/pkg/services/entitlement_service_test.go b/api/pkg/services/entitlement_service_test.go new file mode 100644 index 000000000..18967cb4e --- /dev/null +++ b/api/pkg/services/entitlement_service_test.go @@ -0,0 +1,104 @@ +package services + +import ( + "context" + "strconv" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type entitlementUserRepository struct { + repositories.UserRepository + user *entities.User + loadCalls int +} + +func (repository *entitlementUserRepository) Load(_ context.Context, _ entities.UserID) (*entities.User, error) { + repository.loadCalls++ + return repository.user, nil +} + +func newEntitlementServiceForTest(enabled bool, repository repositories.UserRepository) *EntitlementService { + logger := newRecordingLogger() + return NewEntitlementService(logger, telemetry.NewOtelLogger("test", logger), enabled, repository) +} + +func TestEntitlementService_CheckAdditional_DisabledAllowsWithoutLoadingUserOrCount(t *testing.T) { + repository := &entitlementUserRepository{} + service := newEntitlementServiceForTest(false, repository) + countCalls := 0 + + result, err := service.CheckAdditional(context.Background(), "user-id", "Contact", 1000, func() (int, error) { + countCalls++ + return 200, nil + }) + + require.NoError(t, err) + assert.True(t, result.Allowed) + assert.Zero(t, repository.loadCalls) + assert.Zero(t, countCalls) +} + +func TestEntitlementService_CheckAdditional_UsesSubscriptionLimitForContactBatch(t *testing.T) { + tests := []struct { + name string + subscriptionName entities.SubscriptionName + currentCount int + additionalCount int + allowed bool + }{ + { + name: "free user can reach 200 contacts", + subscriptionName: entities.SubscriptionNameFree, + currentCount: 199, + additionalCount: 1, + allowed: true, + }, + { + name: "free user cannot exceed 200 contacts", + subscriptionName: entities.SubscriptionNameFree, + currentCount: 199, + additionalCount: 2, + allowed: false, + }, + { + name: "pro user can reach 5000 contacts", + subscriptionName: entities.SubscriptionNameProMonthly, + currentCount: 4999, + additionalCount: 1, + allowed: true, + }, + { + name: "pro user cannot exceed 5000 contacts", + subscriptionName: entities.SubscriptionNameProMonthly, + currentCount: 4999, + additionalCount: 2, + allowed: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repository := &entitlementUserRepository{ + user: &entities.User{SubscriptionName: test.subscriptionName}, + } + service := newEntitlementServiceForTest(true, repository) + + result, err := service.CheckAdditional(context.Background(), "user-id", "Contact", test.additionalCount, func() (int, error) { + return test.currentCount, nil + }) + + require.NoError(t, err) + assert.Equal(t, test.allowed, result.Allowed) + if !test.allowed { + assert.Contains(t, result.Message, "more than ["+strconv.FormatUint(uint64(test.subscriptionName.Limit()), 10)+"]") + assert.Contains(t, result.Message, "Upgrade your plan") + } + }) + } +} diff --git a/api/pkg/services/event_dispatcher_service.go b/api/pkg/services/event_dispatcher_service.go index 9b2710b59..245ab0b2d 100644 --- a/api/pkg/services/event_dispatcher_service.go +++ b/api/pkg/services/event_dispatcher_service.go @@ -15,8 +15,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // EventDispatcher dispatches a new event @@ -53,7 +53,7 @@ func (dispatcher *EventDispatcher) DispatchSync(ctx context.Context, event cloud defer span.End() if err := event.Validate(); err != nil { - return dispatcher.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event with ID [%s] and type [%s] because it is invalid", event.ID(), event.Type())) + return dispatcher.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event with ID [%s] and type [%s] because it is invalid", event.ID(), event.Type())) } dispatcher.Publish(ctx, event) @@ -66,16 +66,16 @@ func (dispatcher *EventDispatcher) DispatchWithTimeout(ctx context.Context, even defer span.End() if err = event.Validate(); err != nil { - return queueID, dispatcher.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event with ID [%s] and type [%s] because it is invalid", event.ID(), event.Type())) + return queueID, dispatcher.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event with ID [%s] and type [%s] because it is invalid", event.ID(), event.Type())) } task, err := dispatcher.createCloudTask(event) if err != nil { - return queueID, dispatcher.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create cloud task for event [%s] with id [%s]", event.Type(), event.ID())) + return queueID, dispatcher.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create cloud task for event [%s] with id [%s]", event.Type(), event.ID())) } if queueID, err = dispatcher.enqueue(ctx, event, task, timeout); err != nil { - return queueID, dispatcher.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot enqueue event with ID [%s] and type [%s]", event.ID(), event.Type())) + return queueID, dispatcher.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot enqueue event with ID [%s] and type [%s]", event.ID(), event.Type())) } return queueID, nil @@ -87,7 +87,7 @@ func (dispatcher *EventDispatcher) enqueue(ctx context.Context, event cloudevent queueID, err := dispatcher.queue.Enqueue(ctx, task, timeout) if errors.Is(err, context.DeadlineExceeded) { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot enqueue event with ID [%s] and type [%s] to [%T]", event.ID(), event.Type(), dispatcher.queue)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot enqueue event with ID [%s] and type [%s] to [%T]", event.ID(), event.Type(), dispatcher.queue)) queueID, err = fmt.Sprintf("local-%s", event.ID()), nil time.AfterFunc(timeout, func() { dispatcher.Publish(ctx, event) @@ -133,7 +133,7 @@ func (dispatcher *EventDispatcher) Publish(ctx context.Context, event cloudevent wg.Add(1) go func(ctx context.Context, sub events.EventListener) { if err := sub(ctx, event); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "subscriber [%T] cannot handle event [%s]", sub, event.Type())) + ctxLogger.Error(stacktrace.Propagatef(err, "subscriber [%T] cannot handle event [%s]", sub, event.Type())) } wg.Done() }(ctx, sub) @@ -163,7 +163,7 @@ func (dispatcher *EventDispatcher) addCloudEventAttributes(span trace.Span, even func (dispatcher *EventDispatcher) createCloudTask(event cloudevents.Event) (*PushQueueTask, error) { eventContent, err := json.Marshal(event) if err != nil { - return nil, stacktrace.Propagate(err, "cannot marshall [%T] with ID [%s]", event, event.ID()) + return nil, stacktrace.Propagatef(err, "cannot marshall [%T] with ID [%s]", event, event.ID()) } return &PushQueueTask{ diff --git a/api/pkg/services/fcm_client.go b/api/pkg/services/fcm_client.go index 4e56f3167..8139c808d 100644 --- a/api/pkg/services/fcm_client.go +++ b/api/pkg/services/fcm_client.go @@ -4,12 +4,15 @@ import ( "context" "firebase.google.com/go/messaging" + "github.com/google/uuid" ) -// FCMClient is the interface for sending Firebase Cloud Messaging notifications. +// FCMClient sends Firebase-compatible messages through a phone notification transport. type FCMClient interface { - // Send sends a message via FCM and returns the message name on success. - Send(ctx context.Context, message *messaging.Message) (string, error) + // Send sends a message and returns the transport's delivery identifier on success. + // phoneID identifies the receiving phone (the notification's target) and is used by HTTP + // adapter transports to sign the request. + Send(ctx context.Context, message *messaging.Message, phoneID uuid.UUID) (string, error) } // FirebaseFCMClient wraps the real Firebase messaging.Client. @@ -23,6 +26,6 @@ func NewFirebaseFCMClient(client *messaging.Client) *FirebaseFCMClient { } // Send sends a message via the real Firebase SDK. -func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { +func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message, _ uuid.UUID) (string, error) { return c.client.Send(ctx, message) } diff --git a/api/pkg/services/google_cloud_push_queue_service.go b/api/pkg/services/google_cloud_push_queue_service.go index b8854616e..2208cf218 100644 --- a/api/pkg/services/google_cloud_push_queue_service.go +++ b/api/pkg/services/google_cloud_push_queue_service.go @@ -11,7 +11,7 @@ import ( cloudtasks "cloud.google.com/go/cloudtasks/apiv2" "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -81,8 +81,8 @@ func (queue *googlePushQueue) enqueueImpl(ctx context.Context, task *PushQueueTa queueTask, err := queue.client.CreateTask(requestCtx, req) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot schedule task [%s] to URL [%s]", string(task.Body), task.URL)) - return queueID, queue.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot schedule task [%s] to URL [%s]", string(task.Body), task.URL)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot schedule task [%s] to URL [%s]", string(task.Body), task.URL)) + return queueID, queue.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot schedule task [%s] to URL [%s]", string(task.Body), task.URL)) } ctxLogger.Info(fmt.Sprintf( diff --git a/api/pkg/services/heartbeat_service.go b/api/pkg/services/heartbeat_service.go index a67899b44..d6c442347 100644 --- a/api/pkg/services/heartbeat_service.go +++ b/api/pkg/services/heartbeat_service.go @@ -11,7 +11,7 @@ import ( "github.com/google/uuid" "github.com/NdoleStudio/httpsms/pkg/repositories" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -55,11 +55,11 @@ func (service *HeartbeatService) DeleteAllForUser(ctx context.Context, userID en defer span.End() if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete all [entities.Heartbeat] for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete all [entities.Heartbeat] for user with ID [%s]", userID)) } if err := service.monitorRepository.DeleteAllForUser(ctx, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete all [entities.HeartbeatMonitor] for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete all [entities.HeartbeatMonitor] for user with ID [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.Heartbeat] and [entities.HeartbeatMonitor] for user with ID [%s]", userID)) @@ -75,10 +75,10 @@ func (service *HeartbeatService) Index(ctx context.Context, userID entities.User heartbeats, err := service.repository.Index(ctx, userID, owner, params) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not fetch heartbeats with parms [%+#v]", params)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not fetch heartbeats with parms [%+#v]", params)) } - ctxLogger.Info(fmt.Sprintf("fetched [%d] messages with prams [%+#v]", len(*heartbeats), params)) + ctxLogger.Info(fmt.Sprintf("fetched [%d] entities.Heartbeat with prams [%+#v]", len(*heartbeats), params)) return heartbeats, nil } @@ -109,7 +109,7 @@ func (service *HeartbeatService) Store(ctx context.Context, params HeartbeatStor } if err := service.repository.Store(ctx, heartbeat); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save heartbeat with id [%s]", heartbeat.ID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save heartbeat with id [%s]", heartbeat.ID)) } ctxLogger.Info(fmt.Sprintf("heartbeat saved with id [%s] for user [%s]", heartbeat.ID, heartbeat.UserID)) @@ -120,7 +120,7 @@ func (service *HeartbeatService) Store(ctx context.Context, params HeartbeatStor return heartbeat, nil } if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot load heartbeat monitor for owner [%s] and user [%s]", params.Owner, params.UserID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot load heartbeat monitor for owner [%s] and user [%s]", params.Owner, params.UserID)) return heartbeat, nil } @@ -145,7 +145,7 @@ func (service *HeartbeatService) handleHeartbeatWhenPhoneWasOffline(ctx context. defer span.End() if err := service.UpdatePhoneOnline(ctx, monitor.UserID, monitor.ID, true); err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update phone online status for heartbeat monitor [%s]", monitor.ID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update phone online status for heartbeat monitor [%s]", monitor.ID))) } event, err := service.createEvent(events.EventTypePhoneHeartbeatOnline, source, &events.PhoneHeartbeatOnlinePayload{ @@ -157,12 +157,12 @@ func (service *HeartbeatService) handleHeartbeatWhenPhoneWasOffline(ctx context. Owner: heartbeat.Owner, }) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [%s] event for monitor with ID [%s]", events.EventTypePhoneHeartbeatOnline, monitor.ID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [%s] event for monitor with ID [%s]", events.EventTypePhoneHeartbeatOnline, monitor.ID))) return } if err = service.dispatcher.Dispatch(ctx, event); err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] for heartbeat monitor with phone id [%s]", event.Type(), monitor.PhoneID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] for heartbeat monitor with phone id [%s]", event.Type(), monitor.PhoneID))) return } @@ -177,7 +177,7 @@ func (service *HeartbeatService) StoreMonitor(ctx context.Context, params *Heart monitor, scheduleCheck, err := service.phoneMonitor(ctx, params) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create monitor for with userID [%s] and owner [%s]", params.UserID, params.Owner)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create monitor for with userID [%s] and owner [%s]", params.UserID, params.Owner)) } if !scheduleCheck { @@ -195,7 +195,7 @@ func (service *HeartbeatService) StoreMonitor(ctx context.Context, params *Heart Source: params.Source, } if err = service.scheduleHeartbeatCheck(ctx, time.Now().UTC(), monitorParams); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot schedule healthcheck for monitor [%s] with owner [%s] and userID [%s]", monitor.ID, params.Owner, params.UserID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot schedule healthcheck for monitor [%s] with owner [%s] and userID [%s]", monitor.ID, params.Owner, params.UserID)) } return monitor, nil @@ -218,7 +218,7 @@ func (service *HeartbeatService) phoneMonitor(ctx context.Context, params *Heart } if err = service.monitorRepository.Store(ctx, monitor); err != nil { - return nil, false, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save heartbeat monitor for owner [%s] and user [%s]", monitor.Owner, monitor.UserID)) + return nil, false, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save heartbeat monitor for owner [%s] and user [%s]", monitor.Owner, monitor.UserID)) } ctxLogger.Info(fmt.Sprintf("heartbeat monitor saved with id [%s] for owner [%s] and user [%s]", monitor.ID, monitor.Owner, monitor.UserID)) @@ -226,7 +226,7 @@ func (service *HeartbeatService) phoneMonitor(ctx context.Context, params *Heart } if err != nil { - return nil, false, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot check if monitor exists with userID [%s] and owner [%s]", params.UserID, params.Owner)) + return nil, false, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot check if monitor exists with userID [%s] and owner [%s]", params.UserID, params.Owner)) } return monitor, monitor.RequiresCheck(), nil @@ -240,7 +240,7 @@ func (service *HeartbeatService) DeleteMonitor(ctx context.Context, userID entit ctxLogger := service.tracer.CtxLogger(service.logger, span) if err := service.monitorRepository.Delete(ctx, userID, owner); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete heartbeat monitor with userID [%s] and owner [%s]", userID, owner)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete heartbeat monitor with userID [%s] and owner [%s]", userID, owner)) } ctxLogger.Info(fmt.Sprintf("heartbeat monitor deleted for userID [%s] and owner [%s]", userID, owner)) @@ -255,7 +255,7 @@ func (service *HeartbeatService) UpdatePhoneOnline(ctx context.Context, userID e ctxLogger := service.tracer.CtxLogger(service.logger, span) if err := service.monitorRepository.UpdatePhoneOnline(ctx, userID, monitorID, phoneOnline); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update heartbeat monitor [%s] with userID [%s] and status [%t]", monitorID, userID, phoneOnline)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update heartbeat monitor [%s] with userID [%s] and status [%t]", monitorID, userID, phoneOnline)) } ctxLogger.Info(fmt.Sprintf("heartbeat monitor [%s] updated for userID [%s] and status [%t]", monitorID, userID, phoneOnline)) @@ -285,7 +285,7 @@ func (service *HeartbeatService) Monitor(ctx context.Context, params *HeartbeatM } if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot check if monitor exists with userID [%s] and owner [%s]", params.UserID, params.Owner)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot check if monitor exists with userID [%s] and owner [%s]", params.UserID, params.Owner)) return service.scheduleHeartbeatCheck(ctx, time.Now().UTC(), params) } @@ -295,7 +295,7 @@ func (service *HeartbeatService) Monitor(ctx context.Context, params *HeartbeatM heartbeat, err := service.repository.Last(ctx, params.UserID, params.Owner) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot fetch last heartbeat for userID [%s] and owner [%s] and ID [%s] removing check", params.UserID, params.Owner, params.MonitorID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot fetch last heartbeat for userID [%s] and owner [%s] and ID [%s] removing check", params.UserID, params.Owner, params.MonitorID)) return nil } @@ -326,12 +326,12 @@ func (service *HeartbeatService) handleMissedMonitor(ctx context.Context, lastTi Owner: params.Owner, }) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event when phone monitor [%s] missed heartbeat", params.MonitorID.String()))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event when phone monitor [%s] missed heartbeat", params.MonitorID.String()))) return } if _, err = service.dispatcher.DispatchWithTimeout(ctx, event, heartbeatCheckInterval); err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] for heartbeat monitor with phone id [%s]", event.Type(), params.PhoneID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] for heartbeat monitor with phone id [%s]", event.Type(), params.PhoneID))) } } @@ -341,7 +341,7 @@ func (service *HeartbeatService) handleFailedMonitor(ctx context.Context, lastTi err := service.scheduleHeartbeatCheck(ctx, time.Now().UTC(), params) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot schedule healthcheck for monitor with owner [%s] and userID [%s]", params.Owner, params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot schedule healthcheck for monitor with owner [%s] and userID [%s]", params.Owner, params.UserID)) } event, err := service.createPhoneHeartbeatOfflineEvent(params.Source, &events.PhoneHeartbeatOfflinePayload{ @@ -353,11 +353,11 @@ func (service *HeartbeatService) handleFailedMonitor(ctx context.Context, lastTi Owner: params.Owner, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event when phone monitor failed")) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event when phone monitor failed")) } if err = service.dispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] for heartbeat monitor with phone id [%s]", event.Type(), params.PhoneID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] for heartbeat monitor with phone id [%s]", event.Type(), params.PhoneID)) } ctxLogger.Info(fmt.Sprintf("heartbeat monitor with id [%s] and phone id [%s] failed for user [%s]", params.MonitorID, params.PhoneID, params.UserID)) @@ -376,16 +376,16 @@ func (service *HeartbeatService) scheduleHeartbeatCheck(ctx context.Context, las Owner: params.Owner, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event when phone monitor failed")) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event when phone monitor failed")) } queueID, err := service.dispatcher.DispatchWithTimeout(ctx, event, heartbeatCheckInterval) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] for heartbeat monitor with phone id [%s]", event.Type(), params.PhoneID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] for heartbeat monitor with phone id [%s]", event.Type(), params.PhoneID)) } if err = service.monitorRepository.UpdateQueueID(ctx, params.MonitorID, queueID); err != nil { - service.logger.Error(stacktrace.Propagate(err, "cannot update monitor with id [%s] with queue with ID [%s]", params.MonitorID, queueID)) + service.logger.Error(stacktrace.Propagatef(err, "cannot update monitor with id [%s] with queue with ID [%s]", params.MonitorID, queueID)) } ctxLogger.Info(fmt.Sprintf("heartbeat check scheduled for monitor with id [%s] and phone id [%s] and queue id [%s] for user [%s]", params.MonitorID, params.PhoneID, queueID, params.UserID)) diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go new file mode 100644 index 000000000..5fc115823 --- /dev/null +++ b/api/pkg/services/http_notification_sender.go @@ -0,0 +1,265 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/avast/retry-go/v5" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const ( + maxNotificationResponseDiscardBytes = 4 * 1024 + notificationHTTPAttempts = 3 + notificationHTTPTimeout = 5 * time.Second + notificationHTTPRetryDelay = 250 * time.Millisecond + notificationJWTIssuer = "api.httpsms.com" + notificationJWTValidity = 10 * time.Minute +) + +// HTTPNotificationSender sends FCM-compatible gateway notifications to HTTPS adapters. +type HTTPNotificationSender struct { + logger telemetry.Logger + client *http.Client + retrier *retry.Retrier + timeout time.Duration +} + +var _ FCMClient = (*HTTPNotificationSender)(nil) + +// NewHTTPNotificationSender creates an HTTP notification sender. +func NewHTTPNotificationSender( + logger telemetry.Logger, + client *http.Client, +) *HTTPNotificationSender { + return newHTTPNotificationSenderWithRetrier( + logger, + client, + newHTTPNotificationRetrier(notificationHTTPRetryDelay), + ) +} + +func newHTTPNotificationSenderWithRetrier( + logger telemetry.Logger, + client *http.Client, + retrier *retry.Retrier, +) *HTTPNotificationSender { + return &HTTPNotificationSender{ + logger: logger, + client: client, + retrier: retrier, + timeout: notificationHTTPTimeout, + } +} + +// Send delivers a notification to an HTTPS adapter. A successful response only accepts wake-up delivery. +func (sender *HTTPNotificationSender) Send( + ctx context.Context, + message *messaging.Message, + phoneID uuid.UUID, +) (string, error) { + if message == nil { + return "", sender.notificationError("", "notification message is nil") + } + + endpoint, err := url.Parse(message.Token) + if err != nil { + return "", sender.notificationError("", "cannot parse notification endpoint") + } + hostname := endpoint.Hostname() + + body, err := encodeHTTPNotificationPayload(message) + if err != nil { + return "", sender.notificationError(hostname, "cannot encode notification") + } + + authToken, err := sender.getAuthToken(endpoint, phoneID) + if err != nil { + return "", sender.notificationError(hostname, "cannot generate notification auth token") + } + + err = sender.retrier.Do(func() error { + return sender.deliver(ctx, endpoint, body, authToken) + }) + if err == nil { + return "http/success", nil + } + if ctx.Err() != nil { + return "", sender.notificationError(hostname, "notification request cancelled") + } + + return "", sender.notificationError(hostname, "notification request failed") +} + +// getAuthToken generates a JWT bearer token for the HTTPS adapter, signed with the phone ID +// the same way webhook requests are signed with the webhook signing key. The phone ID is only +// used as the HMAC secret and is intentionally not embedded in any claim: the adapter already +// knows which phone ID to verify against from its own gateway registration, and putting the +// phone ID in a readable claim would let anyone who intercepts one token read the signing +// secret and forge further tokens. +func (sender *HTTPNotificationSender) getAuthToken(endpoint *url.URL, phoneID uuid.UUID) (string, error) { + audience := *endpoint + audience.User = nil + + now := time.Now().UTC() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Audience: []string{audience.String()}, + ExpiresAt: jwt.NewNumericDate(now.Add(notificationJWTValidity)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: notificationJWTIssuer, + NotBefore: jwt.NewNumericDate(now.Add(-notificationJWTValidity)), + }) + return token.SignedString([]byte(phoneID.String())) +} + +func encodeHTTPNotificationPayload(message *messaging.Message) ([]byte, error) { + return json.Marshal(map[string]any{ + "message": message, + }) +} + +func (sender *HTTPNotificationSender) deliver( + ctx context.Context, + endpoint *url.URL, + body []byte, + authToken string, +) error { + if err := ctx.Err(); err != nil { + return terminalNotificationRequestError{cause: err} + } + + attemptCtx, cancel := context.WithTimeout(ctx, sender.timeout) + defer cancel() + + request, err := createHTTPNotificationRequest(attemptCtx, endpoint, body, authToken) + if err != nil { + return terminalNotificationRequestError{cause: err} + } + + err = sender.sendAttempt(request) + if ctx.Err() != nil { + return terminalNotificationRequestError{cause: ctx.Err()} + } + return err +} + +func createHTTPNotificationRequest( + ctx context.Context, + endpoint *url.URL, + body []byte, + authToken string, +) (*http.Request, error) { + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + endpoint.String(), + bytes.NewReader(body), + ) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) + return request, nil +} + +func (sender *HTTPNotificationSender) sendAttempt(request *http.Request) error { + response, err := sender.client.Do(request) + if err != nil { + return err + } + if response.Body != nil { + _, _ = io.CopyN(io.Discard, response.Body, maxNotificationResponseDiscardBytes) + _ = response.Body.Close() + } + if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices { + return nil + } + if isRetryableNotificationStatus(response.StatusCode) { + return retryableNotificationStatusError{statusCode: response.StatusCode} + } + return terminalNotificationStatusError{statusCode: response.StatusCode} +} + +func newHTTPNotificationRetrier(delay time.Duration) *retry.Retrier { + return retry.New( + retry.Attempts(notificationHTTPAttempts), + retry.Delay(delay), + retry.DelayType(retry.BackOffDelay), + retry.LastErrorOnly(true), + retry.RetryIf(isRetryableNotificationError), + ) +} + +func (sender *HTTPNotificationSender) notificationError(hostname string, message string) error { + if hostname == "" { + hostname = "unknown" + } + err := stacktrace.Propagatef(stacktrace.NewErrorf("%s", message), "cannot send notification to [%s]", hostname) + if sender.logger != nil { + sender.logger.Error(err) + } + return err +} + +func isRetryableNotificationStatus(statusCode int) bool { + return statusCode == http.StatusRequestTimeout || + statusCode == http.StatusTooManyRequests || + (statusCode >= http.StatusInternalServerError && statusCode < 600) +} + +type retryableNotificationStatusError struct { + statusCode int +} + +func (error retryableNotificationStatusError) Error() string { + return http.StatusText(error.statusCode) +} + +type terminalNotificationStatusError struct { + statusCode int +} + +func (error terminalNotificationStatusError) Error() string { + return http.StatusText(error.statusCode) +} + +type terminalNotificationRequestError struct { + cause error +} + +func (notificationError terminalNotificationRequestError) Error() string { + return notificationError.cause.Error() +} + +func (notificationError terminalNotificationRequestError) Unwrap() error { + return notificationError.cause +} + +func isRetryableNotificationError(err error) bool { + if err == nil || isTerminalNotificationError(err) { + return false + } + return true +} + +func isTerminalNotificationError(err error) bool { + var statusError terminalNotificationStatusError + if errors.As(err, &statusError) { + return true + } + + var requestError terminalNotificationRequestError + return errors.As(err, &requestError) +} diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go new file mode 100644 index 000000000..018765cf0 --- /dev/null +++ b/api/pkg/services/http_notification_sender_test.go @@ -0,0 +1,466 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "reflect" + "strings" + "testing" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +var testNotificationPhoneID = uuid.New() + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTrip(request) +} + +type httpNotificationPayload struct { + Message struct { + Token string `json:"token"` + Data map[string]string `json:"data"` + Android struct { + Priority string `json:"priority"` + TTL string `json:"ttl,omitempty"` + } `json:"android"` + } `json:"message"` +} + +func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { + ttl := 10 * time.Minute + message := &messaging.Message{ + Token: "https://adapter.example.com/notify", + Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, + Android: &messaging.AndroidConfig{ + Priority: "high", + TTL: &ttl, + }, + } + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "application/json", request.Header.Get("Content-Type")) + + var payload httpNotificationPayload + require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) + assert.Equal(t, "https://adapter.example.com/notify", request.URL.String()) + assert.Equal(t, "https://adapter.example.com/notify", payload.Message.Token) + assert.Equal(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}, payload.Message.Data) + assert.Equal(t, "high", payload.Message.Android.Priority) + assert.Equal(t, "600s", payload.Message.Android.TTL) + + token, err := jwt.Parse(strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer "), func(*jwt.Token) (interface{}, error) { + return []byte(testNotificationPhoneID.String()), nil + }) + require.NoError(t, err) + assert.True(t, token.Valid) + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + assert.Empty(t, claims["sub"], "phone ID must not be embedded in a claim since it is also the signing secret") + assert.Equal(t, "api.httpsms.com", claims["iss"]) + + return response(http.StatusNoContent, http.NoBody), nil + })) + + result, err := sender.Send(context.Background(), message, testNotificationPhoneID) + + require.NoError(t, err) + assert.Equal(t, "http/success", result) +} + +func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { + tests := []struct { + name string + outcomes []roundTripOutcome + wantCalls int + wantErr bool + }{ + { + name: "network error then accepted", + outcomes: []roundTripOutcome{ + {err: errors.New("connection reset")}, + {statusCode: http.StatusAccepted}, + }, + wantCalls: 2, + }, + { + name: "request timeout then success", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusRequestTimeout}, + {statusCode: http.StatusOK}, + }, + wantCalls: 2, + }, + { + name: "rate limited then no content", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusTooManyRequests}, + {statusCode: http.StatusNoContent}, + }, + wantCalls: 2, + }, + { + name: "server errors then no content", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusInternalServerError}, + {statusCode: http.StatusBadGateway}, + {statusCode: http.StatusNoContent}, + }, + wantCalls: 3, + }, + { + name: "bad request fails immediately", + outcomes: []roundTripOutcome{{statusCode: http.StatusBadRequest}}, + wantCalls: 1, + wantErr: true, + }, + { + name: "three service unavailable responses fail", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusServiceUnavailable}, + {statusCode: http.StatusServiceUnavailable}, + {statusCode: http.StatusServiceUnavailable}, + }, + wantCalls: 3, + wantErr: true, + }, + { + name: "redirect fails immediately", + outcomes: []roundTripOutcome{{statusCode: http.StatusFound}}, + wantCalls: 1, + wantErr: true, + }, + { + name: "nonstandard 6xx response fails immediately", + outcomes: []roundTripOutcome{{statusCode: 600}}, + wantCalls: 1, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + outcome := test.outcomes[calls] + calls++ + if outcome.err != nil { + return nil, outcome.err + } + return response(outcome.statusCode, http.NoBody), nil + })) + result, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, + ) + + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, test.wantCalls, calls) + if !test.wantErr { + assert.Equal(t, "http/success", result) + } + }) + } +} + +func TestHTTPNotificationSenderReusesRetrierAcrossSends(t *testing.T) { + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + calls++ + if calls%2 == 1 { + return response(http.StatusServiceUnavailable, http.NoBody), nil + } + return response(http.StatusNoContent, http.NoBody), nil + })) + + for range 2 { + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, + ) + require.NoError(t, err) + } + + assert.Equal(t, 4, calls) +} + +func TestHTTPNotificationSenderCreatesFreshRequestAndBodyForEveryAttempt(t *testing.T) { + var requests []*http.Request + var bodies [][]byte + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + requests = append(requests, request) + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + bodies = append(bodies, body) + if len(requests) < 3 { + return response(http.StatusServiceUnavailable, http.NoBody), nil + } + return response(http.StatusNoContent, http.NoBody), nil + })) + + _, err := sender.Send( + context.Background(), + &messaging.Message{ + Token: "https://adapter.example.com/notify", + Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, + }, + testNotificationPhoneID, + ) + + require.NoError(t, err) + require.Len(t, requests, 3) + assert.NotSame(t, requests[0], requests[1]) + assert.NotSame(t, requests[1], requests[2]) + require.Len(t, bodies, 3) + assert.NotEmpty(t, bodies[0]) + assert.Equal(t, bodies[0], bodies[1]) + assert.Equal(t, bodies[1], bodies[2]) +} + +func TestHTTPNotificationSenderBoundsResponseBodyDiscard(t *testing.T) { + body := &boundedReadCloser{remaining: 8192} + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, body), nil + })) + + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, + ) + + require.NoError(t, err) + assert.Equal(t, int64(4096), body.read) + assert.True(t, body.closed) +} + +func TestHTTPNotificationSenderOmitsTTLForHeartbeat(t *testing.T) { + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload httpNotificationPayload + require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) + assert.Equal(t, "high", payload.Message.Android.Priority) + assert.Empty(t, payload.Message.Android.TTL) + assert.Equal(t, "heartbeat-1", payload.Message.Data["KEY_HEARTBEAT_ID"]) + return response(http.StatusNoContent, http.NoBody), nil + })) + + _, err := sender.Send( + context.Background(), + &messaging.Message{ + Token: "https://adapter.example.com/notify", + Data: map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"}, + Android: &messaging.AndroidConfig{ + Priority: "high", + }, + }, + testNotificationPhoneID, + ) + + require.NoError(t, err) +} + +func TestHTTPNotificationSenderUsesInjectedHTTPClientUnchanged(t *testing.T) { + transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, http.NoBody), nil + }) + client := &http.Client{ + Transport: transport, + Timeout: time.Minute, + } + + sender := NewHTTPNotificationSender(nil, client) + + assert.Same(t, client, sender.client) + assert.Equal(t, reflect.ValueOf(transport).Pointer(), reflect.ValueOf(sender.client.Transport).Pointer()) + assert.Equal(t, time.Minute, sender.client.Timeout) +} + +func TestHTTPNotificationSenderIgnoresEndpointUserInformation(t *testing.T) { + // Adapter endpoints must not rely on HTTP basic auth embedded in the URL; the Authorization + // header always carries the phone-signed JWT instead. + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + _, _, ok := request.BasicAuth() + assert.False(t, ok) + + authorization := request.Header.Get("Authorization") + assert.True(t, strings.HasPrefix(authorization, "Bearer ")) + + token, err := jwt.Parse(strings.TrimPrefix(authorization, "Bearer "), func(*jwt.Token) (interface{}, error) { + return []byte(testNotificationPhoneID.String()), nil + }) + require.NoError(t, err) + assert.True(t, token.Valid) + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + audience, err := claims.GetAudience() + require.NoError(t, err) + require.Len(t, audience, 1) + assert.NotContains(t, audience[0], "adapter-user") + assert.NotContains(t, audience[0], "adapter-password") + + return response(http.StatusNoContent, http.NoBody), nil + })) + endpoint := &url.URL{ + Scheme: "https", + User: url.UserPassword("adapter-user", "adapter-password"), + Host: "adapter.example.com", + Path: "/notify", + } + + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: endpoint.String()}, + testNotificationPhoneID, + ) + + require.NoError(t, err) +} + +func TestHTTPNotificationSenderBoundsEveryAttemptByTimeout(t *testing.T) { + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + <-request.Context().Done() + return nil, request.Context().Err() + })) + sender.timeout = 10 * time.Millisecond + + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, + ) + + require.Error(t, err) + assert.Equal(t, 3, calls) +} + +func TestHTTPNotificationSenderStopsRetriesWhenParentContextIsCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + cancel() + <-request.Context().Done() + return nil, request.Context().Err() + })) + + _, err := sender.Send( + ctx, + &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, + ) + + require.Error(t, err) + assert.Equal(t, 1, calls) +} + +func TestHTTPNotificationSenderRejectsNilMessage(t *testing.T) { + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, http.NoBody), nil + })) + + _, err := sender.Send(context.Background(), nil, testNotificationPhoneID) + + require.Error(t, err) + assert.Contains(t, err.Error(), "notification message is nil") +} + +type roundTripOutcome struct { + statusCode int + err error +} + +type boundedReadCloser struct { + remaining int64 + read int64 + closed bool +} + +func (reader *boundedReadCloser) Read(buffer []byte) (int, error) { + if reader.remaining == 0 { + return 0, io.EOF + } + read := int64(len(buffer)) + if read > reader.remaining { + read = reader.remaining + } + reader.remaining -= read + reader.read += read + return int(read), nil +} + +func (reader *boundedReadCloser) Close() error { + reader.closed = true + return nil +} + +type httpNotificationRecordingLogger struct { + errors []string +} + +func (logger *httpNotificationRecordingLogger) Error(err error) { + logger.errors = append(logger.errors, err.Error()) +} + +func (logger *httpNotificationRecordingLogger) WithService(string) telemetry.Logger { return logger } + +func (logger *httpNotificationRecordingLogger) WithString(string, string) telemetry.Logger { + return logger +} + +func (logger *httpNotificationRecordingLogger) WithSpan(trace.SpanContext) telemetry.Logger { + return logger +} + +func (logger *httpNotificationRecordingLogger) Trace(string) {} +func (logger *httpNotificationRecordingLogger) Info(string) {} +func (logger *httpNotificationRecordingLogger) Warn(error) {} +func (logger *httpNotificationRecordingLogger) Debug(string) {} +func (logger *httpNotificationRecordingLogger) Fatal(error) {} +func (logger *httpNotificationRecordingLogger) Printf(string, ...interface{}) {} + +func newHTTPNotificationSender(t *testing.T, transport roundTripFunc) *HTTPNotificationSender { + t.Helper() + return newHTTPNotificationSenderWithLogger(t, nil, transport) +} + +func newHTTPNotificationSenderWithLogger( + t *testing.T, + logger telemetry.Logger, + transport roundTripFunc, +) *HTTPNotificationSender { + t.Helper() + return newHTTPNotificationSenderWithRetrier( + logger, + &http.Client{Transport: transport}, + newHTTPNotificationRetrier(0), + ) +} + +func response(statusCode int, body io.ReadCloser) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: body, + Header: make(http.Header), + } +} diff --git a/api/pkg/services/integration_3cx_service.go b/api/pkg/services/integration_3cx_service.go index ef9417df3..4b87d39f8 100644 --- a/api/pkg/services/integration_3cx_service.go +++ b/api/pkg/services/integration_3cx_service.go @@ -15,8 +15,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/palantir/stacktrace" ) // Integration3CXService is responsible for handling webhooks @@ -49,7 +49,7 @@ func (service *Integration3CXService) DeleteAllForUser(ctx context.Context, user defer span.End() if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete all [entities.Integration3CX] for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete all [entities.Integration3CX] for user with ID [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.Integration3CX] for user with ID [%s]", userID)) @@ -68,7 +68,7 @@ func (service *Integration3CXService) Send(ctx context.Context, userID entities. } if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load [3cx] integration for user [%s] and event [%s]", userID, event.Type())) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load [3cx] integration for user [%s] and event [%s]", userID, event.Type())) } service.sendNotification(ctx, event, webhooks) @@ -84,30 +84,30 @@ func (service *Integration3CXService) sendNotification(ctx context.Context, even payload, err := service.getPayload(event) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot generate [3cx] payload from [%s] event with ID [%s] for user [%s]", event.Type(), event.ID(), integration.UserID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot generate [3cx] payload from [%s] event with ID [%s] for user [%s]", event.Type(), event.ID(), integration.UserID))) return } body, err := json.Marshal(payload) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot marshal [%T] for [%s] event with ID [%s] for user [%s]", payload, event.Type(), event.ID(), integration.UserID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot marshal [%T] for [%s] event with ID [%s] for user [%s]", payload, event.Type(), event.ID(), integration.UserID))) } request, err := http.NewRequestWithContext(ctx, http.MethodPost, integration.WebhookURL, bytes.NewBuffer(body)) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [%T] for [%s] event with ID [%s] for user [%s]", request, event.Type(), event.ID(), integration.UserID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [%T] for [%s] event with ID [%s] for user [%s]", request, event.Type(), event.ID(), integration.UserID))) return } request.Header.Set("Content-Type", "application/json; charset=UTF-8") response, err := service.client.Do(request) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send [%s] event to [3cx] webhook [%s] for user [%s]", event.Type(), integration.WebhookURL, integration.UserID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send [%s] event to [3cx] webhook [%s] for user [%s]", event.Type(), integration.WebhookURL, integration.UserID))) } defer func() { err = response.Body.Close() if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot close response body")) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot close response body")) } }() @@ -135,14 +135,14 @@ func (service *Integration3CXService) getPayload(event cloudevents.Event) (fiber case events.EventTypeMessageSendFailed: return service.getMessageSendFailedPayload(event) default: - return nil, stacktrace.NewError("cannot generate [3cx] payload for event [%s] with ID [%s]", event.Type(), event.ID()) + return nil, stacktrace.NewErrorf("cannot generate [3cx] payload for event [%s] with ID [%s]", event.Type(), event.ID()) } } func (service *Integration3CXService) getEventDeliveredPayload(event cloudevents.Event) (fiber.Map, error) { payload := new(events.MessagePhoneDeliveredPayload) if err := event.DataAs(payload); err != nil { - return nil, stacktrace.Propagate(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload) + return nil, stacktrace.Propagatef(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload) } return fiber.Map{ @@ -172,7 +172,7 @@ func (service *Integration3CXService) getEventDeliveredPayload(event cloudevents func (service *Integration3CXService) getMessageSentPayload(event cloudevents.Event) (fiber.Map, error) { payload := new(events.MessagePhoneSentPayload) if err := event.DataAs(payload); err != nil { - return nil, stacktrace.Propagate(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload) + return nil, stacktrace.Propagatef(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload) } return fiber.Map{ @@ -202,7 +202,7 @@ func (service *Integration3CXService) getMessageSentPayload(event cloudevents.Ev func (service *Integration3CXService) getMessageSendFailedPayload(event cloudevents.Event) (fiber.Map, error) { payload := new(events.MessageSendFailedPayload) if err := event.DataAs(payload); err != nil { - return nil, stacktrace.Propagate(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload) + return nil, stacktrace.Propagatef(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload) } return fiber.Map{ @@ -232,7 +232,7 @@ func (service *Integration3CXService) getMessageSendFailedPayload(event cloudeve func (service *Integration3CXService) getMessageReceivedPayload(event cloudevents.Event) (fiber.Map, error) { payload := new(events.MessagePhoneReceivedPayload) if err := event.DataAs(payload); err != nil { - return nil, stacktrace.Propagate(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload) + return nil, stacktrace.Propagatef(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload) } return fiber.Map{ diff --git a/api/pkg/services/lemonsqueezy_service.go b/api/pkg/services/lemonsqueezy_service.go index 40b5aaec0..00df1fcd4 100644 --- a/api/pkg/services/lemonsqueezy_service.go +++ b/api/pkg/services/lemonsqueezy_service.go @@ -12,7 +12,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/lemonsqueezy-go" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" ) // LemonsqueezyService is responsible for managing lemonsqueezy events @@ -52,7 +52,7 @@ func (service *LemonsqueezyService) GetUserID(ctx context.Context, request *lemo ctxLogger.Info(fmt.Sprintf("user_id not found in request meta data. Searching via email [%s]", request.Data.Attributes.UserEmail)) user, err := service.userRepository.LoadByEmail(ctx, request.Data.Attributes.UserEmail) if err != nil { - return "", service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load user with email [%s]", request.Data.Attributes.UserEmail)) + return "", service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with email [%s]", request.Data.Attributes.UserEmail)) } return user.ID, nil @@ -65,7 +65,7 @@ func (service *LemonsqueezyService) HandleSubscriptionCreatedEvent(ctx context.C userID, err := service.GetUserID(ctx, request) if err != nil { - return stacktrace.Propagate(err, "cannot get user ID for subscription created event with ID [%s]", request.Data.ID) + return stacktrace.Propagatef(err, "cannot get user ID for subscription created event with ID [%s]", request.Data.ID) } payload := &events.UserSubscriptionCreatedPayload{ @@ -79,11 +79,11 @@ func (service *LemonsqueezyService) HandleSubscriptionCreatedEvent(ctx context.C event, err := service.createEvent(events.UserSubscriptionCreated, source, payload) if err != nil { - return stacktrace.Propagate(err, "cannot create [%s] event for user [%s]", events.UserSubscriptionCreated, payload.UserID) + return stacktrace.Propagatef(err, "cannot create [%s] event for user [%s]", events.UserSubscriptionCreated, payload.UserID) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return stacktrace.Propagate(err, "cannot dispatch [%s] event for user [%s]", events.UserSubscriptionCreated, payload.UserID) + return stacktrace.Propagatef(err, "cannot dispatch [%s] event for user [%s]", events.UserSubscriptionCreated, payload.UserID) } ctxLogger.Info(fmt.Sprintf("[%s] subscription [%s] created for user [%s]", payload.SubscriptionName, payload.SubscriptionID, payload.UserID)) return nil @@ -96,7 +96,7 @@ func (service *LemonsqueezyService) HandleSubscriptionCanceledEvent(ctx context. user, err := service.userRepository.LoadBySubscriptionID(ctx, request.Data.ID) if err != nil { - return stacktrace.Propagate(err, "cannot load user with subscription ID [%s]", request.Data.ID) + return stacktrace.Propagatef(err, "cannot load user with subscription ID [%s]", request.Data.ID) } payload := &events.UserSubscriptionCancelledPayload{ @@ -110,11 +110,11 @@ func (service *LemonsqueezyService) HandleSubscriptionCanceledEvent(ctx context. event, err := service.createEvent(events.UserSubscriptionCancelled, source, payload) if err != nil { - return stacktrace.Propagate(err, "cannot created [%s] event for user [%s]", events.UserSubscriptionCancelled, payload.UserID) + return stacktrace.Propagatef(err, "cannot created [%s] event for user [%s]", events.UserSubscriptionCancelled, payload.UserID) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return stacktrace.Propagate(err, "cannot dispatch [%s] event for user [%s]", events.UserSubscriptionCancelled, payload.UserID) + return stacktrace.Propagatef(err, "cannot dispatch [%s] event for user [%s]", events.UserSubscriptionCancelled, payload.UserID) } ctxLogger.Info(fmt.Sprintf("[%s] subscription [%s] cancelled for user [%s]", payload.SubscriptionName, payload.SubscriptionID, payload.UserID)) return nil @@ -132,7 +132,7 @@ func (service *LemonsqueezyService) HandleSubscriptionUpdatedEvent(ctx context.C user, err := service.userRepository.LoadBySubscriptionID(ctx, request.Data.ID) if err != nil { - return stacktrace.Propagate(err, "cannot load user with subscription ID [%s]", request.Data.ID) + return stacktrace.Propagatef(err, "cannot load user with subscription ID [%s]", request.Data.ID) } payload := &events.UserSubscriptionUpdatedPayload{ @@ -147,11 +147,11 @@ func (service *LemonsqueezyService) HandleSubscriptionUpdatedEvent(ctx context.C event, err := service.createEvent(events.UserSubscriptionUpdated, source, payload) if err != nil { - return stacktrace.Propagate(err, "cannot created [%s] event for user [%s]", events.UserSubscriptionUpdated, payload.UserID) + return stacktrace.Propagatef(err, "cannot created [%s] event for user [%s]", events.UserSubscriptionUpdated, payload.UserID) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return stacktrace.Propagate(err, "cannot dispatch [%s] event for user [%s]", event.Type(), payload.UserID) + return stacktrace.Propagatef(err, "cannot dispatch [%s] event for user [%s]", event.Type(), payload.UserID) } ctxLogger.Info(fmt.Sprintf("[%s] subscription [%s] updated for user [%s]", payload.SubscriptionName, payload.SubscriptionID, payload.UserID)) return nil @@ -164,7 +164,7 @@ func (service *LemonsqueezyService) HandleSubscriptionExpiredEvent(ctx context.C user, err := service.userRepository.LoadBySubscriptionID(ctx, request.Data.ID) if err != nil { - return stacktrace.Propagate(err, "cannot load user with subscription ID [%s]", request.Data.ID) + return stacktrace.Propagatef(err, "cannot load user with subscription ID [%s]", request.Data.ID) } payload := &events.UserSubscriptionExpiredPayload{ @@ -179,11 +179,11 @@ func (service *LemonsqueezyService) HandleSubscriptionExpiredEvent(ctx context.C event, err := service.createEvent(events.UserSubscriptionExpired, source, payload) if err != nil { - return stacktrace.Propagate(err, "cannot created [%s] event for user [%s]", events.UserSubscriptionExpired, payload.UserID) + return stacktrace.Propagatef(err, "cannot created [%s] event for user [%s]", events.UserSubscriptionExpired, payload.UserID) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return stacktrace.Propagate(err, "cannot dispatch [%s] event for user [%s]", event.Type(), payload.UserID) + return stacktrace.Propagatef(err, "cannot dispatch [%s] event for user [%s]", event.Type(), payload.UserID) } ctxLogger.Info(fmt.Sprintf("[%s] subscription [%s] expired for user [%s]", payload.SubscriptionName, payload.SubscriptionID, payload.UserID)) return nil @@ -229,6 +229,6 @@ func (service *LemonsqueezyService) subscriptionName(variant string) entities.Su } } - service.logger.Warn(stacktrace.NewError("unknown subscription variant [%s], defaulting to free", variant)) + service.logger.Warn(stacktrace.NewErrorf("unknown subscription variant [%s], defaulting to free", variant)) return entities.SubscriptionNameFree } diff --git a/api/pkg/services/marketting_service.go b/api/pkg/services/marketting_service.go index b2fc42109..65f608543 100644 --- a/api/pkg/services/marketting_service.go +++ b/api/pkg/services/marketting_service.go @@ -11,8 +11,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" plunk "github.com/NdoleStudio/plunk-go" + "github.com/NdoleStudio/stacktrace" "github.com/gofiber/fiber/v3" - "github.com/palantir/stacktrace" ) // MarketingService is handles marketing requests @@ -45,7 +45,7 @@ func (service *MarketingService) DeleteContact(ctx context.Context, email string response, _, err := service.plunkClient.Contacts.List(ctx, map[string]string{"search": email}) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot search for contact with email [%s]", email)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot search for contact with email [%s]", email)) } if len(response.Data) == 0 { @@ -55,7 +55,7 @@ func (service *MarketingService) DeleteContact(ctx context.Context, email string contact := response.Data[0] if _, err = service.plunkClient.Contacts.Delete(ctx, contact.ID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete user with ID [%s] from contacts", contact.Data[string(semconv.EnduserIDKey)])) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete user with ID [%s] from contacts", contact.Data[string(semconv.EnduserIDKey)])) } ctxLogger.Info(fmt.Sprintf("deleted user with ID [%s] from as marketting contact with ID [%s]", contact.Data[string(semconv.EnduserIDKey)], contact.ID)) @@ -69,7 +69,7 @@ func (service *MarketingService) CreateContact(ctx context.Context, userID entit userRecord, err := service.authClient.GetUser(ctx, userID.String()) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot get auth user with id [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot get auth user with id [%s]", userID)) } data := service.attributes(userRecord) @@ -83,7 +83,7 @@ func (service *MarketingService) CreateContact(ctx context.Context, userID entit Data: data, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create contact for user with id [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create contact for user with id [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("user [%s] added to marketting list with contact ID [%s] and event ID [%s]", userID, event.Data.Contact, event.Data.Event)) diff --git a/api/pkg/services/message_send_schedule_service.go b/api/pkg/services/message_send_schedule_service.go index 21cfb1273..e9e128f03 100644 --- a/api/pkg/services/message_send_schedule_service.go +++ b/api/pkg/services/message_send_schedule_service.go @@ -10,8 +10,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" ) // MessageSendScheduleService manages message send schedules for a user. @@ -92,7 +92,7 @@ func (service *MessageSendScheduleService) Store( if err := service.repository.Store(ctx, schedule); err != nil { return nil, service.tracer.WrapErrorSpan( span, - stacktrace.Propagate( + stacktrace.Propagatef( err, "cannot store message send schedule [%s]", schedule.ID, ), @@ -125,7 +125,7 @@ func (service *MessageSendScheduleService) Update( if err = service.repository.Update(ctx, schedule); err != nil { return nil, service.tracer.WrapErrorSpan( span, - stacktrace.Propagate( + stacktrace.Propagatef( err, "cannot update message send schedule [%s]", schedule.ID, ), @@ -145,7 +145,7 @@ func (service *MessageSendScheduleService) Delete( defer span.End() if err := service.repository.Delete(ctx, userID, scheduleID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete message send schedule with ID [%s] for user [%s]", scheduleID, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete message send schedule with ID [%s] for user [%s]", scheduleID, userID)) } event, err := service.createEvent(events.EventTypeMessageSendScheduleDeleted, fmt.Sprintf("%T", service), events.MessageSendScheduleDeletedPayload{ @@ -154,11 +154,11 @@ func (service *MessageSendScheduleService) Delete( Timestamp: time.Now().UTC(), }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [%s] event for schedule [%s]", events.EventTypeMessageSendScheduleDeleted, scheduleID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [%s] event for schedule [%s]", events.EventTypeMessageSendScheduleDeleted, scheduleID)) } if err = service.dispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch [%s] event for schedule [%s]", event.Type(), scheduleID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch [%s] event for schedule [%s]", event.Type(), scheduleID)) } return nil @@ -199,7 +199,7 @@ func (service *MessageSendScheduleService) DeleteAllForUser( if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { return service.tracer.WrapErrorSpan( span, - stacktrace.Propagate( + stacktrace.Propagatef( err, "cannot delete message send schedules for user [%s]", userID, ), diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 315c4d8f3..89c216817 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -13,9 +13,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/google/uuid" - "github.com/palantir/stacktrace" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -79,7 +79,7 @@ func (service *MessageService) GetOutstanding(ctx context.Context, params Messag message, err := service.repository.GetOutstanding(ctx, params.UserID, params.MessageID, params.PhoneNumbers) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "could not fetch outstanding messages with params [%s]", spew.Sdump(params))) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "could not fetch outstanding messages with params [%s]", spew.Sdump(params))) } event, err := service.createMessagePhoneSendingEvent(params.Source, events.MessagePhoneSendingPayload{ @@ -93,13 +93,13 @@ func (service *MessageService) GetOutstanding(ctx context.Context, params Messag SIM: message.SIM, }) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [%T] for message with ID [%s]", event, message.ID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [%T] for message with ID [%s]", event, message.ID)) } ctxLogger.Info(fmt.Sprintf("created event [%s] with id [%s] for message [%s]", event.Type(), event.ID(), message.ID)) if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] with id [%s] for message [%s]", event.Type(), event.ID(), message.ID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] with id [%s] for message [%s]", event.Type(), event.ID(), message.ID)) } ctxLogger.Info(fmt.Sprintf("dispatched event [%s] with id [%s] for message [%s]", event.Type(), event.ID(), message.ID)) @@ -112,7 +112,7 @@ func (service *MessageService) DeleteAllForUser(ctx context.Context, userID enti defer span.End() if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete [entities.Message] for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete [entities.Message] for user with ID [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.Message] for user with ID [%s]", userID)) @@ -126,7 +126,7 @@ func (service *MessageService) GetBulkMessages(ctx context.Context, userID entit orders, err := service.repository.GetBulkMessages(ctx, userID, 10) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not fetch bulk messages for user [%s]", userID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not fetch bulk messages for user [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("fetched [%d] bulk messages for user [%s]", len(orders), userID)) @@ -141,7 +141,7 @@ func (service *MessageService) DeleteMessage(ctx context.Context, source string, ctxLogger := service.tracer.CtxLogger(service.logger, span) if err := service.repository.Delete(ctx, message.UserID, message.ID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "could not delete message with ID [%s] for user wit ID [%s]", message.ID, message.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "could not delete message with ID [%s] for user wit ID [%s]", message.ID, message.UserID)) } var prevID *uuid.UUID @@ -169,12 +169,12 @@ func (service *MessageService) DeleteMessage(ctx context.Context, source string, SIM: message.SIM, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [%T] for message with ID [%s]", event, message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [%T] for message with ID [%s]", event, message.ID)) } ctxLogger.Info(fmt.Sprintf("created event [%s] with id [%s] for message [%s]", event.Type(), event.ID(), message.ID)) if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] with id [%s] for message [%s]", event.Type(), event.ID(), message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] with id [%s] for message [%s]", event.Type(), event.ID(), message.ID)) } ctxLogger.Info(fmt.Sprintf("dispatched event [%s] with id [%s] for message [%s]", event.Type(), event.ID(), message.ID)) @@ -189,7 +189,7 @@ func (service *MessageService) DeleteByOwnerAndContact(ctx context.Context, user ctxLogger := service.tracer.CtxLogger(service.logger, span) if err := service.repository.DeleteByOwnerAndContact(ctx, userID, owner, contact); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "could not all delete messages for user with ID [%s] between owner [%s] and contact [%s] ", userID, owner, contact)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "could not all delete messages for user with ID [%s] between owner [%s] and contact [%s] ", userID, owner, contact)) } ctxLogger.Info(fmt.Sprintf("deleted all messages for user with ID [%s] between owner [%s] and contact [%s] ", userID, owner, contact)) @@ -203,7 +203,7 @@ func (service *MessageService) RespondToMissedCall(ctx context.Context, source s phone, err := service.phoneService.Load(ctx, payload.UserID, payload.Owner) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find phone with owner [%s] for user with ID [%s] when handling missed phone call message [%s]", payload.Owner, payload.UserID, payload.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find phone with owner [%s] for user with ID [%s] when handling missed phone call message [%s]", payload.Owner, payload.UserID, payload.MessageID)) } if phone.MissedCallAutoReply == nil || strings.TrimSpace(*phone.MissedCallAutoReply) == "" { @@ -225,7 +225,7 @@ func (service *MessageService) RespondToMissedCall(ctx context.Context, source s RequestReceivedAt: time.Now().UTC(), }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot send auto response message for owner [%s] for user with ID [%s] when handling missed phone call message [%s]", payload.Owner, payload.UserID, payload.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot send auto response message for owner [%s] for user with ID [%s] when handling missed phone call message [%s]", payload.Owner, payload.UserID, payload.MessageID)) } ctxLogger.Info(fmt.Sprintf("created response message with ID [%s] for missed call event [%s] for user [%s]", message.ID, payload.MessageID, message.UserID)) @@ -249,7 +249,7 @@ func (service *MessageService) GetMessages(ctx context.Context, params MessageGe messages, err := service.repository.Index(ctx, params.UserID, params.Owner, params.Contact, params.IndexParams) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not fetch messages with parms [%+#v]", params)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not fetch messages with parms [%+#v]", params)) } ctxLogger.Info(fmt.Sprintf("fetched [%d] messages with prams [%+#v]", len(*messages), params)) @@ -263,7 +263,7 @@ func (service *MessageService) GetMessage(ctx context.Context, userID entities.U message, err := service.repository.Load(ctx, userID, messageID) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "could not fetch messages with ID [%s]", messageID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "could not fetch messages with ID [%s]", messageID)) } return message, nil @@ -293,11 +293,11 @@ func (service *MessageService) StoreEvent(ctx context.Context, message *entities case entities.MessageEventNameFailed: err = service.handleMessageFailedEvent(ctx, params, message) default: - return nil, service.tracer.WrapErrorSpan(span, stacktrace.NewError("cannot handle message event [%s]", params.EventName)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("cannot handle message event [%s]", params.EventName)) } if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not handle phone event [%s] for message with id [%s]", params.EventName, message.ID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not handle phone event [%s] for message with id [%s]", params.EventName, message.ID)) } return service.repository.Load(ctx, message.UserID, params.MessageID) @@ -328,7 +328,7 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa ctxLogger.Info(fmt.Sprintf("uploading [%d] attachments for user [%s] message [%s]", len(params.Attachments), params.UserID, messageID)) attachmentURLs, err := service.uploadAttachments(ctx, params.UserID, messageID, params.Attachments) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot upload attachments for user [%s] message [%s]", params.UserID, messageID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot upload attachments for user [%s] message [%s]", params.UserID, messageID)) } owner := phonenumbers.Format(params.Owner, phonenumbers.E164) @@ -349,13 +349,13 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa event, err := service.createMessagePhoneReceivedEvent(params.Source, eventPayload) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create %T from payload with message id [%s]", event, eventPayload.MessageID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create %T from payload with message id [%s]", event, eventPayload.MessageID)) } ctxLogger.Info(fmt.Sprintf("created event [%s] with id [%s] and message id [%s]", event.Type(), event.ID(), eventPayload.MessageID)) if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())) } ctxLogger.Info(fmt.Sprintf("event [%s] dispatched successfully", event.ID())) @@ -378,11 +378,11 @@ func (service *MessageService) handleMessageSentEvent(ctx context.Context, param SIM: message.SIM, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event [%s] for message [%s]", events.EventTypeMessagePhoneSent, message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event [%s] for message [%s]", events.EventTypeMessagePhoneSent, message.ID)) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())) } return nil } @@ -403,11 +403,11 @@ func (service *MessageService) handleMessageDeliveredEvent(ctx context.Context, SIM: message.SIM, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event [%s] for message [%s]", events.EventTypeMessagePhoneSent, message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event [%s] for message [%s]", events.EventTypeMessagePhoneSent, message.ID)) } if _, err = service.eventDispatcher.DispatchWithTimeout(ctx, event, time.Second); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event type [%s] and id [%s] for message [%s]", event.Type(), event.ID(), message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event type [%s] and id [%s] for message [%s]", event.Type(), event.ID(), message.ID)) } return nil } @@ -434,11 +434,11 @@ func (service *MessageService) handleMessageFailedEvent(ctx context.Context, par SIM: message.SIM, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event [%s] for message [%s]", events.EventTypeMessageSendFailed, message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event [%s] for message [%s]", events.EventTypeMessageSendFailed, message.ID)) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())) } return nil } @@ -485,18 +485,18 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe event, err := service.createMessageAPISentEvent(params.Source, eventPayload) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create %T from payload with message id [%s]", event, eventPayload.MessageID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create %T from payload with message id [%s]", event, eventPayload.MessageID)) } ctxLogger.Info(fmt.Sprintf("created event [%s] with id [%s] and message id [%s] and user [%s]", event.Type(), event.ID(), eventPayload.MessageID, eventPayload.UserID)) message, err := service.storeSentMessage(ctx, eventPayload) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot store message with id [%s]", eventPayload.MessageID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store message with id [%s]", eventPayload.MessageID)) } timeout := service.getSendDelay(ctxLogger, eventPayload, params, messagesPerMinute) if _, err = service.eventDispatcher.DispatchWithTimeout(ctx, event, timeout); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())) } ctxLogger.Info(fmt.Sprintf("[%s] event with ID [%s] dispatched succesfully for message [%s] with user [%s] and delay [%s]", event.Type(), event.ID(), eventPayload.MessageID, eventPayload.UserID, timeout)) @@ -531,18 +531,18 @@ func (service *MessageService) RegisterMissedCall(ctx context.Context, params *M event, err := service.createEvent(events.MessageCallMissed, params.Source, eventPayload) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [%T] from payload with message id [%s]", event, eventPayload.MessageID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [%T] from payload with message id [%s]", event, eventPayload.MessageID)) } ctxLogger.Info(fmt.Sprintf("created event [%s] with id [%s] and message id [%s] and user [%s]", event.Type(), event.ID(), eventPayload.MessageID, eventPayload.UserID)) message, err := service.storeMissedCallMessage(ctx, eventPayload) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot store missed call message message with id [%s]", eventPayload.MessageID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store missed call message message with id [%s]", eventPayload.MessageID)) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())) } ctxLogger.Info(fmt.Sprintf("[%s] event with ID [%s] dispatched succesfully for message [%s] with user [%s]", event.Type(), event.ID(), eventPayload.MessageID, eventPayload.UserID)) @@ -595,7 +595,7 @@ func (service *MessageService) storeReceivedMessage(ctx context.Context, params } if err := service.repository.Store(ctx, message); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save message with id [%s]", params.MessageID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save message with id [%s]", params.MessageID)) } ctxLogger.Info(fmt.Sprintf("message saved with id [%s]", message.ID)) @@ -619,7 +619,7 @@ func (service *MessageService) uploadAttachments(ctx context.Context, userID ent g.Go(func() error { decoded, err := base64.StdEncoding.DecodeString(attachment.Content) if err != nil { - return stacktrace.Propagate(err, "cannot decode base64 content for attachment [%d]", i) + return stacktrace.Propagatef(err, "cannot decode base64 content for attachment [%d]", i) } sanitizedName := repositories.SanitizeFilename(attachment.Name, i) @@ -630,7 +630,7 @@ func (service *MessageService) uploadAttachments(ctx context.Context, userID ent paths[i] = path if err = service.attachmentRepository.Upload(gCtx, path, decoded, attachment.ContentType); err != nil { - return stacktrace.Propagate(err, "cannot upload attachment [%d] to path [%s]", i, path) + return stacktrace.Propagatef(err, "cannot upload attachment [%d] to path [%s]", i, path) } urls[i] = fmt.Sprintf("%s/v1/attachments/%s/%s/%d/%s", service.apiBaseURL, userID, messageID, i, filename) @@ -645,7 +645,7 @@ func (service *MessageService) uploadAttachments(ctx context.Context, userID ent _ = service.attachmentRepository.Delete(ctx, path) } } - return nil, stacktrace.Propagate(err, "cannot upload attachments") + return nil, stacktrace.Propagatef(err, "cannot upload attachments") } return urls, nil @@ -668,15 +668,15 @@ func (service *MessageService) HandleMessageSending(ctx context.Context, params message, err := service.repository.Load(ctx, params.UserID, params.ID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find message with id [%s]", params.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", params.ID)) } if !message.IsSending() { - return service.tracer.WrapErrorSpan(span, stacktrace.NewError("message has wrong status [%s]. expected %s", message.Status, entities.MessageStatusSending)) + return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("message has wrong status [%s]. expected %s", message.Status, entities.MessageStatusSending)) } if err = service.repository.Update(ctx, message.AddSendAttempt(params.Timestamp)); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message with id [%s] after sending", message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update message with id [%s] after sending", message.ID)) } ctxLogger.Info(fmt.Sprintf("message with id [%s] updated after adding send attempt", message.ID)) @@ -692,7 +692,7 @@ func (service *MessageService) HandleMessageSent(ctx context.Context, params Han message, err := service.repository.Load(ctx, params.UserID, params.ID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find message with id [%s]", params.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", params.ID)) } if message.IsSent() || message.IsDelivered() { @@ -701,11 +701,11 @@ func (service *MessageService) HandleMessageSent(ctx context.Context, params Han } if !message.IsSending() && !message.IsExpired() && !message.IsScheduled() { - return service.tracer.WrapErrorSpan(span, stacktrace.NewError("message has wrong status [%s]. expected [%s, %s, %s]", message.Status, entities.MessageStatusSending, entities.MessageStatusExpired, entities.MessageStatusScheduled)) + return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("message has wrong status [%s]. expected [%s, %s, %s]", message.Status, entities.MessageStatusSending, entities.MessageStatusExpired, entities.MessageStatusScheduled)) } if err = service.repository.Update(ctx, message.Sent(params.Timestamp)); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message with id [%s] as sent", message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update message with id [%s] as sent", message.ID)) } ctxLogger.Info(fmt.Sprintf("message with id [%s] has been updated to status [%s]", message.ID, message.Status)) @@ -729,15 +729,15 @@ func (service *MessageService) HandleMessageFailed(ctx context.Context, params H message, err := service.repository.Load(ctx, params.UserID, params.ID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find message with id [%s]", params.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", params.ID)) } if message.IsDelivered() { - return service.tracer.WrapErrorSpan(span, stacktrace.NewError("message has already been delivered with status [%s]", message.Status)) + return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("message has already been delivered with status [%s]", message.Status)) } if err = service.repository.Update(ctx, message.Failed(params.Timestamp, params.ErrorMessage)); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message with id [%s] as sent", message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update message with id [%s] as sent", message.ID)) } ctxLogger.Info(fmt.Sprintf("message with id [%s] has been updated to status [%s]", message.ID, message.Status)) @@ -753,16 +753,16 @@ func (service *MessageService) HandleMessageDelivered(ctx context.Context, param message, err := service.repository.Load(ctx, params.UserID, params.ID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find message with id [%s]", params.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", params.ID)) } if !message.IsSent() && !message.IsSending() && !message.IsExpired() && !message.IsScheduled() { - ctxLogger.Warn(stacktrace.NewError("message has wrong status [%s]. expected [%s, %s, %s, %s]", message.Status, entities.MessageStatusSent, entities.MessageStatusScheduled, entities.MessageStatusSending, entities.MessageStatusExpired)) + ctxLogger.Warn(stacktrace.NewErrorf("message has wrong status [%s]. expected [%s, %s, %s, %s]", message.Status, entities.MessageStatusSent, entities.MessageStatusScheduled, entities.MessageStatusSending, entities.MessageStatusExpired)) return nil } if err = service.repository.Update(ctx, message.Delivered(params.Timestamp)); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message with id [%s] as delivered", message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update message with id [%s] as delivered", message.ID)) } ctxLogger.Info(fmt.Sprintf("message with id [%s] has been updated to status [%s]", message.ID, message.Status)) @@ -778,15 +778,15 @@ func (service *MessageService) HandleMessageNotificationScheduled(ctx context.Co message, err := service.repository.Load(ctx, params.UserID, params.ID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find message with id [%s]", params.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", params.ID)) } if !message.IsPending() && !message.IsExpired() && !message.IsSending() { - ctxLogger.Warn(stacktrace.NewError("received scheduled event for message with id [%s] message has status [%s]", message.ID, message.Status)) + ctxLogger.Warn(stacktrace.NewErrorf("received scheduled event for message with id [%s] message has status [%s]", message.ID, message.Status)) } if err = service.repository.Update(ctx, message.NotificationScheduled(params.Timestamp)); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message with id [%s] as expired", message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update message with id [%s] as expired", message.ID)) } ctxLogger.Info(fmt.Sprintf("message with id [%s] has been scheduled to send at [%s]", message.ID, message.NotificationScheduledAt.String())) @@ -802,11 +802,11 @@ func (service *MessageService) HandleMessageNotificationSent(ctx context.Context message, err := service.repository.Load(ctx, params.UserID, params.ID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find message with id [%s]", params.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", params.ID)) } if err = service.repository.Update(ctx, message.AddSendAttemptCount()); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message with id [%s] as expired", message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update message with id [%s] as expired", message.ID)) } ctxLogger.Info(fmt.Sprintf("notification for message with id [%s] has been sent at [%s]", message.ID, params.Timestamp.String())) @@ -822,15 +822,15 @@ func (service *MessageService) HandleMessageExpired(ctx context.Context, params message, err := service.repository.Load(ctx, params.UserID, params.ID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find message with id [%s]", params.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", params.ID)) } if !message.IsSending() && !message.IsScheduled() && !message.IsPending() { - return service.tracer.WrapErrorSpan(span, stacktrace.NewError("message has wrong status [%s]. expected [%s, %s, %s]", message.Status, entities.MessageStatusSending, entities.MessageStatusScheduled, entities.MessageStatusPending)) + return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("message has wrong status [%s]. expected [%s, %s, %s]", message.Status, entities.MessageStatusSending, entities.MessageStatusScheduled, entities.MessageStatusPending)) } if err = service.repository.Update(ctx, message.Expired(params.Timestamp)); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message with id [%s] as expired", message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update message with id [%s] as expired", message.ID)) } ctxLogger.Info(fmt.Sprintf("message with id [%s] has been updated to status [%s]", message.ID, message.Status)) @@ -850,11 +850,11 @@ func (service *MessageService) HandleMessageExpired(ctx context.Context, params SIM: message.SIM, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [%s] event for expired message with ID [%s]", events.EventTypeMessageSendRetry, message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [%s] event for expired message with ID [%s]", events.EventTypeMessageSendRetry, message.ID)) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch [%s] event for message with ID [%s]", event.Type(), message.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch [%s] event for message with ID [%s]", event.Type(), message.ID)) } ctxLogger.Info(fmt.Sprintf("retried sending message with ID [%s]", message.ID)) @@ -889,11 +889,11 @@ func (service *MessageService) ScheduleExpirationCheck(ctx context.Context, para UserID: params.UserID, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event [%s] for message with id [%s]", events.EventTypeMessageSendExpiredCheck, params.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event [%s] for message with id [%s]", events.EventTypeMessageSendExpiredCheck, params.MessageID)) } if _, err = service.eventDispatcher.DispatchWithTimeout(ctx, event, params.MessageExpirationDuration); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] for message with ID [%s]", event.Type(), params.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] for message with ID [%s]", event.Type(), params.MessageID)) } ctxLogger.Info(fmt.Sprintf("scheduled message id [%s] to expire at [%s]", params.MessageID, params.NotificationSentAt.Add(params.MessageExpirationDuration))) @@ -921,7 +921,7 @@ func (service *MessageService) CheckExpired(ctx context.Context, params MessageC } if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load message with userID [%s] and messageID [%s]", params.UserID, params.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load message with userID [%s] and messageID [%s]", params.UserID, params.MessageID)) } if !message.IsPending() && !message.IsSending() && !message.IsScheduled() { @@ -943,11 +943,11 @@ func (service *MessageService) CheckExpired(ctx context.Context, params MessageC SIM: message.SIM, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event [%s] for message with id [%s]", events.EventTypeMessageSendExpired, params.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event [%s] for message with id [%s]", events.EventTypeMessageSendExpired, params.MessageID)) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] for message with ID [%s]", event.Type(), params.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] for message with ID [%s]", event.Type(), params.MessageID)) } ctxLogger.Info(fmt.Sprintf("message [%s] has expired with status [%s]", params.MessageID, message.Status)) @@ -972,7 +972,7 @@ func (service *MessageService) SearchMessages(ctx context.Context, params *Messa messages, err := service.repository.Search(ctx, params.UserID, params.Owners, params.Types, params.Statuses, params.IndexParams) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not search messages with parms [%+#v]", params)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not search messages with parms [%+#v]", params)) } ctxLogger.Info(fmt.Sprintf("fetched [%d] messages with prams [%+#v]", len(messages), params)) @@ -987,7 +987,7 @@ func (service *MessageService) phoneSettings(ctx context.Context, userID entitie phone, err := service.phoneService.Load(ctx, userID, owner) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot load phone for userID [%s] and owner [%s]. using default max send attempt of 2", userID, owner)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot load phone for userID [%s] and owner [%s]. using default max send attempt of 2", userID, owner)) return 2, entities.SIM1, 0 } @@ -1027,7 +1027,7 @@ func (service *MessageService) storeSentMessage(ctx context.Context, payload eve } if err := service.repository.Store(ctx, message); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save message with id [%s]", payload.MessageID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save message with id [%s]", payload.MessageID)) } ctxLogger.Info(fmt.Sprintf("message saved with id [%s]", payload.MessageID)) @@ -1056,7 +1056,7 @@ func (service *MessageService) storeMissedCallMessage(ctx context.Context, paylo } if err := service.repository.Store(ctx, message); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save missed call message with id [%s]", payload.MessageID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save missed call message with id [%s]", payload.MessageID)) } ctxLogger.Info(fmt.Sprintf("missed call message saved with id [%s]", payload.MessageID)) diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index c77458cbf..efcfadd17 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -11,10 +11,14 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" ) +type contactMapProvider interface { + GetContactMap(ctx context.Context, userID entities.UserID, phoneNumbers []string) (map[string]*entities.Contact, error) +} + // MessageThreadService is handles message requests type MessageThreadService struct { service @@ -23,6 +27,7 @@ type MessageThreadService struct { repository repositories.MessageThreadRepository phoneRepository repositories.PhoneRepository eventDispatcher *EventDispatcher + contactService contactMapProvider } // NewMessageThreadService creates a new MessageThreadService @@ -32,6 +37,7 @@ func NewMessageThreadService( repository repositories.MessageThreadRepository, phoneRepository repositories.PhoneRepository, eventDispatcher *EventDispatcher, + contactService contactMapProvider, ) (s *MessageThreadService) { return &MessageThreadService{ logger: logger.WithService(fmt.Sprintf("%T", s)), @@ -39,6 +45,7 @@ func NewMessageThreadService( eventDispatcher: eventDispatcher, repository: repository, phoneRepository: phoneRepository, + contactService: contactService, } } @@ -50,7 +57,10 @@ type MessageThreadUpdateParams struct { Content string UserID entities.UserID MessageID uuid.UUID - Timestamp time.Time + // Timestamp controls thread activity ordering; EventTimestamp is the server-side unread watermark. + Timestamp time.Time + MarkAsUnread bool + EventTimestamp time.Time } // shouldCheckUnarchive reports whether a thread update is a new inbound message @@ -67,7 +77,7 @@ func (service *MessageThreadService) DeleteAllForUser(ctx context.Context, userI defer span.End() if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete [entities.MessageThread] for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete [entities.MessageThread] for user with ID [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.MessageThread] for user with ID [%s]", userID)) @@ -88,40 +98,52 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me } if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find thread with owner [%s], and contact [%s]. creating new thread", params.Owner, params.Contact)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find thread with owner [%s], and contact [%s]. creating new thread", params.Owner, params.Contact)) } if thread.OrderTimestamp.Unix() > params.Timestamp.Unix() && thread.Status != entities.MessageStatusSending && thread.HasLastMessage(params.MessageID) { - ctxLogger.Warn(stacktrace.NewError("thread [%s] has timestamp [%s] and status [%s] which is greater than timestamp [%s] for message [%s] and status [%s]", thread.ID, thread.OrderTimestamp, thread.Status, params.Timestamp, params.MessageID, params.Status)) + ctxLogger.Warn(stacktrace.NewErrorf("thread [%s] has timestamp [%s] and status [%s] which is greater than timestamp [%s] for message [%s] and status [%s]", thread.ID, thread.OrderTimestamp, thread.Status, params.Timestamp, params.MessageID, params.Status)) return nil } if thread.Status == entities.MessageStatusDelivered && thread.LastMessageID != nil && thread.HasLastMessage(params.MessageID) { - ctxLogger.Warn(stacktrace.NewError("thread [%s] already has status [%s] not updating with status [%s] for message [%s]", thread.ID, thread.Status, params.Status, params.MessageID)) + ctxLogger.Warn(stacktrace.NewErrorf("thread [%s] already has status [%s] not updating with status [%s] for message [%s]", thread.ID, thread.Status, params.Status, params.MessageID)) return nil } + activity := repositories.MessageThreadActivityUpdate{ + MessageThreadID: thread.ID, + UserID: params.UserID, + Timestamp: params.Timestamp, + MessageID: params.MessageID, + Content: params.Content, + Status: params.Status, + MarkAsUnread: params.MarkAsUnread, + EventTimestamp: params.EventTimestamp, + } + if service.shouldCheckUnarchive(thread, params) { phone, phoneErr := service.phoneRepository.Load(ctx, params.UserID, params.Owner) if phoneErr != nil { - ctxLogger.Warn(stacktrace.Propagate(phoneErr, "cannot load phone [%s] for user [%s] to resolve UnarchiveThread; leaving thread [%s] archived", params.Owner, params.UserID, thread.ID)) + ctxLogger.Warn(stacktrace.Propagatef(phoneErr, "cannot load phone [%s] for user [%s] to resolve UnarchiveThread; leaving thread [%s] archived", params.Owner, params.UserID, thread.ID)) } else if phone.UnarchiveThread { - thread.UpdateArchive(false) + activity.Unarchive = true ctxLogger.Info(fmt.Sprintf("unarchiving thread [%s] after inbound message [%s]", thread.ID, params.MessageID)) } } - if err = service.repository.Update(ctx, thread.Update(params.Timestamp, params.MessageID, params.Content, params.Status)); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID)) + if err = service.repository.UpdateActivity(ctx, activity); err != nil { + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID)) } - ctxLogger.Info(fmt.Sprintf("thread with id [%s] updated with last message [%s] and status [%s]", thread.ID, thread.LastMessageID, thread.Status)) + ctxLogger.Info(fmt.Sprintf("thread with id [%s] updated with last message [%s] and status [%s]", thread.ID, params.MessageID, params.Status)) return nil } // MessageThreadStatusParams are parameters for updating a thread status type MessageThreadStatusParams struct { - IsArchived bool + IsArchived *bool + UnreadCount *uint UserID entities.UserID MessageThreadID uuid.UUID } @@ -131,18 +153,15 @@ func (service *MessageThreadService) UpdateStatus(ctx context.Context, params Me ctx, span := service.tracer.Start(ctx) defer span.End() - ctxLogger := service.tracer.CtxLogger(service.logger, span) - - thread, err := service.repository.Load(ctx, params.UserID, params.MessageThreadID) - if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find thread with id [%s]", params.MessageThreadID)) + update := repositories.MessageThreadStatusUpdate{ + IsArchived: params.IsArchived, + UnreadCount: params.UnreadCount, } - - if err = service.repository.Update(ctx, thread.UpdateArchive(params.IsArchived)); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update message thread with id [%s] with archive status [%t]", thread.ID, params.IsArchived)) + thread, err := service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update) + if err != nil { + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot update message thread with ID [%s] for user [%s]", params.MessageThreadID, params.UserID)) } - ctxLogger.Info(fmt.Sprintf("thread with id [%s] updated with archive status [%t]", thread.ID, thread.IsArchived)) return thread, nil } @@ -153,12 +172,12 @@ func (service *MessageThreadService) UpdateAfterDeletedMessage(ctx context.Conte thread, err := service.repository.LoadByOwnerContact(ctx, payload.UserID, payload.Owner, payload.Contact) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find thread for user [%s] with owner [%s], and contact [%s]", payload.UserID, payload.Owner, payload.Contact)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find thread for user [%s] with owner [%s], and contact [%s]", payload.UserID, payload.Owner, payload.Contact)) } if payload.PreviousMessageID == nil { if err = service.repository.Delete(ctx, thread.UserID, thread.ID); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot delete thread with ID [%s] for user [%s] and owner [%s]", thread.ID, thread.UserID, thread.Owner)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot delete thread with ID [%s] for user [%s] and owner [%s]", thread.ID, thread.UserID, thread.Owner)) return nil } msg := fmt.Sprintf("previous message ID is nil for thread with ID [%s] and user [%s]", thread.ID, thread.UserID) @@ -172,13 +191,14 @@ func (service *MessageThreadService) UpdateAfterDeletedMessage(ctx context.Conte return nil } - thread.LastMessageContent = payload.PreviousMessageContent - thread.LastMessageID = payload.PreviousMessageID - thread.Status = *payload.PreviousMessageStatus - thread.UpdatedAt = time.Now().UTC() - - if err = service.repository.Update(ctx, thread); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update thread with ID [%s] for user with ID [%s]", thread.ID, thread.UserID)) + if err = service.repository.UpdateAfterDeletedMessage(ctx, repositories.MessageThreadDeletedUpdate{ + MessageThreadID: thread.ID, + UserID: thread.UserID, + LastMessageID: payload.PreviousMessageID, + LastMessageContent: payload.PreviousMessageContent, + LastMessageStatus: *payload.PreviousMessageStatus, + }); err != nil { + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update thread with ID [%s] for user with ID [%s]", thread.ID, thread.UserID)) } ctxLogger.Info(fmt.Sprintf("last message has been removed from thread with ID [%s] and userID [%s]", thread.ID, thread.UserID)) @@ -186,28 +206,33 @@ func (service *MessageThreadService) UpdateAfterDeletedMessage(ctx context.Conte } func (service *MessageThreadService) createThread(ctx context.Context, params MessageThreadUpdateParams) error { - ctx, span := service.tracer.Start(ctx) + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() - ctxLogger := service.tracer.CtxLogger(service.logger, span) + unreadCount := uint(0) + if params.MarkAsUnread { + unreadCount = 1 + } + now := time.Now().UTC() thread := &entities.MessageThread{ ID: uuid.New(), Owner: params.Owner, Contact: params.Contact, UserID: params.UserID, IsArchived: false, + UnreadCount: unreadCount, Color: service.getColor(), LastMessageContent: ¶ms.Content, Status: params.Status, LastMessageID: ¶ms.MessageID, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), + CreatedAt: now, + UpdatedAt: now, OrderTimestamp: params.Timestamp, } if err := service.repository.Store(ctx, thread); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot store thread with id [%s] for message with ID [%s]", thread.ID, params.MessageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot store thread with id [%s] for message with ID [%s]", thread.ID, params.MessageID)) } ctxLogger.Info(fmt.Sprintf( @@ -248,21 +273,38 @@ func (service *MessageThreadService) getColor() string { // MessageThreadGetParams parameters fetching threads type MessageThreadGetParams struct { repositories.IndexParams - IsArchived bool - UserID entities.UserID - Owner string + IsArchived bool + WithContacts bool + UserID entities.UserID + Owner string } // GetThreads fetches threads for an owner func (service *MessageThreadService) GetThreads(ctx context.Context, params MessageThreadGetParams) (*[]entities.MessageThread, error) { - ctx, span := service.tracer.Start(ctx) + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() - ctxLogger := service.tracer.CtxLogger(service.logger, span) - threads, err := service.repository.Index(ctx, params.UserID, params.Owner, params.IsArchived, params.IndexParams) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not fetch messages threads for params [%+#v]", params)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not fetch messages threads for params [%+#v]", params)) + } + + if params.WithContacts && len(*threads) > 0 { + phoneNumbers := make([]string, 0, len(*threads)) + for index := range *threads { + phoneNumbers = append(phoneNumbers, (*threads)[index].Contact) + } + + contactMap, mapErr := service.contactService.GetContactMap(ctx, params.UserID, phoneNumbers) + if mapErr != nil { + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(mapErr, "cannot build contact map for user [%s]", params.UserID))) + } else { + for index := range *threads { + if contact, ok := contactMap[(*threads)[index].Contact]; ok { + (*threads)[index].ContactDetails = contact + } + } + } } ctxLogger.Info(fmt.Sprintf("fetched [%d] threads with params [%+#v]", len(*threads), params)) @@ -276,7 +318,7 @@ func (service *MessageThreadService) GetThread(ctx context.Context, userID entit thread, err := service.repository.Load(ctx, userID, messageThreadID) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "could not fetch thread with ID [%s] for user [%s]", messageThreadID, userID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "could not fetch thread with ID [%s] for user [%s]", messageThreadID, userID)) } return thread, nil @@ -288,7 +330,7 @@ func (service *MessageThreadService) DeleteThread(ctx context.Context, source st defer span.End() if err := service.repository.Delete(ctx, thread.UserID, thread.ID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "could not delete message thread with ID [%s] for user with ID [%s]", thread.ID, thread.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "could not delete message thread with ID [%s] for user with ID [%s]", thread.ID, thread.UserID)) } event, err := service.createEvent(events.MessageThreadAPIDeleted, source, &events.MessageThreadAPIDeletedPayload{ @@ -302,12 +344,12 @@ func (service *MessageThreadService) DeleteThread(ctx context.Context, source st Timestamp: time.Now().UTC(), }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [%T] for message thread dleted with ID [%s]", event, thread.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [%T] for message thread dleted with ID [%s]", event, thread.ID)) } ctxLogger.Info(fmt.Sprintf("created event [%s] with id [%s] for message thread [%s]", event.Type(), event.ID(), thread.ID)) if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] with id [%s] for message thread [%s]", event.Type(), event.ID(), thread.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] with id [%s] for message thread [%s]", event.Type(), event.ID(), thread.ID)) } ctxLogger.Info(fmt.Sprintf("dispatched [%s] event with id [%s] for message thread [%s]", event.Type(), event.ID(), thread.ID)) diff --git a/api/pkg/services/message_thread_service_contacts_test.go b/api/pkg/services/message_thread_service_contacts_test.go new file mode 100644 index 000000000..2c021d2f7 --- /dev/null +++ b/api/pkg/services/message_thread_service_contacts_test.go @@ -0,0 +1,159 @@ +package services + +import ( + "context" + "errors" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +type messageThreadContactRepositoryStub struct { + repositories.MessageThreadRepository + threads []entities.MessageThread + err error + calls int +} + +func (stub *messageThreadContactRepositoryStub) Index(_ context.Context, _ entities.UserID, _ string, _ bool, _ repositories.IndexParams) (*[]entities.MessageThread, error) { + stub.calls++ + if stub.err != nil { + return nil, stub.err + } + threads := make([]entities.MessageThread, len(stub.threads)) + copy(threads, stub.threads) + return &threads, nil +} + +type messageThreadContactProviderStub struct { + contacts map[string]*entities.Contact + err error + calls int + userID entities.UserID + numbers []string +} + +func (stub *messageThreadContactProviderStub) GetContactMap(_ context.Context, userID entities.UserID, phoneNumbers []string) (map[string]*entities.Contact, error) { + stub.calls++ + stub.userID = userID + stub.numbers = append([]string{}, phoneNumbers...) + return stub.contacts, stub.err +} + +type messageThreadContactLogger struct { + errors []error +} + +var _ telemetry.Logger = (*messageThreadContactLogger)(nil) + +func (logger *messageThreadContactLogger) Error(err error) { + logger.errors = append(logger.errors, err) +} + +func (logger *messageThreadContactLogger) WithService(string) telemetry.Logger { return logger } + +func (logger *messageThreadContactLogger) WithString(string, string) telemetry.Logger { return logger } + +func (logger *messageThreadContactLogger) WithSpan(trace.SpanContext) telemetry.Logger { return logger } +func (logger *messageThreadContactLogger) Trace(string) {} +func (logger *messageThreadContactLogger) Info(string) {} +func (logger *messageThreadContactLogger) Warn(error) {} +func (logger *messageThreadContactLogger) Debug(string) {} +func (logger *messageThreadContactLogger) Fatal(error) {} +func (logger *messageThreadContactLogger) Printf(string, ...interface{}) {} + +func newMessageThreadContactServiceForTest(repository repositories.MessageThreadRepository, provider contactMapProvider, logger telemetry.Logger) *MessageThreadService { + if logger == nil { + logger = &noopLogger{} + } + tracer := telemetry.NewOtelLogger("test", logger) + return NewMessageThreadService(logger, tracer, repository, nil, nil, provider) +} + +func TestGetThreads_SkipsContactLookupWhenFlagOff(t *testing.T) { + repository := &messageThreadContactRepositoryStub{threads: []entities.MessageThread{{Contact: "+18005550199"}}} + provider := &messageThreadContactProviderStub{contacts: map[string]*entities.Contact{ + "+18005550199": {Name: "Alice"}, + }} + service := newMessageThreadContactServiceForTest(repository, provider, nil) + + threads, err := service.GetThreads(context.Background(), MessageThreadGetParams{UserID: entities.UserID("user-id"), WithContacts: false}) + + require.NoError(t, err) + require.Len(t, *threads, 1) + assert.Nil(t, (*threads)[0].ContactDetails) + assert.Equal(t, 0, provider.calls) +} + +func TestGetThreads_AttachesContactDetailsWhenFlagOn(t *testing.T) { + alice := &entities.Contact{ID: uuid.New(), Name: "Alice", PhoneNumbers: []string{"+18005550199"}} + repository := &messageThreadContactRepositoryStub{threads: []entities.MessageThread{ + {Contact: "+18005550199"}, + {Contact: "+18005550100"}, + }} + provider := &messageThreadContactProviderStub{contacts: map[string]*entities.Contact{ + "+18005550199": alice, + }} + service := newMessageThreadContactServiceForTest(repository, provider, nil) + + threads, err := service.GetThreads(context.Background(), MessageThreadGetParams{UserID: entities.UserID("user-id"), WithContacts: true}) + + require.NoError(t, err) + require.Len(t, *threads, 2) + assert.Equal(t, 1, provider.calls) + assert.Equal(t, entities.UserID("user-id"), provider.userID) + assert.Equal(t, []string{"+18005550199", "+18005550100"}, provider.numbers) + require.NotNil(t, (*threads)[0].ContactDetails) + assert.Same(t, alice, (*threads)[0].ContactDetails) + assert.Equal(t, "Alice", (*threads)[0].ContactDetails.Name) + assert.Nil(t, (*threads)[1].ContactDetails) +} + +func TestGetThreads_SkipsContactLookupWhenNoThreads(t *testing.T) { + repository := &messageThreadContactRepositoryStub{threads: []entities.MessageThread{}} + provider := &messageThreadContactProviderStub{contacts: map[string]*entities.Contact{}} + service := newMessageThreadContactServiceForTest(repository, provider, nil) + + threads, err := service.GetThreads(context.Background(), MessageThreadGetParams{UserID: entities.UserID("user-id"), WithContacts: true}) + + require.NoError(t, err) + require.Empty(t, *threads) + assert.Equal(t, 0, provider.calls) +} + +func TestGetThreads_LogsContactMapErrorAndReturnsThreads(t *testing.T) { + repository := &messageThreadContactRepositoryStub{threads: []entities.MessageThread{{Contact: "+18005550199"}}} + provider := &messageThreadContactProviderStub{err: errors.New("contacts unavailable")} + logger := &messageThreadContactLogger{} + service := newMessageThreadContactServiceForTest(repository, provider, logger) + + threads, err := service.GetThreads(context.Background(), MessageThreadGetParams{UserID: entities.UserID("user-id"), WithContacts: true}) + + require.NoError(t, err) + require.Len(t, *threads, 1) + assert.Nil(t, (*threads)[0].ContactDetails) + assert.Equal(t, 1, provider.calls) + require.Len(t, logger.errors, 1) + assert.ErrorContains(t, logger.errors[0], "cannot build contact map") +} + +func TestGetThreads_DoesNotLookupContactsWhenRepositoryFails(t *testing.T) { + repository := &messageThreadContactRepositoryStub{err: stacktrace.NewError("repository failed")} + provider := &messageThreadContactProviderStub{contacts: map[string]*entities.Contact{ + "+18005550199": {Name: "Alice"}, + }} + service := newMessageThreadContactServiceForTest(repository, provider, nil) + + threads, err := service.GetThreads(context.Background(), MessageThreadGetParams{UserID: entities.UserID("user-id"), WithContacts: true}) + + require.Error(t, err) + assert.Nil(t, threads) + assert.Equal(t, 0, provider.calls) +} diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go index 7e48ab433..1ef9b4bde 100644 --- a/api/pkg/services/message_thread_service_test.go +++ b/api/pkg/services/message_thread_service_test.go @@ -1,12 +1,224 @@ package services import ( + "context" "testing" + "time" "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" ) +type messageThreadRepositoryStub struct { + loadByOwnerContact func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) + load func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) + store func(context.Context, *entities.MessageThread) error + updateActivity func(context.Context, repositories.MessageThreadActivityUpdate) error + updateStatus func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) +} + +func (stub *messageThreadRepositoryStub) Store(ctx context.Context, thread *entities.MessageThread) error { + if stub.store != nil { + return stub.store(ctx, thread) + } + return nil +} + +func (stub *messageThreadRepositoryStub) UpdateActivity(ctx context.Context, params repositories.MessageThreadActivityUpdate) error { + if stub.updateActivity != nil { + return stub.updateActivity(ctx, params) + } + return nil +} + +func (stub *messageThreadRepositoryStub) UpdateStatus(ctx context.Context, userID entities.UserID, threadID uuid.UUID, params repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) { + if stub.updateStatus != nil { + return stub.updateStatus(ctx, userID, threadID, params) + } + return &entities.MessageThread{ID: threadID}, nil +} + +func (stub *messageThreadRepositoryStub) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { + return nil +} + +func (stub *messageThreadRepositoryStub) LoadByOwnerContact(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.MessageThread, error) { + return stub.loadByOwnerContact(ctx, userID, owner, contact) +} + +func (stub *messageThreadRepositoryStub) Load(ctx context.Context, userID entities.UserID, id uuid.UUID) (*entities.MessageThread, error) { + return stub.load(ctx, userID, id) +} + +func (stub *messageThreadRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { + threads := []entities.MessageThread{} + return &threads, nil +} + +func (stub *messageThreadRepositoryStub) Delete(context.Context, entities.UserID, uuid.UUID) error { + return nil +} + +func (stub *messageThreadRepositoryStub) DeleteAllForUser(context.Context, entities.UserID) error { + return nil +} + +func newMessageThreadServiceForTest(repository repositories.MessageThreadRepository) *MessageThreadService { + logger := &noopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + return NewMessageThreadService(logger, tracer, repository, nil, nil, nil) +} + +func TestUpdateThreadPassesUnreadWatermarkForInboundActivity(t *testing.T) { + threadID := uuid.New() + eventTimestamp := time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC) + var captured repositories.MessageThreadActivityUpdate + repository := &messageThreadRepositoryStub{ + loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: threadID}, nil + }, + updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + captured = params + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + Timestamp: eventTimestamp, + MarkAsUnread: true, + EventTimestamp: eventTimestamp, + }) + + require.NoError(t, err) + assert.True(t, captured.MarkAsUnread) + assert.Equal(t, eventTimestamp, captured.EventTimestamp) +} + +func TestUpdateThreadDoesNotIncrementUnreadCountForOutboundActivity(t *testing.T) { + var captured repositories.MessageThreadActivityUpdate + repository := &messageThreadRepositoryStub{ + loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: uuid.New(), UnreadCount: 2}, nil + }, + updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + captured = params + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "outbound", + Status: entities.MessageStatusSent, + Timestamp: time.Now().UTC(), + }) + + require.NoError(t, err) + assert.False(t, captured.MarkAsUnread) +} + +func TestCreateThreadSetsUnreadCountFromActivityDirection(t *testing.T) { + tests := []struct { + name string + marksUnread bool + wantUnread uint + }{ + {name: "inbound", marksUnread: true, wantUnread: 1}, + {name: "outbound", marksUnread: false, wantUnread: 0}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stored *entities.MessageThread + repository := &messageThreadRepositoryStub{ + loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return nil, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + }, + store: func(_ context.Context, thread *entities.MessageThread) error { + stored = thread + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + Timestamp: time.Now().UTC(), + MarkAsUnread: test.marksUnread, + }) + + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, test.wantUnread, stored.UnreadCount) + }) + } +} + +func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) { + threadID := uuid.New() + unreadCount := uint(0) + var captured repositories.MessageThreadStatusUpdate + repository := &messageThreadRepositoryStub{ + updateStatus: func(_ context.Context, _ entities.UserID, _ uuid.UUID, params repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) { + captured = params + return &entities.MessageThread{ID: threadID, IsArchived: true, UnreadCount: 0}, nil + }, + } + + service := newMessageThreadServiceForTest(repository) + thread, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ + UserID: entities.UserID("user-id"), + MessageThreadID: threadID, + UnreadCount: &unreadCount, + }) + + require.NoError(t, err) + assert.Nil(t, captured.IsArchived) + assert.Same(t, &unreadCount, captured.UnreadCount) + assert.True(t, thread.IsArchived) + assert.Zero(t, thread.UnreadCount) +} + +func TestUpdateStatusPreservesNotFoundCode(t *testing.T) { + repository := &messageThreadRepositoryStub{ + updateStatus: func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) { + return nil, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + }, + } + + service := newMessageThreadServiceForTest(repository) + unreadCount := uint(0) + _, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ + UserID: entities.UserID("user-id"), + MessageThreadID: uuid.New(), + UnreadCount: &unreadCount, + }) + + assert.Equal(t, repositories.ErrCodeNotFound, stacktrace.GetCode(err)) +} + func TestShouldCheckUnarchive(t *testing.T) { service := &MessageThreadService{} diff --git a/api/pkg/services/phone_api_key_service.go b/api/pkg/services/phone_api_key_service.go index 796e42129..83e9e696a 100644 --- a/api/pkg/services/phone_api_key_service.go +++ b/api/pkg/services/phone_api_key_service.go @@ -12,8 +12,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" ) // PhoneAPIKeyService is responsible for managing entities.PhoneAPIKey @@ -55,7 +55,7 @@ func (service *PhoneAPIKeyService) Index(ctx context.Context, userID entities.Us phoneAPIKeys, err := service.repository.Index(ctx, userID, params) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not fetch phone API Keys with params [%+#v]", params)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not fetch phone API Keys with params [%+#v]", params)) } ctxLogger.Info(fmt.Sprintf("fetched [%d] phone API Keys with prams [%+#v]", len(phoneAPIKeys), params)) @@ -69,7 +69,7 @@ func (service *PhoneAPIKeyService) Create(ctx context.Context, authContext entit apiKey, err := service.generateAPIKey(64) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate API key") + return nil, stacktrace.Propagatef(err, "cannot generate API key") } phoneAPIKey := &entities.PhoneAPIKey{ @@ -85,7 +85,7 @@ func (service *PhoneAPIKeyService) Create(ctx context.Context, authContext entit } if err = service.repository.Create(ctx, phoneAPIKey); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create PhoneAPIKey for user [%s]", authContext.ID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create PhoneAPIKey for user [%s]", authContext.ID)) } ctxLogger.Info(fmt.Sprintf("created [%T] with ID [%s] for user ID [%s]", phoneAPIKey, phoneAPIKey.ID, authContext.ID)) @@ -99,11 +99,11 @@ func (service *PhoneAPIKeyService) Delete(ctx context.Context, userID entities.U phoneAPIKey, err := service.repository.Load(ctx, userID, phoneAPIKeyID) if err != nil { - return stacktrace.Propagate(err, "cannot load [%T] with ID [%s] for user [%s]", &entities.PhoneAPIKey{}, phoneAPIKeyID, userID.String()) + return stacktrace.Propagatef(err, "cannot load [%T] with ID [%s] for user [%s]", &entities.PhoneAPIKey{}, phoneAPIKeyID, userID.String()) } if err = service.repository.Delete(ctx, phoneAPIKey); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete [%T] with ID [%s] for user [%s]", phoneAPIKey, phoneAPIKey.ID, phoneAPIKey.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete [%T] with ID [%s] for user [%s]", phoneAPIKey, phoneAPIKey.ID, phoneAPIKey.UserID)) } ctxLogger.Info(fmt.Sprintf("deleted [%T] with ID [%s] for user ID [%s]", phoneAPIKey, phoneAPIKey.ID, userID)) @@ -117,16 +117,16 @@ func (service *PhoneAPIKeyService) RemovePhone(ctx context.Context, userID entit phone, err := service.phoneRepository.LoadByID(ctx, userID, phoneID) if err != nil { - return stacktrace.Propagate(err, "cannot load [%T] with ID [%s] for user [%s]", &entities.Phone{}, phoneID, userID.String()) + return stacktrace.Propagatef(err, "cannot load [%T] with ID [%s] for user [%s]", &entities.Phone{}, phoneID, userID.String()) } phoneAPIKey, err := service.repository.Load(ctx, userID, phoneAPIKeyID) if err != nil { - return stacktrace.Propagate(err, "cannot load [%T] with ID [%s] for user [%s]", &entities.PhoneAPIKey{}, phoneAPIKeyID, userID.String()) + return stacktrace.Propagatef(err, "cannot load [%T] with ID [%s] for user [%s]", &entities.PhoneAPIKey{}, phoneAPIKeyID, userID.String()) } if err = service.repository.RemovePhone(ctx, phoneAPIKey, phone); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot remove [%T] with ID [%s] from phone API key with ID [%s] for user [%s]", phone, phone.ID, phoneAPIKey.ID, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot remove [%T] with ID [%s] from phone API key with ID [%s] for user [%s]", phone, phone.ID, phoneAPIKey.ID, userID)) } ctxLogger.Info(fmt.Sprintf("removed [%T] with ID [%s] from [%T] with ID [%s] for user ID [%s]", phone, phoneID, phoneAPIKey, phoneAPIKeyID, userID)) @@ -139,7 +139,7 @@ func (service *PhoneAPIKeyService) RemovePhoneByID(ctx context.Context, userID e defer span.End() if err := service.repository.RemovePhoneByID(ctx, userID, phoneID, phoneNumber); err != nil { - return stacktrace.Propagate(err, "cannot remove [%T] with ID [%s] and number [%s] for user [%s]", &entities.Phone{}, phoneID, phoneNumber, userID.String()) + return stacktrace.Propagatef(err, "cannot remove [%T] with ID [%s] and number [%s] for user [%s]", &entities.Phone{}, phoneID, phoneNumber, userID.String()) } ctxLogger.Info(fmt.Sprintf("removed phone with ID [%s] from [%T] for user ID [%s]", phoneID, &entities.PhoneAPIKey{}, userID)) @@ -152,7 +152,7 @@ func (service *PhoneAPIKeyService) DeleteAllForUser(ctx context.Context, userID defer span.End() if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { - return stacktrace.Propagate(err, "cannot delete all [%T] for user ID [%s]", &entities.PhoneAPIKey{}, userID) + return stacktrace.Propagatef(err, "cannot delete all [%T] for user ID [%s]", &entities.PhoneAPIKey{}, userID) } ctxLogger.Info(fmt.Sprintf("deleted all [%T] for user ID [%s]", &entities.PhoneAPIKey{}, userID)) @@ -166,16 +166,16 @@ func (service *PhoneAPIKeyService) AddPhone(ctx context.Context, userID entities phone, err := service.phoneRepository.LoadByID(ctx, userID, phoneID) if err != nil { - return stacktrace.Propagate(err, "cannot load [%T] with ID [%s] for user [%s]", &entities.Phone{}, phoneID, userID.String()) + return stacktrace.Propagatef(err, "cannot load [%T] with ID [%s] for user [%s]", &entities.Phone{}, phoneID, userID.String()) } phoneAPIKey, err := service.repository.Load(ctx, userID, phoneAPIKeyID) if err != nil { - return stacktrace.Propagate(err, "cannot load [%T] with ID [%s] for user [%s]", &entities.PhoneAPIKey{}, phoneAPIKeyID, userID.String()) + return stacktrace.Propagatef(err, "cannot load [%T] with ID [%s] for user [%s]", &entities.PhoneAPIKey{}, phoneAPIKeyID, userID.String()) } if err = service.repository.AddPhone(ctx, phoneAPIKey, phone); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot add [%T] with ID [%s] to phone API key with ID [%s] for user [%s]", phone, phone.ID, phoneAPIKey.ID, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot add [%T] with ID [%s] to phone API key with ID [%s] for user [%s]", phone, phone.ID, phoneAPIKey.ID, userID)) } ctxLogger.Info(fmt.Sprintf("added [%T] with ID [%s] to [%T] with ID [%s] for user ID [%s]", phone, phone.ID, phoneAPIKey, phoneAPIKeyID, userID)) @@ -186,7 +186,7 @@ func (service *PhoneAPIKeyService) generateRandomBytes(n int) ([]byte, error) { b := make([]byte, n) // Note that err == nil only if we read len(b) bytes. if _, err := rand.Read(b); err != nil { - return nil, stacktrace.Propagate(err, "cannot generate [%d] random bytes", n) + return nil, stacktrace.Propagatef(err, "cannot generate [%d] random bytes", n) } return b, nil @@ -194,5 +194,5 @@ func (service *PhoneAPIKeyService) generateRandomBytes(n int) ([]byte, error) { func (service *PhoneAPIKeyService) generateAPIKey(n int) (string, error) { b, err := service.generateRandomBytes(n) - return base64.URLEncoding.EncodeToString(b)[0:n], stacktrace.Propagate(err, "cannot generate [%d] random bytes", n) + return base64.URLEncoding.EncodeToString(b)[0:n], stacktrace.Propagatef(err, "cannot generate [%d] random bytes", n) } diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 6e7935fb0..2c63fcd7c 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -4,21 +4,22 @@ import ( "context" "errors" "fmt" + "strings" "time" + "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/events" cloudevents "github.com/cloudevents/sdk-go/v2" - "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" "go.opentelemetry.io/otel/trace" ) -// PhoneNotificationService sends out notifications to mobile phones +// PhoneNotificationService sends wake-up notifications to phone gateways. type PhoneNotificationService struct { service logger telemetry.Logger @@ -26,7 +27,7 @@ type PhoneNotificationService struct { phoneNotificationRepository repositories.PhoneNotificationRepository phoneRepository repositories.PhoneRepository messageSendScheduleRepository repositories.MessageSendScheduleRepository - messagingClient FCMClient + phoneNotificationClients map[entities.NotificationTransport]FCMClient eventDispatcher *EventDispatcher } @@ -34,20 +35,20 @@ type PhoneNotificationService struct { func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - messagingClient FCMClient, + phoneNotificationClients map[entities.NotificationTransport]FCMClient, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, - dispatcher *EventDispatcher, + eventDispatcher *EventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), tracer: tracer, - messagingClient: messagingClient, + phoneNotificationClients: phoneNotificationClients, phoneNotificationRepository: phoneNotificationRepository, phoneRepository: phoneRepository, messageSendScheduleRepository: messageSendScheduleRepository, - eventDispatcher: dispatcher, + eventDispatcher: eventDispatcher, } } @@ -57,7 +58,7 @@ func (service *PhoneNotificationService) DeleteAllForUser(ctx context.Context, u defer span.End() if err := service.phoneNotificationRepository.DeleteAllForUser(ctx, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete all [entities.PhoneNotification] for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete all [entities.PhoneNotification] for user with ID [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.PhoneNotification] for user with ID [%s]", userID)) @@ -70,43 +71,45 @@ func (service *PhoneNotificationService) DeleteByMessageID(ctx context.Context, defer span.End() if err := service.phoneNotificationRepository.DeleteByMessageID(ctx, userID, messageID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete [entities.PhoneNotification] for user [%s] and message with ID [%s]", userID, messageID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete [entities.PhoneNotification] for user [%s] and message with ID [%s]", userID, messageID)) } ctxLogger.Info(fmt.Sprintf("deleted [entities.PhoneNotification] for user [%s] and message with ID [%s]", userID, messageID)) return nil } -// SendHeartbeatFCM sends a heartbeat message so the phone can request a heartbeat +// SendHeartbeatFCM sends a heartbeat notification so the phone gateway can request a heartbeat. func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, payload *events.PhoneHeartbeatMissedPayload) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() phone, err := service.phoneRepository.LoadByID(ctx, payload.UserID, payload.PhoneID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load phone with userID [%s] and phoneID [%s]", payload.UserID, payload.PhoneID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load phone with userID [%s] and phoneID [%s]", payload.UserID, payload.PhoneID)) } if phone.FcmToken == nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "phone with id [%s] has no FCM token", phone.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("phone with id [%s] has no notification token", phone.ID)) } - result, err := service.messagingClient.Send(ctx, &messaging.Message{ + result, _, err := service.sendPhoneNotification(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), }, - Android: &messaging.AndroidConfig{ - Priority: "high", - }, - Token: *phone.FcmToken, + Android: &messaging.AndroidConfig{Priority: "high"}, }) if err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot send heartbeat FCM to phone with id [%s] for user [%s]", phone.ID, phone.UserID)) + ctxLogger.Warn(stacktrace.Propagatef( + err, + "cannot send heartbeat notification to phone with id [%s] for user [%s]", + phone.ID, + phone.UserID, + )) return nil } ctxLogger.Info(fmt.Sprintf( - "successfully sent heartbeat FCM [%s] to phone with ID [%s] for user [%s] and monitor [%s]", + "successfully sent heartbeat notification [%s] to phone with ID [%s] for user [%s] and monitor [%s]", result, payload.PhoneID, payload.UserID, @@ -125,7 +128,7 @@ type PhoneNotificationSendParams struct { MessageID uuid.UUID } -// Send sends a message when a message is sent +// Send sends a phone gateway notification when a message is sent. func (service *PhoneNotificationService) Send(ctx context.Context, params *PhoneNotificationSendParams) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -142,7 +145,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone } ttl := phone.MessageExpirationDuration() - result, err := service.messagingClient.Send(ctx, &messaging.Message{ + result, transport, err := service.sendPhoneNotification(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_MESSAGE_ID": params.MessageID.String(), }, @@ -150,24 +153,80 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone Priority: "normal", TTL: &ttl, }, - Token: *phone.FcmToken, }) if err != nil { - ctxLogger.Warn(stacktrace.Propagate( - err, + if transport == "" { + ctxLogger.Warn(stacktrace.Propagatef( + err, + "cannot determine notification transport for phone with ID [%s] for user with ID [%s] and message [%s]", + phone.ID, + phone.UserID, + params.MessageID, + )) + msg := fmt.Sprintf("cannot send notification to phone [%s]. Check the notification configuration.", phone.PhoneNumber) + return service.handleNotificationFailed(ctx, errors.New(msg), params) + } - "cannot send FCM to phone with ID [%s] for user with ID [%s] and message [%s]", + ctxLogger.Warn(stacktrace.Propagatef( + err, + "cannot send %s notification to phone with ID [%s] for user with ID [%s] and message [%s]", + transport, phone.ID, phone.UserID, params.MessageID, )) msg := fmt.Sprintf("cannot send notification to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber) + if transport == entities.NotificationTransportHTTP { + msg = fmt.Sprintf( + "cannot notify the configured adapter for phone [%s]. Check the adapter URL and availability.", + phone.PhoneNumber, + ) + } return service.handleNotificationFailed(ctx, errors.New(msg), params) } return service.handleNotificationSent(ctx, phone, result, params) } +func (service *PhoneNotificationService) sendPhoneNotification( + ctx context.Context, + phone *entities.Phone, + message *messaging.Message, +) (string, entities.NotificationTransport, error) { + if message == nil { + return "", "", stacktrace.NewErrorf("notification message is nil") + } + + transport, err := phone.NotificationTransport() + if err != nil { + return "", "", stacktrace.Propagatef( + err, + "cannot determine notification transport for phone [%s]", + phone.ID, + ) + } + + client, ok := service.phoneNotificationClients[transport] + if !ok || client == nil { + return "", transport, stacktrace.NewErrorf( + "notification client is not configured for transport [%s]", + transport, + ) + } + + message.Token = strings.TrimSpace(*phone.FcmToken) + result, err := client.Send(ctx, message, phone.ID) + if err != nil { + return "", transport, stacktrace.Propagatef( + err, + "cannot send [%s] notification to phone [%s]", + transport, + phone.ID, + ) + } + return result, transport, nil +} + // PhoneNotificationScheduleParams are parameters for sending a notification type PhoneNotificationScheduleParams struct { UserID entities.UserID @@ -191,7 +250,7 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P phone, err := service.phoneRepository.Load(ctx, params.UserID, params.Owner) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load phone with userID [%s] and phone [%s]", params.UserID, params.Owner)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load phone with userID [%s] and phone [%s]", params.UserID, params.Owner)) } notification := &entities.PhoneNotification{ @@ -213,7 +272,7 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P if phone.MessageSendScheduleID != nil { schedule, err = service.messageSendScheduleRepository.Load(ctx, params.UserID, *phone.MessageSendScheduleID) if err != nil && stacktrace.GetCode(err) != repositories.ErrCodeNotFound { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load send schedule [%s] for phone [%s]", *phone.MessageSendScheduleID, phone.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load send schedule [%s] for phone [%s]", *phone.MessageSendScheduleID, phone.ID)) } } @@ -222,7 +281,7 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P } if err = service.phoneNotificationRepository.Schedule(ctx, phone.MessagesPerMinute, schedule, notification); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot schedule notification for message [%s] to phone [%s]", params.MessageID, phone.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot schedule notification for message [%s] to phone [%s]", params.MessageID, phone.ID)) } if err = service.dispatchMessageNotificationScheduled(ctx, params, notification); err != nil { @@ -258,7 +317,7 @@ func (service *PhoneNotificationService) scheduleExact( notification.ScheduledAt = scheduledAt if err := service.phoneNotificationRepository.ScheduleExact(ctx, notification); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot schedule exact notification for message [%s] to phone [%s]", params.MessageID, phone.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot schedule exact notification for message [%s] to phone [%s]", params.MessageID, phone.ID)) } if err := service.dispatchMessageNotificationScheduled(ctx, params, notification); err != nil { @@ -291,11 +350,11 @@ func (service *PhoneNotificationService) dispatchMessageNotificationSend( NotificationID: notification.ID, }) if err != nil { - return stacktrace.Propagate(err, "cannot create [%s] event for notification [%s]", events.EventTypeMessageNotificationSend, notification.ID) + return stacktrace.Propagatef(err, "cannot create [%s] event for notification [%s]", events.EventTypeMessageNotificationSend, notification.ID) } if _, err = service.eventDispatcher.DispatchWithTimeout(ctx, event, notification.ScheduledAt.Sub(time.Now())); err != nil { - return stacktrace.Propagate(err, "cannot dispatch event [%s] for notification [%s]", event.Type(), notification.ID) + return stacktrace.Propagatef(err, "cannot dispatch event [%s] for notification [%s]", event.Type(), notification.ID) } return nil } @@ -318,11 +377,11 @@ func (service *PhoneNotificationService) dispatchMessageNotificationScheduled( NotificationID: notification.ID, }) if err != nil { - return stacktrace.Propagate(err, "cannot create [%s] event for notification [%s]", events.EventTypeMessageNotificationScheduled, notification.ID) + return stacktrace.Propagatef(err, "cannot create [%s] event for notification [%s]", events.EventTypeMessageNotificationScheduled, notification.ID) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return stacktrace.Propagate(err, "cannot dispatch event [%s] for notification [%s]", event.Type(), notification.ID) + return stacktrace.Propagatef(err, "cannot dispatch event [%s] for notification [%s]", event.Type(), notification.ID) } return nil } @@ -333,15 +392,15 @@ func (service *PhoneNotificationService) handleNotificationFailed(ctx context.Co ctxLogger := service.tracer.CtxLogger(service.logger, span) - ctxLogger.Warn(stacktrace.Propagate(err, "cannot send notification for message [%s] to phone [%s]", params.MessageID, params.PhoneNotificationID)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot send notification for message [%s] to phone [%s]", params.MessageID, params.PhoneNotificationID)) event, err := service.createMessageNotificationFailedEvent(params.Source, err.Error(), params) if err != nil { - return stacktrace.Propagate(err, "cannot create [%s] event for notification [%s]", events.EventTypeMessageNotificationFailed, params.PhoneNotificationID) + return stacktrace.Propagatef(err, "cannot create [%s] event for notification [%s]", events.EventTypeMessageNotificationFailed, params.PhoneNotificationID) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return stacktrace.Propagate(err, "cannot dispatch event [%s] for notification [%s]", event.Type(), params.PhoneNotificationID) + return stacktrace.Propagatef(err, "cannot dispatch event [%s] for notification [%s]", event.Type(), params.PhoneNotificationID) } service.updateStatus(ctx, params.PhoneNotificationID, entities.PhoneNotificationStatusFailed) @@ -363,11 +422,11 @@ func (service *PhoneNotificationService) handleNotificationSent( event, err := service.createMessageNotificationSentEvent(params.Source, phone, result, params) if err != nil { - return stacktrace.Propagate(err, "cannot create [%s] event for notification [%s]", events.EventTypeMessageNotificationSent, params.PhoneNotificationID) + return stacktrace.Propagatef(err, "cannot create [%s] event for notification [%s]", events.EventTypeMessageNotificationSent, params.PhoneNotificationID) } if err = service.eventDispatcher.Dispatch(ctx, event); err != nil { - return stacktrace.Propagate(err, "cannot dispatch event [%s] for notification [%s]", event.Type(), params.PhoneNotificationID) + return stacktrace.Propagatef(err, "cannot dispatch event [%s] for notification [%s]", event.Type(), params.PhoneNotificationID) } service.updateStatus(ctx, params.PhoneNotificationID, entities.PhoneNotificationStatusSent) @@ -413,7 +472,7 @@ func (service *PhoneNotificationService) createMessageNotificationSentEvent( } if err := event.SetData(cloudevents.ApplicationJSON, payload); err != nil { - return event, stacktrace.Propagate(err, "cannot encode %T [%#+v] as JSON", payload, payload) + return event, stacktrace.Propagatef(err, "cannot encode %T [%#+v] as JSON", payload, payload) } return event, nil @@ -441,7 +500,7 @@ func (service *PhoneNotificationService) createMessageNotificationFailedEvent( } if err := event.SetData(cloudevents.ApplicationJSON, payload); err != nil { - return event, stacktrace.Propagate(err, "cannot encode %T [%#+v] as JSON", payload, payload) + return event, stacktrace.Propagatef(err, "cannot encode %T [%#+v] as JSON", payload, payload) } return event, nil @@ -459,7 +518,7 @@ func (service *PhoneNotificationService) updateStatus( err := service.phoneNotificationRepository.UpdateStatus(ctx, notificationID, status) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot update status of notification with id [%s] to [%s]", notificationID, status)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot update status of notification with id [%s] to [%s]", notificationID, status)) } ctxLogger.Info(fmt.Sprintf("updated status of notification with id [%s] to [%s]", notificationID, status)) diff --git a/api/pkg/services/phone_notification_service_test.go b/api/pkg/services/phone_notification_service_test.go new file mode 100644 index 000000000..9f0c42a46 --- /dev/null +++ b/api/pkg/services/phone_notification_service_test.go @@ -0,0 +1,329 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +type phoneNotificationPhoneRepository struct { + repositories.PhoneRepository + phone *entities.Phone + err error +} + +func (repository *phoneNotificationPhoneRepository) LoadByID( + _ context.Context, + _ entities.UserID, + _ uuid.UUID, +) (*entities.Phone, error) { + return repository.phone, repository.err +} + +type phoneNotificationRepository struct { + repositories.PhoneNotificationRepository + notificationID uuid.UUID + status entities.PhoneNotificationStatus +} + +func (repository *phoneNotificationRepository) UpdateStatus( + _ context.Context, + notificationID uuid.UUID, + status entities.PhoneNotificationStatus, +) error { + repository.notificationID = notificationID + repository.status = status + return nil +} + +type phoneNotificationEventQueue struct { + events []cloudevents.Event +} + +func (queue *phoneNotificationEventQueue) Enqueue( + _ context.Context, + task *PushQueueTask, + _ time.Duration, +) (string, error) { + var event cloudevents.Event + if err := json.Unmarshal(task.Body, &event); err != nil { + return "", err + } + queue.events = append(queue.events, event) + return "", nil +} + +type phoneNotificationLogger struct { + warnings []string +} + +var _ telemetry.Logger = (*phoneNotificationLogger)(nil) + +func (logger *phoneNotificationLogger) Error(error) {} +func (logger *phoneNotificationLogger) WithService(string) telemetry.Logger { return logger } +func (logger *phoneNotificationLogger) WithString(string, string) telemetry.Logger { + return logger +} + +func (logger *phoneNotificationLogger) WithSpan(trace.SpanContext) telemetry.Logger { return logger } +func (logger *phoneNotificationLogger) Trace(string) {} +func (logger *phoneNotificationLogger) Info(string) {} +func (logger *phoneNotificationLogger) Warn(err error) { + logger.warnings = append(logger.warnings, err.Error()) +} +func (logger *phoneNotificationLogger) Debug(string) {} +func (logger *phoneNotificationLogger) Fatal(error) {} +func (logger *phoneNotificationLogger) Printf(string, ...interface{}) {} + +type recordingPhoneNotificationClient struct { + message *messaging.Message + phoneID uuid.UUID + result string + err error + calls int +} + +func (client *recordingPhoneNotificationClient) Send( + _ context.Context, + message *messaging.Message, + phoneID uuid.UUID, +) (string, error) { + client.calls++ + client.message = message + client.phoneID = phoneID + return client.result, client.err +} + +func TestPhoneNotificationServiceSendPhoneNotificationUsesMappedClient(t *testing.T) { + endpoint := " https://adapter.example.com/notify " + phone := &entities.Phone{ID: uuid.New(), FcmToken: &endpoint} + httpClient := &recordingPhoneNotificationClient{result: "accepted"} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportHTTP: httpClient, + }, + } + message := &messaging.Message{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + + result, transport, err := service.sendPhoneNotification(context.Background(), phone, message) + + require.NoError(t, err) + assert.Equal(t, "accepted", result) + assert.Equal(t, entities.NotificationTransportHTTP, transport) + assert.Equal(t, "https://adapter.example.com/notify", message.Token) + assert.Same(t, message, httpClient.message) + assert.Equal(t, phone.ID, httpClient.phoneID) + assert.Equal(t, 1, httpClient.calls) +} + +func TestPhoneNotificationServiceSendPhoneNotificationRejectsMissingClient(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), FcmToken: &endpoint} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{}, + } + + _, transport, err := service.sendPhoneNotification( + context.Background(), + phone, + &messaging.Message{}, + ) + + require.Error(t, err) + assert.Equal(t, entities.NotificationTransportHTTP, transport) + assert.Contains(t, err.Error(), "notification client is not configured for transport [http]") +} + +func TestPhoneNotificationServiceSendPhoneNotificationRejectsNilMessage(t *testing.T) { + token := "fcm-token" + phone := &entities.Phone{ID: uuid.New(), FcmToken: &token} + fcmClient := &recordingPhoneNotificationClient{} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: fcmClient, + }, + } + + _, transport, err := service.sendPhoneNotification(context.Background(), phone, nil) + + require.Error(t, err) + assert.Empty(t, transport) + assert.Contains(t, err.Error(), "notification message is nil") + assert.Zero(t, fcmClient.calls) +} + +func TestPhoneNotificationServiceSendUsesHTTPSMessage(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ + ID: uuid.New(), + UserID: "user-1", + FcmToken: &endpoint, + PhoneNumber: "+18005550199", + MessageExpirationSeconds: 90, + } + httpClient := &recordingPhoneNotificationClient{result: "http/success"} + eventQueue := &phoneNotificationEventQueue{} + notificationRepository := &phoneNotificationRepository{} + service := newPhoneNotificationServiceForTest( + phone, + notificationRepository, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: httpClient, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + ScheduledAt: time.Now().UTC(), + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.NotNil(t, httpClient.message) + assert.Equal(t, endpoint, httpClient.message.Token) + assert.Equal(t, params.MessageID.String(), httpClient.message.Data["KEY_MESSAGE_ID"]) + require.NotNil(t, httpClient.message.Android) + assert.Equal(t, "normal", httpClient.message.Android.Priority) + require.NotNil(t, httpClient.message.Android.TTL) + assert.Equal(t, phone.MessageExpirationDuration(), *httpClient.message.Android.TTL) + require.Len(t, eventQueue.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationSent, eventQueue.events[0].Type()) + assert.Equal(t, params.PhoneNotificationID, notificationRepository.notificationID) + assert.Equal(t, entities.PhoneNotificationStatus(entities.PhoneNotificationStatusSent), notificationRepository.status) +} + +func TestPhoneNotificationServiceSendHTTPFailureUsesAdapterGuidance(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint, PhoneNumber: "+18005550199"} + eventQueue := &phoneNotificationEventQueue{} + notificationRepository := &phoneNotificationRepository{} + service := newPhoneNotificationServiceForTest( + phone, + notificationRepository, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: &recordingPhoneNotificationClient{ + err: errors.New("adapter unavailable"), + }, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.Len(t, eventQueue.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationFailed, eventQueue.events[0].Type()) + var payload events.MessageNotificationFailedPayload + require.NoError(t, eventQueue.events[0].DataAs(&payload)) + assert.Equal(t, "cannot notify the configured adapter for phone [+18005550199]. Check the adapter URL and availability.", payload.ErrorMessage) + assert.NotContains(t, payload.ErrorMessage, "Reinstall the httpSMS app") + assert.Equal(t, entities.PhoneNotificationStatus(entities.PhoneNotificationStatusFailed), notificationRepository.status) +} + +func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testing.T) { + token := "fcm-token" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &token, PhoneNumber: "+18005550199"} + eventQueue := &phoneNotificationEventQueue{} + fcmClient := &recordingPhoneNotificationClient{err: errors.New("firebase unavailable")} + service := newPhoneNotificationServiceForTest( + phone, + &phoneNotificationRepository{}, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: fcmClient, + entities.NotificationTransportHTTP: &recordingPhoneNotificationClient{}, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.Len(t, eventQueue.events, 1) + require.NotNil(t, fcmClient.message) + assert.Equal(t, token, fcmClient.message.Token) + assert.Equal(t, 1, fcmClient.calls) + var payload events.MessageNotificationFailedPayload + require.NoError(t, eventQueue.events[0].DataAs(&payload)) + assert.Equal(t, "cannot send notification to your phone [+18005550199]. Reinstall the httpSMS app on your Android phone.", payload.ErrorMessage) +} + +func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSMessage(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint} + httpClient := &recordingPhoneNotificationClient{err: errors.New("adapter unavailable")} + service := newPhoneNotificationServiceForTest( + phone, + &phoneNotificationRepository{}, + &phoneNotificationEventQueue{}, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: httpClient, + }, + ) + + err := service.SendHeartbeatFCM(context.Background(), &events.PhoneHeartbeatMissedPayload{ + UserID: phone.UserID, + PhoneID: phone.ID, + MonitorID: uuid.New(), + }) + + require.NoError(t, err) + require.NotNil(t, httpClient.message) + assert.Equal(t, endpoint, httpClient.message.Token) + heartbeatID := httpClient.message.Data["KEY_HEARTBEAT_ID"] + _, err = time.Parse(time.RFC3339, heartbeatID) + require.NoError(t, err) + require.NotNil(t, httpClient.message.Android) + assert.Equal(t, "high", httpClient.message.Android.Priority) + assert.Nil(t, httpClient.message.Android.TTL) +} + +func newPhoneNotificationServiceForTest( + phone *entities.Phone, + notificationRepository repositories.PhoneNotificationRepository, + eventQueue *phoneNotificationEventQueue, + clients map[entities.NotificationTransport]FCMClient, +) *PhoneNotificationService { + logger := &phoneNotificationLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + return NewNotificationService( + logger, + tracer, + clients, + &phoneNotificationPhoneRepository{phone: phone}, + notificationRepository, + nil, + NewEventDispatcher(logger, tracer, nil, eventQueue, PushQueueConfig{}), + ) +} diff --git a/api/pkg/services/phone_service.go b/api/pkg/services/phone_service.go index 517925231..c7078375b 100644 --- a/api/pkg/services/phone_service.go +++ b/api/pkg/services/phone_service.go @@ -12,7 +12,7 @@ import ( "github.com/nyaruka/phonenumbers" "github.com/NdoleStudio/httpsms/pkg/repositories" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -48,7 +48,7 @@ func (service *PhoneService) DeleteAllForUser(ctx context.Context, userID entiti defer span.End() if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete all [entities.Phone] for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete all [entities.Phone] for user with ID [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.Phone] for user with ID [%s]", userID)) @@ -61,7 +61,7 @@ func (service *PhoneService) NullifyScheduleID(ctx context.Context, userID entit defer span.End() if err := service.repository.NullifyScheduleID(ctx, userID, scheduleID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot nullify schedule ID [%s] for user [%s]", scheduleID, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot nullify schedule ID [%s] for user [%s]", scheduleID, userID)) } service.tracer.CtxLogger(service.logger, span).Info(fmt.Sprintf("nullified schedule ID [%s] on phones for user [%s]", scheduleID, userID)) @@ -77,7 +77,7 @@ func (service *PhoneService) Index(ctx context.Context, authUser entities.AuthCo phones, err := service.repository.Index(ctx, authUser.ID, params) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not fetch phones with parms [%+#v]", params)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not fetch phones with parms [%+#v]", params)) } ctxLogger.Info(fmt.Sprintf("fetched [%d] phones with prams [%+#v]", len(*phones), params)) @@ -129,11 +129,11 @@ func (service *PhoneService) Upsert(ctx context.Context, params *PhoneUpsertPara } if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot upsert phone with id [%s] and number [%s]", phone.ID, phone.PhoneNumber)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot upsert phone with id [%s] and number [%s]", phone.ID, phone.PhoneNumber)) } if err = service.repository.Save(ctx, service.update(phone, params)); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update phone with id [%s] and number [%s]", phone.ID, phone.PhoneNumber)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update phone with id [%s] and number [%s]", phone.ID, phone.PhoneNumber)) } ctxLogger.Info(fmt.Sprintf("phone updated with id [%s] in the phone repository for user [%s]", phone.ID, phone.UserID)) @@ -161,11 +161,11 @@ func (service *PhoneService) dispatchPhoneUpdatedEvent(ctx context.Context, phon SIM: phone.SIM, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event when phone [%s] is updated for user [%s]", phone.ID, phone.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event when phone [%s] is updated for user [%s]", phone.ID, phone.UserID)) } if err = service.dispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] for phone with id [%s]", event.Type(), phone.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] for phone with id [%s]", event.Type(), phone.ID)) } return nil } @@ -179,11 +179,11 @@ func (service *PhoneService) Delete(ctx context.Context, source string, userID e phone, err := service.repository.LoadByID(ctx, userID, phoneID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load phone with userID [%s] and phoneID [%s]", userID, phoneID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load phone with userID [%s] and phoneID [%s]", userID, phoneID)) } if err = service.repository.Delete(ctx, userID, phoneID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete phone with id [%s] and user id [%s]", phoneID, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete phone with id [%s] and user id [%s]", phoneID, userID)) } ctxLogger.Info(fmt.Sprintf("deleted phone with id [%s] and user id [%s]", phoneID, userID)) @@ -196,11 +196,11 @@ func (service *PhoneService) Delete(ctx context.Context, source string, userID e SIM: phone.SIM, }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event when phone is deleted")) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event when phone is deleted")) } if err = service.dispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] for phone with id [%s]", event.Type(), phone.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] for phone with id [%s]", event.Type(), phone.ID)) } return nil @@ -228,14 +228,14 @@ func (service *PhoneService) UpsertFCMToken(ctx context.Context, params *PhoneFC } if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot upsert FCM token for user with id [%s] and number [%s]", params.UserID, params.PhoneNumber)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot upsert FCM token for user with id [%s] and number [%s]", params.UserID, params.PhoneNumber)) } phone.FcmToken = params.FcmToken phone.SIM = params.SIM if err = service.repository.Save(ctx, phone); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update phone with id [%s] and number [%s]", phone.ID, phone.PhoneNumber)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update phone with id [%s] and number [%s]", phone.ID, phone.PhoneNumber)) } ctxLogger.Info(fmt.Sprintf("phone updated with id [%s] in the phone repository for user [%s]", phone.ID, phone.UserID)) @@ -264,7 +264,7 @@ func (service *PhoneService) createPhone(ctx context.Context, params *PhoneFCMTo } if err := service.repository.Save(ctx, phone); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create phone with id [%s] and number [%s]", phone.ID, phone.PhoneNumber)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create phone with id [%s] and number [%s]", phone.ID, phone.PhoneNumber)) } ctxLogger.Info(fmt.Sprintf("phone updated with id [%s] in the phone repository for user [%s]", phone.ID, phone.UserID)) diff --git a/api/pkg/services/service.go b/api/pkg/services/service.go index f733d8b38..2705bec2d 100644 --- a/api/pkg/services/service.go +++ b/api/pkg/services/service.go @@ -7,9 +7,9 @@ import ( "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/nyaruka/phonenumbers" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/google/uuid" - "github.com/palantir/stacktrace" ) type service struct{} @@ -23,7 +23,7 @@ func (service *service) createEvent(eventType string, source string, payload any event.SetID(uuid.New().String()) if err := event.SetData(cloudevents.ApplicationJSON, payload); err != nil { - return event, stacktrace.Propagate(err, "cannot encode %T [%#+v] as JSON", payload, payload) + return event, stacktrace.Propagatef(err, "cannot encode %T [%#+v] as JSON", payload, payload) } return event, nil @@ -34,7 +34,7 @@ func (service *service) getFormattedNumber(ctxLogger telemetry.Logger, phoneNumb matched, err := regexp.MatchString(phoneNumberPattern, phoneNumber) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "error while matching phoneNumber [%s] with regex [%s]", phoneNumber, phoneNumberPattern)) + ctxLogger.Error(stacktrace.Propagatef(err, "error while matching phoneNumber [%s] with regex [%s]", phoneNumber, phoneNumberPattern)) return phoneNumber } if !matched { @@ -43,7 +43,7 @@ func (service *service) getFormattedNumber(ctxLogger telemetry.Logger, phoneNumb number, err := phonenumbers.Parse(phoneNumber, phonenumbers.UNKNOWN_REGION) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot parse number [%s]", phoneNumber)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot parse number [%s]", phoneNumber)) return phoneNumber } diff --git a/api/pkg/services/user_service.go b/api/pkg/services/user_service.go index bbd5ad463..eaadc5e2a 100644 --- a/api/pkg/services/user_service.go +++ b/api/pkg/services/user_service.go @@ -10,11 +10,10 @@ import ( "firebase.google.com/go/auth" "github.com/NdoleStudio/httpsms/pkg/emails" "github.com/NdoleStudio/httpsms/pkg/events" - "github.com/NdoleStudio/lemonsqueezy-go" - "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/lemonsqueezy-go" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" - "github.com/palantir/stacktrace" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -66,7 +65,7 @@ func (service *UserService) GetSubscriptionPayments(ctx context.Context, userID user, err := service.repository.Load(ctx, userID) if err != nil { - return invoices, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with with ID [%s]", user, userID)) + return invoices, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with with ID [%s]", user, userID)) } if user.SubscriptionID == nil { @@ -77,7 +76,7 @@ func (service *UserService) GetSubscriptionPayments(ctx context.Context, userID ctxLogger.Info(fmt.Sprintf("fetching subscription payments for [%T] with ID [%s] and subscription [%s]", user, user.ID, *user.SubscriptionID)) invoicesResponse, _, err := service.lemonsqueezyClient.SubscriptionInvoices.List(ctx, map[string]string{"filter[subscription_id]": *user.SubscriptionID}) if err != nil { - return invoices, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get invoices for subscription [%s] for [%T] with with ID [%s]", *user.SubscriptionID, user, user.ID)) + return invoices, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get invoices for subscription [%s] for [%T] with with ID [%s]", *user.SubscriptionID, user, user.ID)) } ctxLogger.Info(fmt.Sprintf("fetched [%d] payments for [%T] with ID [%s] and subscription ID [%s]", len(invoicesResponse.Data), user, user.ID, *user.SubscriptionID)) @@ -115,12 +114,12 @@ func (service *UserService) GenerateReceipt(ctx context.Context, params *UserInv invoice, _, err := service.lemonsqueezyClient.SubscriptionInvoices.Generate(ctx, params.SubscriptionInvoiceID, payload) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not generate subscription payment invoice user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not generate subscription payment invoice user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID)) } response, err := service.httpClient.Get(invoice.Meta.Urls.DownloadInvoice) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not download subscription payment invoice for user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not download subscription payment invoice for user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID)) } ctxLogger.Info(fmt.Sprintf("generated subscription payment invoice for user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID)) @@ -134,7 +133,7 @@ func (service *UserService) Get(ctx context.Context, source string, authUser ent user, isNew, err := service.repository.LoadOrStore(ctx, authUser) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with from [%+#v]", user, authUser)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with from [%+#v]", user, authUser)) } if isNew { @@ -153,12 +152,12 @@ func (service *UserService) dispatchUserCreatedEvent(ctx context.Context, source Timestamp: time.Now().UTC(), }) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot create event [%s] for user [%s]", events.UserAccountCreated, user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot create event [%s] for user [%s]", events.UserAccountCreated, user.ID)) return } if err = service.dispatcher.Dispatch(ctx, event); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot dispatch [%s] event for user [%s]", event.Type(), user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot dispatch [%s] event for user [%s]", event.Type(), user.ID)) return } } @@ -170,7 +169,7 @@ func (service *UserService) GetByID(ctx context.Context, userID entities.UserID) user, err := service.repository.Load(ctx, userID) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with ID [%s]", user, userID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with ID [%s]", user, userID)) } return user, nil @@ -191,7 +190,7 @@ func (service *UserService) Update(ctx context.Context, source string, authUser user, isNew, err := service.repository.LoadOrStore(ctx, authUser) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with from [%+#v]", user, authUser)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with from [%+#v]", user, authUser)) } if isNew { @@ -202,7 +201,7 @@ func (service *UserService) Update(ctx context.Context, source string, authUser user.ActivePhoneID = params.ActivePhoneID if err = service.repository.Update(ctx, user); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save user with id [%s]", user.ID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save user with id [%s]", user.ID)) } ctxLogger.Info(fmt.Sprintf("user saved with id [%s] in the userRepository", user.ID)) @@ -224,7 +223,7 @@ func (service *UserService) UpdateNotificationSettings(ctx context.Context, user user, err := service.repository.Load(ctx, userID) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not load [%T] with ID [%s]", user, userID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not load [%T] with ID [%s]", user, userID)) } user.NotificationWebhookEnabled = params.WebhookEnabled @@ -233,7 +232,7 @@ func (service *UserService) UpdateNotificationSettings(ctx context.Context, user user.NotificationNewsletterEnabled = params.NewsletterEnabled if err = service.repository.Update(ctx, user); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save user with id [%s] in [%T]", user.ID, service.repository)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save user with id [%s] in [%T]", user.ID, service.repository)) } ctxLogger.Info(fmt.Sprintf("updated notification settings for [%T] with ID [%s] in the [%T]", user, user.ID, service.repository)) @@ -247,7 +246,7 @@ func (service *UserService) RotateAPIKey(ctx context.Context, source string, use user, err := service.repository.RotateAPIKey(ctx, userID) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not rotate API key for [%T] with ID [%s]", user, userID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not rotate API key for [%T] with ID [%s]", user, userID)) } ctxLogger.Info(fmt.Sprintf("rotated the api key for [%T] with ID [%s] in the [%T]", user, user.ID, service.repository)) @@ -259,12 +258,12 @@ func (service *UserService) RotateAPIKey(ctx context.Context, source string, use Timezone: user.Timezone, }) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot create event [%s] for user [%s]", events.UserAPIKeyRotated, user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot create event [%s] for user [%s]", events.UserAPIKeyRotated, user.ID)) return user, nil } if err = service.dispatcher.Dispatch(ctx, event); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot dispatch [%s] event for user [%s]", event.Type(), user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot dispatch [%s] event for user [%s]", event.Type(), user.ID)) return user, nil } @@ -278,15 +277,15 @@ func (service *UserService) Delete(ctx context.Context, source string, userID en user, err := service.repository.Load(ctx, userID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load user with ID [%s] from the [%T]", userID, service.repository)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with ID [%s] from the [%T]", userID, service.repository)) } if !user.IsOnFreePlan() && user.SubscriptionRenewsAt != nil && user.SubscriptionRenewsAt.After(time.Now()) { - return service.tracer.WrapErrorSpan(span, stacktrace.NewError("cannot delete user with ID [%s] because they are have an active [%s] subscription which renews at [%s]", userID, user.SubscriptionName, user.SubscriptionRenewsAt)) + return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("cannot delete user with ID [%s] because they are have an active [%s] subscription which renews at [%s]", userID, user.SubscriptionName, user.SubscriptionRenewsAt)) } if err = service.repository.Delete(ctx, user); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete user with ID [%s] from the [%T]", userID, service.repository)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete user with ID [%s] from the [%T]", userID, service.repository)) } ctxLogger.Info(fmt.Sprintf("sucessfully deleted user with ID [%s] in the [%T]", userID, service.repository)) @@ -297,11 +296,11 @@ func (service *UserService) Delete(ctx context.Context, source string, userID en Timestamp: time.Now().UTC(), }) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event [%s] for user [%s]", events.UserAccountDeleted, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event [%s] for user [%s]", events.UserAccountDeleted, userID)) } if err = service.dispatcher.Dispatch(ctx, event); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch [%s] event for user [%s]", event.Type(), userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch [%s] event for user [%s]", event.Type(), userID)) } return nil @@ -316,11 +315,11 @@ func (service *UserService) SendAPIKeyRotatedEmail(ctx context.Context, payload email, err := service.emailFactory.APIKeyRotated(payload.Email, payload.Timestamp, payload.Timezone) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create api key rotated email for user [%s]", payload.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create api key rotated email for user [%s]", payload.UserID)) } if err = service.mailer.Send(ctx, email); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "canot create api key rotated email to user [%s]", payload.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "canot create api key rotated email to user [%s]", payload.UserID)) } ctxLogger.Info(fmt.Sprintf("api key rotated email sent successfully to [%s] with user ID [%s]", payload.Email, payload.UserID)) @@ -344,7 +343,7 @@ func (service *UserService) SendPhoneDeadEmail(ctx context.Context, params *User user, err := service.repository.Load(ctx, params.UserID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with ID [%s]", user, params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with ID [%s]", user, params.UserID)) } if !user.NotificationHeartbeatEnabled { @@ -354,11 +353,11 @@ func (service *UserService) SendPhoneDeadEmail(ctx context.Context, params *User email, err := service.emailFactory.PhoneDead(user, params.LastHeartbeatTimestamp, params.Owner) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create phone dead email for user [%s]", params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create phone dead email for user [%s]", params.UserID)) } if err = service.mailer.Send(ctx, email); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "canot send phone dead notification to user [%s]", params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "canot send phone dead notification to user [%s]", params.UserID)) } ctxLogger.Info(fmt.Sprintf("phone dead notification sent successfully to [%s] about [%s]", user.Email, params.Owner)) @@ -372,7 +371,7 @@ func (service *UserService) StartSubscription(ctx context.Context, params *event user, err := service.repository.Load(ctx, params.UserID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with with ID [%s]", user, params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with with ID [%s]", user, params.UserID)) } user.SubscriptionID = ¶ms.SubscriptionID @@ -382,7 +381,7 @@ func (service *UserService) StartSubscription(ctx context.Context, params *event user.SubscriptionEndsAt = nil if err = service.repository.Update(ctx, user); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not update [%T] with with ID [%s] after update", user, params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not update [%T] with with ID [%s] after update", user, params.UserID)) } return nil @@ -395,30 +394,30 @@ func (service *UserService) InitiateSubscriptionCancel(ctx context.Context, user user, err := service.repository.Load(ctx, userID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with with ID [%s]", user, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with with ID [%s]", user, userID)) } if _, _, err = service.lemonsqueezyClient.Subscriptions.Cancel(ctx, *user.SubscriptionID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not cancel subscription [%s] for [%T] with with ID [%s]", *user.SubscriptionID, user, user.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not cancel subscription [%s] for [%T] with with ID [%s]", *user.SubscriptionID, user, user.ID)) } ctxLogger.Info(fmt.Sprintf("cancelled subscription [%s] for user [%s]", *user.SubscriptionID, user.ID)) return nil } -// GetSubscriptionUpdateURL initiates the cancelling of a subscription on lemonsqueezy +// GetSubscriptionUpdateURL fetches the LemonSqueezy page for updating a subscription. func (service *UserService) GetSubscriptionUpdateURL(ctx context.Context, userID entities.UserID) (url string, err error) { ctx, span := service.tracer.Start(ctx) defer span.End() user, err := service.repository.Load(ctx, userID) if err != nil { - return "", service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with with ID [%s]", user, userID)) + return "", service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with with ID [%s]", user, userID)) } subscription, _, err := service.lemonsqueezyClient.Subscriptions.Get(ctx, *user.SubscriptionID) if err != nil { - return url, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get subscription [%s] for [%T] with with ID [%s]", *user.SubscriptionID, user, user.ID)) + return url, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get subscription [%s] for [%T] with with ID [%s]", *user.SubscriptionID, user, user.ID)) } return subscription.Data.Attributes.Urls.CustomerPortal, nil @@ -431,7 +430,7 @@ func (service *UserService) CancelSubscription(ctx context.Context, params *even user, err := service.repository.Load(ctx, params.UserID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with with ID [%s]", user, params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with with ID [%s]", user, params.UserID)) } user.SubscriptionID = ¶ms.SubscriptionID @@ -441,7 +440,7 @@ func (service *UserService) CancelSubscription(ctx context.Context, params *even user.SubscriptionEndsAt = ¶ms.SubscriptionEndsAt if err = service.repository.Update(ctx, user); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not update [%T] with with ID [%s] after update", user, params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not update [%T] with with ID [%s] after update", user, params.UserID)) } return nil @@ -454,7 +453,7 @@ func (service *UserService) ExpireSubscription(ctx context.Context, params *even user, err := service.repository.Load(ctx, params.UserID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with with ID [%s]", user, params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with with ID [%s]", user, params.UserID)) } user.SubscriptionID = nil @@ -464,7 +463,7 @@ func (service *UserService) ExpireSubscription(ctx context.Context, params *even user.SubscriptionEndsAt = nil if err = service.repository.Update(ctx, user); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not update [%T] with with ID [%s] after expired subscription update", user, params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not update [%T] with with ID [%s] after expired subscription update", user, params.UserID)) } return nil @@ -477,7 +476,7 @@ func (service *UserService) UpdateSubscription(ctx context.Context, params *even user, err := service.repository.Load(ctx, params.UserID) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not get [%T] with with ID [%s]", user, params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not get [%T] with with ID [%s]", user, params.UserID)) } if params.SubscriptionStatus != "active" { @@ -493,7 +492,7 @@ func (service *UserService) UpdateSubscription(ctx context.Context, params *even user.SubscriptionStatus = ¶ms.SubscriptionStatus if err = service.repository.Update(ctx, user); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not update [%T] with with ID [%s] after subscription update", user, params.UserID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not update [%T] with with ID [%s] after subscription update", user, params.UserID)) } return nil @@ -505,7 +504,7 @@ func (service *UserService) DeleteAuthUser(ctx context.Context, userID entities. defer span.End() if err := service.authClient.DeleteUser(ctx, userID.String()); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete [entities.AuthContext] from firebase with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete [entities.AuthContext] from firebase with ID [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("deleted [entities.AuthContext] from firebase for user with ID [%s]", userID)) diff --git a/api/pkg/services/webhook_service.go b/api/pkg/services/webhook_service.go index b8ff84900..ee5918d37 100644 --- a/api/pkg/services/webhook_service.go +++ b/api/pkg/services/webhook_service.go @@ -21,11 +21,11 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/lib/pq" - "github.com/palantir/stacktrace" ) // WebhookService is responsible for handling webhooks @@ -61,7 +61,7 @@ func (service *WebhookService) DeleteAllForUser(ctx context.Context, userID enti defer span.End() if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not delete all [entities.Webhook] for user with ID [%s]", userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not delete all [entities.Webhook] for user with ID [%s]", userID)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.Webhook] for user with ID [%s]", userID)) @@ -77,7 +77,7 @@ func (service *WebhookService) Index(ctx context.Context, userID entities.UserID webhooks, err := service.repository.Index(ctx, userID, params) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not fetch webhooks with params [%+#v]", params)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not fetch webhooks with params [%+#v]", params)) } ctxLogger.Info(fmt.Sprintf("fetched [%d] webhooks with prams [%+#v]", len(webhooks), params)) @@ -92,11 +92,11 @@ func (service *WebhookService) Delete(ctx context.Context, userID entities.UserI ctxLogger := service.tracer.CtxLogger(service.logger, span) if _, err := service.repository.Load(ctx, userID, webhookID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load webhook with userID [%s] and phoneID [%s]", userID, webhookID)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load webhook with userID [%s] and phoneID [%s]", userID, webhookID)) } if err := service.repository.Delete(ctx, userID, webhookID); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete webhook with id [%s] and user id [%s]", webhookID, userID)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot delete webhook with id [%s] and user id [%s]", webhookID, userID)) } ctxLogger.Info(fmt.Sprintf("deleted webhook with id [%s] and user id [%s]", webhookID, userID)) @@ -131,7 +131,7 @@ func (service *WebhookService) Store(ctx context.Context, params *WebhookStorePa } if err := service.repository.Save(ctx, webhook); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save webhook with id [%s]", webhook.ID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save webhook with id [%s]", webhook.ID)) } ctxLogger.Info(fmt.Sprintf("webhook saved with id [%s] for user [%s] in the [%T]", webhook.ID, webhook.UserID, service.repository)) @@ -155,7 +155,7 @@ func (service *WebhookService) Update(ctx context.Context, params *WebhookUpdate webhook, err := service.repository.Load(ctx, params.UserID, params.WebhookID) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load webhook with userID [%s] and phoneID [%s]", params.UserID, params.WebhookID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load webhook with userID [%s] and phoneID [%s]", params.UserID, params.WebhookID)) } webhook.URL = params.URL @@ -164,7 +164,7 @@ func (service *WebhookService) Update(ctx context.Context, params *WebhookUpdate webhook.PhoneNumbers = params.PhoneNumbers if err = service.repository.Save(ctx, webhook); err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot save webhook with id [%s] after update", webhook.ID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot save webhook with id [%s] after update", webhook.ID)) } ctxLogger.Info(fmt.Sprintf("webhook updated with id [%s] in the [%T]", webhook.ID, service.repository)) @@ -178,7 +178,7 @@ func (service *WebhookService) Send(ctx context.Context, userID entities.UserID, webhooks, err := service.repository.LoadByEvent(ctx, userID, event.Type(), phoneNumber) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot load webhooks for userID [%s] and event [%s]", userID, event.Type())) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "cannot load webhooks for userID [%s] and event [%s]", userID, event.Type())) } if len(webhooks) == 0 { @@ -212,12 +212,12 @@ func (service *WebhookService) sendNotification(ctx context.Context, event cloud request, err := service.createRequest(requestCtx, event, webhook) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts)) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts)) } response, err := service.client.Do(request) if err != nil { - ctxLogger.Warn(stacktrace.Propagate(err, "cannot send [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot send [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts)) if attempts == 1 { return err } @@ -228,16 +228,16 @@ func (service *WebhookService) sendNotification(ctx context.Context, event cloud defer func() { err = response.Body.Close() if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot close response body for [%s] event with ID [%s] after [%d] attempts", event.Type(), event.ID(), attempts)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot close response body for [%s] event with ID [%s] after [%d] attempts", event.Type(), event.ID(), attempts)) } }() if response.StatusCode >= 400 { ctxLogger.Info(fmt.Sprintf("cannot send [%s] event to webhook [%s] for user [%s] with response code [%d] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, response.StatusCode, attempts)) if attempts == 1 { - return stacktrace.NewError("%s", http.StatusText(response.StatusCode)) + return stacktrace.NewErrorf("%s", http.StatusText(response.StatusCode)) } - service.handleWebhookSendFailed(ctx, event, webhook, owner, stacktrace.NewError("%s", http.StatusText(response.StatusCode)), response) + service.handleWebhookSendFailed(ctx, event, webhook, owner, stacktrace.NewErrorf("%s", http.StatusText(response.StatusCode)), response) return nil } @@ -245,7 +245,7 @@ func (service *WebhookService) sendNotification(ctx context.Context, event cloud return nil }) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot handle [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot handle [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts))) } } @@ -255,12 +255,12 @@ func (service *WebhookService) createRequest(ctx context.Context, event cloudeve payload, err := json.Marshal(service.getPayload(ctxLogger, event, webhook)) if err != nil { - return nil, stacktrace.Propagate(err, "cannot marshal payload for user [%s] and webhook [%s] for event [%s]", webhook.UserID, webhook.ID, event.ID()) + return nil, stacktrace.Propagatef(err, "cannot marshal payload for user [%s] and webhook [%s] for event [%s]", webhook.UserID, webhook.ID, event.ID()) } request, err := http.NewRequestWithContext(ctx, http.MethodPost, webhook.URL, bytes.NewReader(payload)) if err != nil { - return nil, stacktrace.Propagate(err, "cannot create request for user [%s] and webhook [%s] for event [%s]", webhook.UserID, webhook.ID, event.ID()) + return nil, stacktrace.Propagatef(err, "cannot create request for user [%s] and webhook [%s] for event [%s]", webhook.UserID, webhook.ID, event.ID()) } request.Header.Add("X-Event-Type", event.Type()) @@ -269,7 +269,7 @@ func (service *WebhookService) createRequest(ctx context.Context, event cloudeve if strings.TrimSpace(webhook.SigningKey) != "" { token, err := service.getAuthToken(webhook) if err != nil { - return nil, stacktrace.Propagate(err, "cannot generate auth token for user [%s] and webhook [%s]", webhook.UserID, webhook.ID) + return nil, stacktrace.Propagatef(err, "cannot generate auth token for user [%s] and webhook [%s]", webhook.UserID, webhook.ID) } request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) } @@ -290,7 +290,7 @@ func (service *WebhookService) getPayload(ctxLogger telemetry.Logger, event clou err := event.DataAs(payload) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload)) return event } @@ -369,12 +369,12 @@ func (service *WebhookService) handleWebhookSendFailed(ctx context.Context, even event, err = service.createEvent(events.EventTypeWebhookSendFailed, event.Source(), payload) if err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot create event [%s] for user with id [%s]", events.EventTypeWebhookSendFailed, payload.UserID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot create event [%s] for user with id [%s]", events.EventTypeWebhookSendFailed, payload.UserID))) return } if err = service.dispatcher.Dispatch(ctx, event); err != nil { - ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot dispatch event [%s] for user with id [%s]", event.Type(), payload.UserID))) + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot dispatch event [%s] for user with id [%s]", event.Type(), payload.UserID))) return } diff --git a/api/pkg/telemetry/gorm_logger.go b/api/pkg/telemetry/gorm_logger.go index 2a6ef3e9e..7f9d9517c 100644 --- a/api/pkg/telemetry/gorm_logger.go +++ b/api/pkg/telemetry/gorm_logger.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "gorm.io/gorm/logger" ) @@ -46,7 +46,7 @@ func (gorm *gormLogger) Trace(ctx context.Context, begin time.Time, fc func() (s msg := fmt.Sprintf("[ROWS:%d][%s]", rows, sql) if err != nil { - l.Error(stacktrace.Propagate(err, "[ROWS:%d][%s]", rows, sql)) + l.Error(stacktrace.Propagatef(err, "[ROWS:%d][%s]", rows, sql)) return } diff --git a/api/pkg/validators/bulk_message_handler_validator.go b/api/pkg/validators/bulk_message_handler_validator.go index 53a18a4e0..956c14898 100644 --- a/api/pkg/validators/bulk_message_handler_validator.go +++ b/api/pkg/validators/bulk_message_handler_validator.go @@ -12,16 +12,15 @@ import ( "github.com/xuri/excelize/v2" - "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/requests" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" "github.com/dustin/go-humanize" "github.com/jszwec/csvutil" "github.com/nyaruka/phonenumbers" - "github.com/palantir/stacktrace" ) // BulkMessageHandlerValidator validates models used in handlers.BillingHandler @@ -31,7 +30,6 @@ type BulkMessageHandlerValidator struct { userService *services.UserService logger telemetry.Logger tracer telemetry.Tracer - cache cache.Cache } // NewBulkMessageHandlerValidator creates a new handlers.BulkMessageHandlerValidator validator @@ -40,14 +38,12 @@ func NewBulkMessageHandlerValidator( tracer telemetry.Tracer, phoneService *services.PhoneService, userService *services.UserService, - appCache cache.Cache, ) (v *BulkMessageHandlerValidator) { return &BulkMessageHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, userService: userService, phoneService: phoneService, - cache: appCache, } } @@ -60,7 +56,7 @@ func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID if err != nil { result := url.Values{} result.Add("document", "Cannot load your account. Please try again later or contact support.") - ctxLogger.Error(v.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load user [%s]", userID))) + ctxLogger.Error(v.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user [%s]", userID))) return nil, nil, result } @@ -104,7 +100,7 @@ func (v *BulkMessageHandlerValidator) parseFile(ctxLogger telemetry.Logger, user return v.parseXlsx(ctxLogger, user, header) } - ctxLogger.Error(stacktrace.NewError("cannot parse file [%s] for user [%s] with content type [%s]", header.Filename, user.ID, header.Header.Get("Content-Type"))) + ctxLogger.Error(stacktrace.NewErrorf("cannot parse file [%s] for user [%s] with content type [%s]", header.Filename, user.ID, header.Header.Get("Content-Type"))) result := url.Values{} result.Add("document", fmt.Sprintf("The file [%s] is not a valid CSV or Excel file.", header.Filename)) @@ -119,7 +115,7 @@ func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user excel, err := excelize.OpenReader(bytes.NewReader(content)) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot generate excel file from [%s] for user [%s]", header.Filename, user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot generate excel file from [%s] for user [%s]", header.Filename, user.ID)) result.Add("document", fmt.Sprintf("Cannot parse the uploaded excel file with name [%s].", header.Filename)) return nil, result } @@ -127,7 +123,7 @@ func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user rows, err := excel.GetRows(excel.GetSheetName(0)) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot get rows from excel file [%s] for user [%s]", header.Filename, user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get rows from excel file [%s] for user [%s]", header.Filename, user.ID)) result.Add("document", fmt.Sprintf("Cannot parse the uploaded excel file with name [%s].", header.Filename)) return nil, result } @@ -177,19 +173,19 @@ func (v *BulkMessageHandlerValidator) parseBytes(ctxLogger telemetry.Logger, use file, err := header.Open() if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot open file [%s] for reading for user [%s]", header.Filename, userID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot open file [%s] for reading for user [%s]", header.Filename, userID)) result.Add("document", fmt.Sprintf("Cannot open the uploaded file with name [%s].", header.Filename)) return nil, result } defer func() { if e := file.Close(); e != nil { - ctxLogger.Error(stacktrace.Propagate(e, "cannot close file [%s] for user [%s]", header.Filename, userID)) + ctxLogger.Error(stacktrace.Propagatef(e, "cannot close file [%s] for user [%s]", header.Filename, userID)) } }() b := new(bytes.Buffer) if _, err = io.Copy(b, file); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot copy file [%s] to buffer for user [%s]", header.Filename, userID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot copy file [%s] to buffer for user [%s]", header.Filename, userID)) result.Add("document", fmt.Sprintf("Cannot read the conents of the uploaded file [%s].", header.Filename)) return nil, result } @@ -205,7 +201,7 @@ func (v *BulkMessageHandlerValidator) parseCSV(ctxLogger telemetry.Logger, user var messages []*requests.BulkMessage if err := csvutil.Unmarshal(content, &messages); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "cannot unmarshall contents [%s] into type [%T] for file [%s] and user [%s]", content, messages, header.Filename, user.ID)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot unmarshall contents [%s] into type [%T] for file [%s] and user [%s]", content, messages, header.Filename, user.ID)) result.Add("document", fmt.Sprintf("Cannot read the contents of the uploaded file [%s].", header.Filename)) return nil, result } diff --git a/api/pkg/validators/contact_handler_validator.go b/api/pkg/validators/contact_handler_validator.go new file mode 100644 index 000000000..f3256160f --- /dev/null +++ b/api/pkg/validators/contact_handler_validator.go @@ -0,0 +1,359 @@ +package validators + +import ( + "bytes" + "context" + "encoding/csv" + "fmt" + "io" + "mime/multipart" + "net/mail" + "net/url" + "path/filepath" + "strconv" + "strings" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/nyaruka/phonenumbers" + "github.com/thedevsaddam/govalidator" +) + +const ( + maxContactBatch = 1000 + maxContactUploadBytes = 500 * 1024 + contactUploadDocumentKey = "document" + contactUploadContactsKey = "contacts" + contactCSVColumnName = "Name" + contactCSVColumnEmails = "Emails" + contactCSVColumnPhones = "PhoneNumbers" + contactCSVContentType = "text/csv" + contactCSVContentTypeAlt = "application/csv" + contactCSVContentTypeBin = "application/octet-stream" + contactCSVContentTypeXls = "application/vnd.ms-excel" +) + +// ContactHandlerValidator validates models used in handlers.ContactHandler +type ContactHandlerValidator struct { + validator + logger telemetry.Logger + tracer telemetry.Tracer +} + +// NewContactHandlerValidator creates a new handlers.ContactHandler validator +func NewContactHandlerValidator( + logger telemetry.Logger, + tracer telemetry.Tracer, +) (v *ContactHandlerValidator) { + return &ContactHandlerValidator{ + logger: logger.WithService(fmt.Sprintf("%T", v)), + tracer: tracer, + } +} + +// ValidateStore validates a contact create request. +func (validator *ContactHandlerValidator) ValidateStore(_ context.Context, request requests.ContactStoreRequest) url.Values { + result := url.Values{} + + if len(request.Contacts) == 0 { + result.Add(contactUploadContactsKey, "You must provide at least one contact.") + return result + } + + if len(request.Contacts) > maxContactBatch { + result.Add(contactUploadContactsKey, fmt.Sprintf("You cannot create more than %d contacts in one request.", maxContactBatch)) + return result + } + + for index, item := range request.Contacts { + validator.validateItem(result, contactUploadContactsKey, "Contact", index+1, item) + } + + return result +} + +// ValidateUpdate validates a contact update request. +func (validator *ContactHandlerValidator) ValidateUpdate(_ context.Context, request requests.ContactUpdateRequest) url.Values { + result := url.Values{} + validator.validateItem(result, contactUploadContactsKey, "Contact", 1, requests.ContactItem{ + Name: request.Name, + Emails: request.Emails, + PhoneNumbers: request.PhoneNumbers, + Properties: request.Properties, + }) + return result +} + +// ValidateIndex validates a contact index request. +func (validator *ContactHandlerValidator) ValidateIndex(_ context.Context, request requests.ContactIndex) url.Values { + result := govalidator.New(govalidator.Options{ + Data: &request, + Rules: govalidator.MapData{ + "limit": []string{"required", "numeric"}, + "skip": []string{"required", "numeric"}, + "query": []string{"max:100"}, + "sort_by": []string{"in:name,updated_at"}, + }, + }).ValidateStruct() + + if limit, err := strconv.Atoi(request.Limit); request.Limit != "" && err == nil { + if limit < 1 || limit > 100 { + result.Add("limit", "The limit must be between 1 and 100.") + } + } + + if skip, err := strconv.Atoi(request.Skip); request.Skip != "" && err == nil { + if skip < 0 { + result.Add("skip", "The skip must be greater than or equal to 0.") + } + } + + return result +} + +// ValidateUpload parses and validates a CSV contacts upload. +func (validator *ContactHandlerValidator) ValidateUpload(ctx context.Context, userID entities.UserID, header *multipart.FileHeader) ([]requests.ContactItem, url.Values) { + _, span, ctxLogger := validator.tracer.StartWithLogger(ctx, validator.logger) + defer span.End() + + result := url.Values{} + if header == nil { + result.Add(contactUploadDocumentKey, "The CSV file is required.") + return nil, result + } + + if !isContactCSVFile(header) { + ctxLogger.Error(stacktrace.NewErrorf("cannot parse file [%s] for user [%s] with content type [%s]", header.Filename, userID, header.Header.Get("Content-Type"))) + result.Add(contactUploadDocumentKey, fmt.Sprintf("The file [%s] is not a valid CSV file. Only CSV files are supported.", header.Filename)) + return nil, result + } + + content, errors := validator.parseContactUploadBytes(ctxLogger, userID, header) + if len(errors) != 0 { + return nil, errors + } + + rows, errors := validator.parseContactCSV(ctxLogger, userID, header.Filename, content) + if len(errors) != 0 { + return nil, errors + } + + if len(rows) > maxContactBatch { + result.Add(contactUploadDocumentKey, fmt.Sprintf("The uploaded file must contain no more than %d records.", maxContactBatch)) + return nil, result + } + + items := make([]requests.ContactItem, 0, len(rows)) + for _, row := range rows { + // Sanitize each row exactly like the JSON create path (SanitizeContactItem) + // before validating it, so both entry points accept and normalize the same + // phone/email formats. Row-indexed error messages are preserved because the + // row number is still passed to validateItem. + item := requests.SanitizeContactItem(requests.ContactItem{ + Name: row.values[contactCSVColumnName], + Emails: splitContactMultiValue(row.values[contactCSVColumnEmails]), + PhoneNumbers: splitContactMultiValue(row.values[contactCSVColumnPhones]), + }) + items = append(items, item) + validator.validateItem(result, contactUploadDocumentKey, "Row", row.number, item) + } + + if len(items) == 0 { + result.Add(contactUploadDocumentKey, "The uploaded file must contain at least one contact.") + } + + return items, result +} + +func (validator *ContactHandlerValidator) validateItem(result url.Values, key string, label string, index int, item requests.ContactItem) { + prefix := fmt.Sprintf("%s [%d]", label, index) + + if strings.TrimSpace(item.Name) == "" { + result.Add(key, fmt.Sprintf("%s: The name is required.", prefix)) + } + + if !hasNonEmptyValue(item.PhoneNumbers) { + result.Add(key, fmt.Sprintf("%s: At least one phone number is required.", prefix)) + } + + for _, number := range item.PhoneNumbers { + cleanNumber := strings.TrimSpace(number) + if cleanNumber == "" { + result.Add(key, fmt.Sprintf("%s: The phone number is required.", prefix)) + continue + } + + parsed, err := phonenumbers.Parse(cleanNumber, phonenumbers.UNKNOWN_REGION) + if err != nil || !phonenumbers.IsValidNumber(parsed) { + result.Add(key, fmt.Sprintf("%s: The phone number [%s] is not a valid E.164 phone number.", prefix, cleanNumber)) + } + } + + for _, email := range item.Emails { + cleanEmail := strings.TrimSpace(email) + if cleanEmail == "" { + result.Add(key, fmt.Sprintf("%s: The email is not a valid email address.", prefix)) + continue + } + + address, err := mail.ParseAddress(cleanEmail) + if err != nil || address.Address != cleanEmail { + result.Add(key, fmt.Sprintf("%s: The email [%s] is not a valid email address.", prefix, cleanEmail)) + } + } +} + +func (validator *ContactHandlerValidator) parseContactUploadBytes(ctxLogger telemetry.Logger, userID entities.UserID, header *multipart.FileHeader) ([]byte, url.Values) { + result := url.Values{} + + if header.Size > maxContactUploadBytes { + result.Add(contactUploadDocumentKey, "The CSV file must be less than or equal to 500 KB.") + return nil, result + } + + file, err := header.Open() + if err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot open file [%s] for reading for user [%s]", header.Filename, userID)) + result.Add(contactUploadDocumentKey, fmt.Sprintf("Cannot open the uploaded file [%s].", header.Filename)) + return nil, result + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + ctxLogger.Error(stacktrace.Propagatef(closeErr, "cannot close file [%s] for user [%s]", header.Filename, userID)) + } + }() + + content, err := io.ReadAll(io.LimitReader(file, maxContactUploadBytes+1)) + if err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot read file [%s] for user [%s]", header.Filename, userID)) + result.Add(contactUploadDocumentKey, fmt.Sprintf("Cannot read the uploaded file [%s].", header.Filename)) + return nil, result + } + + if len(content) > maxContactUploadBytes { + result.Add(contactUploadDocumentKey, "The CSV file must be less than or equal to 500 KB.") + return nil, result + } + + return content, result +} + +type contactCSVRow struct { + number int + values map[string]string +} + +func (validator *ContactHandlerValidator) parseContactCSV(ctxLogger telemetry.Logger, userID entities.UserID, filename string, content []byte) ([]contactCSVRow, url.Values) { + result := url.Values{} + reader := csv.NewReader(bytes.NewReader(content)) + reader.TrimLeadingSpace = true + + headers, err := reader.Read() + if err == io.EOF { + result.Add(contactUploadDocumentKey, fmt.Sprintf("Cannot parse the uploaded CSV file [%s]. The file is empty.", filename)) + return nil, result + } + if err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot read CSV header from file [%s] for user [%s]", filename, userID)) + result.Add(contactUploadDocumentKey, fmt.Sprintf("Cannot parse the uploaded CSV file [%s]. Use the official httpSMS contacts template.", filename)) + return nil, result + } + + columnIndexes, ok := contactCSVColumnIndexes(headers) + if !ok { + result.Add(contactUploadDocumentKey, "The uploaded CSV file must contain the columns [Name, Emails, PhoneNumbers].") + return nil, result + } + + reader.FieldsPerRecord = len(headers) + + var rows []contactCSVRow + for rowNumber := 2; ; rowNumber++ { + record, readErr := reader.Read() + if readErr == io.EOF { + break + } + if readErr != nil { + ctxLogger.Error(stacktrace.Propagatef(readErr, "cannot read CSV row [%d] from file [%s] for user [%s]", rowNumber, filename, userID)) + result.Add(contactUploadDocumentKey, fmt.Sprintf("Cannot parse the uploaded CSV file [%s]. Use the official httpSMS contacts template.", filename)) + return nil, result + } + + row := contactCSVRow{ + number: rowNumber, + values: map[string]string{ + contactCSVColumnName: record[columnIndexes[contactCSVColumnName]], + contactCSVColumnEmails: record[columnIndexes[contactCSVColumnEmails]], + contactCSVColumnPhones: record[columnIndexes[contactCSVColumnPhones]], + }, + } + rows = append(rows, row) + if len(rows) > maxContactBatch { + return rows, result + } + } + + return rows, result +} + +func contactCSVColumnIndexes(headers []string) (map[string]int, bool) { + required := []string{contactCSVColumnName, contactCSVColumnEmails, contactCSVColumnPhones} + indexes := map[string]int{} + for index, header := range headers { + indexes[strings.TrimSpace(header)] = index + } + + for _, column := range required { + if _, ok := indexes[column]; !ok { + return nil, false + } + } + + return indexes, true +} + +func isContactCSVFile(header *multipart.FileHeader) bool { + if strings.ToLower(filepath.Ext(header.Filename)) != ".csv" { + return false + } + + contentType := strings.ToLower(strings.TrimSpace(header.Header.Get("Content-Type"))) + if index := strings.Index(contentType, ";"); index >= 0 { + contentType = strings.TrimSpace(contentType[:index]) + } + + return contentType == "" || + contentType == contactCSVContentType || + contentType == contactCSVContentTypeAlt || + contentType == contactCSVContentTypeBin || + contentType == contactCSVContentTypeXls +} + +func splitContactMultiValue(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + + var result []string + for _, item := range strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' + }) { + item = strings.TrimSpace(item) + if item != "" { + result = append(result, item) + } + } + + return result +} + +func hasNonEmptyValue(values []string) bool { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return true + } + } + return false +} diff --git a/api/pkg/validators/contact_handler_validator_test.go b/api/pkg/validators/contact_handler_validator_test.go new file mode 100644 index 000000000..bb145dbf1 --- /dev/null +++ b/api/pkg/validators/contact_handler_validator_test.go @@ -0,0 +1,435 @@ +package validators + +import ( + "bytes" + "context" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "strings" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +func newContactValidator() *ContactHandlerValidator { + return &ContactHandlerValidator{} +} + +func newContactUploadValidator() *ContactHandlerValidator { + logger := &contactValidatorNoopLogger{} + return NewContactHandlerValidator(logger, telemetry.NewOtelLogger("test", logger)) +} + +func TestContactValidator_ValidateStore_ValidOneAndMany(t *testing.T) { + validator := newContactValidator() + + tests := []struct { + name string + request requests.ContactStoreRequest + }{ + { + name: "one contact", + request: requests.ContactStoreRequest{Contacts: []requests.ContactItem{{ + Name: "Alice", + PhoneNumbers: []string{"+18005550199"}, + Emails: []string{"alice@example.com"}, + }}}, + }, + { + name: "many contacts", + request: requests.ContactStoreRequest{Contacts: []requests.ContactItem{ + { + Name: "Alice", + PhoneNumbers: []string{"+18005550199", "+18005550100"}, + Emails: []string{"alice@example.com", "alice.work@example.com"}, + }, + { + Name: "Bob", + PhoneNumbers: []string{"+14155552671"}, + }, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validator.ValidateStore(context.Background(), tt.request) + + assert.Empty(t, errs) + }) + } +} + +func TestContactValidator_ValidateStore_MissingName(t *testing.T) { + validator := newContactValidator() + + errs := validator.ValidateStore(context.Background(), requests.ContactStoreRequest{Contacts: []requests.ContactItem{{ + PhoneNumbers: []string{"+18005550199"}, + }}}) + + require.NotEmpty(t, errs.Get("contacts")) + assert.Contains(t, errs["contacts"][0], "Contact [1]") + assert.Contains(t, errs["contacts"][0], "name") +} + +func TestContactValidator_ValidateStore_NoPhoneNumber(t *testing.T) { + validator := newContactValidator() + + errs := validator.ValidateStore(context.Background(), requests.ContactStoreRequest{Contacts: []requests.ContactItem{{ + Name: "Alice", + }}}) + + require.NotEmpty(t, errs.Get("contacts")) + assert.Contains(t, errs["contacts"][0], "Contact [1]") + assert.Contains(t, errs["contacts"][0], "phone number") +} + +func TestContactValidator_ValidateStore_EmptyBatch(t *testing.T) { + validator := newContactValidator() + + errs := validator.ValidateStore(context.Background(), requests.ContactStoreRequest{}) + + require.NotEmpty(t, errs.Get("contacts")) + assert.Contains(t, errs["contacts"][0], "at least one contact") +} + +func TestContactValidator_ValidateStore_InvalidPhoneNumber(t *testing.T) { + validator := newContactValidator() + + errs := validator.ValidateStore(context.Background(), requests.ContactStoreRequest{Contacts: []requests.ContactItem{{ + Name: "Alice", + PhoneNumbers: []string{"not-a-number"}, + }}}) + + require.NotEmpty(t, errs.Get("contacts")) + assert.Contains(t, errs["contacts"][0], "Contact [1]") + assert.Contains(t, errs["contacts"][0], "not-a-number") +} + +func TestContactValidator_ValidateStore_InvalidEmail(t *testing.T) { + validator := newContactValidator() + + errs := validator.ValidateStore(context.Background(), requests.ContactStoreRequest{Contacts: []requests.ContactItem{{ + Name: "Alice", + PhoneNumbers: []string{"+18005550199"}, + Emails: []string{"not-an-email"}, + }}}) + + require.NotEmpty(t, errs.Get("contacts")) + assert.Contains(t, errs["contacts"][0], "Contact [1]") + assert.Contains(t, errs["contacts"][0], "not-an-email") +} + +func TestContactValidator_ValidateStore_RejectsEmptyPhoneAndEmailElements(t *testing.T) { + validator := newContactValidator() + + errs := validator.ValidateStore(context.Background(), requests.ContactStoreRequest{Contacts: []requests.ContactItem{{ + Name: "Alice", + PhoneNumbers: []string{"+18005550199", " "}, + Emails: []string{" "}, + }}}) + + contactsErrors := strings.Join(errs["contacts"], "\n") + assert.Contains(t, contactsErrors, "phone number") + assert.Contains(t, contactsErrors, "email") +} + +func TestContactValidator_ValidateStore_RejectsMoreThan1000Contacts(t *testing.T) { + validator := newContactValidator() + contacts := make([]requests.ContactItem, 1001) + for index := range contacts { + contacts[index] = requests.ContactItem{Name: "Alice", PhoneNumbers: []string{"+18005550199"}} + } + + errs := validator.ValidateStore(context.Background(), requests.ContactStoreRequest{Contacts: contacts}) + + require.NotEmpty(t, errs.Get("contacts")) + assert.Contains(t, errs["contacts"][0], "1000") +} + +func TestContactValidator_ValidateUpdate_ValidAndInvalid(t *testing.T) { + validator := newContactValidator() + + validErrs := validator.ValidateUpdate(context.Background(), requests.ContactUpdateRequest{ + Name: "Alice", + PhoneNumbers: []string{"+18005550199"}, + Emails: []string{"alice@example.com"}, + }) + assert.Empty(t, validErrs) + + invalidErrs := validator.ValidateUpdate(context.Background(), requests.ContactUpdateRequest{ + Name: "", + PhoneNumbers: []string{"not-a-number"}, + Emails: []string{"not-an-email"}, + }) + assert.NotEmpty(t, invalidErrs.Get("contacts")) +} + +func TestContactValidator_ValidateIndex(t *testing.T) { + validator := newContactValidator() + + validErrs := validator.ValidateIndex(context.Background(), requests.ContactIndex{ + Skip: "0", + Limit: "100", + Query: strings.Repeat("a", 100), + SortBy: "updated_at", + }) + assert.Empty(t, validErrs) + + tests := []struct { + name string + request requests.ContactIndex + key string + }{ + {name: "limit too low", request: requests.ContactIndex{Skip: "0", Limit: "0"}, key: "limit"}, + {name: "limit too high", request: requests.ContactIndex{Skip: "0", Limit: "101"}, key: "limit"}, + {name: "skip negative", request: requests.ContactIndex{Skip: "-1", Limit: "20"}, key: "skip"}, + {name: "query too long", request: requests.ContactIndex{Skip: "0", Limit: "20", Query: strings.Repeat("a", 101)}, key: "query"}, + {name: "unsupported sort field", request: requests.ContactIndex{Skip: "0", Limit: "20", SortBy: "created_at"}, key: "sort_by"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validator.ValidateIndex(context.Background(), tt.request) + + assert.NotEmpty(t, errs.Get(tt.key)) + }) + } +} + +func TestContactValidator_ValidateUpload_ParsesValidCSVWithQuotedMultiValueCells(t *testing.T) { + validator := newContactUploadValidator() + header := multipartFileHeader(t, "contacts.csv", "text/csv", strings.Join([]string{ + "Name,Emails,PhoneNumbers", + `Alice,"alice@example.com,alice.work@example.com","+18005550199,+18005550100"`, + `Bob,bob@example.com;bob.work@example.com,+14155552671;+14155552672`, + }, "\n")) + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + require.Empty(t, errs) + require.Len(t, items, 2) + assert.Equal(t, "Alice", items[0].Name) + assert.Equal(t, []string{"alice@example.com", "alice.work@example.com"}, items[0].Emails) + assert.Equal(t, []string{"+18005550199", "+18005550100"}, items[0].PhoneNumbers) + assert.Equal(t, "Bob", items[1].Name) + assert.Equal(t, []string{"bob@example.com", "bob.work@example.com"}, items[1].Emails) + assert.Equal(t, []string{"+14155552671", "+14155552672"}, items[1].PhoneNumbers) +} + +func TestContactValidator_ValidateUpload_RejectsNonCSVExtensionAndMIME(t *testing.T) { + validator := newContactUploadValidator() + + tests := []struct { + name string + filename string + contentType string + }{ + {name: "xlsx extension", filename: "contacts.xlsx", contentType: "text/csv"}, + {name: "xlsx mime", filename: "contacts.csv", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := multipartFileHeader(t, tt.filename, tt.contentType, "Name,Emails,PhoneNumbers\nAlice,alice@example.com,+18005550199") + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + assert.Nil(t, items) + assert.NotEmpty(t, errs.Get("document")) + }) + } +} + +func TestContactValidator_ValidateUpload_AcceptsCSVWithOctetStreamMIME(t *testing.T) { + validator := newContactUploadValidator() + header := multipartFileHeader(t, "contacts.csv", "application/octet-stream", "Name,Emails,PhoneNumbers\nAlice,alice@example.com,+18005550199") + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + require.Empty(t, errs) + require.Len(t, items, 1) + assert.Equal(t, "Alice", items[0].Name) +} + +func TestContactValidator_ValidateUpload_AcceptsCSVWithVndMsExcelMIME(t *testing.T) { + validator := newContactUploadValidator() + header := multipartFileHeader(t, "contacts.csv", "application/vnd.ms-excel", "Name,Emails,PhoneNumbers\nAlice,alice@example.com,+18005550199") + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + require.Empty(t, errs) + require.Len(t, items, 1) + assert.Equal(t, "Alice", items[0].Name) +} + +func TestContactValidator_ValidateUpload_AcceptsCSVWithEmptyMIME(t *testing.T) { + validator := newContactUploadValidator() + header := multipartFileHeader(t, "contacts.csv", "", "Name,Emails,PhoneNumbers\nAlice,alice@example.com,+18005550199") + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + require.Empty(t, errs) + require.Len(t, items, 1) + assert.Equal(t, "Alice", items[0].Name) +} + +func TestContactValidator_ValidateUpload_RejectsXlsxDespiteSpreadsheetMIME(t *testing.T) { + validator := newContactUploadValidator() + header := multipartFileHeader(t, "contacts.xlsx", "application/vnd.ms-excel", "Name,Emails,PhoneNumbers\nAlice,alice@example.com,+18005550199") + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + assert.Nil(t, items) + require.NotEmpty(t, errs.Get("document")) + assert.Contains(t, errs["document"][0], "not a valid CSV file") +} + +func TestContactValidator_ValidateUpload_RejectsCSVWithUnrelatedMIME(t *testing.T) { + validator := newContactUploadValidator() + header := multipartFileHeader(t, "contacts.csv", "image/png", "Name,Emails,PhoneNumbers\nAlice,alice@example.com,+18005550199") + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + assert.Nil(t, items) + require.NotEmpty(t, errs.Get("document")) + assert.Contains(t, errs["document"][0], "not a valid CSV file") +} + +func TestContactValidator_ValidateUpload_RejectsOversizedFile(t *testing.T) { + validator := newContactUploadValidator() + header := multipartFileHeader(t, "contacts.csv", "text/csv", strings.Repeat("a", 500*1024+1)) + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + assert.Nil(t, items) + require.NotEmpty(t, errs.Get("document")) + assert.Contains(t, errs["document"][0], "500 KB") +} + +func TestContactValidator_ValidateUpload_RejectsMalformedCSV(t *testing.T) { + validator := newContactUploadValidator() + header := multipartFileHeader(t, "contacts.csv", "text/csv", "Name,Emails,PhoneNumbers\nAlice,\"alice@example.com,+18005550199") + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + assert.Nil(t, items) + require.NotEmpty(t, errs.Get("document")) + assert.Contains(t, errs["document"][0], "Cannot parse") +} + +func TestContactValidator_ValidateUpload_RejectsMissingRequiredColumns(t *testing.T) { + validator := newContactUploadValidator() + header := multipartFileHeader(t, "contacts.csv", "text/csv", "Name,Emails\nAlice,alice@example.com") + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + assert.Nil(t, items) + require.NotEmpty(t, errs.Get("document")) + assert.Contains(t, errs["document"][0], "Name, Emails, PhoneNumbers") +} + +func TestContactValidator_ValidateUpload_RejectsMoreThan1000Rows(t *testing.T) { + validator := newContactUploadValidator() + rows := []string{"Name,Emails,PhoneNumbers"} + for range 1001 { + rows = append(rows, "Alice,alice@example.com,+18005550199") + } + header := multipartFileHeader(t, "contacts.csv", "text/csv", strings.Join(rows, "\n")) + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + assert.Nil(t, items) + require.NotEmpty(t, errs.Get("document")) + assert.Contains(t, errs["document"][0], "1000") +} + +func TestContactValidator_ValidateUpload_ReturnsRowIndexedValidationErrors(t *testing.T) { + validator := newContactUploadValidator() + header := multipartFileHeader(t, "contacts.csv", "text/csv", strings.Join([]string{ + "Name,Emails,PhoneNumbers", + ",alice@example.com,+18005550199", + "Alice,,", + "Bob,not-an-email,not-a-number", + }, "\n")) + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + require.Len(t, items, 3) + documentErrors := strings.Join(errs["document"], "\n") + assert.Contains(t, documentErrors, "Row [2]") + assert.Contains(t, documentErrors, "Row [3]") + assert.Contains(t, documentErrors, "Row [4]") + assert.Contains(t, documentErrors, "not-an-email") + assert.Contains(t, documentErrors, "not-a-number") +} + +func TestContactValidator_ValidateUpload_SanitizesItemsLikeJSONBeforeValidation(t *testing.T) { + validator := newContactUploadValidator() + // The phone number lacks a leading "+" and the email has mixed case and + // surrounding whitespace. The JSON create path sanitizes these before + // validation, so the CSV path must accept and normalize them identically. + header := multipartFileHeader(t, "contacts.csv", "text/csv", strings.Join([]string{ + "Name,Emails,PhoneNumbers", + "Alice, Alice@Example.com ,18005550199", + }, "\n")) + + items, errs := validator.ValidateUpload(context.Background(), entities.UserID("user-1"), header) + + require.Empty(t, errs, "sanitized CSV rows must pass validation like the JSON path") + require.Len(t, items, 1) + assert.Equal(t, "Alice", items[0].Name) + assert.Equal(t, []string{"+18005550199"}, items[0].PhoneNumbers) + assert.Equal(t, []string{"alice@example.com"}, items[0].Emails) +} + +func multipartFileHeader(t *testing.T, filename string, contentType string, content string) *multipart.FileHeader { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + header := textproto.MIMEHeader{} + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="document"; filename="%s"`, filename)) + header.Set("Content-Type", contentType) + + part, err := writer.CreatePart(header) + require.NoError(t, err) + _, err = part.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + request := httptest.NewRequest(http.MethodPost, "/", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + require.NoError(t, request.ParseMultipartForm(int64(body.Len()+1024))) + files := request.MultipartForm.File["document"] + require.Len(t, files, 1) + + return files[0] +} + +type contactValidatorNoopLogger struct{} + +var _ telemetry.Logger = (*contactValidatorNoopLogger)(nil) + +func (logger *contactValidatorNoopLogger) Error(_ error) {} +func (logger *contactValidatorNoopLogger) WithService(_ string) telemetry.Logger { return logger } + +func (logger *contactValidatorNoopLogger) WithString(_, _ string) telemetry.Logger { return logger } + +func (logger *contactValidatorNoopLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { + return logger +} +func (logger *contactValidatorNoopLogger) Trace(_ string) {} +func (logger *contactValidatorNoopLogger) Info(_ string) {} +func (logger *contactValidatorNoopLogger) Warn(_ error) {} +func (logger *contactValidatorNoopLogger) Debug(_ string) {} +func (logger *contactValidatorNoopLogger) Fatal(_ error) {} +func (logger *contactValidatorNoopLogger) Printf(_ string, _ ...interface{}) {} diff --git a/api/pkg/validators/discord_handler_validator.go b/api/pkg/validators/discord_handler_validator.go index f9e486f45..69f4284ef 100644 --- a/api/pkg/validators/discord_handler_validator.go +++ b/api/pkg/validators/discord_handler_validator.go @@ -6,7 +6,7 @@ import ( "net/url" "github.com/NdoleStudio/httpsms/pkg/discord" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "github.com/NdoleStudio/httpsms/pkg/requests" @@ -91,12 +91,12 @@ func (validator *DiscordHandlerValidator) ValidateStore(ctx context.Context, req } if _, _, err := validator.client.Channel.Get(ctx, request.IncomingChannelID); err != nil { - ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch discord channel with ID [%s]", request.IncomingChannelID))) + ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch discord channel with ID [%s]", request.IncomingChannelID))) result.Add("incoming_channel_id", fmt.Sprintf("cannot fetch discord channel with ID [%s] make sure the bot has access to the channel", request.IncomingChannelID)) } if _, _, err := validator.client.Guild.Get(ctx, request.ServerID); err != nil { - ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch discord server with ID [%s]", request.ServerID))) + ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch discord server with ID [%s]", request.ServerID))) result.Add("server_id", fmt.Sprintf("cannot fetch discord server with ID [%s] make sure the bot has access to the server", request.ServerID)) } @@ -139,12 +139,12 @@ func (validator *DiscordHandlerValidator) ValidateUpdate(ctx context.Context, re } if _, _, err := validator.client.Channel.Get(ctx, request.IncomingChannelID); err != nil { - ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch discord channel with ID [%s]", request.IncomingChannelID))) + ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch discord channel with ID [%s]", request.IncomingChannelID))) result.Add("incoming_channel_id", fmt.Sprintf("cannot fetch discord channel with ID [%s] make sure the bot has access to the channel", request.IncomingChannelID)) } if _, _, err := validator.client.Guild.Get(ctx, request.ServerID); err != nil { - ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot fetch discord server with ID [%s]", request.ServerID))) + ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch discord server with ID [%s]", request.ServerID))) result.Add("server_id", fmt.Sprintf("cannot fetch discord server with ID [%s] make sure the bot has access to the server", request.ServerID)) } diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index f48109c80..f14575faf 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -11,7 +11,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/services" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "github.com/NdoleStudio/httpsms/pkg/entities" @@ -36,14 +36,12 @@ func NewMessageHandlerValidator( tracer telemetry.Tracer, phoneService *services.PhoneService, tokenValidator *TurnstileTokenValidator, - appCache cache.Cache, ) (v *MessageHandlerValidator) { return &MessageHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, phoneService: phoneService, tokenValidator: tokenValidator, - cache: appCache, } } @@ -179,7 +177,7 @@ func (validator MessageHandlerValidator) ValidateMessageSend(ctx context.Context } if err != nil { - ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not load phone for user [%s] and phone [%s]", userID, request.From))) + ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not load phone for user [%s] and phone [%s]", userID, request.From))) result.Add("from", fmt.Sprintf("could not validate 'from' number [%s], please try again later", request.From)) } @@ -229,7 +227,7 @@ func (validator MessageHandlerValidator) ValidateMessageBulkSend(ctx context.Con } if err != nil { - ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "could not load phone for user [%s] and phone [%s]", userID, request.From))) + ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not load phone for user [%s] and phone [%s]", userID, request.From))) result.Add("from", fmt.Sprintf("could not validate 'from' number [%s], please try again later", request.From)) } diff --git a/api/pkg/validators/message_thread_handler_validator.go b/api/pkg/validators/message_thread_handler_validator.go index a4e5cd647..d4f08002f 100644 --- a/api/pkg/validators/message_thread_handler_validator.go +++ b/api/pkg/validators/message_thread_handler_validator.go @@ -72,5 +72,13 @@ func (validator *MessageThreadHandlerValidator) ValidateUpdate(_ context.Context }, }) - return v.ValidateStruct() + errors := v.ValidateStruct() + if request.IsArchived == nil && request.UnreadCount == nil { + if errors == nil { + errors = url.Values{} + } + errors.Add("payload", "at least one of is_archived or unread_count is required") + } + + return errors } diff --git a/api/pkg/validators/message_thread_handler_validator_test.go b/api/pkg/validators/message_thread_handler_validator_test.go new file mode 100644 index 000000000..4f7453662 --- /dev/null +++ b/api/pkg/validators/message_thread_handler_validator_test.go @@ -0,0 +1,34 @@ +package validators + +import ( + "context" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestValidateUpdateRequiresAtLeastOneStatusField(t *testing.T) { + validator := &MessageThreadHandlerValidator{} + request := requests.MessageThreadUpdate{ + MessageThreadID: uuid.NewString(), + } + + errors := validator.ValidateUpdate(context.Background(), request) + + assert.NotEmpty(t, errors.Get("payload")) +} + +func TestValidateUpdateAcceptsUnreadCountOnlyUpdate(t *testing.T) { + validator := &MessageThreadHandlerValidator{} + unreadCount := uint(0) + request := requests.MessageThreadUpdate{ + MessageThreadID: uuid.NewString(), + UnreadCount: &unreadCount, + } + + errors := validator.ValidateUpdate(context.Background(), request) + + assert.Empty(t, errors) +} diff --git a/api/pkg/validators/phone_handler_validator.go b/api/pkg/validators/phone_handler_validator.go index e9d4274e7..9e21d26a9 100644 --- a/api/pkg/validators/phone_handler_validator.go +++ b/api/pkg/validators/phone_handler_validator.go @@ -103,6 +103,11 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(ctx context.Context, user return result } + validator.validateNotificationToken(request.FcmToken, result) + if len(result) > 0 { + return result + } + if strings.TrimSpace(request.MessageSendScheduleID) != "" { scheduleID, _ := uuid.Parse(strings.TrimSpace(request.MessageSendScheduleID)) if _, err := validator.scheduleService.Load(ctx, userID, scheduleID); err != nil { @@ -133,7 +138,29 @@ func (validator *PhoneHandlerValidator) ValidateFCMToken(_ context.Context, requ }, }) - return v.ValidateStruct() + result := v.ValidateStruct() + if len(result) > 0 { + return result + } + + validator.validateNotificationToken(request.FcmToken, result) + return result +} + +func (validator *PhoneHandlerValidator) validateNotificationToken( + token string, + result url.Values, +) { + token = strings.TrimSpace(token) + if token == "" { + return + } + + phone := &entities.Phone{FcmToken: &token} + _, err := phone.NotificationTransport() + if err != nil { + result.Add("fcm_token", err.Error()) + } } // ValidateDelete ValidateUpsert validates requests.PhoneDelete diff --git a/api/pkg/validators/phone_handler_validator_test.go b/api/pkg/validators/phone_handler_validator_test.go new file mode 100644 index 000000000..c9dc72b63 --- /dev/null +++ b/api/pkg/validators/phone_handler_validator_test.go @@ -0,0 +1,144 @@ +package validators + +import ( + "context" + "net/url" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/stretchr/testify/assert" +) + +func TestPhoneHandlerValidatorAcceptsHTTPSNotificationURL(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: "https://adapter.example.com/notify", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsHTTPSNotificationURLOnUpsert(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateUpsert(context.Background(), "", requests.PhoneUpsert{ + PhoneNumber: "+18005550199", + FcmToken: "https://adapter.example.com/notify", + SIM: entities.SIM1.String(), + MessageExpirationSeconds: 60, + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsPrivateAndLoopbackNotificationHosts(t *testing.T) { + validator := newPhoneHandlerValidator() + + for _, token := range []string{ + "https://localhost/notify", + "https://127.0.0.1/notify", + "https://10.0.0.5/notify", + } { + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors, token) + } +} + +func TestPhoneHandlerValidatorRejectsInvalidNotificationURLs(t *testing.T) { + tests := []struct { + name string + token string + }{ + {name: "insecure HTTP", token: "http://adapter.example.com/notify"}, + {name: "missing host", token: "https:///notify"}, + {name: "malformed HTTPS", token: "https://%"}, + } + + validationPaths := []struct { + name string + validate func(*PhoneHandlerValidator, string) map[string][]string + }{ + { + name: "FCM token upsert", + validate: func(validator *PhoneHandlerValidator, token string) map[string][]string { + return validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + }) + }, + }, + { + name: "phone upsert", + validate: func(validator *PhoneHandlerValidator, token string) map[string][]string { + return validator.ValidateUpsert(context.Background(), "", requests.PhoneUpsert{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + MessageExpirationSeconds: 60, + }) + }, + }, + } + + for _, validationPath := range validationPaths { + t.Run(validationPath.name, func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + validationErrors := validationPath.validate(newPhoneHandlerValidator(), test.token) + + assert.NotEmpty(t, validationErrors["fcm_token"]) + }) + } + }) + } +} + +func TestPhoneHandlerValidatorAcceptsOpaqueFirebaseNotificationToken(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: "opaque-firebase-registration-token", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsNotificationURLWithUserInformation(t *testing.T) { + validator := newPhoneHandlerValidator() + endpoint := &url.URL{ + Scheme: "https", + User: url.UserPassword("adapter-user", "adapter-password"), + Host: "adapter.example.com", + Path: "/notify", + } + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: endpoint.String(), + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func newPhoneHandlerValidator() *PhoneHandlerValidator { + logger := &contactValidatorNoopLogger{} + return NewPhoneHandlerValidator( + logger, + telemetry.NewOtelLogger("test", logger), + nil, + ) +} diff --git a/api/pkg/validators/turnstile_token_validator.go b/api/pkg/validators/turnstile_token_validator.go index 8a4d65c68..ac1a3d3e5 100644 --- a/api/pkg/validators/turnstile_token_validator.go +++ b/api/pkg/validators/turnstile_token_validator.go @@ -10,7 +10,7 @@ import ( "time" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" ) // TurnstileTokenValidator validates the token used to validate captchas from cloudflare @@ -55,27 +55,27 @@ func (v *TurnstileTokenValidator) ValidateToken(ctx context.Context, ipAddress, "remoteip": ipAddress, }) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "failed to marshal payload")) + ctxLogger.Error(stacktrace.Propagatef(err, "failed to marshal payload")) return false } request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", bytes.NewBuffer(payload)) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "failed to create http request request")) + ctxLogger.Error(stacktrace.Propagatef(err, "failed to create http request request")) return false } request.Header.Set("Content-Type", "application/json") response, err := v.httpClient.Do(request) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "failed to send http request to [%s]", request.URL.String())) + ctxLogger.Error(stacktrace.Propagatef(err, "failed to send http request to [%s]", request.URL.String())) return false } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "failed to read response body from cloudflare turnstile")) + ctxLogger.Error(stacktrace.Propagatef(err, "failed to read response body from cloudflare turnstile")) return false } @@ -83,7 +83,7 @@ func (v *TurnstileTokenValidator) ValidateToken(ctx context.Context, ipAddress, data := new(turnstileVerifyResponse) if err = json.Unmarshal(body, data); err != nil { - ctxLogger.Error(stacktrace.Propagate(err, "failed to unmarshal response from cloudflare turnstile")) + ctxLogger.Error(stacktrace.Propagatef(err, "failed to unmarshal response from cloudflare turnstile")) return false } diff --git a/api/pkg/validators/user_handler_validator.go b/api/pkg/validators/user_handler_validator.go index 31ec84023..42a6b91f9 100644 --- a/api/pkg/validators/user_handler_validator.go +++ b/api/pkg/validators/user_handler_validator.go @@ -9,7 +9,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/requests" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "github.com/thedevsaddam/govalidator" ) @@ -112,7 +112,7 @@ func (validator *UserHandlerValidator) ValidatePaymentInvoice(ctx context.Contex payments, err := validator.service.GetSubscriptionPayments(ctx, userID) if err != nil { - ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot get subscription payments for user with ID [%s]", userID))) + ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot get subscription payments for user with ID [%s]", userID))) validationErrors.Add("subscriptionInvoiceID", "failed to validate subscription payment invoice ID") return validationErrors } diff --git a/api/pkg/validators/webhook_handler_validator.go b/api/pkg/validators/webhook_handler_validator.go index 88c7c5ff5..f7b6c101e 100644 --- a/api/pkg/validators/webhook_handler_validator.go +++ b/api/pkg/validators/webhook_handler_validator.go @@ -8,7 +8,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/services" - "github.com/palantir/stacktrace" + "github.com/NdoleStudio/stacktrace" "github.com/NdoleStudio/httpsms/pkg/requests" diff --git a/docs/superpowers/plans/2026-05-26-nuxt4-vuetify4-migration.md b/docs/superpowers/plans/2026-05-26-nuxt4-vuetify4-migration.md deleted file mode 100644 index d5853283d..000000000 --- a/docs/superpowers/plans/2026-05-26-nuxt4-vuetify4-migration.md +++ /dev/null @@ -1,3300 +0,0 @@ -# Nuxt 4 + Vuetify 4 Migration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the httpSMS frontend from Nuxt 2 + Vue 2 + Vuetify 2 to Nuxt 4 + Vue 3 + Vuetify 4 with full TypeScript, Pinia state management, and ` - - - - -``` - -- [ ] **Step 2: Create website layout** - -```vue - - - - -``` - -- [ ] **Step 3: Create error layout** - -```vue - - - - -``` - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat(web): port layouts to Nuxt 4 with Vuetify 4" -``` - ---- - -## Task 13: Port Components — Toast, LoadingDashboard, LoadingButton, BackButton - -**Files:** -- Create: `web/app/components/Toast.vue` -- Create: `web/app/components/LoadingDashboard.vue` -- Create: `web/app/components/LoadingButton.vue` -- Create: `web/app/components/BackButton.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for v-snackbar, v-btn, v-progress-circular, v-icon, v-container, v-row, v-col before writing these. - -- [ ] **Step 1: Create Toast.vue** - -```vue - - - - -``` - -- [ ] **Step 2: Create LoadingDashboard.vue** - -```vue - - - - -``` - -- [ ] **Step 3: Create LoadingButton.vue** - -```vue - - - - -``` - -- [ ] **Step 4: Create BackButton.vue** - -```vue - - - - -``` - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(web): port Toast, LoadingDashboard, LoadingButton, BackButton components" -``` - ---- - -## Task 14: Port Components — CopyButton, FixedHeader, BlogAuthorBio, BlogInfo, NuxtLogo - -**Files:** -- Create: `web/app/components/CopyButton.vue` -- Create: `web/app/components/FixedHeader.vue` -- Create: `web/app/components/BlogAuthorBio.vue` -- Create: `web/app/components/BlogInfo.vue` -- Create: `web/app/components/NuxtLogo.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for each Vuetify component used. - -- [ ] **Step 1: Create CopyButton.vue** - -```vue - - - - -``` - -- [ ] **Step 2: Create FixedHeader.vue** - -```vue - - - - -``` - -- [ ] **Step 3: Create BlogAuthorBio.vue** - -```vue - - - - -``` - -- [ ] **Step 4: Create BlogInfo.vue** - -```vue - - - - -``` - -- [ ] **Step 5: Create NuxtLogo.vue (minimal placeholder)** - -```vue - - -``` - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat(web): port CopyButton, FixedHeader, BlogAuthorBio, BlogInfo, NuxtLogo" -``` - ---- - -## Task 15: Port Components — FirebaseAuth, MessageThread, MessageThreadHeader - -**Files:** -- Create: `web/app/components/FirebaseAuth.vue` -- Create: `web/app/components/MessageThread.vue` -- Create: `web/app/components/MessageThreadHeader.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for v-select, v-list, v-list-item, v-menu, v-tooltip, v-sheet, v-progress-linear before writing. - -- [ ] **Step 1: Create FirebaseAuth.vue** - -This component uses FirebaseUI which must be loaded client-side only. In Nuxt 4, wrap with `` or use `.client.vue` suffix. - -```vue - - - - -``` - -- [ ] **Step 2: Create MessageThread.vue** - -Port from the existing component. This is a larger component — read the full source from the backup branch and rewrite using ` - - -``` - -Note: The full MessageThread.vue content should be ported from the backup branch. The pattern above shows the migration approach. - -- [ ] **Step 3: Create MessageThreadHeader.vue** - -```vue - - - - -``` - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat(web): port FirebaseAuth, MessageThread, MessageThreadHeader components" -``` - ---- - -## Task 16: Port Pages — Login & Index (Homepage) - -**Files:** -- Create: `web/app/pages/login.vue` -- Create: `web/app/pages/index.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for all Vuetify components used. Port from the backup branch source, converting class-based syntax to `&"}`) - html := string(rich) - - assert.Contains(t, plain, ` -``` - -**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/docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md b/docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md deleted file mode 100644 index 312033520..000000000 --- a/docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md +++ /dev/null @@ -1,109 +0,0 @@ -# Affiliates Landing Page — Design - -**Date:** 2026-07-05 -**Component:** `web/` (Nuxt 4 SPA, Vuetify 4) - -## Goal - -Create a marketing landing page at `/affiliates` that convinces people to join the -httpSMS affiliate program, and replace the current external "Affiliates" footer link -(`https://httpsms.lemonsqueezy.com/affiliates`) with a `NuxtLink` to this new internal page. - -All primary calls-to-action link to the LemonSqueezy affiliate signup: -`https://affiliates.lemonsqueezy.com/programs/httpsms` (opens in a new tab). - -## Files - -- **New:** `web/app/pages/affiliates/index.vue` — the landing page. -- **Edit:** `web/app/layouts/website.vue` — change the footer "Affiliates" link from the - external `` to - ``, keeping the existing `mdiShieldStar` warning icon and - `text-white text-decoration-none footer-link` classes. - -## Conventions to follow - -- ` + + diff --git a/web/app/components/FirebaseAuth.vue b/web/app/components/FirebaseAuth.vue index 1c8e076af..5f783e07c 100644 --- a/web/app/components/FirebaseAuth.vue +++ b/web/app/components/FirebaseAuth.vue @@ -7,6 +7,8 @@ import { signInWithEmailAndPassword, createUserWithEmailAndPassword, sendPasswordResetEmail, + updateProfile, + deleteUser, } from 'firebase/auth' import { mdiGoogle, mdiGithub, mdiEmail } from '@mdi/js' import type { User as FirebaseUser } from 'firebase/auth' @@ -29,6 +31,7 @@ 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 generalError = ref('') @@ -71,6 +74,10 @@ function validateEmail(): boolean { 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 @@ -126,6 +133,18 @@ async function submitEmail() { 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, @@ -167,6 +186,11 @@ function backToSignIn() { showForgotPassword.value = false } +function toggleAuthMode() { + clearErrors() + isSignUp.value = !isSignUp.value +} + function onSuccess(user: FirebaseUser, method: LoginMethod) { try { localStorage.setItem(LAST_LOGIN_METHOD_KEY, method) @@ -405,11 +429,27 @@ function getGeneralErrorMessage( class="mt-4" @submit.prevent="submitEmail" > + {{ isSignUp ? 'Already have an account? Sign In' : 'No account? Sign Up' diff --git a/web/app/components/MessageThread.vue b/web/app/components/MessageThread.vue index 029599180..2a699408a 100644 --- a/web/app/components/MessageThread.vue +++ b/web/app/components/MessageThread.vue @@ -7,12 +7,17 @@ import { mdiAlert, mdiAccount, } from '@mdi/js' -import { formatPhoneNumber, startsWithLetter } from '~/utils/filters' +import type { EntitiesMessageThread } from '~~/shared/types/api' +import { + isUrlNotificationToken, + formatWhatsappText, +} from '~/utils/whatsapp-format' const threadsStore = useThreadsStore() const phonesStore = usePhonesStore() const appStore = useAppStore() const notificationsStore = useNotificationsStore() +const { formatPhoneNumber, startsWithLetter } = useFilters() function threadDate(date: string): string { return new Date(date).toLocaleString(undefined, { @@ -21,12 +26,56 @@ function threadDate(date: string): string { }) } +function hasUnreadMessages(unreadCount: number): boolean { + return unreadCount > 0 +} + +function unreadBadge( + unreadCount: number, +): false | { color: string; content: string; dot: false } { + if (unreadCount === 0) return false + + return { + color: 'primary', + content: unreadCount > 99 ? '99+' : String(unreadCount), + dot: false, + } +} + function onInstallApp() { notificationsStore.addNotification({ type: 'info', message: 'Downloading the httpSMS Android App', }) } + +function isWhatsappFormatted(thread: EntitiesMessageThread): boolean { + const phone = phonesStore.phones.find((x) => x.phone_number === thread.owner) + return isUrlNotificationToken(phone?.fcm_token) +} + +function threadPreviewHtml(thread: EntitiesMessageThread): string { + // Links are unwrapped because the preview sits inside a router link. + return formatWhatsappText(thread.last_message_content ?? '').replace( + /]*>(.*?)<\/a>/g, + '$1', + ) +} + +function threadContactName(thread: EntitiesMessageThread): string { + return thread.contact_details?.name?.trim() ?? '' +} + +function threadContactTitle(thread: EntitiesMessageThread): string { + return threadContactName(thread) || formatPhoneNumber(thread.contact) +} + +function threadAvatarInitial(thread: EntitiesMessageThread): string { + const contactName = threadContactName(thread) + if (contactName) return contactName.substring(0, 1) + + return startsWithLetter(thread.contact) ? thread.contact.substring(0, 1) : '' +}