diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 3ac3e122e..50813d153 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -37,7 +37,7 @@ jobs: version: 11.11.0 - name: Install dependencies 📦 - run: pnpm install + run: pnpm install --trust-lockfile - name: Run linter 👀 run: pnpm lint @@ -69,7 +69,7 @@ jobs: version: 11.11.0 - name: Install dependencies 📦 - run: pnpm install + run: pnpm install --trust-lockfile - name: Build 🏗️ run: mv .env.production .env && echo "GITHUB_SHA=${GITHUB_SHA}" >> .env && pnpm run generate diff --git a/.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..8eb6dafe6 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": { @@ -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": [ @@ -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" @@ -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": [ @@ -4743,6 +5259,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..21c6bbf80 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": { @@ -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": [ @@ -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" @@ -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": [ @@ -4740,6 +5256,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..4fe2f2b7c 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 @@ -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: @@ -1109,6 +1208,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 +1902,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 +2502,10 @@ paths: minimum: 1 name: limit type: integer + - description: include matching contact details + in: query + name: contacts + type: boolean produces: - application/json responses: diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index ad9a18858..ebe57662e 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 } @@ -579,7 +588,6 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes container.Tracer(), container.PhoneService(), container.TurnstileTokenValidator(), - container.Cache(), ) } @@ -602,7 +610,6 @@ func (container *Container) BulkMessageHandlerValidator() (validator *validators container.Tracer(), container.PhoneService(), container.UserService(), - container.Cache(), ) } @@ -697,6 +704,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 +923,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,7 +1158,23 @@ 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 @@ -1374,6 +1438,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{})) @@ -1664,6 +1742,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 +1857,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/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..d4d893a49 100644 --- a/api/pkg/entities/message_thread.go +++ b/api/pkg/entities/message_thread.go @@ -22,6 +22,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..b587bacbc 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" @@ -23,3 +24,20 @@ func TestMessageThreadReadFieldsHaveBackwardCompatibleDefaults(t *testing.T) { assert.Contains(t, lastReadAt.Tag.Get("gorm"), "default:CURRENT_TIMESTAMP") assert.Equal(t, "-", lastReadAt.Tag.Get("json")) } + +func TestMessageThreadContactDetailsAreTransientAndOmittedWhenNil(t *testing.T) { + threadType := reflect.TypeOf(MessageThread{}) + + contactDetails, ok := threadType.FieldByName("ContactDetails") + require.True(t, ok) + assert.Equal(t, "*entities.Contact", contactDetails.Type.String()) + assert.Equal(t, "contact_details,omitempty", contactDetails.Tag.Get("json")) + assert.Equal(t, "-", contactDetails.Tag.Get("gorm")) + + data, err := json.Marshal(MessageThread{}) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(data, &payload)) + assert.NotContains(t, payload, "contact_details") +} diff --git a/api/pkg/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..56dfa0c79 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() 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/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/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/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/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/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/message_thread_service.go b/api/pkg/services/message_thread_service.go index 532a223e2..8431672b7 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, } } @@ -265,23 +272,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..44a3bcfdd 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) { 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/docs/superpowers/plans/2026-07-19-contacts-feature.md b/docs/superpowers/plans/2026-07-19-contacts-feature.md new file mode 100644 index 000000000..8a72fe407 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-contacts-feature.md @@ -0,0 +1,3025 @@ +# Contacts Feature 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:** Add a Contacts feature (name, emails, phone numbers, free-form properties) with full CRUD + CSV import, and display the resolved contact name instead of the raw phone number on the threads page, using as few DB queries as possible. + +**Architecture:** New `Contact` entity + layered backend (handler → service → repository → validator → requests/responses) mirroring existing patterns. Thread-name resolution is opt-in per request and served from a per-user `phone_number → *Contact` map cached in `cache.Cache`, so the common path adds zero DB queries and a name change is a single-row `UPDATE` + cache invalidation. Frontend adds a Pinia store and a Plunk-style Contacts page (Nuxt 4 SPA). + +**Tech Stack:** Go (Fiber v3, GORM v1.31.2, lib/pq, nyaruka/phonenumbers, jszwec/csvutil), Nuxt 4 + Pinia + Vuetify 4, Swagger (`swag`), swagger-typescript-api. + +## Global Constraints + +- Source of truth: `docs/superpowers/specs/2026-07-19-contacts-feature-design.md`. Follow it exactly. +- Run Go tests with `go test -vet=off ./...` from `api/` (Go 1.26 vet rejects the repo's `stacktrace.Propagate(err, fmt.Sprintf(...))` pattern). +- Wrap all Go errors with `github.com/NdoleStudio/stacktrace` (`Propagatef` / `PropagateWithCodef`) — never return bare errors. +- All GORM queries use `repository.db.WithContext(ctx)`. No raw SQL. +- Register Fiber routes via the `h.register(router, fiber.MethodX, path, middlewares, route)` helper (Fiber v3), not `Get`/`Post` directly. +- Contacts are global to the user account. **No** uniqueness constraint on phone numbers; **no** `contact_phone_numbers` lookup table. +- On phone-number collision across contacts, the **most recently updated** contact wins for display. +- Thread-name resolution is **opt-in** via `?contacts=true` (default off). +- Create endpoint accepts one or many; batch capped at **≤ 1000** contacts; cache invalidated **once** after the batch commits. +- CSV import is **CSV only** — Excel/XLSX is not supported for contacts. Max 500 KB, ≤ 1000 rows. +- `pq.StringArray` with `gorm:"type:text[]" swaggertype:"array,string"` for array fields (convention from `phone_api_key.go`/`webhook.go`). +- Web conventions: every `VDialog` has `opacity="0.9"` and a Close button with `color="warning"`; hyperlinks get `text-decoration-none hover:text-decoration-underline`; destructure filters from `useFilters()` in ` + + +``` + +- [ ] **Step 2: Lint the page** + +Run: `cd web && pnpm lint:js && pnpm lint:prettier` +Expected: no errors for `app/pages/contacts/index.vue`. If Prettier reports formatting, run `pnpm lintfix` and re-check. + +- [ ] **Step 3: Build the site to confirm it compiles** + +Run: `cd web && pnpm generate` +Expected: build completes without type errors (the `/contacts` route is emitted). + +- [ ] **Step 4: Commit** + +```bash +git add web/app/pages/contacts/index.vue +git commit -m "feat(web): add contacts page with add/edit/delete/import dialogs" +``` + +--- + +### Task 13: Show contact names on threads + Contacts nav link + +**Files:** +- Modify: `web/app/stores/threads.ts` (send `contacts: true`) +- Modify: `web/app/components/MessageThread.vue` (list title + avatar initial) +- Modify: `web/app/pages/threads/[id]/index.vue` (toolbar title) +- Modify: `web/app/components/MessageThreadHeader.vue` (Contacts nav item + icon import) + +**Interfaces:** +- Consumes: `EntitiesMessageThread.contact_details?: EntitiesContact` (Task 10), the `/contacts` route (Task 12). +- Produces: no new exports — UI wiring only. + +- [ ] **Step 1: Request contact resolution when loading threads** + +In `web/app/stores/threads.ts`, add `contacts: true` to the `loadThreads` request params: + +```ts + const response = await apiFetch<{ data: EntitiesMessageThread[] }>( + '/v1/message-threads', + { + params: { + owner: phonesStore.owner ?? phonesStore.phones[0]?.phone_number, + limit: 100, + is_archived: archivedThreads.value, + contacts: true, + }, + }, + ) +``` + +- [ ] **Step 2: Show the contact name in the threads list** + +In `web/app/components/MessageThread.vue`, replace the list-item title expression: + +```vue + {{ + thread.contact_details?.name ?? formatPhoneNumber(thread.contact) + }} +``` + +And update the avatar initial to prefer the contact name (replace the existing ` 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..c5af811e3 100644 --- a/web/app/pages/billing/index.vue +++ b/web/app/pages/billing/index.vue @@ -27,7 +27,7 @@ useHead({ }) const config = useRuntimeConfig() -const { lgAndUp } = useDisplay() +const { lgAndUp } = useVDisplay() const authStore = useAuthStore() const billingStore = useBillingStore() const notificationsStore = useNotificationsStore() 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..3dd1a544a 100644 --- a/web/app/pages/messages/index.vue +++ b/web/app/pages/messages/index.vue @@ -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() diff --git a/web/app/pages/phone-api-keys/index.vue b/web/app/pages/phone-api-keys/index.vue index 27804cbde..df13c5a67 100644 --- a/web/app/pages/phone-api-keys/index.vue +++ b/web/app/pages/phone-api-keys/index.vue @@ -16,7 +16,7 @@ useHead({ }) const config = useRuntimeConfig() -const { lgAndUp } = useDisplay() +const { lgAndUp } = useVDisplay() const authStore = useAuthStore() const appStore = useAppStore() const phonesStore = usePhonesStore() diff --git a/web/app/pages/search-messages/index.vue b/web/app/pages/search-messages/index.vue index 485dae3b5..0e5419f71 100644 --- a/web/app/pages/search-messages/index.vue +++ b/web/app/pages/search-messages/index.vue @@ -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() @@ -645,8 +645,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..63a8c2975 100644 --- a/web/app/pages/settings/index.vue +++ b/web/app/pages/settings/index.vue @@ -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() diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue index 73632a64c..f933973dd 100644 --- a/web/app/pages/threads/[id]/index.vue +++ b/web/app/pages/threads/[id]/index.vue @@ -14,12 +14,14 @@ 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' definePageMeta({ middleware: ['auth'], @@ -32,13 +34,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,6 +49,7 @@ 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) @@ -59,7 +63,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 +71,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' @@ -104,6 +108,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) { @@ -155,7 +171,7 @@ async function loadData() { async function archiveThread() { await threadsStore.updateThread({ - threadId: threadsStore.currentThread!.id, + threadId: currentThread.value!.id, isArchived: true, }) await router.push('/threads') @@ -163,7 +179,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 +234,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', }) @@ -267,8 +283,17 @@ onBeforeUnmount(() => { - - {{ formatPhoneNumber(threadsStore.currentThread.contact) }} + + + + +