From c2709f7db2e29c42ed02f91411bc95b027586578 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Wed, 26 Aug 2026 10:24:34 +0300 Subject: [PATCH 01/53] feat: add contacts management and thread resolution (#957) * docs: add contacts feature implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8dd82cae-8bc6-4eaa-9b90-95073720c577 * feat(api): add Contact entity and ContactProperties jsonb type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): wrap contact property errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): add ContactDetails to message threads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): add ContactRepository with GORM implementation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): add contact store/update/index requests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): keep contact sanitization ordered Move sanitizeUniqueStrings into the shared requests helper. Preserve first-seen ordering after normalization. Add a direct ToContacts nil-properties regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): add contact validator Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): accept common CSV MIME types Allow .csv extension only; accept common browser MIME fallbacks for uploads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): add ContactService with cached contact-map resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): opt in contact resolution Add contacts query parsing and optional ContactDetails enrichment through a contact map provider. DI passes nil until Task 8 wires ContactService. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): add ContactHandler and wire contacts into DI container - Add ContactHandler with authenticated /v1/contacts routes: GET/POST /v1/contacts, POST /v1/contacts/upload, PUT/DELETE /v1/contacts/:contactID. - Load user-scoped contact before Update/Delete so a missing or cross-user contact returns 404 (avoids the repo's zero-row Delete returning 204 while advertising 404). - Support one-and-many JSON create via ContactStoreRequest and reuse ContactService.CreateMany so cache invalidation stays single-shot for Store and CSV upload alike. - Add DI getters ContactRepository, ContactService, ContactHandlerValidator, ContactHandler and RegisterContactRoutes; register the routes alongside RegisterMessageThreadRoutes. - Finalise Task 7 by injecting container.ContactService() into MessageThreadService, replacing the temporary nil dependency. - Cover all five routes with real Fiber requests plus a shared ContactRepository fake that verifies service/repository effects, and pin the DI wiring with a compile-time test that constructs MessageThreadService with the ContactService. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(api): regenerate contacts swagger Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): mark contact fields optional Make emails/properties optional in contact request DTOs. Regenerate Swagger docs and add request JSON regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(web): regenerate API models with Contact types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(web): add contacts Pinia store Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(web): avoid false contact reload errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(web): add contacts management page with add/edit/delete/import dialogs Add the /contacts page grounded in the existing Vuetify theme. Header uses text-display-large; debounced search drives the API ?query= param. VDataTable columns: Name, Phone Numbers, Emails, Created, Updated, Actions. Rows show avatar initials, phone chips and relative timestamps via humanizeTime. Add/edit/delete/import-CSV dialogs use opacity 0.9 and warning-colored Close controls. Add/Edit has repeatable phone numbers, emails and free-form properties, preserved on edit. CSV import surfaces row-indexed API validation errors inline, not only via a toast. loadContacts forwards the trimmed search value as the query param, keeping Task 11's contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(web): guard stale contacts loads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(web): display contact names on threads Preserve contact details across mark-read updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): return accurate contacts total for server pagination Extend the contact repository/service/handler so GET /v1/contacts returns a top-level total count for the same user/query filter, independent of skip/limit, enabling true server-side pagination. - add ContactRepository.Count reusing a shared scopedContactQuery helper so Index and Count filters can never drift; Count ignores limit/offset - add ContactService.Count and handler responseOKWithTotal; Index returns total - add Total to responses.ContactsResponse and regenerate Swagger - sanitize parsed CSV rows before validation so CSV and JSON accept the same phone/email formats; drop the now-redundant re-sanitize on upload - assert Scan error behaviour instead of the unexported stacktrace type - tests for count filter parity, total propagation, and CSV sanitization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(web): server-side pagination for the contacts page - store total now reflects the server count; loadContacts accepts skip and limit and remembers the current window so mutation refreshes stay in place - contacts page uses VDataTableServer with items-length bound to the server total; page/size changes fetch the correct skip/limit and a debounced query resets to page 1 without duplicate requests - delete bumps the load generation so an in-flight load cannot resurrect a deleted contact, without leaving loading stuck or hiding delete errors - remove the dead filteredContacts computed and add defensive null coalescing for emails/phone_numbers in the table display - regenerate API models with the new contacts total field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(web): mark contacts headers unsortable Remove misleading sort affordances from the contacts table. Keep the subtitle aligned with the active search state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add contacts integration coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f3addc9-71dd-4cb7-bfae-e6fdcc8511af * fix(web): prefix Vuetify composables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(web): define contacts header styling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(web): refine contacts table Improve the contacts table layout, actions, relative timestamps, and header emphasis. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(web): define contact phone input Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(web): improve contact dialog fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: display of buttons on contacts page * feat(api): limit contacts by plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: updated mcp.json to remove axiom and playwright * fix(web): update reactive page composables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4a93bfbb-739a-414c-8eb4-48b15a69fb9d * fix: align contacts sorting and caching Propagate contact table sorting through the API and repository while keeping contact lookups on the process-local cache. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dec094f9-5386-4607-998a-1701ac10b7cf * Update api/pkg/repositories/gorm_contact_repository.go Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(api): delete contacts with user account Subscribe contact cleanup to account deletion events so user contact data is removed from storage and invalidated from the contact-map cache. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dec094f9-5386-4607-998a-1701ac10b7cf * fix(api): restore contact repository build Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dec094f9-5386-4607-998a-1701ac10b7cf * perf(contacts): bound thread contact lookups Fetch and cache only phone numbers present in the current thread page. Keep cache entries consistent across mutations and include the staged contact form UX updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffe6b904-8f76-4e89-a786-d07a0cc9096d * fix(contacts): address review regressions Stabilize asynchronous integration flows. Keep contact pagination, resolution, and generated API contracts deterministic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58beb14a-b8b6-4d9c-a27d-5e70118cef95 * feat(contacts): add MongoDB repository Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47a18375-9f8b-4f08-9014-ef78dcb72780 * feat(contacts): manage contacts from threads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 369472a4-40f8-4604-81c2-c4942b88f0b6 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Copilot-Session: 8dd82cae-8bc6-4eaa-9b90-95073720c577 Copilot-Session: 1f3addc9-71dd-4cb7-bfae-e6fdcc8511af Copilot-Session: 4a93bfbb-739a-414c-8eb4-48b15a69fb9d Copilot-Session: dec094f9-5386-4607-998a-1701ac10b7cf Copilot-Session: ffe6b904-8f76-4e89-a786-d07a0cc9096d Copilot-Session: 58beb14a-b8b6-4d9c-a27d-5e70118cef95 Copilot-Session: 47a18375-9f8b-4f08-9014-ef78dcb72780 Copilot-Session: 369472a4-40f8-4604-81c2-c4942b88f0b6 --- .mcp.json | 18 - api/docs/docs.go | 591 ++++ api/docs/swagger.json | 591 ++++ api/docs/swagger.yaml | 400 +++ api/pkg/di/container.go | 130 +- api/pkg/entities/contact.go | 79 + api/pkg/entities/contact_test.go | 71 + api/pkg/entities/message_thread.go | 2 + api/pkg/entities/message_thread_test.go | 18 + api/pkg/handlers/contact_handler.go | 322 ++ api/pkg/handlers/contact_handler_test.go | 658 ++++ api/pkg/handlers/handler.go | 9 + api/pkg/handlers/message_thread_handler.go | 1 + .../message_thread_handler_contacts_test.go | 75 + .../handlers/message_thread_handler_test.go | 2 +- api/pkg/listeners/contact_listener.go | 57 + api/pkg/listeners/contact_listener_test.go | 70 + .../listeners/message_thread_listener_test.go | 2 +- api/pkg/repositories/contact_repository.go | 37 + .../repositories/gorm_contact_repository.go | 187 + .../gorm_contact_repository_test.go | 365 ++ .../repositories/mongo_contact_repository.go | 239 ++ .../mongo_contact_repository_test.go | 124 + api/pkg/repositories/mongodb.go | 28 + api/pkg/requests/contact_index.go | 44 + api/pkg/requests/contact_store.go | 97 + api/pkg/requests/contact_store_test.go | 208 ++ api/pkg/requests/contact_update.go | 43 + .../requests/message_thread_index_request.go | 17 +- .../message_thread_index_request_test.go | 40 + api/pkg/requests/request.go | 18 + api/pkg/responses/contact_responses.go | 24 + api/pkg/services/contact_service.go | 288 ++ api/pkg/services/contact_service_test.go | 598 ++++ api/pkg/services/entitlement_service.go | 30 +- api/pkg/services/entitlement_service_test.go | 104 + api/pkg/services/message_thread_service.go | 36 +- .../message_thread_service_contacts_test.go | 159 + .../services/message_thread_service_test.go | 2 +- .../bulk_message_handler_validator.go | 4 - .../validators/contact_handler_validator.go | 359 ++ .../contact_handler_validator_test.go | 435 +++ .../validators/message_handler_validator.go | 2 - .../plans/2026-07-19-contacts-feature.md | 3025 +++++++++++++++++ .../2026-08-21-contact-phone-input-design.md | 29 + ...2026-08-21-contacts-table-header-design.md | 29 + tests/.env.test | 1 + tests/README.md | 1 + tests/contacts_integration_test.go | 332 ++ tests/helpers_test.go | 51 + tests/integration_test.go | 10 +- web/app/components/ContactDialog.vue | 411 +++ web/app/components/MessageThread.vue | 24 +- web/app/components/MessageThreadHeader.vue | 7 +- web/app/composables/useFilters.ts | 4 + web/app/pages/billing/index.vue | 2 +- .../end-to-end-encryption-to-sms-messages.vue | 4 +- ...ard-incoming-sms-from-phone-to-webhook.vue | 4 +- ...nd-and-read-sms-permissions-on-android.vue | 3 +- .../how-to-send-sms-messages-from-excel.vue | 4 +- web/app/pages/blog/index.vue | 2 +- ...nd-bulk-sms-from-csv-file-with-no-code.vue | 4 +- ...end-sms-from-android-phone-with-python.vue | 4 +- ...is-added-to-google-sheets-using-zapier.vue | 4 +- web/app/pages/bulk-messages/index.vue | 2 +- web/app/pages/contacts/index.vue | 535 +++ web/app/pages/heartbeats/[id].vue | 2 +- web/app/pages/index.vue | 2 +- web/app/pages/messages/index.vue | 2 +- web/app/pages/phone-api-keys/index.vue | 2 +- web/app/pages/search-messages/index.vue | 2 +- web/app/pages/settings/index.vue | 2 +- web/app/pages/threads/[id]/index.vue | 88 +- web/app/pages/threads/index.vue | 2 +- web/app/stores/contacts.ts | 263 ++ web/app/stores/threads.ts | 15 +- web/app/utils/filters.ts | 22 +- web/nuxt.config.ts | 3 + web/public/templates/httpsms-contacts.csv | 4 + web/shared/types/api.ts | 70 + 80 files changed, 11435 insertions(+), 120 deletions(-) create mode 100644 api/pkg/entities/contact.go create mode 100644 api/pkg/entities/contact_test.go create mode 100644 api/pkg/handlers/contact_handler.go create mode 100644 api/pkg/handlers/contact_handler_test.go create mode 100644 api/pkg/handlers/message_thread_handler_contacts_test.go create mode 100644 api/pkg/listeners/contact_listener.go create mode 100644 api/pkg/listeners/contact_listener_test.go create mode 100644 api/pkg/repositories/contact_repository.go create mode 100644 api/pkg/repositories/gorm_contact_repository.go create mode 100644 api/pkg/repositories/gorm_contact_repository_test.go create mode 100644 api/pkg/repositories/mongo_contact_repository.go create mode 100644 api/pkg/repositories/mongo_contact_repository_test.go create mode 100644 api/pkg/requests/contact_index.go create mode 100644 api/pkg/requests/contact_store.go create mode 100644 api/pkg/requests/contact_store_test.go create mode 100644 api/pkg/requests/contact_update.go create mode 100644 api/pkg/requests/message_thread_index_request_test.go create mode 100644 api/pkg/responses/contact_responses.go create mode 100644 api/pkg/services/contact_service.go create mode 100644 api/pkg/services/contact_service_test.go create mode 100644 api/pkg/services/entitlement_service_test.go create mode 100644 api/pkg/services/message_thread_service_contacts_test.go create mode 100644 api/pkg/validators/contact_handler_validator.go create mode 100644 api/pkg/validators/contact_handler_validator_test.go create mode 100644 docs/superpowers/plans/2026-07-19-contacts-feature.md create mode 100644 docs/superpowers/specs/2026-08-21-contact-phone-input-design.md create mode 100644 docs/superpowers/specs/2026-08-21-contacts-table-header-design.md create mode 100644 tests/contacts_integration_test.go create mode 100644 web/app/components/ContactDialog.vue create mode 100644 web/app/pages/contacts/index.vue create mode 100644 web/app/stores/contacts.ts create mode 100644 web/public/templates/httpsms-contacts.csv 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) }} + + + + +