diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dd2341311..d1a887310 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -116,6 +116,11 @@ The API uses a **DI container** (`pkg/di/container.go`) that lazily initializes - **HTTP client**: OkHttp with `x-api-key` authentication against the API. - **Encryption**: AES-256/CFB with SHA-256 key derivation (`Encrypter.kt`). +## Documentation & PR Workflow + +- **Superpowers docs (`docs/superpowers/`) are never committed to git** — the directory is gitignored. Do not remove it from `.gitignore` or add its files with `git add -f`. +- When a change is driven by a superpowers SPEC, include a **summarized version of the SPEC in the PR description** (goal, key decisions, scope) instead of the full doc, so reviewers/agents can understand the change without needing the uncommitted file. + ## Key Conventions ### API (Go) diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index f9b756ed6..7d3a8a4ea 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -30,6 +30,9 @@ jobs: bash tests/generate-firebase-credentials.sh tests/firebase-credentials.json echo "FIREBASE_CREDENTIALS=$(jq -c . tests/firebase-credentials.json)" >> $GITHUB_ENV + - name: Generate adapter certificates + run: bash tests/generate-adapter-certificates.sh tests/certs + - name: Start Services working-directory: ./tests run: docker compose up -d --build diff --git a/.gitignore b/.gitignore index 8f4caf4b5..cc68ad44b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ android/app/debug/ android/app/release/ tests/firebase-credentials.json +tests/certs/ tests/emulator/emulator.exe SECURITY_AUDIT_REPORT.md @@ -16,3 +17,5 @@ SECURITY_AUDIT_REPORT.md .output .agents/ skills-lock.json +.worktrees/ +docs/superpowers diff --git a/.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 21e88f544..42a6b0d36 100644 --- a/README.md +++ b/README.md @@ -64,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 @@ -96,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. @@ -245,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 8cd408cb1..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": { @@ -2048,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" ], @@ -2111,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" ], @@ -3401,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": [ @@ -3697,12 +4129,12 @@ const docTemplate = `{ "created_at", "id", "is_archived", - "is_read", "last_message_content", "last_message_id", "order_timestamp", "owner", "status", + "unread_count", "updated_at", "user_id" ], @@ -3715,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" @@ -3727,10 +4167,6 @@ const docTemplate = `{ "type": "boolean", "example": false }, - "is_read": { - "type": "boolean", - "example": true - }, "last_message_content": { "type": "string", "example": "This is a sample message content" @@ -3751,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" @@ -4080,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": [ @@ -4404,9 +4920,9 @@ const docTemplate = `{ "type": "boolean", "example": true }, - "is_read": { - "type": "boolean", - "example": true + "unread_count": { + "type": "integer", + "example": 0 } } }, @@ -4431,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....." }, @@ -4459,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....." }, @@ -4743,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": [ diff --git a/api/docs/swagger.json b/api/docs/swagger.json index ac9c12c98..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": { @@ -2045,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" ], @@ -2108,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" ], @@ -3398,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": [ @@ -3694,12 +4126,12 @@ "created_at", "id", "is_archived", - "is_read", "last_message_content", "last_message_id", "order_timestamp", "owner", "status", + "unread_count", "updated_at", "user_id" ], @@ -3712,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" @@ -3724,10 +4164,6 @@ "type": "boolean", "example": false }, - "is_read": { - "type": "boolean", - "example": true - }, "last_message_content": { "type": "string", "example": "This is a sample message content" @@ -3748,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" @@ -4077,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": [ @@ -4401,9 +4917,9 @@ "type": "boolean", "example": true }, - "is_read": { - "type": "boolean", - "example": true + "unread_count": { + "type": "integer", + "example": 0 } } }, @@ -4428,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....." }, @@ -4456,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....." }, @@ -4740,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": [ diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index ddc6ae700..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 @@ -319,9 +367,6 @@ definitions: is_archived: example: false type: boolean - is_read: - example: true - type: boolean last_message_content: example: This is a sample message content type: string @@ -337,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 @@ -349,12 +397,12 @@ definitions: - created_at - id - is_archived - - is_read - last_message_content - last_message_id - order_timestamp - owner - status + - unread_count - updated_at - user_id type: object @@ -605,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: @@ -857,9 +956,9 @@ definitions: is_archived: example: true type: boolean - is_read: - example: true - type: boolean + unread_count: + example: 0 + type: integer type: object requests.PhoneAPIKeyStoreRequest: properties: @@ -872,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: @@ -890,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: @@ -1109,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: @@ -1747,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: @@ -2106,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: @@ -2937,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 @@ -3017,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 6e3d5bdb0..1f305a110 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,21 +1,21 @@ module github.com/NdoleStudio/httpsms -go 1.25.8 +go 1.26.0 require ( cloud.google.com/go/cloudtasks v1.19.0 cloud.google.com/go/compute/metadata v0.9.0 cloud.google.com/go/storage v1.64.0 firebase.google.com/go v3.13.0+incompatible - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.35.0 + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.38.0 github.com/NdoleStudio/go-otelroundtripper v0.0.15 github.com/NdoleStudio/lemonsqueezy-go v1.3.2 github.com/NdoleStudio/plunk-go v0.0.2 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 @@ -23,9 +23,9 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/gertd/go-pluralize v0.2.1 github.com/go-hermes/hermes/v2 v2.6.2 - github.com/gofiber/contrib/v3/otel v1.2.2 + github.com/gofiber/contrib/v3/otel v1.2.5 github.com/gofiber/contrib/v3/swaggo v1.0.8 - github.com/gofiber/fiber/v3 v3.4.0 + 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 @@ -39,26 +39,26 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 github.com/pusher/pusher-http-go/v5 v5.1.1 - github.com/redis/go-redis/extra/redisotel/v9 v9.21.0 - github.com/redis/go-redis/v9 v9.21.0 + github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 + github.com/redis/go-redis/v9 v9.22.0 github.com/rs/zerolog v1.35.1 - github.com/stretchr/testify v1.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.8.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.22.0 - google.golang.org/api v0.287.1 - 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,19 +79,19 @@ 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/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.29.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.59.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 @@ -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.24.0 // indirect - github.com/go-openapi/jsonreference v0.21.6 // indirect - github.com/go-openapi/spec v0.22.6 // indirect - github.com/go-openapi/swag/conv v0.27.0 // indirect - github.com/go-openapi/swag/jsonname v0.27.0 // indirect - github.com/go-openapi/swag/jsonutils v0.27.0 // indirect - github.com/go-openapi/swag/loading v0.27.0 // indirect - github.com/go-openapi/swag/stringutils v0.27.0 // indirect - github.com/go-openapi/swag/typeutils v0.27.0 // indirect - github.com/go-openapi/swag/yamlutils v0.27.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/goccy/go-json v0.10.6 // indirect - github.com/gofiber/schema v1.8.2 // indirect - github.com/gofiber/utils/v2 v2.2.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.23.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.19.0 // 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.72.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.54.0 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.57.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.47.0 // indirect - golang.org/x/text v0.40.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.47.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-20260630182238-925bb5da69e7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect - google.golang.org/grpc v1.82.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 bea96100b..809da4525 100644 --- a/api/go.sum +++ b/api/go.sum @@ -4,24 +4,24 @@ 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.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/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.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/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= @@ -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.59.0 h1:c/Ivw7FuawPLfrr+zB0LZKeCchO2cAHQpF2qZ6OV7rQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0/go.mod h1:Zba7lknY/d78oxbKqFTmCsaGwfpzeJ3ktrrLXtnTV6g= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.35.0 h1:PaC2zW6tfeh9rQydOQq6Pff4xpWwUDMCMolQXiypq0o= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.35.0/go.mod h1:3zRs1e/YFBfj7yv/2HFUlH1EKaP0priee5IqmYUHMP4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0 h1:xTXsqDOj5k9mK3VVWHYUryryJCIdYfXxdjKFwpzINUw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 h1:18FRm6ZcN/x9+ZmhMr96hLcTtlLn2/gHPuDLVeg7XcY= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.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= @@ -78,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= @@ -120,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= @@ -133,33 +135,33 @@ 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.24.0 h1:AA6mCjHYHmZ+1RU2Js089EaOK/iwXXNwQsTgnsTha2M= -github.com/go-openapi/jsonpointer v0.24.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= -github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= -github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= -github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= -github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= -github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE= -github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk= -github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE= -github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0 h1:+d7C7Ur/SsGg/UZ9G0JEovnfRqtMNZCJQGKc2h/ojoE= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= -github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI= -github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4= -github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio= -github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= -github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= -github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM= -github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= @@ -168,16 +170,16 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/gofiber/contrib/v3/otel v1.2.2 h1:DQ0S5dIDb5UsnUT2HI5rXAwzy/pIiOMZkO6izQr8Fok= -github.com/gofiber/contrib/v3/otel v1.2.2/go.mod h1:icVCf1XJoqCuY7Fq/EAbaTk30ngA1Fc9r4zq/P2FBvk= +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.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw= -github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk= -github.com/gofiber/schema v1.8.2 h1:wq+LO2xEGlsqma/8Akp9PUebQ6vcsYmF0xYQ4F2ijvU= -github.com/gofiber/schema v1.8.2/go.mod h1:iyAMJztdyky7Pk2U7bwUP3EHjzMqUNjZ/hglE0nYO/g= -github.com/gofiber/utils/v2 v2.2.0 h1:YSSmCzQponq/f9uSOg2HtXC5qK1Dmor0o6DqaQVz8GE= -github.com/gofiber/utils/v2 v2.2.0/go.mod h1:Ieopk6sQh7rbhQ12aBNCJtJuG0gxAg0nz63sFCrrOmE= +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= @@ -199,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.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= -github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= +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= @@ -243,8 +245,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI= github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I= -github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= -github.com/klauspost/compress v1.19.0/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= @@ -255,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= @@ -270,6 +272,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/molecule-man/go-brrr v1.0.1 h1:cEjgx8hgNw6UGdhQ94SPDbPkKuRbkUcxBO3IzbGpA/o= +github.com/molecule-man/go-brrr v1.0.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/nyaruka/phonenumbers v1.8.1 h1:2K9YMQuv1dCGqjjzB1DwmdCe89khT4KPBQb2CxAMMlU= github.com/nyaruka/phonenumbers v1.8.1/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= @@ -297,12 +301,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pusher/pusher-http-go/v5 v5.1.1 h1:ZLUGdLA8yXMvByafIkS47nvuXOHrYmlh4bsQvuZnYVQ= github.com/pusher/pusher-http-go/v5 v5.1.1/go.mod h1:Ibji4SGoUDtOy7CVRhCiEpgy+n5Xv6hSL/QqYOhmWW8= -github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0 h1:jsV3tyMeJrEoc2f3EhNf7qoBW3NEZW7l/4ziT3M+OJI= -github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0/go.mod h1:e5t17bY9cEpVV+xw2U7jsPOKkXBtL5IQmNVABShnHUk= -github.com/redis/go-redis/extra/redisotel/v9 v9.21.0 h1:36qq3rbF2If2CP0zGHHF8o/4XDluErn6DD0c9/L2iNI= -github.com/redis/go-redis/extra/redisotel/v9 v9.21.0/go.mod h1:7y2cVB/LXXLHqHOO2jCVzBqimIQk1w7Rp9WSpyVY/o8= -github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= -github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 h1:MQPzEEnpD0BMPufBLABnMYLJVwM7xi7vZ+srO8Nr0s8= +github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0/go.mod h1:eve0JFcLRwFVj3RA85rrrV5+UJ+K9LDyU7kf2UdSueM= +github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 h1:t5ul1Gl0o1rYQj5f5bK12G9xcg1niq2ON4yZFjvy1kA= +github.com/redis/go-redis/extra/redisotel/v9 v9.22.0/go.mod h1:hcS9L2RBBjYXkrfSOF26ZGejgo+yOC+28ZD2fkk3sGs= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= @@ -313,8 +317,8 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8= -github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo= +github.com/shamaton/msgpack/v3 v3.2.2 h1:lZITRsXLQkdJ9J2qvh6gjGpWxci/dwuiUf+jm9AudJM= +github.com/shamaton/msgpack/v3 v3.2.2/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -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.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M= -github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk= +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.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= -go.mongodb.org/mongo-driver/v2 v2.8.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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +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.1 h1:LiyJx32VU3cwQfLchn/513qKhc25hq0pEANYJoWNnnI= -google.golang.org/api v0.287.1/go.mod h1:lM2kYRzYUCBY91P9h6VF1PYmvhxii3O5hji37qRvIcY= +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-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= -google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +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/di/container.go b/api/pkg/di/container.go index ad9a18858..8d9e778d4 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -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() @@ -414,6 +419,10 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK ( 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 } @@ -556,6 +565,24 @@ func (container *Container) FCMClient() services.FCMClient { return services.NewFirebaseFCMClient(messagingClient) } +// NotificationHTTPClient creates the OpenTelemetry-instrumented client for phone notification adapters. +func (container *Container) NotificationHTTPClient() *http.Client { + return &http.Client{ + Transport: container.HTTPRoundTripperWithoutRetry("phone_notification_http"), + } +} + +// PhoneNotificationClients creates notification clients keyed by phone transport. +func (container *Container) PhoneNotificationClients() map[entities.NotificationTransport]services.FCMClient { + return map[entities.NotificationTransport]services.FCMClient{ + entities.NotificationTransportFCM: container.FCMClient(), + entities.NotificationTransportHTTP: services.NewHTTPNotificationSender( + container.Logger(), + container.NotificationHTTPClient(), + ), + } +} + // FirebaseCredentials returns firebase credentials as bytes. func (container *Container) FirebaseCredentials() []byte { container.logger.Debug("creating firebase credentials") @@ -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)) @@ -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{})) @@ -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{})) @@ -1773,6 +1875,24 @@ func (container *Container) PhoneRistrettoCache() *ristretto.Cache[string, *enti 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 { diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go new file mode 100644 index 000000000..611708d31 --- /dev/null +++ b/api/pkg/di/container_test.go @@ -0,0 +1,36 @@ +package di + +import ( + "reflect" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNotificationHTTPClientUsesOTelRoundTripperWithoutRetries(t *testing.T) { + t.Setenv("ENV", "local") + client := NewLiteContainer().NotificationHTTPClient() + + assert.Zero(t, client.Timeout) + assert.Equal(t, "*otelroundtripper.otelRoundTripper", reflect.TypeOf(client.Transport).String()) + assert.Nil(t, client.CheckRedirect) +} + +func TestPhoneNotificationClientsMapsConfiguredTransports(t *testing.T) { + t.Setenv("ENV", "local") + t.Setenv("FCM_ENDPOINT", "http://localhost") + + clients := NewLiteContainer().PhoneNotificationClients() + + require.Len(t, clients, 2) + assert.IsType(t, &services.EmulatorFCMClient{}, clients[entities.NotificationTransportFCM]) + httpSender, ok := clients[entities.NotificationTransportHTTP].(*services.HTTPNotificationSender) + require.True(t, ok) + client := reflect.ValueOf(httpSender).Elem().FieldByName("client").Elem() + transport := client.FieldByName("Transport").Elem() + + assert.Equal(t, "*otelroundtripper.otelRoundTripper", transport.Type().String()) +} diff --git a/api/pkg/entities/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 3766acc77..e8f1f687f 100644 --- a/api/pkg/entities/message_thread.go +++ b/api/pkg/entities/message_thread.go @@ -9,12 +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"` - IsRead bool `json:"is_read" gorm:"not null;default:true" example:"true"` - LastReadAt time.Time `json:"-" gorm:"not null;default:CURRENT_TIMESTAMP"` - 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"` @@ -22,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 index 1409dff29..47eaf2778 100644 --- a/api/pkg/entities/message_thread_test.go +++ b/api/pkg/entities/message_thread_test.go @@ -1,6 +1,7 @@ package entities import ( + "encoding/json" "reflect" "testing" @@ -8,18 +9,44 @@ import ( "github.com/stretchr/testify/require" ) -func TestMessageThreadReadFieldsHaveBackwardCompatibleDefaults(t *testing.T) { +func TestMessageThreadUnreadCountHasDefault(t *testing.T) { threadType := reflect.TypeOf(MessageThread{}) - isRead, ok := threadType.FieldByName("IsRead") + unreadCount, ok := threadType.FieldByName("UnreadCount") require.True(t, ok) - assert.Contains(t, isRead.Tag.Get("gorm"), "not null") - assert.Contains(t, isRead.Tag.Get("gorm"), "default:true") - assert.Equal(t, "is_read", isRead.Tag.Get("json")) + 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) + } +} - lastReadAt, ok := threadType.FieldByName("LastReadAt") +func TestMessageThreadContactDetailsAreTransientAndOmittedWhenNil(t *testing.T) { + threadType := reflect.TypeOf(MessageThread{}) + + contactDetails, ok := threadType.FieldByName("ContactDetails") require.True(t, ok) - assert.Contains(t, lastReadAt.Tag.Get("gorm"), "not null") - assert.Contains(t, lastReadAt.Tag.Get("gorm"), "default:CURRENT_TIMESTAMP") - assert.Equal(t, "-", lastReadAt.Tag.Get("json")) + 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/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/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 660bdeed4..20554b049 100644 --- a/api/pkg/handlers/heartbeat_handler.go +++ b/api/pkg/handlers/heartbeat_handler.go @@ -85,7 +85,7 @@ func (h *HeartbeatHandler) Index(c fiber.Ctx) error { heartbeats, err := h.service.Index(ctx, h.userIDFomContext(c), request.Owner, request.ToIndexParams()) if err != nil { - ctxLogger.Error(stacktrace.Propagatef(err, "cannot get messgaes with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot get heartbeats with params [%+#v]", request)) return h.responseInternalServerError(c) } diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index a931302dc..72e0b543d 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -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 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 index fefb81895..a304f4ad3 100644 --- a/api/pkg/handlers/message_thread_handler_test.go +++ b/api/pkg/handlers/message_thread_handler_test.go @@ -64,7 +64,7 @@ func (stub *messageThreadHandlerRepositoryStub) DeleteAllForUser(context.Context func TestMessageThreadHandlerUpdate_ReturnsNotFoundWhenThreadIsMissing(t *testing.T) { logger := &messageThreadHandlerNoopLogger{} tracer := telemetry.NewOtelLogger("test", logger) - service := services.NewMessageThreadService(logger, tracer, &messageThreadHandlerRepositoryStub{}, nil, nil) + service := services.NewMessageThreadService(logger, tracer, &messageThreadHandlerRepositoryStub{}, nil, nil, nil) handler := NewMessageThreadHandler(logger, tracer, validators.NewMessageThreadHandlerValidator(logger, tracer), service) app := fiber.New() @@ -75,7 +75,7 @@ func TestMessageThreadHandlerUpdate_ReturnsNotFoundWhenThreadIsMissing(t *testin handler.RegisterRoutes(app) messageThreadID := uuid.New() - req := httptest.NewRequest(http.MethodPut, "/v1/message-threads/"+messageThreadID.String(), bytes.NewBufferString(`{"is_read":true}`)) + 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}) diff --git a/api/pkg/handlers/phone_handler.go b/api/pkg/handlers/phone_handler.go index 55380ed91..c81ef4ddd 100644 --- a/api/pkg/handlers/phone_handler.go +++ b/api/pkg/handlers/phone_handler.go @@ -94,7 +94,7 @@ func (h *PhoneHandler) Index(c fiber.Ctx) error { // Upsert a phone // @Summary Upsert Phone -// @Description Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' +// @Description Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups. // @Security ApiKeyAuth // @Tags Phones // @Accept json @@ -172,7 +172,7 @@ func (h *PhoneHandler) Delete(c fiber.Ctx) error { // UpsertFCMToken upserts the FCM token of a phone // @Summary Upserts the FCM token of a phone -// @Description Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' +// @Description Updates the FCM token or adapter callback URL of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups. // @Security ApiKeyAuth // @Tags Phones // @Accept json diff --git a/api/pkg/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/message_thread_listener_test.go b/api/pkg/listeners/message_thread_listener_test.go index 39a444cce..f0410dfaa 100644 --- a/api/pkg/listeners/message_thread_listener_test.go +++ b/api/pkg/listeners/message_thread_listener_test.go @@ -66,7 +66,7 @@ func newMessageThreadListenerForTest() (*listenerMessageThreadRepository, map[st repository := &listenerMessageThreadRepository{} logger := &noopListenerLogger{} tracer := telemetry.NewOtelLogger("test", logger) - service := services.NewMessageThreadService(logger, tracer, repository, nil, nil) + service := services.NewMessageThreadService(logger, tracer, repository, nil, nil, nil) _, routes := NewMessageThreadListener(logger, tracer, service) return repository, routes } diff --git a/api/pkg/middlewares/http_request_logger_middleware.go b/api/pkg/middlewares/http_request_logger_middleware.go index e8de42e2a..cd11c6009 100644 --- a/api/pkg/middlewares/http_request_logger_middleware.go +++ b/api/pkg/middlewares/http_request_logger_middleware.go @@ -24,7 +24,7 @@ func HTTPRequestLogger(tracer telemetry.Tracer, logger telemetry.Logger) fiber.H statusCode := c.Response().StatusCode() span.AddEvent(fmt.Sprintf("finished handling request with traceID: [%s], statusCode: [%d]", span.SpanContext().TraceID().String(), statusCode)) if statusCode >= 300 && len(c.Request().Body()) > 0 && !slices.Contains([]int{401, 402}, statusCode) { - ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewErrorf("http.status [%d], body [%s]", statusCode, string(c.Request().Body()))) + ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewErrorf("http.status [%d], body [%s]", statusCode, c.Request().Body())) } return response diff --git a/api/pkg/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/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_message_thread_repository.go b/api/pkg/repositories/gorm_message_thread_repository.go index 092418040..c30091b9b 100644 --- a/api/pkg/repositories/gorm_message_thread_repository.go +++ b/api/pkg/repositories/gorm_message_thread_repository.go @@ -46,11 +46,7 @@ func messageThreadActivityUpdates(params MessageThreadActivityUpdate) map[string updates["is_archived"] = false } if params.MarkAsUnread { - updates["is_read"] = gorm.Expr( - "CASE WHEN last_read_at < ? THEN ? ELSE is_read END", - params.EventTimestamp, - false, - ) + updates["unread_count"] = gorm.Expr("unread_count + ?", 1) } return updates } @@ -68,11 +64,8 @@ func messageThreadStatusUpdates(params MessageThreadStatusUpdate) map[string]any if params.IsArchived != nil { updates["is_archived"] = *params.IsArchived } - if params.IsRead != nil { - updates["is_read"] = *params.IsRead - if *params.IsRead { - updates["last_read_at"] = params.ReadAt - } + if params.UnreadCount != nil { + updates["unread_count"] = *params.UnreadCount } return updates } @@ -123,27 +116,24 @@ func (repository *gormMessageThreadRepository) Store(ctx context.Context, thread ctx, span := repository.tracer.Start(ctx) defer span.End() - isRead := thread.IsRead - err := repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(thread) - thread.IsRead = isRead - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 || isRead { - return nil - } - - return tx.Model(&entities.MessageThread{}). - Where("user_id = ?", thread.UserID). - Where("id = ?", thread.ID). - UpdateColumn("is_read", false). - Error - }) - if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(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 } diff --git a/api/pkg/repositories/gorm_message_thread_repository_test.go b/api/pkg/repositories/gorm_message_thread_repository_test.go index 31dc43ed5..5ca7d4a3f 100644 --- a/api/pkg/repositories/gorm_message_thread_repository_test.go +++ b/api/pkg/repositories/gorm_message_thread_repository_test.go @@ -75,7 +75,7 @@ func (logger *messageThreadTestLogger) Debug(string) func (logger *messageThreadTestLogger) Fatal(error) {} func (logger *messageThreadTestLogger) Printf(string, ...interface{}) {} -func TestMessageThreadStorePreservesExplicitUnreadState(t *testing.T) { +func TestMessageThreadStoreIncrementsUnreadCountOnConflict(t *testing.T) { pool := &messageThreadTestConnPool{} db, err := gorm.Open( postgres.New(postgres.Config{ @@ -89,18 +89,17 @@ func TestMessageThreadStorePreservesExplicitUnreadState(t *testing.T) { logger := &messageThreadTestLogger{} repository := NewGormMessageThreadRepository(logger, telemetry.NewOtelLogger("test", logger), db) thread := &entities.MessageThread{ - ID: uuid.New(), - IsRead: false, + ID: uuid.New(), + UnreadCount: 1, } require.NoError(t, repository.Store(context.Background(), thread)) - assert.False(t, thread.IsRead) + assert.Equal(t, uint(1), thread.UnreadCount) require.NotEmpty(t, pool.statements) - update := pool.statements[len(pool.statements)-1] - assert.True(t, strings.HasPrefix(update.query, `UPDATE "message_threads"`)) - assert.Contains(t, update.query, `"is_read"=$1`) - assert.Contains(t, update.args, false) + 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) { @@ -118,9 +117,8 @@ func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) { "last_message_content": "hello", "status": entities.MessageStatus(entities.MessageStatusReceived), }, updates) - assert.NotContains(t, updates, "is_read") + assert.NotContains(t, updates, "unread_count") assert.NotContains(t, updates, "is_archived") - assert.NotContains(t, updates, "last_read_at") } func TestUpdateActivityMarksUnreadWithOneQuery(t *testing.T) { @@ -156,7 +154,7 @@ func TestUpdateActivityMarksUnreadWithOneQuery(t *testing.T) { } } require.Len(t, updates, 1) - assert.Contains(t, updates[0].query, `"is_read"=CASE WHEN last_read_at <`) + assert.Contains(t, updates[0].query, `"unread_count"=unread_count + $`) } func TestMessageThreadDeletedUpdatesPreserveStatusType(t *testing.T) { @@ -175,19 +173,14 @@ func TestMessageThreadDeletedUpdatesPreserveStatusType(t *testing.T) { }, updates) } -func TestMessageThreadStatusUpdatesReadOnly(t *testing.T) { - isRead := true - readAt := time.Date(2026, 7, 18, 7, 1, 0, 0, time.UTC) +func TestMessageThreadStatusUpdatesUnreadCountOnly(t *testing.T) { + unreadCount := uint(0) updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ - IsRead: &isRead, - ReadAt: readAt, + UnreadCount: &unreadCount, }) - assert.Equal(t, map[string]any{ - "is_read": true, - "last_read_at": readAt, - }, updates) + assert.Equal(t, map[string]any{"unread_count": uint(0)}, updates) assert.NotContains(t, updates, "is_archived") } @@ -197,8 +190,6 @@ func TestMessageThreadStatusUpdatesArchiveOnly(t *testing.T) { updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ IsArchived: &isArchived, }) - assert.Equal(t, map[string]any{"is_archived": true}, updates) - assert.NotContains(t, updates, "is_read") - assert.NotContains(t, updates, "last_read_at") + assert.NotContains(t, updates, "unread_count") } diff --git a/api/pkg/repositories/message_thread_repository.go b/api/pkg/repositories/message_thread_repository.go index e60931419..28efc11ee 100644 --- a/api/pkg/repositories/message_thread_repository.go +++ b/api/pkg/repositories/message_thread_repository.go @@ -23,9 +23,8 @@ type MessageThreadActivityUpdate struct { } type MessageThreadStatusUpdate struct { - IsArchived *bool - IsRead *bool - ReadAt time.Time + IsArchived *bool + UnreadCount *uint } type MessageThreadDeletedUpdate struct { 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/mongodb.go b/api/pkg/repositories/mongodb.go index ebefc0f8b..44f35bd4b 100644 --- a/api/pkg/repositories/mongodb.go +++ b/api/pkg/repositories/mongodb.go @@ -17,6 +17,7 @@ import ( const ( collectionHeartbeats = "heartbeats" collectionHeartbeatMonitors = "heartbeat_monitors" + collectionContacts = "contacts" ) // uuidEncodeValue encodes uuid.UUID as a BSON string @@ -122,5 +123,32 @@ func createMongoIndexes(ctx context.Context, db *mongo.Database) error { 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/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 3309fc91d..0832ad819 100644 --- a/api/pkg/requests/message_thread_update_request.go +++ b/api/pkg/requests/message_thread_update_request.go @@ -10,8 +10,8 @@ import ( // MessageThreadUpdate is the payload for updating a message thread type MessageThreadUpdate struct { request - IsArchived *bool `json:"is_archived,omitempty" example:"true"` - IsRead *bool `json:"is_read,omitempty" 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 } @@ -22,6 +22,6 @@ func (input *MessageThreadUpdate) ToUpdateParams(userID entities.UserID) service UserID: userID, MessageThreadID: uuid.MustParse(input.MessageThreadID), IsArchived: input.IsArchived, - IsRead: input.IsRead, + 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 index 9f9579fda..58ba7e4f7 100644 --- a/api/pkg/requests/message_thread_update_request_test.go +++ b/api/pkg/requests/message_thread_update_request_test.go @@ -10,10 +10,10 @@ import ( func TestMessageThreadUpdateToUpdateParamsPreservesOptionalFields(t *testing.T) { threadID := uuid.New() - isRead := true + unreadCount := uint(0) input := MessageThreadUpdate{ MessageThreadID: threadID.String(), - IsRead: &isRead, + UnreadCount: &unreadCount, } params := input.ToUpdateParams(entities.UserID("user-id")) @@ -21,5 +21,5 @@ func TestMessageThreadUpdateToUpdateParamsPreservesOptionalFields(t *testing.T) assert.Equal(t, threadID, params.MessageThreadID) assert.Equal(t, entities.UserID("user-id"), params.UserID) assert.Nil(t, params.IsArchived) - assert.Same(t, &isRead, params.IsRead) + 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/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/emulator_fcm_client.go b/api/pkg/services/emulator_fcm_client.go index 6b890a14a..09dce8e54 100644 --- a/api/pkg/services/emulator_fcm_client.go +++ b/api/pkg/services/emulator_fcm_client.go @@ -11,6 +11,7 @@ import ( "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" ) // EmulatorFCMClient sends FCM messages to the phone emulator via HTTP. @@ -50,7 +51,7 @@ type emulatorFCMResponse struct { } // Send sends a message to the emulator's FCM endpoint. -func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { +func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message, _ uuid.UUID) (string, error) { payload := &emulatorFCMRequest{ Message: &emulatorFCMMessage{ Token: message.Token, diff --git a/api/pkg/services/entitlement_service.go b/api/pkg/services/entitlement_service.go index 96a5af144..01ddf4411 100644 --- a/api/pkg/services/entitlement_service.go +++ b/api/pkg/services/entitlement_service.go @@ -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 } @@ -83,9 +95,13 @@ func (service *EntitlementService) Check( ) } - 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() @@ -96,11 +112,11 @@ func (service *EntitlementService) Check( ) } - 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/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/heartbeat_service.go b/api/pkg/services/heartbeat_service.go index 7441d2aa9..d6c442347 100644 --- a/api/pkg/services/heartbeat_service.go +++ b/api/pkg/services/heartbeat_service.go @@ -78,7 +78,7 @@ func (service *HeartbeatService) Index(ctx context.Context, userID entities.User 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 } 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/message_thread_service.go b/api/pkg/services/message_thread_service.go index 532a223e2..efcfadd17 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -15,6 +15,10 @@ import ( "github.com/google/uuid" ) +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, } } @@ -136,7 +143,7 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me // MessageThreadStatusParams are parameters for updating a thread status type MessageThreadStatusParams struct { IsArchived *bool - IsRead *bool + UnreadCount *uint UserID entities.UserID MessageThreadID uuid.UUID } @@ -147,9 +154,8 @@ func (service *MessageThreadService) UpdateStatus(ctx context.Context, params Me defer span.End() update := repositories.MessageThreadStatusUpdate{ - IsArchived: params.IsArchived, - IsRead: params.IsRead, - ReadAt: time.Now().UTC(), + IsArchived: params.IsArchived, + UnreadCount: params.UnreadCount, } thread, err := service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update) if err != nil { @@ -200,10 +206,13 @@ 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{ @@ -212,8 +221,7 @@ func (service *MessageThreadService) createThread(ctx context.Context, params Me Contact: params.Contact, UserID: params.UserID, IsArchived: false, - IsRead: !params.MarkAsUnread, - LastReadAt: now, + UnreadCount: unreadCount, Color: service.getColor(), LastMessageContent: ¶ms.Content, Status: params.Status, @@ -265,23 +273,40 @@ 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.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)) return threads, nil } 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 8cc8bdcbe..1ef9b4bde 100644 --- a/api/pkg/services/message_thread_service_test.go +++ b/api/pkg/services/message_thread_service_test.go @@ -72,7 +72,7 @@ func (stub *messageThreadRepositoryStub) DeleteAllForUser(context.Context, entit func newMessageThreadServiceForTest(repository repositories.MessageThreadRepository) *MessageThreadService { logger := &noopLogger{} tracer := telemetry.NewOtelLogger("test", logger) - return NewMessageThreadService(logger, tracer, repository, nil, nil) + return NewMessageThreadService(logger, tracer, repository, nil, nil, nil) } func TestUpdateThreadPassesUnreadWatermarkForInboundActivity(t *testing.T) { @@ -107,11 +107,11 @@ func TestUpdateThreadPassesUnreadWatermarkForInboundActivity(t *testing.T) { assert.Equal(t, eventTimestamp, captured.EventTimestamp) } -func TestUpdateThreadPreservesReadStateForOutboundActivity(t *testing.T) { +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(), IsRead: false}, nil + return &entities.MessageThread{ID: uuid.New(), UnreadCount: 2}, nil }, updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { captured = params @@ -134,14 +134,14 @@ func TestUpdateThreadPreservesReadStateForOutboundActivity(t *testing.T) { assert.False(t, captured.MarkAsUnread) } -func TestCreateThreadSetsReadStateFromActivityDirection(t *testing.T) { +func TestCreateThreadSetsUnreadCountFromActivityDirection(t *testing.T) { tests := []struct { name string marksUnread bool - wantRead bool + wantUnread uint }{ - {name: "inbound", marksUnread: true, wantRead: false}, - {name: "outbound", marksUnread: false, wantRead: true}, + {name: "inbound", marksUnread: true, wantUnread: 1}, + {name: "outbound", marksUnread: false, wantUnread: 0}, } for _, test := range tests { @@ -171,20 +171,19 @@ func TestCreateThreadSetsReadStateFromActivityDirection(t *testing.T) { require.NoError(t, err) require.NotNil(t, stored) - assert.Equal(t, test.wantRead, stored.IsRead) - assert.False(t, stored.LastReadAt.IsZero()) + assert.Equal(t, test.wantUnread, stored.UnreadCount) }) } } func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) { threadID := uuid.New() - isRead := false + 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, IsRead: false}, nil + return &entities.MessageThread{ID: threadID, IsArchived: true, UnreadCount: 0}, nil }, } @@ -192,15 +191,14 @@ func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) { thread, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ UserID: entities.UserID("user-id"), MessageThreadID: threadID, - IsRead: &isRead, + UnreadCount: &unreadCount, }) require.NoError(t, err) assert.Nil(t, captured.IsArchived) - assert.Same(t, &isRead, captured.IsRead) - assert.False(t, captured.ReadAt.IsZero()) + assert.Same(t, &unreadCount, captured.UnreadCount) assert.True(t, thread.IsArchived) - assert.False(t, thread.IsRead) + assert.Zero(t, thread.UnreadCount) } func TestUpdateStatusPreservesNotFoundCode(t *testing.T) { @@ -211,11 +209,11 @@ func TestUpdateStatusPreservesNotFoundCode(t *testing.T) { } service := newMessageThreadServiceForTest(repository) - isRead := true + unreadCount := uint(0) _, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ UserID: entities.UserID("user-id"), MessageThreadID: uuid.New(), - IsRead: &isRead, + UnreadCount: &unreadCount, }) assert.Equal(t, repositories.ErrCodeNotFound, stacktrace.GetCode(err)) diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 0d4d8d209..2c63fcd7c 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -4,12 +4,13 @@ import ( "context" "errors" "fmt" + "strings" "time" + "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/events" cloudevents "github.com/cloudevents/sdk-go/v2" - "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -18,7 +19,7 @@ import ( "go.opentelemetry.io/otel/trace" ) -// PhoneNotificationService sends out notifications to mobile phones +// PhoneNotificationService sends wake-up notifications to phone gateways. type PhoneNotificationService struct { service logger telemetry.Logger @@ -26,7 +27,7 @@ type PhoneNotificationService struct { phoneNotificationRepository repositories.PhoneNotificationRepository phoneRepository repositories.PhoneRepository messageSendScheduleRepository repositories.MessageSendScheduleRepository - messagingClient FCMClient + phoneNotificationClients map[entities.NotificationTransport]FCMClient eventDispatcher *EventDispatcher } @@ -34,20 +35,20 @@ type PhoneNotificationService struct { func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - messagingClient FCMClient, + phoneNotificationClients map[entities.NotificationTransport]FCMClient, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, - dispatcher *EventDispatcher, + eventDispatcher *EventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), tracer: tracer, - messagingClient: messagingClient, + phoneNotificationClients: phoneNotificationClients, phoneNotificationRepository: phoneNotificationRepository, phoneRepository: phoneRepository, messageSendScheduleRepository: messageSendScheduleRepository, - eventDispatcher: dispatcher, + eventDispatcher: eventDispatcher, } } @@ -77,7 +78,7 @@ func (service *PhoneNotificationService) DeleteByMessageID(ctx context.Context, return nil } -// SendHeartbeatFCM sends a heartbeat message so the phone can request a heartbeat +// SendHeartbeatFCM sends a heartbeat notification so the phone gateway can request a heartbeat. func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, payload *events.PhoneHeartbeatMissedPayload) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -88,25 +89,27 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p } if phone.FcmToken == nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "phone with id [%s] has no FCM token", phone.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("phone with id [%s] has no notification token", phone.ID)) } - result, err := service.messagingClient.Send(ctx, &messaging.Message{ + result, _, err := service.sendPhoneNotification(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), }, - Android: &messaging.AndroidConfig{ - Priority: "high", - }, - Token: *phone.FcmToken, + Android: &messaging.AndroidConfig{Priority: "high"}, }) if err != nil { - ctxLogger.Warn(stacktrace.Propagatef(err, "cannot send heartbeat FCM to phone with id [%s] for user [%s]", phone.ID, phone.UserID)) + ctxLogger.Warn(stacktrace.Propagatef( + err, + "cannot send heartbeat notification to phone with id [%s] for user [%s]", + phone.ID, + phone.UserID, + )) return nil } ctxLogger.Info(fmt.Sprintf( - "successfully sent heartbeat FCM [%s] to phone with ID [%s] for user [%s] and monitor [%s]", + "successfully sent heartbeat notification [%s] to phone with ID [%s] for user [%s] and monitor [%s]", result, payload.PhoneID, payload.UserID, @@ -125,7 +128,7 @@ type PhoneNotificationSendParams struct { MessageID uuid.UUID } -// Send sends a message when a message is sent +// Send sends a phone gateway notification when a message is sent. func (service *PhoneNotificationService) Send(ctx context.Context, params *PhoneNotificationSendParams) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -142,7 +145,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone } ttl := phone.MessageExpirationDuration() - result, err := service.messagingClient.Send(ctx, &messaging.Message{ + result, transport, err := service.sendPhoneNotification(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_MESSAGE_ID": params.MessageID.String(), }, @@ -150,24 +153,80 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone Priority: "normal", TTL: &ttl, }, - Token: *phone.FcmToken, }) if err != nil { + if transport == "" { + ctxLogger.Warn(stacktrace.Propagatef( + err, + "cannot determine notification transport for phone with ID [%s] for user with ID [%s] and message [%s]", + phone.ID, + phone.UserID, + params.MessageID, + )) + msg := fmt.Sprintf("cannot send notification to phone [%s]. Check the notification configuration.", phone.PhoneNumber) + return service.handleNotificationFailed(ctx, errors.New(msg), params) + } + ctxLogger.Warn(stacktrace.Propagatef( err, - - "cannot send FCM to phone with ID [%s] for user with ID [%s] and message [%s]", + "cannot send %s notification to phone with ID [%s] for user with ID [%s] and message [%s]", + transport, phone.ID, phone.UserID, params.MessageID, )) msg := fmt.Sprintf("cannot send notification to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber) + if transport == entities.NotificationTransportHTTP { + msg = fmt.Sprintf( + "cannot notify the configured adapter for phone [%s]. Check the adapter URL and availability.", + phone.PhoneNumber, + ) + } return service.handleNotificationFailed(ctx, errors.New(msg), params) } return service.handleNotificationSent(ctx, phone, result, params) } +func (service *PhoneNotificationService) sendPhoneNotification( + ctx context.Context, + phone *entities.Phone, + message *messaging.Message, +) (string, entities.NotificationTransport, error) { + if message == nil { + return "", "", stacktrace.NewErrorf("notification message is nil") + } + + transport, err := phone.NotificationTransport() + if err != nil { + return "", "", stacktrace.Propagatef( + err, + "cannot determine notification transport for phone [%s]", + phone.ID, + ) + } + + client, ok := service.phoneNotificationClients[transport] + if !ok || client == nil { + return "", transport, stacktrace.NewErrorf( + "notification client is not configured for transport [%s]", + transport, + ) + } + + message.Token = strings.TrimSpace(*phone.FcmToken) + result, err := client.Send(ctx, message, phone.ID) + if err != nil { + return "", transport, stacktrace.Propagatef( + err, + "cannot send [%s] notification to phone [%s]", + transport, + phone.ID, + ) + } + return result, transport, nil +} + // PhoneNotificationScheduleParams are parameters for sending a notification type PhoneNotificationScheduleParams struct { UserID entities.UserID diff --git a/api/pkg/services/phone_notification_service_test.go b/api/pkg/services/phone_notification_service_test.go new file mode 100644 index 000000000..9f0c42a46 --- /dev/null +++ b/api/pkg/services/phone_notification_service_test.go @@ -0,0 +1,329 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +type phoneNotificationPhoneRepository struct { + repositories.PhoneRepository + phone *entities.Phone + err error +} + +func (repository *phoneNotificationPhoneRepository) LoadByID( + _ context.Context, + _ entities.UserID, + _ uuid.UUID, +) (*entities.Phone, error) { + return repository.phone, repository.err +} + +type phoneNotificationRepository struct { + repositories.PhoneNotificationRepository + notificationID uuid.UUID + status entities.PhoneNotificationStatus +} + +func (repository *phoneNotificationRepository) UpdateStatus( + _ context.Context, + notificationID uuid.UUID, + status entities.PhoneNotificationStatus, +) error { + repository.notificationID = notificationID + repository.status = status + return nil +} + +type phoneNotificationEventQueue struct { + events []cloudevents.Event +} + +func (queue *phoneNotificationEventQueue) Enqueue( + _ context.Context, + task *PushQueueTask, + _ time.Duration, +) (string, error) { + var event cloudevents.Event + if err := json.Unmarshal(task.Body, &event); err != nil { + return "", err + } + queue.events = append(queue.events, event) + return "", nil +} + +type phoneNotificationLogger struct { + warnings []string +} + +var _ telemetry.Logger = (*phoneNotificationLogger)(nil) + +func (logger *phoneNotificationLogger) Error(error) {} +func (logger *phoneNotificationLogger) WithService(string) telemetry.Logger { return logger } +func (logger *phoneNotificationLogger) WithString(string, string) telemetry.Logger { + return logger +} + +func (logger *phoneNotificationLogger) WithSpan(trace.SpanContext) telemetry.Logger { return logger } +func (logger *phoneNotificationLogger) Trace(string) {} +func (logger *phoneNotificationLogger) Info(string) {} +func (logger *phoneNotificationLogger) Warn(err error) { + logger.warnings = append(logger.warnings, err.Error()) +} +func (logger *phoneNotificationLogger) Debug(string) {} +func (logger *phoneNotificationLogger) Fatal(error) {} +func (logger *phoneNotificationLogger) Printf(string, ...interface{}) {} + +type recordingPhoneNotificationClient struct { + message *messaging.Message + phoneID uuid.UUID + result string + err error + calls int +} + +func (client *recordingPhoneNotificationClient) Send( + _ context.Context, + message *messaging.Message, + phoneID uuid.UUID, +) (string, error) { + client.calls++ + client.message = message + client.phoneID = phoneID + return client.result, client.err +} + +func TestPhoneNotificationServiceSendPhoneNotificationUsesMappedClient(t *testing.T) { + endpoint := " https://adapter.example.com/notify " + phone := &entities.Phone{ID: uuid.New(), FcmToken: &endpoint} + httpClient := &recordingPhoneNotificationClient{result: "accepted"} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportHTTP: httpClient, + }, + } + message := &messaging.Message{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + + result, transport, err := service.sendPhoneNotification(context.Background(), phone, message) + + require.NoError(t, err) + assert.Equal(t, "accepted", result) + assert.Equal(t, entities.NotificationTransportHTTP, transport) + assert.Equal(t, "https://adapter.example.com/notify", message.Token) + assert.Same(t, message, httpClient.message) + assert.Equal(t, phone.ID, httpClient.phoneID) + assert.Equal(t, 1, httpClient.calls) +} + +func TestPhoneNotificationServiceSendPhoneNotificationRejectsMissingClient(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), FcmToken: &endpoint} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{}, + } + + _, transport, err := service.sendPhoneNotification( + context.Background(), + phone, + &messaging.Message{}, + ) + + require.Error(t, err) + assert.Equal(t, entities.NotificationTransportHTTP, transport) + assert.Contains(t, err.Error(), "notification client is not configured for transport [http]") +} + +func TestPhoneNotificationServiceSendPhoneNotificationRejectsNilMessage(t *testing.T) { + token := "fcm-token" + phone := &entities.Phone{ID: uuid.New(), FcmToken: &token} + fcmClient := &recordingPhoneNotificationClient{} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: fcmClient, + }, + } + + _, transport, err := service.sendPhoneNotification(context.Background(), phone, nil) + + require.Error(t, err) + assert.Empty(t, transport) + assert.Contains(t, err.Error(), "notification message is nil") + assert.Zero(t, fcmClient.calls) +} + +func TestPhoneNotificationServiceSendUsesHTTPSMessage(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ + ID: uuid.New(), + UserID: "user-1", + FcmToken: &endpoint, + PhoneNumber: "+18005550199", + MessageExpirationSeconds: 90, + } + httpClient := &recordingPhoneNotificationClient{result: "http/success"} + eventQueue := &phoneNotificationEventQueue{} + notificationRepository := &phoneNotificationRepository{} + service := newPhoneNotificationServiceForTest( + phone, + notificationRepository, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: httpClient, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + ScheduledAt: time.Now().UTC(), + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.NotNil(t, httpClient.message) + assert.Equal(t, endpoint, httpClient.message.Token) + assert.Equal(t, params.MessageID.String(), httpClient.message.Data["KEY_MESSAGE_ID"]) + require.NotNil(t, httpClient.message.Android) + assert.Equal(t, "normal", httpClient.message.Android.Priority) + require.NotNil(t, httpClient.message.Android.TTL) + assert.Equal(t, phone.MessageExpirationDuration(), *httpClient.message.Android.TTL) + require.Len(t, eventQueue.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationSent, eventQueue.events[0].Type()) + assert.Equal(t, params.PhoneNotificationID, notificationRepository.notificationID) + assert.Equal(t, entities.PhoneNotificationStatus(entities.PhoneNotificationStatusSent), notificationRepository.status) +} + +func TestPhoneNotificationServiceSendHTTPFailureUsesAdapterGuidance(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint, PhoneNumber: "+18005550199"} + eventQueue := &phoneNotificationEventQueue{} + notificationRepository := &phoneNotificationRepository{} + service := newPhoneNotificationServiceForTest( + phone, + notificationRepository, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: &recordingPhoneNotificationClient{ + err: errors.New("adapter unavailable"), + }, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.Len(t, eventQueue.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationFailed, eventQueue.events[0].Type()) + var payload events.MessageNotificationFailedPayload + require.NoError(t, eventQueue.events[0].DataAs(&payload)) + assert.Equal(t, "cannot notify the configured adapter for phone [+18005550199]. Check the adapter URL and availability.", payload.ErrorMessage) + assert.NotContains(t, payload.ErrorMessage, "Reinstall the httpSMS app") + assert.Equal(t, entities.PhoneNotificationStatus(entities.PhoneNotificationStatusFailed), notificationRepository.status) +} + +func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testing.T) { + token := "fcm-token" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &token, PhoneNumber: "+18005550199"} + eventQueue := &phoneNotificationEventQueue{} + fcmClient := &recordingPhoneNotificationClient{err: errors.New("firebase unavailable")} + service := newPhoneNotificationServiceForTest( + phone, + &phoneNotificationRepository{}, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: fcmClient, + entities.NotificationTransportHTTP: &recordingPhoneNotificationClient{}, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.Len(t, eventQueue.events, 1) + require.NotNil(t, fcmClient.message) + assert.Equal(t, token, fcmClient.message.Token) + assert.Equal(t, 1, fcmClient.calls) + var payload events.MessageNotificationFailedPayload + require.NoError(t, eventQueue.events[0].DataAs(&payload)) + assert.Equal(t, "cannot send notification to your phone [+18005550199]. Reinstall the httpSMS app on your Android phone.", payload.ErrorMessage) +} + +func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSMessage(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint} + httpClient := &recordingPhoneNotificationClient{err: errors.New("adapter unavailable")} + service := newPhoneNotificationServiceForTest( + phone, + &phoneNotificationRepository{}, + &phoneNotificationEventQueue{}, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: httpClient, + }, + ) + + err := service.SendHeartbeatFCM(context.Background(), &events.PhoneHeartbeatMissedPayload{ + UserID: phone.UserID, + PhoneID: phone.ID, + MonitorID: uuid.New(), + }) + + require.NoError(t, err) + require.NotNil(t, httpClient.message) + assert.Equal(t, endpoint, httpClient.message.Token) + heartbeatID := httpClient.message.Data["KEY_HEARTBEAT_ID"] + _, err = time.Parse(time.RFC3339, heartbeatID) + require.NoError(t, err) + require.NotNil(t, httpClient.message.Android) + assert.Equal(t, "high", httpClient.message.Android.Priority) + assert.Nil(t, httpClient.message.Android.TTL) +} + +func newPhoneNotificationServiceForTest( + phone *entities.Phone, + notificationRepository repositories.PhoneNotificationRepository, + eventQueue *phoneNotificationEventQueue, + clients map[entities.NotificationTransport]FCMClient, +) *PhoneNotificationService { + logger := &phoneNotificationLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + return NewNotificationService( + logger, + tracer, + clients, + &phoneNotificationPhoneRepository{phone: phone}, + notificationRepository, + nil, + NewEventDispatcher(logger, tracer, nil, eventQueue, PushQueueConfig{}), + ) +} diff --git a/api/pkg/validators/bulk_message_handler_validator.go b/api/pkg/validators/bulk_message_handler_validator.go index 588407ac8..956c14898 100644 --- a/api/pkg/validators/bulk_message_handler_validator.go +++ b/api/pkg/validators/bulk_message_handler_validator.go @@ -12,7 +12,6 @@ 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" @@ -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, } } 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/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index d4da9e109..f14575faf 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -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, } } diff --git a/api/pkg/validators/message_thread_handler_validator.go b/api/pkg/validators/message_thread_handler_validator.go index 72a64194f..d4f08002f 100644 --- a/api/pkg/validators/message_thread_handler_validator.go +++ b/api/pkg/validators/message_thread_handler_validator.go @@ -73,11 +73,11 @@ func (validator *MessageThreadHandlerValidator) ValidateUpdate(_ context.Context }) errors := v.ValidateStruct() - if request.IsArchived == nil && request.IsRead == nil { + if request.IsArchived == nil && request.UnreadCount == nil { if errors == nil { errors = url.Values{} } - errors.Add("payload", "at least one of is_archived or is_read is required") + 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 index e5ec7c1b7..4f7453662 100644 --- a/api/pkg/validators/message_thread_handler_validator_test.go +++ b/api/pkg/validators/message_thread_handler_validator_test.go @@ -20,12 +20,12 @@ func TestValidateUpdateRequiresAtLeastOneStatusField(t *testing.T) { assert.NotEmpty(t, errors.Get("payload")) } -func TestValidateUpdateAcceptsReadOnlyUpdate(t *testing.T) { +func TestValidateUpdateAcceptsUnreadCountOnlyUpdate(t *testing.T) { validator := &MessageThreadHandlerValidator{} - isRead := true + unreadCount := uint(0) request := requests.MessageThreadUpdate{ MessageThreadID: uuid.NewString(), - IsRead: &isRead, + UnreadCount: &unreadCount, } errors := validator.ValidateUpdate(context.Background(), request) diff --git a/api/pkg/validators/phone_handler_validator.go b/api/pkg/validators/phone_handler_validator.go index e9d4274e7..9e21d26a9 100644 --- a/api/pkg/validators/phone_handler_validator.go +++ b/api/pkg/validators/phone_handler_validator.go @@ -103,6 +103,11 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(ctx context.Context, user return result } + validator.validateNotificationToken(request.FcmToken, result) + if len(result) > 0 { + return result + } + if strings.TrimSpace(request.MessageSendScheduleID) != "" { scheduleID, _ := uuid.Parse(strings.TrimSpace(request.MessageSendScheduleID)) if _, err := validator.scheduleService.Load(ctx, userID, scheduleID); err != nil { @@ -133,7 +138,29 @@ func (validator *PhoneHandlerValidator) ValidateFCMToken(_ context.Context, requ }, }) - return v.ValidateStruct() + result := v.ValidateStruct() + if len(result) > 0 { + return result + } + + validator.validateNotificationToken(request.FcmToken, result) + return result +} + +func (validator *PhoneHandlerValidator) validateNotificationToken( + token string, + result url.Values, +) { + token = strings.TrimSpace(token) + if token == "" { + return + } + + phone := &entities.Phone{FcmToken: &token} + _, err := phone.NotificationTransport() + if err != nil { + result.Add("fcm_token", err.Error()) + } } // ValidateDelete ValidateUpsert validates requests.PhoneDelete diff --git a/api/pkg/validators/phone_handler_validator_test.go b/api/pkg/validators/phone_handler_validator_test.go new file mode 100644 index 000000000..c9dc72b63 --- /dev/null +++ b/api/pkg/validators/phone_handler_validator_test.go @@ -0,0 +1,144 @@ +package validators + +import ( + "context" + "net/url" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/stretchr/testify/assert" +) + +func TestPhoneHandlerValidatorAcceptsHTTPSNotificationURL(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: "https://adapter.example.com/notify", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsHTTPSNotificationURLOnUpsert(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateUpsert(context.Background(), "", requests.PhoneUpsert{ + PhoneNumber: "+18005550199", + FcmToken: "https://adapter.example.com/notify", + SIM: entities.SIM1.String(), + MessageExpirationSeconds: 60, + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsPrivateAndLoopbackNotificationHosts(t *testing.T) { + validator := newPhoneHandlerValidator() + + for _, token := range []string{ + "https://localhost/notify", + "https://127.0.0.1/notify", + "https://10.0.0.5/notify", + } { + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors, token) + } +} + +func TestPhoneHandlerValidatorRejectsInvalidNotificationURLs(t *testing.T) { + tests := []struct { + name string + token string + }{ + {name: "insecure HTTP", token: "http://adapter.example.com/notify"}, + {name: "missing host", token: "https:///notify"}, + {name: "malformed HTTPS", token: "https://%"}, + } + + validationPaths := []struct { + name string + validate func(*PhoneHandlerValidator, string) map[string][]string + }{ + { + name: "FCM token upsert", + validate: func(validator *PhoneHandlerValidator, token string) map[string][]string { + return validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + }) + }, + }, + { + name: "phone upsert", + validate: func(validator *PhoneHandlerValidator, token string) map[string][]string { + return validator.ValidateUpsert(context.Background(), "", requests.PhoneUpsert{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + MessageExpirationSeconds: 60, + }) + }, + }, + } + + for _, validationPath := range validationPaths { + t.Run(validationPath.name, func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + validationErrors := validationPath.validate(newPhoneHandlerValidator(), test.token) + + assert.NotEmpty(t, validationErrors["fcm_token"]) + }) + } + }) + } +} + +func TestPhoneHandlerValidatorAcceptsOpaqueFirebaseNotificationToken(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: "opaque-firebase-registration-token", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsNotificationURLWithUserInformation(t *testing.T) { + validator := newPhoneHandlerValidator() + endpoint := &url.URL{ + Scheme: "https", + User: url.UserPassword("adapter-user", "adapter-password"), + Host: "adapter.example.com", + Path: "/notify", + } + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: endpoint.String(), + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func newPhoneHandlerValidator() *PhoneHandlerValidator { + logger := &contactValidatorNoopLogger{} + return NewPhoneHandlerValidator( + logger, + telemetry.NewOtelLogger("test", logger), + nil, + ) +} diff --git a/docs/superpowers/plans/2026-05-26-nuxt4-vuetify4-migration.md b/docs/superpowers/plans/2026-05-26-nuxt4-vuetify4-migration.md deleted file mode 100644 index d5853283d..000000000 --- a/docs/superpowers/plans/2026-05-26-nuxt4-vuetify4-migration.md +++ /dev/null @@ -1,3300 +0,0 @@ -# Nuxt 4 + Vuetify 4 Migration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the httpSMS frontend from Nuxt 2 + Vue 2 + Vuetify 2 to Nuxt 4 + Vue 3 + Vuetify 4 with full TypeScript, Pinia state management, and ` - - - - -``` - -- [ ] **Step 2: Create website layout** - -```vue - - - - -``` - -- [ ] **Step 3: Create error layout** - -```vue - - - - -``` - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat(web): port layouts to Nuxt 4 with Vuetify 4" -``` - ---- - -## Task 13: Port Components — Toast, LoadingDashboard, LoadingButton, BackButton - -**Files:** -- Create: `web/app/components/Toast.vue` -- Create: `web/app/components/LoadingDashboard.vue` -- Create: `web/app/components/LoadingButton.vue` -- Create: `web/app/components/BackButton.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for v-snackbar, v-btn, v-progress-circular, v-icon, v-container, v-row, v-col before writing these. - -- [ ] **Step 1: Create Toast.vue** - -```vue - - - - -``` - -- [ ] **Step 2: Create LoadingDashboard.vue** - -```vue - - - - -``` - -- [ ] **Step 3: Create LoadingButton.vue** - -```vue - - - - -``` - -- [ ] **Step 4: Create BackButton.vue** - -```vue - - - - -``` - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(web): port Toast, LoadingDashboard, LoadingButton, BackButton components" -``` - ---- - -## Task 14: Port Components — CopyButton, FixedHeader, BlogAuthorBio, BlogInfo, NuxtLogo - -**Files:** -- Create: `web/app/components/CopyButton.vue` -- Create: `web/app/components/FixedHeader.vue` -- Create: `web/app/components/BlogAuthorBio.vue` -- Create: `web/app/components/BlogInfo.vue` -- Create: `web/app/components/NuxtLogo.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for each Vuetify component used. - -- [ ] **Step 1: Create CopyButton.vue** - -```vue - - - - -``` - -- [ ] **Step 2: Create FixedHeader.vue** - -```vue - - - - -``` - -- [ ] **Step 3: Create BlogAuthorBio.vue** - -```vue - - - - -``` - -- [ ] **Step 4: Create BlogInfo.vue** - -```vue - - - - -``` - -- [ ] **Step 5: Create NuxtLogo.vue (minimal placeholder)** - -```vue - - -``` - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat(web): port CopyButton, FixedHeader, BlogAuthorBio, BlogInfo, NuxtLogo" -``` - ---- - -## Task 15: Port Components — FirebaseAuth, MessageThread, MessageThreadHeader - -**Files:** -- Create: `web/app/components/FirebaseAuth.vue` -- Create: `web/app/components/MessageThread.vue` -- Create: `web/app/components/MessageThreadHeader.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for v-select, v-list, v-list-item, v-menu, v-tooltip, v-sheet, v-progress-linear before writing. - -- [ ] **Step 1: Create FirebaseAuth.vue** - -This component uses FirebaseUI which must be loaded client-side only. In Nuxt 4, wrap with `` or use `.client.vue` suffix. - -```vue - - - - -``` - -- [ ] **Step 2: Create MessageThread.vue** - -Port from the existing component. This is a larger component — read the full source from the backup branch and rewrite using ` - - -``` - -Note: The full MessageThread.vue content should be ported from the backup branch. The pattern above shows the migration approach. - -- [ ] **Step 3: Create MessageThreadHeader.vue** - -```vue - - - - -``` - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat(web): port FirebaseAuth, MessageThread, MessageThreadHeader components" -``` - ---- - -## Task 16: Port Pages — Login & Index (Homepage) - -**Files:** -- Create: `web/app/pages/login.vue` -- Create: `web/app/pages/index.vue` - -**IMPORTANT:** Call `vuetify-mcp-get_component_api_by_version` for all Vuetify components used. Port from the backup branch source, converting class-based syntax to `&"}`) - html := string(rich) - - assert.Contains(t, plain, ` -``` - -**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/MessageThread.vue b/web/app/components/MessageThread.vue index 2a9834317..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) : '' +} - {{ - formatPhoneNumber(thread.contact) - }} + + {{ threadContactTitle(thread) }} + - {{ thread.last_message_content }} + + + + diff --git a/web/app/components/MessageThreadHeader.vue b/web/app/components/MessageThreadHeader.vue index 1e1a81eb2..12917b598 100644 --- a/web/app/components/MessageThreadHeader.vue +++ b/web/app/components/MessageThreadHeader.vue @@ -14,9 +14,9 @@ import { mdiDotsVertical, mdiMagnify, mdiCommentTextMultipleOutline, + mdiAccountMultiple, mdiCircle, } from '@mdi/js' -import { formatPhoneNumber, phoneCountry, humanizeTime } from '~/utils/filters' import type { EntitiesPhone } from '~~/shared/types/api' const router = useRouter() @@ -28,6 +28,7 @@ const threadsStore = useThreadsStore() const appStore = useAppStore() const notificationsStore = useNotificationsStore() const redirectPreferenceStore = useRedirectPreferenceStore() +const { formatPhoneNumber, phoneCountry, humanizeTime } = useFilters() const selectedMenuItem = ref(-1) @@ -180,6 +181,10 @@ async function logout() { Search Messages + + + Contacts + Settings diff --git a/web/app/composables/useFilters.ts b/web/app/composables/useFilters.ts index c84157ea5..2b54bbb43 100644 --- a/web/app/composables/useFilters.ts +++ b/web/app/composables/useFilters.ts @@ -7,6 +7,8 @@ import { formatBillingPeriod, formatBillingPeriodDateOrdinal, humanizeTime, + humanizeTimeShort, + startsWithLetter, } from '../utils/filters' import { capitalize } from '../utils/capitalize' @@ -18,8 +20,10 @@ export function useFilters() { formatMoney, formatDecimal, formatBillingPeriod, + humanizeTimeShort, formatBillingPeriodDateOrdinal, humanizeTime, + startsWithLetter, capitalize, } } diff --git a/web/app/pages/billing/index.vue b/web/app/pages/billing/index.vue index 555bd129c..8a22aed23 100644 --- a/web/app/pages/billing/index.vue +++ b/web/app/pages/billing/index.vue @@ -14,6 +14,7 @@ import type { } from '~~/shared/types/api' import { countries, getStateOptions } from '~/utils/countries' +import { getApiErrorMessage } from '~/utils/api-error' type SubscriptionPayment = ResponsesUserSubscriptionPaymentsResponse['data'][number] @@ -27,7 +28,7 @@ useHead({ }) const config = useRuntimeConfig() -const { lgAndUp } = useDisplay() +const { lgAndUp } = useVDisplay() const authStore = useAuthStore() const billingStore = useBillingStore() const notificationsStore = useNotificationsStore() @@ -197,9 +198,12 @@ async function updateDetails() { loading.value = true try { window.location.href = await billingStore.getSubscriptionUpdateLink() - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'We could not redirect you to the subscription update portal.', + message: getApiErrorMessage( + error, + 'We could not redirect you to the subscription update portal.', + ), type: 'error', }) loading.value = false @@ -215,9 +219,12 @@ async function cancelPlan() { type: 'success', }) navigateTo('/') - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'We could not cancel your subscription.', + message: getApiErrorMessage( + error, + 'We could not cancel your subscription.', + ), type: 'error', }) loading.value = false diff --git a/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue b/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue index 5e7e0a6f9..bda47bca9 100644 --- a/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue +++ b/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue @@ -1,10 +1,8 @@ + + diff --git a/web/app/pages/heartbeats/[id].vue b/web/app/pages/heartbeats/[id].vue index 8a05d6944..3ef5d212c 100644 --- a/web/app/pages/heartbeats/[id].vue +++ b/web/app/pages/heartbeats/[id].vue @@ -17,7 +17,7 @@ useHead({ }) const route = useRoute() -const { mdAndDown, mdAndUp, lgAndUp } = useDisplay() +const { mdAndDown, mdAndUp, lgAndUp } = useVDisplay() const authStore = useAuthStore() const phonesStore = usePhonesStore() const { formatPhoneNumber, formatTimestamp } = useFilters() diff --git a/web/app/pages/index.vue b/web/app/pages/index.vue index 281745691..be30493f7 100644 --- a/web/app/pages/index.vue +++ b/web/app/pages/index.vue @@ -43,7 +43,7 @@ useSeoMeta({ }) const config = useRuntimeConfig() -const { lgAndUp, mdAndUp, mdAndDown, md, smAndDown, xl } = useDisplay() +const { lgAndUp, mdAndUp, mdAndDown, md, smAndDown, xl } = useVDisplay() const selectedTab = ref('javascript') const yearlyPricing = ref(false) diff --git a/web/app/pages/messages/index.vue b/web/app/pages/messages/index.vue index 0a6d53a99..5b6abd53f 100644 --- a/web/app/pages/messages/index.vue +++ b/web/app/pages/messages/index.vue @@ -6,7 +6,7 @@ import { parsePhoneNumber, type CountryCode, } from 'libphonenumber-js' -import { toApiError } from '~/utils/api-error' +import { getApiErrorMessage, toApiError } from '~/utils/api-error' definePageMeta({ middleware: ['auth'], @@ -17,7 +17,7 @@ useHead({ }) const router = useRouter() -const { mdAndDown, mdAndUp } = useDisplay() +const { mdAndDown, mdAndUp } = useVDisplay() const notificationsStore = useNotificationsStore() const phonesStore = usePhonesStore() const { useApi } = useApiComposable() @@ -91,6 +91,11 @@ async function sendMessage() { }) } errors.value = newErrors + } else { + notificationsStore.addNotification({ + message: getApiErrorMessage(err, 'Failed to send message'), + type: 'error', + }) } } finally { sending.value = false diff --git a/web/app/pages/phone-api-keys/index.vue b/web/app/pages/phone-api-keys/index.vue index 27804cbde..aded9453d 100644 --- a/web/app/pages/phone-api-keys/index.vue +++ b/web/app/pages/phone-api-keys/index.vue @@ -4,7 +4,7 @@ import QRCode from 'qrcode' import Pusher from 'pusher-js' import type { Channel } from 'pusher-js' import { ErrorMessages } from '~/utils/errors' -import { toApiError } from '~/utils/api-error' +import { getApiErrorMessage, toApiError } from '~/utils/api-error' import type { EntitiesPhoneAPIKey } from '~~/shared/types/api' definePageMeta({ @@ -16,7 +16,7 @@ useHead({ }) const config = useRuntimeConfig() -const { lgAndUp } = useDisplay() +const { lgAndUp } = useVDisplay() const authStore = useAuthStore() const appStore = useAppStore() const phonesStore = usePhonesStore() @@ -58,9 +58,9 @@ async function loadPhoneApiKeys() { { query: { limit: 100 } }, ) phoneApiKeys.value = response.data ?? [] - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'Failed to load Phone API Keys', + message: getApiErrorMessage(error, 'Failed to load Phone API Keys'), type: 'error', }) } finally { @@ -88,7 +88,7 @@ async function createPhoneApiKey() { errorMessages.value = parseErrors(error) if (errorMessages.value.size() === 0) { notificationsStore.addNotification({ - message: 'Failed to create Phone API Key', + message: getApiErrorMessage(error, 'Failed to create Phone API Key'), type: 'error', }) } @@ -155,9 +155,9 @@ async function deleteApiKey() { }) deleteApiKeyDialog.value = false await loadPhoneApiKeys() - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'Failed to delete Phone API Key', + message: getApiErrorMessage(error, 'Failed to delete Phone API Key'), type: 'error', }) loading.value = false @@ -191,9 +191,12 @@ async function removePhoneFromPhoneKey() { }) removePhoneFromApiKeyDialog.value = false await loadPhoneApiKeys() - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'Failed to remove the phone from the Phone API Key', + message: getApiErrorMessage( + error, + 'Failed to remove the phone from the Phone API Key', + ), type: 'error', }) loading.value = false diff --git a/web/app/pages/search-messages/index.vue b/web/app/pages/search-messages/index.vue index 485dae3b5..324131b7b 100644 --- a/web/app/pages/search-messages/index.vue +++ b/web/app/pages/search-messages/index.vue @@ -16,7 +16,7 @@ import { import type { EntitiesMessage, EntitiesPhone } from '~~/shared/types/api' import type { SearchMessagesRequest } from '~~/shared/types/message' import { ErrorMessages } from '~/utils/errors' -import { toApiError } from '~/utils/api-error' +import { getApiErrorMessage, toApiError } from '~/utils/api-error' interface Turnstile { ready(callback: () => void): void @@ -42,7 +42,7 @@ useHead({ const route = useRoute() const config = useRuntimeConfig() -const { mdAndUp, smAndDown, lgAndUp } = useDisplay() +const { mdAndUp, smAndDown, lgAndUp } = useVDisplay() const messagesStore = useMessagesStore() const phonesStore = usePhonesStore() const authStore = useAuthStore() @@ -261,9 +261,12 @@ async function deleteMessages() { type: 'success', }) selectedIds.value = [] - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'Error while deleting the selected messages', + message: getApiErrorMessage( + error, + 'Error while deleting the selected messages', + ), type: 'error', }) } finally { @@ -300,8 +303,15 @@ async function resendMessages() { }) selectedIds.value = [] } else if (failed.length === results.length) { + const firstFailure = failed[0] notificationsStore.addNotification({ - message: 'Error while resending the selected messages', + message: + firstFailure?.status === 'rejected' + ? getApiErrorMessage( + firstFailure.reason, + 'Error while resending the selected messages', + ) + : 'Error while resending the selected messages', type: 'error', }) } else { @@ -645,8 +655,7 @@ onBeforeUnmount(() => { max-width: 300px; word-break: break-all; " - >{{ item.content }} + >{{ item.content }} diff --git a/web/app/pages/settings/index.vue b/web/app/pages/settings/index.vue index 1f381416b..f4c312035 100644 --- a/web/app/pages/settings/index.vue +++ b/web/app/pages/settings/index.vue @@ -23,7 +23,7 @@ import { } from 'firebase/auth' import QRCode from 'qrcode' import { ErrorMessages } from '~/utils/errors' -import { toApiError } from '~/utils/api-error' +import { getApiErrorMessage, toApiError } from '~/utils/api-error' import type { EntitiesPhone, EntitiesWebhook, @@ -42,7 +42,7 @@ useHead({ const config = useRuntimeConfig() const route = useRoute() const router = useRouter() -const { mdAndDown, mdAndUp, lgAndUp, xlAndUp, smAndUp } = useDisplay() +const { mdAndDown, mdAndUp, lgAndUp, xlAndUp, smAndUp } = useVDisplay() const authStore = useAuthStore() const phonesStore = usePhonesStore() const billingStore = useBillingStore() @@ -157,9 +157,9 @@ async function rotateApiKey() { message: 'API Key rotated successfully', type: 'success', }) - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'Failed to rotate API Key', + message: getApiErrorMessage(error, 'Failed to rotate API Key'), type: 'error', }) } finally { @@ -198,9 +198,9 @@ async function updateTimezone(timezone: string) { message: 'Timezone updated successfully', type: 'success', }) - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'Failed to update timezone', + message: getApiErrorMessage(error, 'Failed to update timezone'), type: 'error', }) } @@ -298,7 +298,7 @@ async function saveWebhook() { errorMessages.value = parseErrors(error) if (errorMessages.value.size() === 0) { notificationsStore.addNotification({ - message: 'Failed to save webhook', + message: getApiErrorMessage(error, 'Failed to save webhook'), type: 'error', }) } @@ -317,9 +317,9 @@ async function deleteWebhook(id: string) { }) showWebhookEdit.value = false await loadWebhooks() - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'Failed to delete webhook', + message: getApiErrorMessage(error, 'Failed to delete webhook'), type: 'error', }) } finally { @@ -406,7 +406,10 @@ async function saveDiscord() { errorMessages.value = parseErrors(error) if (errorMessages.value.size() === 0) { notificationsStore.addNotification({ - message: 'Failed to save discord integration', + message: getApiErrorMessage( + error, + 'Failed to save discord integration', + ), type: 'error', }) } @@ -425,9 +428,12 @@ async function deleteDiscord(id: string) { }) showDiscordEdit.value = false await loadDiscordIntegrations() - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'Failed to delete discord integration', + message: getApiErrorMessage( + error, + 'Failed to delete discord integration', + ), type: 'error', }) } finally { @@ -472,9 +478,9 @@ async function deletePhone(phoneId: string) { }) showPhoneEdit.value = false activePhone.value = null - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'Failed to delete phone', + message: getApiErrorMessage(error, 'Failed to delete phone'), type: 'error', }) } finally { @@ -675,10 +681,10 @@ async function saveSchedule() { await loadSendSchedules() } catch (error: unknown) { errorMessages.value = parseErrors(error) - if (errorMessages.value.size() != 0) { + if (errorMessages.value.size() === 0) { notificationsStore.addNotification({ type: 'error', - message: 'Failed to save send schedule', + message: getApiErrorMessage(error, 'Failed to save send schedule'), }) } } finally { @@ -702,10 +708,10 @@ async function deleteSchedule() { showScheduleDelete.value = false showScheduleEdit.value = false await loadSendSchedules() - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ type: 'error', - message: 'Failed to delete send schedule', + message: getApiErrorMessage(error, 'Failed to delete send schedule'), }) } finally { savingSchedule.value = false @@ -746,9 +752,9 @@ async function saveEmailNotifications() { type: 'success', }) syncEmailNotifications() - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: 'Failed to save email notifications', + message: getApiErrorMessage(error, 'Failed to save email notifications'), type: 'error', }) } finally { @@ -780,10 +786,12 @@ async function deleteUserAccount() { message: 'You have successfully logged out', }) await router.push({ name: 'index' }) - } catch { + } catch (error: unknown) { notificationsStore.addNotification({ - message: - 'We ran into an internal error while deleteing your account please contact us.', + message: getApiErrorMessage( + error, + 'We ran into an internal error while deleting your account. Please contact us.', + ), type: 'error', }) } finally { diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue index 73632a64c..87527c4df 100644 --- a/web/app/pages/threads/[id]/index.vue +++ b/web/app/pages/threads/[id]/index.vue @@ -14,12 +14,18 @@ import { mdiAccount, mdiRefresh, mdiContentCopy, + mdiAccountPlus, + mdiSquareEditOutline, } from '@mdi/js' import Pusher from 'pusher-js' import type { Channel } from 'pusher-js' import { isValidPhoneNumber } from 'libphonenumber-js' +import { storeToRefs } from 'pinia' import type { EntitiesMessage } from '~~/shared/types/api' -import { startsWithLetter } from '~/utils/filters' +import { + isUrlNotificationToken, + formatWhatsappText, +} from '~/utils/whatsapp-format' definePageMeta({ middleware: ['auth'], @@ -32,13 +38,14 @@ useHead({ const route = useRoute() const router = useRouter() const config = useRuntimeConfig() -const { lgAndUp, mdAndDown, mdAndUp } = useDisplay() -const { formatPhoneNumber } = useFilters() +const { lgAndUp, mdAndDown, mdAndUp } = useVDisplay() +const { formatPhoneNumber, startsWithLetter } = useFilters() const notificationsStore = useNotificationsStore() const authStore = useAuthStore() const phonesStore = usePhonesStore() const threadsStore = useThreadsStore() const messagesStore = useMessagesStore() +const { currentThread } = storeToRefs(threadsStore) const formMessage = ref('') const submitting = ref(false) @@ -46,8 +53,12 @@ const loadingMessages = ref(false) const hideMessages = ref(true) const messages = ref([]) const selectedMenuItem = ref(-1) +const contactDialog = ref(false) const messageBody = ref(null) const form = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null) +let messageRequestId = 0 + +threadsStore.setThreadId(route.params.id as string) const formMessageRules = [ (v: string) => @@ -59,7 +70,7 @@ const formMessageRules = [ let webhookChannel: Channel | null = null const contactIsPhoneNumber = computed(() => { - const thread = threadsStore.currentThread + const thread = currentThread.value if (!thread) return false return isValidPhoneNumber(thread.contact) || !isNaN(Number(thread.contact)) }) @@ -67,7 +78,7 @@ const contactIsPhoneNumber = computed(() => { const messageVisibility = computed(() => hideMessages.value ? 'hidden' : 'visible', ) -const contact = computed(() => threadsStore.currentThread?.contact ?? '') +const contact = computed(() => currentThread.value?.contact ?? '') function isMT(message: EntitiesMessage): boolean { return message.type === 'mobile-terminated' @@ -81,6 +92,11 @@ function isMissedCall(message: EntitiesMessage): boolean { return message.type === 'call/missed' } +function isWhatsappFormatted(message: EntitiesMessage): boolean { + const phone = phonesStore.phones.find((x) => x.phone_number === message.owner) + return isUrlNotificationToken(phone?.fcm_token) +} + function isPending(message: EntitiesMessage): boolean { return ['sending', 'pending', 'scheduled'].includes(message.status) } @@ -104,6 +120,18 @@ function formatAttachmentName(url: string): string { return url } +function currentThreadContactTitle(): string { + const thread = currentThread.value + if (!thread) return '' + return ( + thread.contact_details?.name?.trim() || formatPhoneNumber(thread.contact) + ) +} + +async function refreshThreadContact() { + await threadsStore.loadThreads() +} + function scrollToElement() { const el = messageBody.value if (el) { @@ -112,50 +140,79 @@ function scrollToElement() { hideMessages.value = false } -async function markCurrentThreadRead(force = false) { - const threadId = route.params.id as string +async function resetCurrentThreadUnreadCount( + force = false, + threadId = route.params.id as string, +) { try { - await threadsStore.markThreadRead(threadId, force) + await threadsStore.resetThreadUnreadCount(threadId, force) } catch (error) { console.error(error) } } -function loadMessages(hide = true, markRead = true) { +async function handleInboundMessage() { + if (loadingMessages.value) return + + try { + const thread = currentThread.value + await threadsStore.loadThreads() + if (!thread || loadingMessages.value) { + return + } + + await resetCurrentThreadUnreadCount(true) + loadMessages(false, false) + } catch (error) { + console.error(error) + } +} + +async function loadMessages(hide = true, resetUnreadCount = true) { + const requestId = ++messageRequestId loadingMessages.value = true const threadId = route.params.id as string - if (markRead) void markCurrentThreadRead() - threadsStore - .loadThreadMessages(threadId) - .then((response: EntitiesMessage[]) => { - messages.value = [...response].reverse() - }) - .finally(() => { - setTimeout(() => { - loadingMessages.value = false - }, 1100) - }) hideMessages.value = hide - setTimeout(() => { + + if (resetUnreadCount) { + void resetCurrentThreadUnreadCount(false, threadId) + } + + try { + const response = await threadsStore.loadThreadMessages(threadId) + if (requestId !== messageRequestId) return + + messages.value = [...response].reverse() + await nextTick() + if (requestId !== messageRequestId) return + scrollToElement() - }, 950) + } finally { + if (requestId === messageRequestId) { + loadingMessages.value = false + } + } } async function loadData() { - await authStore.loadUser() - await phonesStore.loadPhones() - await threadsStore.loadThreads() + const threadId = route.params.id as string - if (!threadsStore.hasThreadId(route.params.id as string)) { + if (!threadsStore.hasThreadId(threadId)) { + if (!authStore.user) await authStore.loadUser() + await phonesStore.loadPhones() + await threadsStore.loadThreads() + } + + if (!threadsStore.hasThreadId(threadId)) { await router.push('/threads') return } - loadMessages() + await loadMessages() } async function archiveThread() { await threadsStore.updateThread({ - threadId: threadsStore.currentThread!.id, + threadId: currentThread.value!.id, isArchived: true, }) await router.push('/threads') @@ -163,7 +220,7 @@ async function archiveThread() { async function unArchiveThread() { await threadsStore.updateThread({ - threadId: threadsStore.currentThread!.id, + threadId: currentThread.value!.id, isArchived: false, }) await router.push('/threads') @@ -218,7 +275,7 @@ async function sendMessage(event: KeyboardEvent | Event) { submitting.value = true await messagesStore.sendMessage({ from: phonesStore.owner!, - to: threadsStore.currentThread!.contact, + to: currentThread.value!.contact, content: formMessage.value, sim: 'DEFAULT', }) @@ -241,20 +298,24 @@ onMounted(async () => { webhookChannel.bind('message.send.failed', () => { if (!loadingMessages.value) loadMessages(false) }) - webhookChannel.bind('message.phone.received', () => { - if (!loadingMessages.value) { - void markCurrentThreadRead(true) - loadMessages(false, false) - } + webhookChannel.bind('message.phone.received', (_: never) => { + void handleInboundMessage() }) - webhookChannel.bind('message.call.missed', () => { - if (!loadingMessages.value) { - void markCurrentThreadRead(true) - loadMessages(false, false) - } + webhookChannel.bind('message.call.missed', (_: never) => { + void handleInboundMessage() }) }) +watch( + () => route.params.id as string, + (threadId, previousThreadId) => { + if (!previousThreadId || threadId === previousThreadId) return + + threadsStore.setThreadId(threadId) + void loadMessages() + }, +) + onBeforeUnmount(() => { if (webhookChannel) webhookChannel.unsubscribe() }) @@ -267,8 +328,17 @@ onBeforeUnmount(() => { - - {{ formatPhoneNumber(threadsStore.currentThread.contact) }} + + + + +