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/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..71723bf1a --- /dev/null +++ b/api/pkg/entities/contact.go @@ -0,0 +1,79 @@ +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 + +// 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/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..6cca028ce --- /dev/null +++ b/api/pkg/repositories/mongo_contact_repository.go @@ -0,0 +1,239 @@ +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 + } + + session, err := repository.collection.Database().Client().StartSession() + if err != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot start transaction to store [%d] contacts", len(contacts))) + } + defer session.EndSession(ctx) + + _, err = session.WithTransaction(ctx, func(transactionCtx context.Context) (any, error) { + return repository.collection.InsertMany(transactionCtx, documents) + }) + if 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..de6fa40f0 --- /dev/null +++ b/api/pkg/services/contact_service.go @@ -0,0 +1,288 @@ +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 := service.tracer.Start(ctx) + 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)) + } + 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 + } + + 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/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 ` 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 925800d75..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() 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) }} + + + + +