feat(contacts): align contact inbox creation

This commit is contained in:
2026-06-06 16:21:12 +08:00
parent 46a08e04c0
commit c6dd78d60c
7 changed files with 421 additions and 36 deletions
+11 -10
View File
@@ -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.
+110 -9
View File
@@ -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.
@@ -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)
+13 -1
View File
@@ -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
}
}
+14 -1
View File
@@ -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)
}
}
+108 -15
View File
@@ -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
}
}
@@ -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
}