feat(conversations): expose reporting events

This commit is contained in:
2026-06-06 15:39:49 +08:00
parent 495b194411
commit 0631249224
9 changed files with 187 additions and 9 deletions
+1
View File
@@ -107,6 +107,7 @@ var criticalRoutes = []route{
{Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/toggle_priority", Controller: "api/v1/accounts/conversations#toggle_priority", Source: "routes.rb:159"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/transcript", Controller: "api/v1/accounts/conversations#transcript", Source: "routes.rb:157"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/inbox_assistant", Controller: "api/v1/accounts/conversations#inbox_assistant", Source: "routes.rb:165"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events", Controller: "api/v1/accounts/conversations#reporting_events", Source: "routes.rb:166"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/messages/", Controller: "api/v1/accounts/conversations/messages#index", Source: "routes.rb:142"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/messages/", Controller: "api/v1/accounts/conversations/messages#create", Source: "routes.rb:142"},
+11 -7
View File
@@ -49,15 +49,15 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-06 conversation draft messages checkpoint, prepared as `feat(conversations): align draft message route`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align draft message route`.
- Latest documentation/tooling checkpoint: this tracker update for P3.20 plus the landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
- Current tracking checkpoint: 2026-06-06 conversation reporting events checkpoint, prepared as `feat(conversations): expose reporting events`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): expose reporting events`.
- Latest documentation/tooling checkpoint: this tracker update for P3.21 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 this implementation checkpoint: conversation draft messages from `reference/chatwoot/config/routes.rb:152`, `Api::V1::Accounts::Conversations::DraftMessagesController`, and request specs are implemented for Chatwoot's singular conversation-scoped draft contract. `GET/PATCH/PUT/DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` are now tracked and routed without a draft ID, resolve conversations by display ID with legacy ID fallback, store one effective draft per conversation, return raw `{ has_draft, message }` JSON for show, and return empty `200 OK` for update/delete. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: conversation reporting events from `reference/chatwoot/config/routes.rb:166`, enterprise `Api::V1::Accounts::ConversationsController#reporting_events`, and `enterprise/app/views/api/v1/models/_reporting_event.json.jbuilder` are implemented. `GET /api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events` is now tracked and routed, 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 reporting-event array with nullable relation keys preserved. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 932` after adding singular conversation draft message routes.
- Route dump succeeds with `TOTAL: 933` after adding the enterprise conversation reporting events route.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
- Tracked frontend-critical route audit covers 385 Chatwoot routes: 372 exact, 0 method-compatible, 13 parameter-compatible, 0 missing. The 13 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher.
- Tracked frontend-critical route audit covers 386 Chatwoot routes: 373 exact, 0 method-compatible, 13 parameter-compatible, 0 missing. The 13 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher.
- `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers.
- Handler test stability fixes are committed into the baseline before feature parity work continues.
- `.codegraph/` is generated indexing output and is not part of tracked product code.
@@ -140,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 |
| --- | --- | --- | --- | --- |
| 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 385-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`, and conversation inbox assistant route from `routes.rb:165` 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, 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 conversation draft message parity or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 386-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`, and conversation reporting events route from `routes.rb:166` 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, 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 conversation 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. |
| 4 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 5 | P3.10 account webhooks API | Implemented for the reused dashboard Webhooks settings route with PATCH update, Chatwoot `{ payload }` list/mutation serializers, nested `{ webhook: ... }` bodies, generated secret, account-scoped mutations, URL/subscription validation, optional inbox serialization, and empty `200 OK` delete. | Keep in Review; reopen only if live settings smoke exposes audit writer or delivery-signature drift beyond the existing delivery service boundary. | Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
@@ -154,6 +154,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| 13 | P3.18 conversation direct uploads API | Implemented for reused dashboard composer uploads: `POST /conversations/:conversation_id/direct_uploads` is tracked and registered, ActiveStorage blob metadata returns raw `signed_id/direct_upload` JSON, the returned local PUT URL stores bytes under account-scoped direct uploads, and conversation lookup uses display ID with legacy ID fallback. | Keep in Review; reopen only if live composer upload smoke exposes ActiveStorage header/status/storage drift beyond the inspected controller/frontend contract. | Focused upload handler/service/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 14 | P3.19 conversation inbox assistant API | Implemented for reused dashboard Copilot state: `GET /conversations/:conversation_id/inbox_assistant` is tracked and registered, conversation lookup uses display ID with legacy ID fallback, account-scoped `captain_inboxes` binds the inbox to a Captain assistant, and responses are exactly `{ assistant: { id, name } }` or `{ assistant: null }`. | Keep in Review; reopen only if live conversation/Copilot smoke exposes feature-flag, authorization, or serializer drift beyond the inspected enterprise controller/store contract. | Focused ConversationCrud handler tests, service/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 15 | P3.20 conversation draft messages API | Implemented for Chatwoot's singular draft-message route: `GET/PATCH/PUT/DELETE /conversations/:conversation_id/draft_messages` are tracked and registered without a draft ID, conversation lookup uses display ID with legacy ID fallback, show returns raw `{ has_draft, message }`, update upserts the nested `draft_message.message`, and delete returns empty `200 OK`. | Keep in Review; reopen only if live conversation composer smoke exposes Redis-backed multi-agent or authorization drift beyond the inspected controller/spec contract. | Focused DraftMessage handler/service/repository/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 16 | P3.21 conversation reporting events API | Implemented for Chatwoot enterprise conversation reporting events: `GET /conversations/:conversation_id/reporting_events` is tracked and registered, conversation lookup uses display ID with legacy ID fallback, events are account/conversation scoped and ordered by `created_at ASC`, and the response is the raw `_reporting_event` array with `id/name/value/value_in_business_hours/event_start_time/event_end_time/account_id/inbox_id/user_id/conversation_id/created_at/updated_at`. | Keep in Review; reopen only if live enterprise conversation analytics smoke exposes pagination, authorization, or serializer drift beyond the inspected enterprise controller/Jbuilder contract. | Focused Conversation handler tests, combined handler/service/repository/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 16 | P6.8 contact outbound voice call | Implemented for the reused dashboard route with Twilio voice-enabled inbox validation, assigned-inbox check, open-conversation reuse, ContactInbox/conversation/call/message persistence, and Chatwoot response shape. | Keep in Review; reopen only if live smoke exposes provider initiation/status-update drift beyond the fakeable persisted boundary. | Focused contact call tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 17 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. |
| 18 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. |
@@ -189,6 +190,7 @@ These rows are the executable development plan from this point forward. A checkp
| P3.18 conversation direct uploads API parity | `internal/router/router.go`, `internal/handler/api/v1/upload_handler.go`, `internal/service/upload_service.go`, `internal/app/bootstrap.go`, upload handler tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:151`, `reference/chatwoot/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb`, `reference/chatwoot/app/javascript/dashboard/composables/useFileUpload.js`, `reference/chatwoot/app/javascript/dashboard/mixins/fileUploadMixin.js` | Conversation direct uploads now match the reused dashboard composer boundary: Chatwoot's nested `POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads` route is tracked and registered, JSON ActiveStorage blob metadata returns raw `signed_id`, `direct_upload.url`, and headers, the returned local PUT target writes bytes to account-scoped storage, direct-upload rows keep `Source=account` and current account scope, and conversation resolution uses display ID with legacy ID fallback. | Review by `feat(conversations): align direct upload routes`; focused upload handler/service/router/route-parity tests, route dump/parity regeneration to `TOTAL: 927` and `367 exact, 13 parameter-compatible, 0 missing out of 380`, full `go test ./...`, and `git diff --check` passed. |
| P3.19 conversation inbox assistant API parity | `internal/router/router.go`, `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, conversation CRUD/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:165`, `reference/chatwoot/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/conversations/actions.js`, conversation store mutation that writes `copilotAssistant` | Conversation inbox assistant now matches the reused dashboard Copilot sidebar contract: `GET /api/v1/accounts/:account_id/conversations/:conversation_id/inbox_assistant` is tracked and registered, resolves conversations by account display ID with legacy ID fallback, joins `captain_inboxes` to `captain_assistants` with account and inbox scope, serializes only assistant `id/name`, and returns `{ assistant: null }` when no assistant is bound. | Review by `feat(conversations): expose inbox assistant`; focused ConversationCrud handler tests cover bound, unbound, and display-ID lookup paths; service/router/route-parity tests pass; route dump/parity regenerated to `TOTAL: 928` and `368 exact, 13 parameter-compatible, 0 missing out of 381`; full `go test ./...` and `git diff --check` passed. |
| P3.20 conversation draft messages API parity | `internal/router/router.go`, `internal/handler/api/v1/draft_message_handler.go`, `internal/service/draft_message_service.go`, `internal/repository/draft_message_repo.go`, draft message handler tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:152`, `reference/chatwoot/app/controllers/api/v1/accounts/conversations/draft_messages_controller.rb`, `reference/chatwoot/spec/controllers/api/v1/accounts/conversations/draft_messages_controller_spec.rb`, `reference/chatwoot/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue` local draft call sites for composer behavior context | Conversation draft messages now match Chatwoot's singular route contract: `GET/PATCH/PUT/DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` are tracked and registered without `:draft_id`, show returns raw `{ has_draft: false }` or `{ has_draft: true, message }`, update accepts nested `{ draft_message: { message } }` and returns empty `200 OK`, delete clears the conversation draft and returns empty `200 OK`, and conversation resolution uses display ID with legacy ID fallback. Legacy local collection/id draft routes remain only compatibility aliases. | Review by `feat(conversations): align draft message route`; focused DraftMessage handler tests cover raw show/no-draft, nested update, empty delete, display-ID lookup, and legacy route compatibility; service/repository/router/route-parity tests pass; route dump/parity regenerated to `TOTAL: 932` and `372 exact, 13 parameter-compatible, 0 missing out of 385`; full `go test ./...` and `git diff --check` passed. |
| P3.21 conversation reporting events API parity | `internal/router/router.go`, `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, `internal/repository/reporting_event_repo.go`, conversation handler tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:166`, `reference/chatwoot/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb`, `reference/chatwoot/enterprise/app/views/api/v1/accounts/conversations/reporting_events.json.jbuilder`, `reference/chatwoot/enterprise/app/views/api/v1/models/_reporting_event.json.jbuilder`, `reference/chatwoot/app/models/reporting_event.rb` | Conversation reporting events now match Chatwoot's enterprise member route boundary: `GET /api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events` is tracked and registered, resolves conversations by account display ID with legacy ID fallback, scopes event rows by account and conversation, orders by `created_at ASC`, and serializes a raw array with nullable `inbox_id`, `user_id`, and `conversation_id` keys preserved. | Review by `feat(conversations): expose reporting events`; focused Conversation handler tests cover raw array shape, ascending order, display-ID lookup, invalid params, and cross-account non-leakage; route dump/parity regenerated to `TOTAL: 933` and `373 exact, 13 parameter-compatible, 0 missing out of 386`; full `go test ./...` and `git diff --check` passed. |
| P6.8 contact outbound voice call parity | `internal/router/router.go`, `internal/handler/api/v1/contact_handler.go`, `internal/service/contact_service.go`, `internal/model/call.go`, contact handler tests | `reference/chatwoot/config/routes.rb:216`, `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb`, `reference/chatwoot/enterprise/app/services/voice/outbound_call_builder.rb`, `reference/chatwoot/enterprise/app/services/voice/call_message_builder.rb`, `reference/chatwoot/enterprise/app/models/call.rb`, dashboard `api/contacts.js`, `store/modules/contacts/actions.js`, `api/channel/voice/voiceAPIClient.js` | Contact outbound calls now match the Chatwoot enterprise route boundary: account contact lookup, current user's assigned `Channel::TwilioSms` inbox lookup, `voice_enabled` guard, phone-number guard, open display-ID conversation reuse only for same inbox/contact, new ContactInbox/open conversation creation when needed, persisted outgoing Twilio call metadata, linked `voice_call` message content attributes, and raw `{ conversation_id, inbox_id, call_sid, conference_sid }` response. | Review by `feat(contacts): initiate voice calls`; focused contact call tests cover success/reuse/resolved-hint ignored/no-phone/non-voice/unassigned cases; route dump/parity regenerated to `TOTAL: 861` and `299 exact, 7 parameter-compatible, 0 missing out of 306`; full `go test ./...` and `git diff --check` passed. |
| P6 conversation transcript response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, dashboard `api/inbox/conversation.js` | Account conversation transcript now follows Chatwoot's controller contract: missing email returns `422 { error: "email param missing" }`, nonblank email schedules through the existing service boundary and returns empty `200 OK`, and invalid-looking but nonblank email values are not rejected by local email format validation. | Review by `feat(conversations): align transcript responses`; focused transcript handler/service tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. |
| P6 conversation custom attributes response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, `app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder`, dashboard `api/inbox/conversation.js`, conversation store action | Account conversation custom attribute updates now return Chatwoot `{ custom_attributes: ... }` only, matching the store action that reads `response.data.custom_attributes`, instead of returning the full conversation serializer with unrelated fields. Empty/null JSON serializes as `{}`. | Review by `feat(conversations): align custom attribute response`; focused handler tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed. |
@@ -230,6 +232,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `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. |
| `feat(conversations): align draft message route` | Advances P3.20 conversation draft-message parity by matching Chatwoot `Api::V1::Accounts::Conversations::DraftMessagesController`, route `152`, and its request specs. GoChat now registers and tracks the singular `GET/PATCH/PUT/DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` routes without a draft ID, resolves conversations by display ID with legacy ID fallback, models the Redis key behavior as one effective persisted draft per conversation, returns raw `{ has_draft, message }` show payloads, accepts nested `{ draft_message: { message } }` updates, and returns empty `200 OK` for update/delete. | `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router ./cmd/route_parity -run 'DraftMessage\|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: 932`; tracked route parity is `372 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 385`. | P3.20 moves to Review for current conversation draft-message evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(conversations): expose inbox assistant` | Advances P3.19 conversation inbox assistant parity by matching Chatwoot enterprise `Api::V1::Accounts::ConversationsController#inbox_assistant`, route `165`, and reused dashboard `api/inbox/conversation.js` plus conversation store Copilot assistant state. GoChat now registers and tracks `GET /api/v1/accounts/:account_id/conversations/:conversation_id/inbox_assistant`, resolves conversations by display ID with legacy ID fallback, account-scopes the inbox-to-Captain-assistant join through `captain_inboxes` and `captain_assistants`, returns only assistant `id/name`, and returns `{ assistant: null }` for unbound inboxes. | `go test ./internal/handler/api/v1 ./internal/service ./internal/router ./cmd/route_parity -run 'ConversationCrud\|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: 928`; tracked route parity is `368 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 381`. | P3.19 moves to Review for current conversation Copilot assistant evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(conversations): align direct upload routes` | Advances P3.18 conversation direct upload parity by matching Chatwoot nested `Conversations::DirectUploadsController`, route `151`, and the reused dashboard ActiveStorage upload callers. GoChat now registers and tracks `POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads`, returns raw ActiveStorage `signed_id/direct_upload` metadata, validates the account-scoped conversation by display ID with legacy ID fallback, stores upload rows as account direct uploads, and exposes the local PUT URL used by the metadata response to persist bytes. | `go test ./internal/handler/api/v1 ./internal/service ./internal/router ./cmd/route_parity -run 'Upload\|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: 927`; tracked route parity is `367 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 380`. | P3.18 moves to Review for current composer upload evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
@@ -2421,3 +2424,4 @@ Verification milestone gates:
- 2026-06-06: P3.18 conversation direct upload checkpoint prepared as `feat(conversations): align direct upload routes`; audited Chatwoot nested direct upload route/controller and reused dashboard ActiveStorage upload callers. GoChat now exposes and tracks `POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads`, returns raw ActiveStorage `signed_id/direct_upload` metadata, validates account-scoped conversations by display ID with legacy ID fallback, and supports the returned local PUT upload target for account-scoped bytes. Focused upload handler/service/router/route-parity tests, route dump/parity regeneration (`TOTAL: 927`, `367 exact`, `13 parameter-compatible`, `0 missing out of 380`), 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.19 conversation inbox assistant checkpoint prepared as `feat(conversations): expose inbox assistant`; audited Chatwoot enterprise conversation `inbox_assistant`, route `165`, and reused dashboard conversation API/store Copilot assistant state. GoChat now exposes and tracks `GET /api/v1/accounts/:account_id/conversations/:conversation_id/inbox_assistant`, resolves conversations by display ID with legacy ID fallback, looks up the account-scoped Captain assistant bound to the conversation inbox, and returns `{ assistant: { id, name } }` or `{ assistant: null }`. Focused ConversationCrud handler/service/router/route-parity tests, route dump/parity regeneration (`TOTAL: 928`, `368 exact`, `13 parameter-compatible`, `0 missing out of 381`), 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.20 conversation draft message checkpoint prepared as `feat(conversations): align draft message route`; audited Chatwoot singular draft message route/controller/specs. GoChat now exposes and tracks `GET/PATCH/PUT/DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` without a draft ID, resolves conversations by display ID with legacy ID fallback, persists one effective draft per conversation, returns raw `{ has_draft, message }` show payloads, accepts nested `draft_message.message`, and returns empty `200 OK` for update/delete. Focused DraftMessage handler/service/repository/router/route-parity tests, route dump/parity regeneration (`TOTAL: 932`, `372 exact`, `13 parameter-compatible`, `0 missing out of 385`), 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.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.
+2 -1
View File
@@ -227,6 +227,7 @@ GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:messag
GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/delivery_status/
GET /api/v1/accounts/:account_id/conversations/:conversation_id/participants
GET /api/v1/accounts/:account_id/conversations/:conversation_id/participants/
GET /api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events
GET /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/
GET /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/:call_id
GET /api/v1/accounts/:account_id/conversations/meta
@@ -930,4 +931,4 @@ PUT /public/api/v1/csat_survey/:id
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
PUT /widget/direct_uploads/:upload_uuid
TOTAL: 932
TOTAL: 933
+2 -1
View File
@@ -7,7 +7,7 @@ Generated from:
This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`.
Summary: 372 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 385 tracked critical routes.
Summary: 373 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 386 tracked critical routes.
## Missing Critical Routes
@@ -150,6 +150,7 @@ These routes exist with equivalent method and path shape but different parameter
| GET | `/api/v1/accounts/:account_id/conversations/:conversation_id/labels` | `/api/v1/accounts/:account_id/conversations/:conversation_id/labels` | `api/v1/accounts/conversations/labels#index` | `routes.rb:149` | exact |
| GET | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/` | `api/v1/accounts/conversations/messages#index` | `routes.rb:142` | exact |
| GET | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `api/v1/accounts/conversations/participants#show` | `routes.rb:150` | exact |
| GET | `/api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events` | `/api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events` | `api/v1/accounts/conversations#reporting_events` | `routes.rb:166` | exact |
| GET | `/api/v1/accounts/:account_id/conversations/meta` | `/api/v1/accounts/:account_id/conversations/meta` | `api/v1/accounts/conversations#meta` | `routes.rb:136` | exact |
| GET | `/api/v1/accounts/:account_id/conversations/search` | `/api/v1/accounts/:account_id/conversations/search` | `api/v1/accounts/conversations#search` | `routes.rb:137` | exact |
| GET | `/api/v1/accounts/:account_id/conversations/unread_counts` | `/api/v1/accounts/:account_id/conversations/unread_counts` | `api/v1/accounts/conversations/unread_counts#index` | `routes.rb:138` | exact |
@@ -811,6 +811,52 @@ func (h *ConversationHandler) InboxAssistant(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"assistant": gin.H{"id": assistant.ID, "name": assistant.Name}})
}
// ReportingEvents returns raw reporting events for a conversation.
// GET /api/v1/accounts/:account_id/conversations/:conversation_id/reporting_events
// Reference: Chatwoot enterprise conversations_controller#reporting_events.
func (h *ConversationHandler) ReportingEvents(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
conversationID, err := parseUintParam(c, "conversation_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
return
}
events, svcErr := h.conversationSvc.ListReportingEvents(c.Request.Context(), accountID, conversationID)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
payload := make([]gin.H, 0, len(events))
for i := range events {
payload = append(payload, serializeReportingEvent(events[i]))
}
c.JSON(http.StatusOK, payload)
}
func serializeReportingEvent(event model.ReportingEvent) gin.H {
return gin.H{
"id": event.ID,
"name": event.Name,
"value": event.Value,
"value_in_business_hours": event.ValueInBusinessHours,
"event_start_time": event.EventStartTime,
"event_end_time": event.EventEndTime,
"account_id": event.AccountID,
"inbox_id": event.InboxID,
"user_id": event.UserID,
"conversation_id": event.ConversationID,
"created_at": event.CreatedAt,
"updated_at": event.UpdatedAt,
}
}
// ToggleTyping toggles the typing status for an agent in a conversation.
// POST /api/v1/accounts/:account_id/conversations/:id/toggle_typing_status
// Reference: Chatwoot conversations_controller#toggle_typing_status (member route)
@@ -68,6 +68,7 @@ func (s *ConversationHandlerTestSuite) SetupSuite() {
&model.ContactInbox{},
&model.Conversation{},
&model.ConversationParticipant{},
&model.ReportingEvent{},
&model.Message{},
&model.Attachment{},
&model.InboxMember{},
@@ -122,6 +123,7 @@ func (s *ConversationHandlerTestSuite) SetupSuite() {
conversations.POST("/:conversation_id/transcript", handler.Transcript)
conversations.POST("/:conversation_id/custom_attributes", handler.UpdateCustomAttributes)
conversations.GET("/:conversation_id/attachments", handler.ListAttachments)
conversations.GET("/:conversation_id/reporting_events", handler.ReportingEvents)
conversations.POST("/:conversation_id/toggle_typing", handler.ToggleTyping)
conversations.POST("/:conversation_id/update_last_seen", handler.UpdateLastSeen)
}
@@ -137,6 +139,7 @@ func (s *ConversationHandlerTestSuite) TearDownSuite() {
func (s *ConversationHandlerTestSuite) TearDownTest() {
s.db.Exec("DELETE FROM attachments")
s.db.Exec("DELETE FROM reporting_events")
s.db.Exec("DELETE FROM conversations")
s.db.Exec("DELETE FROM contact_inboxes")
s.db.Exec("DELETE FROM contacts")
@@ -504,6 +507,102 @@ func (s *ConversationHandlerTestSuite) TestListAttachmentsUsesChatwootFixedPageS
assert.Equal(s.T(), float64(newestID), first["id"])
}
func (s *ConversationHandlerTestSuite) TestReportingEventsReturnsRawArrayInCreatedOrder() {
conversationID := s.testConv.ID
baseTime := time.Now().Add(-2 * time.Hour).UTC()
newer := &model.ReportingEvent{
Base: model.Base{CreatedAt: baseTime.Add(time.Hour), UpdatedAt: baseTime.Add(time.Hour)},
AccountID: s.testAccount.ID,
Name: "resolution_time",
Value: 42,
ValueInBusinessHours: 21,
ConversationID: &conversationID,
EventStartTime: baseTime,
EventEndTime: baseTime.Add(time.Minute),
}
older := &model.ReportingEvent{
Base: model.Base{CreatedAt: baseTime, UpdatedAt: baseTime},
AccountID: s.testAccount.ID,
Name: "first_response",
Value: 10,
ValueInBusinessHours: 5,
ConversationID: &conversationID,
EventStartTime: baseTime.Add(-time.Minute),
EventEndTime: baseTime,
}
s.Require().NoError(s.db.Create(newer).Error)
s.Require().NoError(s.db.Create(older).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/reporting_events", s.accountURL(), s.testConv.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
var resp []map[string]any
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(s.T(), resp, 2)
assert.Equal(s.T(), float64(older.ID), resp[0]["id"])
assert.Equal(s.T(), "first_response", resp[0]["name"])
assert.Equal(s.T(), float64(10), resp[0]["value"])
assert.Equal(s.T(), float64(5), resp[0]["value_in_business_hours"])
assert.Equal(s.T(), float64(s.testAccount.ID), resp[0]["account_id"])
assert.Equal(s.T(), float64(s.testConv.ID), resp[0]["conversation_id"])
assert.Contains(s.T(), resp[0], "inbox_id")
assert.Nil(s.T(), resp[0]["inbox_id"])
assert.Contains(s.T(), resp[0], "user_id")
assert.Nil(s.T(), resp[0]["user_id"])
assert.Contains(s.T(), resp[0], "event_start_time")
assert.Contains(s.T(), resp[0], "event_end_time")
assert.NotContains(s.T(), resp[0], "success")
assert.NotContains(s.T(), resp[0], "payload")
assert.NotContains(s.T(), resp[0], "meta")
}
func (s *ConversationHandlerTestSuite) TestReportingEventsUsesDisplayIDAndScopesAccount() {
displayID := uint(880)
s.Require().NoError(s.db.Model(s.testConv).Update("display_id", displayID).Error)
conversationID := s.testConv.ID
event := &model.ReportingEvent{
AccountID: s.testAccount.ID,
Name: "reply_time",
Value: 7,
ValueInBusinessHours: 3,
ConversationID: &conversationID,
EventStartTime: time.Now().Add(-time.Minute).UTC(),
EventEndTime: time.Now().UTC(),
}
s.Require().NoError(s.db.Create(event).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/reporting_events", s.accountURL(), displayID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
var resp []map[string]any
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(s.T(), resp, 1)
assert.Equal(s.T(), float64(event.ID), resp[0]["id"])
otherAccount := &model.Account{Name: "OtherOrg", Locale: "en", Active: true}
s.Require().NoError(s.db.Create(otherAccount).Error)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/reporting_events", otherAccount.ID, displayID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ConversationHandlerTestSuite) TestReportingEventsInvalidParams() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/conversations/1/reporting_events", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/reporting_events", s.testAccount.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/toggle_typing", bytes.NewBufferString(`{"typing_status":"on"}`))
@@ -60,6 +60,15 @@ func (r *ReportingEventRepo) FindByInboxID(ctx context.Context, accountID, inbox
return events, err
}
func (r *ReportingEventRepo) FindByConversationID(ctx context.Context, accountID, conversationID uint) ([]model.ReportingEvent, error) {
var events []model.ReportingEvent
err := r.db.WithContext(ctx).
Where("account_id = ? AND conversation_id = ?", accountID, conversationID).
Order("created_at ASC").
Find(&events).Error
return events, err
}
// AggregateByMetric returns aggregated values (average, count) for a given metric name.
func (r *ReportingEventRepo) AggregateByMetric(ctx context.Context, accountID uint, metricName string, since, until time.Time) (float64, int64, error) {
var result struct {
+1
View File
@@ -977,6 +977,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
// Additional Conversation endpoints matching Chatwoot member routes
conversations.GET("/:conversation_id/attachments", h.Conversation.ListAttachments)
conversations.GET("/:conversation_id/inbox_assistant", h.Conversation.InboxAssistant)
conversations.GET("/:conversation_id/reporting_events", h.Conversation.ReportingEvents)
conversations.POST("/:conversation_id/direct_uploads", h.Upload.ConversationDirectUpload)
conversations.PUT("/:conversation_id/direct_uploads/:upload_uuid", h.Upload.CompleteConversationDirectUpload)
conversations.POST("/:conversation_id/toggle_typing_status", h.Conversation.ToggleTyping)
+16
View File
@@ -184,6 +184,22 @@ func (s *ConversationService) GetInboxAssistant(ctx context.Context, accountID,
return &assistant, nil
}
// ListReportingEvents returns conversation-scoped reporting events in Chatwoot order.
// Reference: Chatwoot enterprise ConversationsController#reporting_events.
func (s *ConversationService) ListReportingEvents(ctx context.Context, accountID, routeID uint) ([]model.ReportingEvent, error) {
conversation, err := s.repo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID)
if err != nil {
return nil, err
}
var events []model.ReportingEvent
err = s.repo.DB().WithContext(ctx).
Where("account_id = ? AND conversation_id = ?", accountID, conversation.ID).
Order("created_at ASC").
Find(&events).Error
return events, err
}
// CreateConversationRequest is the DTO for creating a conversation.
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations_controller.rb #create
// Supports creating conversation with an initial message (like Chatwoot's ConversationBuilder).