From c6dd78d60c0c37dc96135126b6999a6a5020d6e4 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 16:21:12 +0800 Subject: [PATCH] feat(contacts): align contact inbox creation --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 21 +-- internal/handler/api/v1/contact_handler.go | 119 +++++++++++++++-- .../api/v1/contact_handler_crud_test.go | 87 +++++++++++++ internal/repository/contact_inbox_repo.go | 14 +- .../repository/contact_inbox_repo_test.go | 15 ++- internal/service/contact_inbox_service.go | 123 +++++++++++++++--- .../service/contact_inbox_service_test.go | 78 +++++++++++ 7 files changed, 421 insertions(+), 36 deletions(-) create mode 100644 internal/service/contact_inbox_service_test.go diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index bfb8eb13..1b21fa39 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,12 +49,12 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 contact inbox creation parity planning checkpoint, prepared as `docs: land contact inbox parity plan`. -- Latest implementation checkpoint: 2026-06-06 account reporting events checkpoint, prepared as `feat(reporting): align account events`. +- Current tracking checkpoint: 2026-06-06 nested contact inbox creation checkpoint, prepared as `feat(contacts): align contact inbox creation`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(contacts): align contact inbox creation`. - Latest documentation/tooling checkpoint: this tracker update for P3.23 plus the landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at the latest implementation checkpoint: account reporting events from `reference/chatwoot/config/routes.rb:234`, enterprise `Api::V1::Accounts::ReportingEventsController#index`, and its index Jbuilder are implemented. `GET /api/v1/accounts/:account_id/reporting_events` now returns Chatwoot `{ payload, meta }`, filters by optional Unix `since/until`, `inbox_id`, `user_id`, and `name`, orders by `created_at DESC`, paginates at 25 rows per page, and reuses the `_reporting_event` raw serializer. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. -- Next executable implementation checkpoint: P3.23 nested contact inbox creation parity from `reference/chatwoot/config/routes.rb:212`, `Api::V1::Accounts::Contacts::ContactInboxesController#create`, `ContactInboxBuilder`, `HmacConcern`, and the contact inbox Jbuilder partial. +- Worktree status at this implementation checkpoint: nested contact inbox creation from `reference/chatwoot/config/routes.rb:212`, `Api::V1::Accounts::Contacts::ContactInboxesController#create`, `ContactInboxBuilder`, `HmacConcern`, and the contact inbox Jbuilder partial is implemented. `POST /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes` now account-scopes contact/inbox resolution, accepts `inbox_id`, optional `source_id`, and `hmac_verified`, generates missing source IDs by supported channel, returns existing contact+inbox+source rows idempotently, stores HMAC verification only on creation, and returns raw `{ source_id, inbox }`. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Next executable implementation checkpoint: continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 933`; P3.22 changes account reporting-events behavior and expands tracked route parity without adding a new Go route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -88,10 +88,9 @@ Execution queue for the next agent turn: | Order | Slice ID | Why now | Required commit contents | | --- | --- | --- | --- | -| 1 | P3.23 nested contact inbox creation | Fresh reference inspection found payload/side-effect drift in `POST /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes`: Go currently requires `source_id`, ignores `hmac_verified`, returns the local model shape, and does not generate channel-specific source IDs like Chatwoot. | Handler/service/repository parity for account-scoped contact/inbox resolution, raw `source_id` + `inbox` response, idempotent contact/inbox/source creation, HMAC flag creation, generated source IDs, focused tests, route artifact check, full `go test ./...`, and `git diff --check`. | -| 2 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. | -| 3 | Phase 6 placeholder audit | `chatwootParityStub` remains only as webhook nil-handler fallback, but every audit result must stay recorded so no frontend-critical stub becomes ownerless. | `rg` audit result, either a burn-down implementation or explicit non-frontend fallback classification, focused tests if code changes. | -| 4 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. | +| 1 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. | +| 2 | Phase 6 placeholder audit | `chatwootParityStub` remains only as webhook nil-handler fallback, but every audit result must stay recorded so no frontend-critical stub becomes ownerless. | `rg` audit result, either a burn-down implementation or explicit non-frontend fallback classification, focused tests if code changes. | +| 3 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. | Slice lifecycle: @@ -141,7 +140,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 0 | P3.23 nested contact inbox creation API | Fresh drift is tracked from Chatwoot nested contact inbox creation. Current Go route exists, but create behavior is not yet Chatwoot-compatible: request parsing is JSON-body only, `source_id` is required instead of generated per channel, duplicate lookup is contact+inbox rather than contact+inbox+source, `hmac_verified` is ignored, account scoping needs tightening, and the response is the local model instead of the raw contact-inbox partial. | Implement `feat(contacts): align contact inbox creation` from the P3.23 contract, then regenerate/check route artifacts if route tracking changes. | Focused nested ContactInbox handler/service/repository tests, route parity check, full `go test ./...`, `git diff --check`, and this tracker row updated to Review. | +| 0 | P3.23 nested contact inbox creation API | Implemented for Chatwoot nested contact inbox creation: raw JSON/form/query params are accepted, contact and inbox are account-scoped, missing source IDs are generated through Chatwoot channel rules, duplicate contact+inbox+source rows are returned idempotently, `hmac_verified` is persisted on creation, and the response is raw `{ source_id, inbox }` rather than the local model/envelope. | Keep in Review; reopen only if live CRM/new-conversation smoke exposes inbox access-policy, unsupported channel, or serializer drift beyond the inspected controller/builder/Jbuilder contract. | Focused nested ContactInbox handler/service/repository tests, route parity check, full `go test ./...`, and `git diff --check` passed. | | 1 | P3.2a invitation/confirmation mail parity | Implemented for current non-SSO reference behavior: profile resend is no longer a TODO-only log, new invited agents and unconfirmed invited profile resends generate reset-password invitation links, normal unconfirmed profile resends generate confirmation links, `users.unconfirmed_email` is modeled for email-update routing, and fakeable/environment SMTP mailers keep default tests offline. | Keep in Review; reopen only if reused frontend smoke or fresh reference evidence exposes additional Devise confirmation states outside excluded SSO/SAML/LDAP/OIDC variants. | Focused profile and agent invitation tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed. | | 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 387-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Dyte routes from `routes.rb:357-358`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Dyte create/join payload behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after account reporting-events parity or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 3 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; refreshed `docs/parity/placeholder_audit.md` shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Keep in Review; reopen only if fresh `rg`, route smoke, or B12 finds a frontend-reachable placeholder/stub in account/contact/conversation/message/inbox/widget/public paths. | `rg` placeholder audit and `scripts/parity_frontend_smoke.sh --check` are recorded; no reused-frontend blocker is ownerless. | @@ -170,7 +169,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | -| P3.23 nested contact inbox creation parity | `internal/handler/api/v1/contact_handler.go`, `internal/service/contact_inbox_service.go`, `internal/repository/contact_inbox_repo.go`, `internal/handler/api/v1/crm_serializer.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, nested contact inbox handler/service/repository tests | `reference/chatwoot/config/routes.rb:212`, `reference/chatwoot/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb`, `reference/chatwoot/app/controllers/concerns/hmac_concern.rb`, `reference/chatwoot/app/builders/contact_inbox_builder.rb`, `reference/chatwoot/app/views/api/v1/accounts/contacts/contact_inboxes/create.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_contact_inbox.json.jbuilder`, nested contact inbox request specs if present | Implement Chatwoot `ContactInboxBuilder` behavior for `POST /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes`: parse `inbox_id`, optional `source_id`, and `hmac_verified` from raw frontend-compatible params; resolve both contact and inbox inside the account; generate missing source IDs by channel (`api`/`web_widget` UUID, email from contact email, sms from phone, whatsapp phone without `+`, twilio sms/whatsapp medium); create or return the existing contact+inbox+source row; set `hmac_verified` only on creation; preserve generated tokens; preload inbox; and return only `{ source_id, inbox: inbox_slim }` without the local envelope/model fields. | Focused tests cover raw response shape, source ID generation, missing email/phone validation, hmac flag creation, duplicate idempotency by source, cross-account contact/inbox rejection, route parity, full `go test ./...`, and `git diff --check`. | +| P3.23 nested contact inbox creation parity | `internal/handler/api/v1/contact_handler.go`, `internal/service/contact_inbox_service.go`, `internal/repository/contact_inbox_repo.go`, `internal/handler/api/v1/crm_serializer.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, nested contact inbox handler/service/repository tests | `reference/chatwoot/config/routes.rb:212`, `reference/chatwoot/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb`, `reference/chatwoot/app/controllers/concerns/hmac_concern.rb`, `reference/chatwoot/app/builders/contact_inbox_builder.rb`, `reference/chatwoot/app/views/api/v1/accounts/contacts/contact_inboxes/create.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_contact_inbox.json.jbuilder`, `reference/chatwoot/spec/controllers/api/v1/accounts/contacts/contact_inboxes_controller_spec.rb` | Done. Chatwoot `ContactInboxBuilder` behavior is implemented for `POST /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes`: raw JSON/form/query params provide `inbox_id`, optional `source_id`, and `hmac_verified`; contact and inbox resolution is account-scoped; missing source IDs are generated by supported channel (`api`/`web_widget` UUID, email, sms phone, whatsapp phone without `+`, twilio sms/whatsapp medium); existing contact+inbox+source rows are returned idempotently; `hmac_verified` is set on creation; tokens are generated; inbox is preloaded; and the response is only `{ source_id, inbox: inbox_slim }`. | Review by `feat(contacts): align contact inbox creation`; focused handler tests cover raw payload shape, HMAC creation, generated source IDs, email idempotency, cross-account inbox rejection, and missing-phone Twilio failure; service tests cover WhatsApp/Twilio generation and idempotency; repository tests cover contact+inbox+source lookup; route parity, full `go test ./...`, and `git diff --check` passed. | | P3.2a invitation/confirmation mail parity | `internal/service/profile_service.go`, `internal/service/profile_confirmation_mailer.go`, `internal/handler/api/v1/profile_handler.go`, `internal/service/agent_service.go`, `internal/repository/agent_repo.go`, `internal/model/user.go`, `migrations/000032_add_users_unconfirmed_email.*`, profile/agent handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/agents_controller.rb`, `reference/chatwoot/app/builders/agent_builder.rb`, `reference/chatwoot/app/views/devise/mailer/confirmation_instructions.html.erb`, `reference/chatwoot/spec/mailers/confirmation_instructions_spec.rb`, `reference/chatwoot/spec/enterprise/mailers/devise_mailer_spec.rb` for non-SAML invitation wording only | Done. A shared fakeable confirmation mailer builds Chatwoot-shaped confirmation/invitation payloads; profile resend persists confirmation/reset tokens and delivers no-op/confirmation/invitation states; newly created invited agents get workspace invitation mail; `unconfirmed_email` is modeled for email-update branch routing; environment SMTP remains a no-op when not configured. SSO/SAML/LDAP/OIDC mail variants stay excluded. | Review by `feat(profile): send confirmation invitations`; focused tests cover confirmed no-op, normal confirmation mail, invited workspace invitation mail, agent creation/inviter context, hashed reset-token persistence, and no network in default tests; full `go test ./...` and `git diff --check` passed. | | P5.11a Captain document crawl/schedule | `internal/service/captain_document_service.go`, `internal/service/captain_document_worker.go`, `internal/app/bootstrap.go` | `reference/chatwoot/enterprise/app/jobs/captain/documents/crawl_job.rb`, `schedule_syncs_job.rb`, `perform_sync_job.rb`, Firecrawl/simple parser jobs | Durable schedule/crawl producers and handlers with fakeable crawl/parser boundaries. Missing provider config is a failed `crawl_disabled` state, not placeholder success. | Review by `feat(captain): queue document crawl jobs`; focused worker tests prove enqueue, replay, account scope, idempotent scheduler, and disabled/failure states. | | P5.11b Captain response/embedding fan-out | Captain document/assistant-response services and repositories, Meilisearch/embedding boundaries | `response_builder_job.rb`, `enterprise/app/jobs/captain/llm/update_embedding_job.rb`, FAQ generator/embedding services | Queue FAQ response generation after successful document content changes, reset unedited responses, create/update assistant responses, and fan out embedding update work behind fakeable LLM gates. | Review by `feat(captain): queue response embedding jobs`; tests cover response reset/create, embedding-disabled retry, fake embedding success, account scope, and no external network in default tests. | @@ -238,6 +237,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(contacts): align contact inbox creation` | Advances P3.23 nested contact inbox creation parity by matching Chatwoot `Api::V1::Accounts::Contacts::ContactInboxesController#create`, `ContactInboxBuilder`, `HmacConcern`, route `212`, request specs, and the contact inbox Jbuilder partial. GoChat now accepts raw JSON/form/query params, account-scopes contact and inbox lookup, generates source IDs for API/WebWidget/Email/Sms/Whatsapp/Twilio channels, returns existing contact+inbox+source rows idempotently, persists `hmac_verified` on creation, and returns raw `{ source_id, inbox }` instead of the local model. | `go test ./internal/handler/api/v1 ./internal/service ./internal/repository -run 'ContactInbox\|ContactHandlerCRUD' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router ./cmd/route_parity -run 'ContactInbox\|ContactHandlerCRUD\|Router\|RouteParity' -count=1`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump remains `TOTAL: 933`; tracked route parity remains `374 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 387`. | P3.23 moves to Review for current nested contact inbox creation evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `docs: land contact inbox parity plan` | Documentation-only checkpoint requested before continuing implementation. Confirms the clean committed baseline at `2072396 feat(reporting): align account events`, lands P3.23 nested contact inbox creation as the next executable slice, and records the exact Chatwoot route/controller/builder/HMAC/Jbuilder references plus Go owner files, behavior gaps, and exit gates. | `git diff --check`. No Go code or route artifacts changed. | Start `feat(contacts): align contact inbox creation`, then continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from concrete reference/smoke evidence. | | `feat(reporting): align account events` | Advances P3.22 account reporting-events parity by matching Chatwoot enterprise `Api::V1::Accounts::ReportingEventsController#index`, route `234`, `DateRangeHelper`, request specs, and the `_reporting_event` Jbuilder partial. GoChat now returns `{ payload, meta }` instead of the local success envelope, treats date filtering as optional Unix `since/until`, supports `inbox_id`, `user_id`, and `name` filters, orders by `created_at DESC`, paginates at Chatwoot's fixed 25 rows per page, and tracks the account route in route parity. | `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router ./cmd/route_parity -run 'ReportingEvent\|Router\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 933`; tracked route parity is `374 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 387`. | P3.22 moves to Review for current enterprise account reporting-events evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(conversations): expose reporting events` | Advances P3.21 conversation reporting-events parity by matching Chatwoot enterprise `Api::V1::Accounts::ConversationsController#reporting_events`, route `166`, and the `_reporting_event` Jbuilder partial. GoChat now registers and tracks `GET /api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events`, resolves conversations by display ID with legacy ID fallback, scopes reporting events by account and conversation, orders by `created_at ASC`, and returns the raw Chatwoot array shape with nullable relation keys preserved. | `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router ./cmd/route_parity -run 'ReportingEvent\|Conversation\|Router\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 933`; tracked route parity is `373 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 386`. | P3.21 moves to Review for current enterprise conversation reporting-event evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2435,3 +2435,4 @@ Verification milestone gates: - 2026-06-06: P3.21 conversation reporting events checkpoint prepared as `feat(conversations): expose reporting events`; audited Chatwoot enterprise conversation `reporting_events`, route `166`, and `_reporting_event` Jbuilder serializer. GoChat now exposes and tracks `GET /api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events`, resolves conversations by display ID with legacy ID fallback, scopes raw reporting-event rows by account and conversation, orders by `created_at ASC`, and returns the raw Chatwoot event array with nullable relation keys preserved. Focused Conversation handler tests, route dump/parity regeneration (`TOTAL: 933`, `373 exact`, `13 parameter-compatible`, `0 missing out of 386`), full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: P3.22 account reporting events checkpoint prepared as `feat(reporting): align account events`; audited Chatwoot enterprise account reporting events controller/specs, route `234`, `DateRangeHelper`, and `_reporting_event` Jbuilder serializer. GoChat now returns `{ payload, meta }`, accepts optional Unix `since/until`, `inbox_id`, `user_id`, and `name` filters, orders account reporting events by `created_at DESC`, uses fixed 25-row pagination, and tracks the account reporting-events route. Focused ReportingEvent handler tests and route parity regeneration (`TOTAL: 933`, `374 exact`, `13 parameter-compatible`, `0 missing out of 387`) passed; full `go test ./...` and `git diff --check` passed. - 2026-06-06: Documentation checkpoint prepared as `docs: land contact inbox parity plan`; worktree was clean at `2072396 feat(reporting): align account events`, and the active plan now lands P3.23 nested contact inbox creation as the next executable slice. The row records Chatwoot `contacts/contact_inboxes#create`, `ContactInboxBuilder`, `HmacConcern`, contact inbox Jbuilder references, current Go owner files, source-ID/HMAC/idempotency/account-scope/serializer gaps, and focused exit gates. Verification for this docs-only checkpoint: `git diff --check`; next slice is `feat(contacts): align contact inbox creation`. +- 2026-06-06: P3.23 nested contact inbox creation checkpoint prepared as `feat(contacts): align contact inbox creation`; audited Chatwoot nested contact inbox controller/specs, `ContactInboxBuilder`, `HmacConcern`, route `212`, and contact inbox Jbuilder serializer. GoChat now account-scopes nested contact/inbox creation, accepts raw JSON/form/query `inbox_id`, `source_id`, and `hmac_verified`, generates missing source IDs for API/WebWidget/Email/Sms/Whatsapp/Twilio channels, returns existing contact+inbox+source rows idempotently, persists HMAC verification on creation, and returns raw `{ source_id, inbox }`. Focused handler/service/repository tests passed; route parity and full `go test ./...` plus `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. diff --git a/internal/handler/api/v1/contact_handler.go b/internal/handler/api/v1/contact_handler.go index 35e75e4b..242e1077 100644 --- a/internal/handler/api/v1/contact_handler.go +++ b/internal/handler/api/v1/contact_handler.go @@ -1,10 +1,12 @@ package v1 import ( + "encoding/json" "errors" "io" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -668,32 +670,131 @@ func parseIntOrDefault(c *gin.Context, key string, defaultVal int) int { // POST /api/v1/accounts/:id/contacts/:contact_id/contact_inboxes // Reference: Chatwoot contact_inboxes#create func (h *ContactHandler) CreateContactInbox(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) + return + } + contactID, err := parseUintParam(c, "contact_id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"}) return } - var req struct { - InboxID uint `json:"inbox_id" binding:"required"` - SourceID string `json:"source_id"` + contact, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID) + if svcErr != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "contact not found"}) + return } - if bindErr := c.ShouldBindJSON(&req); bindErr != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()}) + + req, bindErr := parseNestedContactInboxCreateParams(c) + if bindErr != nil || req.InboxID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "inbox_id is required"}) + return + } + + var inbox model.Inbox + db := h.svc.DB() + if db == nil || db.WithContext(c.Request.Context()).Where("account_id = ? AND id = ?", accountID, req.InboxID).First(&inbox).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "inbox not found"}) return } ci, svcErr := h.contactInboxSvc.Create(c.Request.Context(), service.CreateContactInboxRequest{ - ContactID: contactID, - InboxID: req.InboxID, - SourceID: req.SourceID, + ContactID: contact.ID, + InboxID: inbox.ID, + SourceID: req.SourceID, + HMACVerified: req.HMACVerified, + Contact: contact, + Inbox: &inbox, }) if svcErr != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create contact inbox"}) return } - c.JSON(http.StatusCreated, ci) + c.JSON(http.StatusOK, serializeContactInbox(ci)) +} + +type nestedContactInboxCreateParams struct { + InboxID uint + SourceID string + HMACVerified bool +} + +func parseNestedContactInboxCreateParams(c *gin.Context) (nestedContactInboxCreateParams, error) { + params := nestedContactInboxCreateParams{} + if strings.Contains(strings.ToLower(c.GetHeader("Content-Type")), "application/json") { + var body map[string]any + if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { + return params, err + } + params.InboxID = uintValue(body["inbox_id"]) + params.SourceID = stringValue(body["source_id"]) + params.HMACVerified = boolValue(body["hmac_verified"]) + } else { + if err := c.Request.ParseForm(); err != nil { + return params, err + } + params.InboxID = uintStringValue(c.PostForm("inbox_id")) + params.SourceID = c.PostForm("source_id") + params.HMACVerified = boolStringValue(c.PostForm("hmac_verified")) + } + if value := c.Query("inbox_id"); value != "" { + params.InboxID = uintStringValue(value) + } + if value := c.Query("source_id"); value != "" { + params.SourceID = value + } + if value := c.Query("hmac_verified"); value != "" { + params.HMACVerified = boolStringValue(value) + } + return params, nil +} + +func uintValue(value any) uint { + switch v := value.(type) { + case float64: + return uint(v) + case json.Number: + if n, err := strconv.ParseUint(string(v), 10, 64); err == nil { + return uint(n) + } + case string: + return uintStringValue(v) + } + return 0 +} + +func uintStringValue(value string) uint { + n, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0 + } + return uint(n) +} + +func stringValue(value any) string { + if v, ok := value.(string); ok { + return v + } + return "" +} + +func boolValue(value any) bool { + switch v := value.(type) { + case bool: + return v + case string: + return boolStringValue(v) + } + return false +} + +func boolStringValue(value string) bool { + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && parsed } // DeleteContactInbox removes a contact-inbox association. diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index 2b5504eb..95c99fbd 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -103,6 +103,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() { s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.ListLabels) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.UpdateLabels) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.ListContactInboxes) + s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.CreateContactInbox) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/destroy_custom_attributes", s.handler.DestroyCustomAttributes) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.ListNotes) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.CreateNote) @@ -979,6 +980,92 @@ func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_EmptyResult() { s.Equal(float64(0), meta["count"]) } +func (s *ContactHandlerCRUDTestSuite) TestCreateContactInbox_GeneratesWebWidgetSourceAndRawPayload() { + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1} + s.Require().NoError(s.db.Create(inbox).Error) + + w := httptest.NewRecorder() + body := []byte(fmt.Sprintf(`{"inbox_id":%d,"hmac_verified":true}`, inbox.ID)) + req, _ := http.NewRequest(http.MethodPost, + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, s.contact.ID), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code, w.Body.String()) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Contains(resp, "source_id") + s.NotContains(resp, "id") + s.NotContains(resp, "contact_id") + s.Len(resp["source_id"].(string), 36) + inboxPayload := resp["inbox"].(map[string]interface{}) + s.Equal(float64(inbox.ID), inboxPayload["id"]) + + var ci model.ContactInbox + s.Require().NoError(s.db.Where("contact_id = ? AND inbox_id = ?", s.contact.ID, inbox.ID).First(&ci).Error) + s.True(ci.HMACVerified) + s.Equal(resp["source_id"], ci.SourceID) +} + +func (s *ContactHandlerCRUDTestSuite) TestCreateContactInbox_EmailSourceIsIdempotent() { + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Email", ChannelType: "Channel::Email", ChannelID: 1} + s.Require().NoError(s.db.Create(inbox).Error) + body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID)) + + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, s.contact.ID), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Equal(s.contact.Email, resp["source_id"]) + } + + var count int64 + s.Require().NoError(s.db.Model(&model.ContactInbox{}). + Where("contact_id = ? AND inbox_id = ? AND source_id = ?", s.contact.ID, inbox.ID, s.contact.Email). + Count(&count).Error) + s.Equal(int64(1), count) +} + +func (s *ContactHandlerCRUDTestSuite) TestCreateContactInbox_CrossAccountInboxRejected() { + otherAccount := &model.Account{Name: "Other"} + s.Require().NoError(s.db.Create(otherAccount).Error) + inbox := &model.Inbox{AccountID: otherAccount.ID, Name: "Other Web", ChannelType: "web_widget", ChannelID: 1} + s.Require().NoError(s.db.Create(inbox).Error) + + w := httptest.NewRecorder() + body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID)) + req, _ := http.NewRequest(http.MethodPost, + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, s.contact.ID), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusNotFound, w.Code) +} + +func (s *ContactHandlerCRUDTestSuite) TestCreateContactInbox_TwilioWithoutPhoneReturnsUnprocessable() { + contact := &model.Contact{AccountID: s.account.ID, Name: "No Phone", Email: "no-phone@example.com"} + s.Require().NoError(s.db.Create(contact).Error) + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Twilio", ChannelType: "Channel::TwilioSms", ChannelID: 1} + s.Require().NoError(s.db.Create(inbox).Error) + + w := httptest.NewRecorder() + body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID)) + req, _ := http.NewRequest(http.MethodPost, + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, contact.ID), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusUnprocessableEntity, w.Code) + var count int64 + s.Require().NoError(s.db.Model(&model.ContactInbox{}).Where("contact_id = ? AND inbox_id = ?", contact.ID, inbox.ID).Count(&count).Error) + s.Equal(int64(0), count) +} + func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_CreatesConversationCallAndVoiceMessage() { inbox := s.createVoiceInbox(true, true) diff --git a/internal/repository/contact_inbox_repo.go b/internal/repository/contact_inbox_repo.go index 7a6dce82..38325548 100644 --- a/internal/repository/contact_inbox_repo.go +++ b/internal/repository/contact_inbox_repo.go @@ -39,6 +39,18 @@ func (r *ContactInboxRepo) FindByContactAndInbox(ctx context.Context, contactID, return &ci, nil } +// FindByContactInboxSource retrieves a contact_inbox by the Chatwoot builder identity. +func (r *ContactInboxRepo) FindByContactInboxSource(ctx context.Context, contactID, inboxID uint, sourceID string) (*model.ContactInbox, error) { + var ci model.ContactInbox + err := r.db.WithContext(ctx).Preload("Inbox"). + Where("contact_id = ? AND inbox_id = ? AND source_id = ?", contactID, inboxID, sourceID). + First(&ci).Error + if err != nil { + return nil, err + } + return &ci, nil +} + // FindByPubsubToken retrieves a contact_inbox by its pubsub_token, with Contact preloaded. // Used by WebSocket contact authentication (Chatwoot RoomChannel pubsub_token flow). func (r *ContactInboxRepo) FindByPubsubToken(ctx context.Context, pubsubToken string) (*model.ContactInbox, error) { @@ -132,4 +144,4 @@ func (r *ContactInboxRepo) Filter(ctx context.Context, accountID uint, inboxID, Preload("Contact").Preload("Inbox"). Find(&cis).Error return cis, total, err -} \ No newline at end of file +} diff --git a/internal/repository/contact_inbox_repo_test.go b/internal/repository/contact_inbox_repo_test.go index c49dda59..5bee9e62 100644 --- a/internal/repository/contact_inbox_repo_test.go +++ b/internal/repository/contact_inbox_repo_test.go @@ -84,6 +84,19 @@ func TestContactInboxRepo_FindByContactAndInbox_NotFound(t *testing.T) { assert.Nil(t, found) } +func TestContactInboxRepo_FindByContactInboxSource(t *testing.T) { + db := setupTestDB(t) + repo := NewContactInboxRepo(db) + + ci := createTestContactInbox(t, db, "browser-abc", "pub-source") + + found, err := repo.FindByContactInboxSource(context.Background(), ci.ContactID, ci.InboxID, "browser-abc") + assert.NoError(t, err) + assert.Equal(t, ci.ID, found.ID) + assert.Equal(t, ci.SourceID, found.SourceID) + assert.Equal(t, ci.InboxID, found.Inbox.ID) +} + // 5. FindByPubsubToken – success (with Contact preloaded) func TestContactInboxRepo_FindByPubsubToken(t *testing.T) { db := setupTestDB(t) @@ -290,4 +303,4 @@ func TestContactInboxRepo_DeleteByContactAndInbox(t *testing.T) { found, err := repo.FindByContactAndInbox(context.Background(), ci.ContactID, ci.InboxID) assert.Error(t, err) assert.Nil(t, found) -} \ No newline at end of file +} diff --git a/internal/service/contact_inbox_service.go b/internal/service/contact_inbox_service.go index a7c93edb..11c8f924 100644 --- a/internal/service/contact_inbox_service.go +++ b/internal/service/contact_inbox_service.go @@ -4,12 +4,15 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" + "fmt" + "strings" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" - pkgvalidator "github.com/gochat/gochat/pkg/validator" + "github.com/google/uuid" ) // ContactInboxService implements business logic for ContactInbox operations. @@ -50,21 +53,35 @@ func (s *ContactInboxService) GetBySourceID(ctx context.Context, inboxID uint, s // CreateContactInboxRequest is the DTO for creating a contact_inbox. type CreateContactInboxRequest struct { - ContactID uint `json:"contact_id" validate:"required"` - InboxID uint `json:"inbox_id" validate:"required"` - SourceID string `json:"source_id" validate:"required"` + ContactID uint `json:"contact_id"` + InboxID uint `json:"inbox_id"` + SourceID string `json:"source_id"` + HMACVerified bool `json:"hmac_verified"` + Contact *model.Contact `json:"-"` + Inbox *model.Inbox `json:"-"` } // Create adds a new contact_inbox join record. func (s *ContactInboxService) Create(ctx context.Context, req CreateContactInboxRequest) (*model.ContactInbox, error) { - if err := pkgvalidator.ValidateStruct(req); err != nil { - return nil, err + if req.ContactID == 0 || req.InboxID == 0 { + return nil, errors.New("contact_id and inbox_id are required") } - // Check for duplicate - existing, err := s.repo.FindByContactAndInbox(ctx, req.ContactID, req.InboxID) + sourceID := strings.TrimSpace(req.SourceID) + if sourceID == "" { + generated, err := generateContactInboxSourceID(req.Contact, req.Inbox) + if err != nil { + return nil, err + } + sourceID = generated + } + if sourceID == "" { + return nil, errors.New("source_id is required") + } + + existing, err := s.repo.FindByContactInboxSource(ctx, req.ContactID, req.InboxID, sourceID) if err == nil && existing != nil { - return nil, errors.New("contact_inbox already exists for this contact and inbox") + return existing, nil } hmacToken, err := generateToken(24) @@ -77,11 +94,15 @@ func (s *ContactInboxService) Create(ctx context.Context, req CreateContactInbox } ci := &model.ContactInbox{ - ContactID: req.ContactID, - InboxID: req.InboxID, - SourceID: req.SourceID, - HMACToken: hmacToken, - PubsubToken: pubsubToken, + ContactID: req.ContactID, + InboxID: req.InboxID, + SourceID: sourceID, + HMACToken: hmacToken, + PubsubToken: pubsubToken, + HMACVerified: req.HMACVerified, + } + if req.Inbox != nil { + ci.Inbox = *req.Inbox } if err := s.repo.Create(ctx, ci); err != nil { applogger.L().Errorf("ContactInboxService.Create failed: %v", err) @@ -90,6 +111,78 @@ func (s *ContactInboxService) Create(ctx context.Context, req CreateContactInbox return ci, nil } +func generateContactInboxSourceID(contact *model.Contact, inbox *model.Inbox) (string, error) { + if contact == nil || inbox == nil { + return "", errors.New("contact and inbox are required to generate source_id") + } + switch normalizedInboxChannelType(inbox.ChannelType) { + case "api", "web_widget": + return uuid.NewString(), nil + case "email": + if strings.TrimSpace(contact.Email) == "" { + return "", errors.New("contact email is required") + } + return contact.Email, nil + case "sms": + return contactPhoneSourceID(contact) + case "whatsapp": + phone, err := contactPhoneSourceID(contact) + if err != nil { + return "", err + } + return strings.ReplaceAll(phone, "+", ""), nil + case "twilio_sms": + phone, err := contactPhoneSourceID(contact) + if err != nil { + return "", err + } + if twilioInboxMedium(inbox) == "whatsapp" { + return fmt.Sprintf("whatsapp:%s", phone), nil + } + return phone, nil + default: + return "", fmt.Errorf("unsupported operation for this channel: %s", inbox.ChannelType) + } +} + +func contactPhoneSourceID(contact *model.Contact) (string, error) { + if strings.TrimSpace(contact.PhoneNumber) == "" { + return "", errors.New("contact phone number is required") + } + return contact.PhoneNumber, nil +} + +func normalizedInboxChannelType(channelType string) string { + normalized := strings.TrimSpace(channelType) + normalized = strings.TrimPrefix(normalized, "Channel::") + normalized = strings.ToLower(normalized) + normalized = strings.ReplaceAll(normalized, "_", "") + switch normalized { + case "webwidget": + return "web_widget" + case "twiliosms": + return "twilio_sms" + case "api", "email", "sms", "whatsapp": + return normalized + default: + return strings.ToLower(channelType) + } +} + +func twilioInboxMedium(inbox *model.Inbox) string { + if inbox == nil || strings.TrimSpace(inbox.ChannelConfig) == "" { + return "sms" + } + var config map[string]any + if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil { + return "sms" + } + if medium, ok := config["medium"].(string); ok && strings.TrimSpace(medium) != "" { + return strings.ToLower(strings.TrimSpace(medium)) + } + return "sms" +} + // Delete removes a contact_inbox by ID. func (s *ContactInboxService) Delete(ctx context.Context, id uint) error { return s.repo.Delete(ctx, id) @@ -114,4 +207,4 @@ func generateToken(byteLen int) (string, error) { return "", err } return hex.EncodeToString(b), nil -} \ No newline at end of file +} diff --git a/internal/service/contact_inbox_service_test.go b/internal/service/contact_inbox_service_test.go new file mode 100644 index 00000000..3e6b4d24 --- /dev/null +++ b/internal/service/contact_inbox_service_test.go @@ -0,0 +1,78 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" +) + +func TestContactInboxServiceCreate_GeneratesWhatsAppSourceID(t *testing.T) { + db := contactInboxServiceTestDB(t) + svc := NewContactInboxService(repository.NewContactInboxRepo(db)) + contact := &model.Contact{AccountID: 1, Name: "Jane", PhoneNumber: "+15550101010"} + require.NoError(t, db.Create(contact).Error) + inbox := &model.Inbox{AccountID: 1, Name: "WA", ChannelType: "Channel::Whatsapp", ChannelID: 1} + require.NoError(t, db.Create(inbox).Error) + + ci, err := svc.Create(context.Background(), CreateContactInboxRequest{ + ContactID: contact.ID, + InboxID: inbox.ID, + Contact: contact, + Inbox: inbox, + }) + require.NoError(t, err) + require.Equal(t, "15550101010", ci.SourceID) + require.Equal(t, inbox.ID, ci.Inbox.ID) +} + +func TestContactInboxServiceCreate_TwilioWhatsAppSourceID(t *testing.T) { + db := contactInboxServiceTestDB(t) + svc := NewContactInboxService(repository.NewContactInboxRepo(db)) + contact := &model.Contact{AccountID: 1, Name: "Jane", PhoneNumber: "+15550101010"} + require.NoError(t, db.Create(contact).Error) + inbox := &model.Inbox{AccountID: 1, Name: "Twilio WA", ChannelType: "twilio_sms", ChannelID: 1, ChannelConfig: `{"medium":"whatsapp"}`} + require.NoError(t, db.Create(inbox).Error) + + ci, err := svc.Create(context.Background(), CreateContactInboxRequest{ + ContactID: contact.ID, + InboxID: inbox.ID, + Contact: contact, + Inbox: inbox, + }) + require.NoError(t, err) + require.Equal(t, "whatsapp:+15550101010", ci.SourceID) +} + +func TestContactInboxServiceCreate_ReturnsExistingByContactInboxSource(t *testing.T) { + db := contactInboxServiceTestDB(t) + svc := NewContactInboxService(repository.NewContactInboxRepo(db)) + contact := &model.Contact{AccountID: 1, Name: "Jane"} + require.NoError(t, db.Create(contact).Error) + inbox := &model.Inbox{AccountID: 1, Name: "API", ChannelType: "api", ChannelID: 1} + require.NoError(t, db.Create(inbox).Error) + + first, err := svc.Create(context.Background(), CreateContactInboxRequest{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "browser-1", HMACVerified: true, Contact: contact, Inbox: inbox}) + require.NoError(t, err) + second, err := svc.Create(context.Background(), CreateContactInboxRequest{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "browser-1", Contact: contact, Inbox: inbox}) + require.NoError(t, err) + require.Equal(t, first.ID, second.ID) + require.True(t, second.HMACVerified) + + var count int64 + require.NoError(t, db.Model(&model.ContactInbox{}).Where("contact_id = ? AND inbox_id = ? AND source_id = ?", contact.ID, inbox.ID, "browser-1").Count(&count).Error) + require.Equal(t, int64(1), count) +} + +func contactInboxServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Contact{}, &model.Inbox{}, &model.ContactInbox{})) + return db +}