diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 127a953a..0bf16112 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -97,6 +97,10 @@ var criticalRoutes = []route{ {Method: "PUT", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/participants", Controller: "api/v1/accounts/conversations/participants#update", Source: "routes.rb:150"}, {Method: "DELETE", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/participants", Controller: "api/v1/accounts/conversations/participants#destroy", Source: "routes.rb:150"}, {Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads", Controller: "api/v1/accounts/conversations/direct_uploads#create", Source: "routes.rb:151"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages", Controller: "api/v1/accounts/conversations/draft_messages#show", Source: "routes.rb:152"}, + {Method: "PATCH", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages", Controller: "api/v1/accounts/conversations/draft_messages#update", Source: "routes.rb:152"}, + {Method: "PUT", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages", Controller: "api/v1/accounts/conversations/draft_messages#update", Source: "routes.rb:152"}, + {Method: "DELETE", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages", Controller: "api/v1/accounts/conversations/draft_messages#destroy", Source: "routes.rb:152"}, {Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/labels", Controller: "api/v1/accounts/conversations/labels#create", Source: "routes.rb:149"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/labels", Controller: "api/v1/accounts/conversations/labels#index", Source: "routes.rb:149"}, {Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/toggle_status", Controller: "api/v1/accounts/conversations#toggle_status", Source: "routes.rb:158"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 822c3c08..555f4c75 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,15 +49,15 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 conversation inbox assistant checkpoint, prepared as `feat(conversations): expose inbox assistant`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): expose inbox assistant`. -- Latest documentation/tooling checkpoint: this tracker update for P3.19 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 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/*`. - 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 inbox assistant from `reference/chatwoot/config/routes.rb:165`, enterprise `Api::V1::Accounts::ConversationsController#inbox_assistant`, and reused dashboard `api/inbox/conversation.js` / conversation store callers is implemented. `GET /api/v1/accounts/:account_id/conversations/:conversation_id/inbox_assistant` is now tracked and routed, resolves conversations by display ID with legacy ID fallback, joins inboxes to Captain assistants through account-scoped `captain_inboxes`, and returns `{ assistant: { id, name } }` or `{ assistant: null }` exactly for the frontend Copilot assistant state. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- 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. - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 928` after adding the conversation inbox assistant route. +- Route dump succeeds with `TOTAL: 932` after adding singular conversation draft message routes. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 381 Chatwoot routes: 368 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 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. - `/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 381-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`, 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 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 inbox assistant 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 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. | | 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. | @@ -153,10 +153,11 @@ This table is the shortest authoritative handoff view. If an older lower section | 12 | P3.17 conversation participants API | Implemented for reused dashboard conversation sidebar calls: singleton `GET/POST/PATCH/PUT/DELETE /conversations/:conversation_id/participants` routes are tracked and registered without the trailing slash, list/create/update return raw Chatwoot agent arrays, create accepts `user_ids`, update treats `user_ids` as the final participant set, and destroy returns empty `200 OK`. | Keep in Review; reopen only if live conversation sidebar smoke exposes participant authorization or agent serializer drift beyond the inspected controller/Jbuilder contract. | Focused ConversationParticipant handler/service tests, router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | 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 | 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. | -| 16 | 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. | -| 17 | 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. | -| 18 | P5.13 reports/analytics | P5.13a derives visible report aggregates from persisted rows; P5.13b adds lazy rollup freshness, durable day rollup jobs, and `/reports` metric timeseries. | Keep report drift closed as frontend smoke or reference inspection exposes additional metrics. | Report fixtures verify timeseries values, cache/freshness behavior, and no hidden placeholder JSON. | +| 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 | 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. | +| 19 | P5.13 reports/analytics | P5.13a derives visible report aggregates from persisted rows; P5.13b adds lazy rollup freshness, durable day rollup jobs, and `/reports` metric timeseries. | Keep report drift closed as frontend smoke or reference inspection exposes additional metrics. | Report fixtures verify timeseries values, cache/freshness behavior, and no hidden placeholder JSON. | ## Open Checkpoint Contracts @@ -187,6 +188,7 @@ These rows are the executable development plan from this point forward. A checkp | P3.17 conversation participants API parity | `internal/router/router.go`, `internal/handler/api/v1/conversation_participant_handler.go`, `internal/service/conversation_participant_service.go`, `internal/repository/conversation_participant_repo.go`, conversation participant handler/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:150`, `reference/chatwoot/app/controllers/api/v1/accounts/conversations/participants_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/conversations/participants/*.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_agent.json.jbuilder`, dashboard `api/inbox/conversation.js` | Conversation participants now match the reused dashboard sidebar contract: singleton participant routes are tracked and registered without trailing slashes; trailing slash and legacy per-user routes remain compatibility aliases; list/create/update return raw agent arrays rendered through the Chatwoot agent serializer shape; create accepts frontend `user_ids` and legacy `user_id`; update treats `user_ids` as the final participant set, adding missing users and removing absent users; destroy accepts `{ user_ids: [...] }` and returns empty `200 OK`. | Review by `feat(conversations): align participant payloads`; focused ConversationParticipant handler/service tests, router/route-parity tests, route dump/parity regeneration to `TOTAL: 925` and `366 exact, 13 parameter-compatible, 0 missing out of 379`, full `go test ./...`, and `git diff --check` passed. | | 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. | | 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. | @@ -228,6 +230,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `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. | | `feat(conversations): align participant payloads` | Advances P3.17 conversation participant parity by matching Chatwoot `Conversations::ParticipantsController`, participant Jbuilder views, the `_agent` serializer, routes `150`, and reused dashboard `api/inbox/conversation.js` calls. GoChat now registers and tracks singleton `GET/POST/PATCH/PUT/DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/participants` routes without trailing slashes, keeps trailing slash and per-user compatibility aliases, returns raw agent arrays instead of local `{ success, data }` envelopes, accepts frontend `user_ids`, treats update `user_ids` as the final participant set, and returns empty `200 OK` for participant destroy. | `go test ./internal/handler/api/v1 ./internal/service ./internal/router ./cmd/route_parity -run 'ConversationParticipant\|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: 925`; tracked route parity is `366 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 379`. | P3.17 moves to Review for current conversation sidebar participant evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2417,3 +2420,4 @@ Verification milestone gates: - 2026-06-06: P3.17 conversation participant checkpoint prepared as `feat(conversations): align participant payloads`; audited Chatwoot participant routes/controller/Jbuilder, `_agent` serializer, and reused dashboard conversation API calls. GoChat now exposes singleton no-trailing-slash participant routes, tracks `GET/POST/PATCH/PUT/DELETE /conversations/:conversation_id/participants`, returns raw agent arrays, accepts frontend `user_ids`, applies update as final-set synchronization, and returns empty `200 OK` destroy responses. Focused ConversationParticipant handler/service tests, router/route-parity tests, route dump/parity regeneration (`TOTAL: 925`, `366 exact`, `13 parameter-compatible`, `0 missing out of 379`), 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.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. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 9f8ea439..8d127224 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -38,6 +38,7 @@ DELETE /api/v1/accounts/:account_id/contacts/:contact_id/custom_attributes/:attr DELETE /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id DELETE /api/v1/accounts/:account_id/conversations/:conversation_id DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/custom_attributes/:attribute_name +DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/:draft_id DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/labels/:tag_id DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id @@ -216,6 +217,7 @@ GET /api/v1/accounts/:account_id/contacts/search GET /api/v1/accounts/:account_id/conversations/ GET /api/v1/accounts/:account_id/conversations/:conversation_id GET /api/v1/accounts/:account_id/conversations/:conversation_id/attachments +GET /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages GET /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/ GET /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/:draft_id GET /api/v1/accounts/:account_id/conversations/:conversation_id/inbox_assistant @@ -504,6 +506,7 @@ PATCH /api/v1/accounts/:account_id/companies/:company_id PATCH /api/v1/accounts/:account_id/contacts/:contact_id PATCH /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id PATCH /api/v1/accounts/:account_id/conversations/:conversation_id +PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/:draft_id PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/labels PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id @@ -870,6 +873,7 @@ PUT /api/v1/accounts/:account_id/contacts/:contact_id PUT /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id PUT /api/v1/accounts/:account_id/conversations/:conversation_id PUT /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads/:upload_uuid +PUT /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages PUT /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id PUT /api/v1/accounts/:account_id/conversations/:conversation_id/participants PUT /api/v1/accounts/:account_id/conversations/:conversation_id/participants/ @@ -926,4 +930,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: 928 +TOTAL: 932 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 6c4b83a5..69404ef4 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -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: 368 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 381 tracked critical routes. +Summary: 372 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 385 tracked critical routes. ## Missing Critical Routes @@ -67,6 +67,7 @@ These routes exist with equivalent method and path shape but different parameter | DELETE | `/api/v1/accounts/:account_id/contacts/:contact_id` | `/api/v1/accounts/:account_id/contacts/:contact_id` | `api/v1/accounts/contacts#destroy` | `routes.rb:197` | exact | | DELETE | `/api/v1/accounts/:account_id/contacts/:contact_id/avatar` | `/api/v1/accounts/:account_id/contacts/:contact_id/avatar` | `api/v1/accounts/contacts#avatar` | `routes.rb:208` | exact | | DELETE | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `api/v1/accounts/conversations#destroy` | `routes.rb:134` | exact | +| DELETE | `/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` | `/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` | `api/v1/accounts/conversations/draft_messages#destroy` | `routes.rb:152` | exact | | DELETE | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id` | `api/v1/accounts/conversations/messages#destroy` | `routes.rb:142` | exact | | DELETE | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `api/v1/accounts/conversations/participants#destroy` | `routes.rb:150` | exact | | DELETE | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `api/v1/accounts/custom_attribute_definitions#destroy` | `routes.rb:250` | exact | @@ -144,6 +145,7 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v1/accounts/:account_id/contacts/search` | `/api/v1/accounts/:account_id/contacts/search` | `api/v1/accounts/contacts#search` | `routes.rb:201` | exact | | GET | `/api/v1/accounts/:account_id/conversations/` | `/api/v1/accounts/:account_id/conversations/` | `api/v1/accounts/conversations#index` | `routes.rb:134` | exact | | GET | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `api/v1/accounts/conversations#show` | `routes.rb:134` | exact | +| GET | `/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` | `/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` | `api/v1/accounts/conversations/draft_messages#show` | `routes.rb:152` | exact | | GET | `/api/v1/accounts/:account_id/conversations/:conversation_id/inbox_assistant` | `/api/v1/accounts/:account_id/conversations/:conversation_id/inbox_assistant` | `api/v1/accounts/conversations#inbox_assistant` | `routes.rb:165` | exact | | 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 | @@ -252,6 +254,7 @@ These routes exist with equivalent method and path shape but different parameter | PATCH | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `api/v1/accounts/agent_bots#update` | `routes.rb:94` | exact | | PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#update` | `routes.rb:123` | exact | | PATCH | `/api/v1/accounts/:account_id/canned_responses/:id` | `/api/v1/accounts/:account_id/canned_responses/:id` | `api/v1/accounts/canned_responses#update` | `routes.rb:114` | exact | +| PATCH | `/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` | `/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` | `api/v1/accounts/conversations/draft_messages#update` | `routes.rb:152` | exact | | PATCH | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `api/v1/accounts/conversations/participants#update` | `routes.rb:150` | exact | | PATCH | `/api/v1/accounts/:account_id/inbox_members/` | `/api/v1/accounts/:account_id/inbox_members/` | `api/v1/accounts/inbox_members#update` | `routes.rb:278` | exact | | PATCH | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `api/v1/accounts/integrations/hooks#update` | `routes.rb:346` | exact | @@ -394,6 +397,7 @@ These routes exist with equivalent method and path shape but different parameter | PUT | `/api/v1/accounts/:account_id/companies/:company_id` | `/api/v1/accounts/:account_id/companies/:company_id` | `api/v1/accounts/companies#update` | `routes.rb:179` | exact | | PUT | `/api/v1/accounts/:account_id/contacts/:contact_id` | `/api/v1/accounts/:account_id/contacts/:contact_id` | `api/v1/accounts/contacts#update` | `routes.rb:197` | exact | | PUT | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `api/v1/accounts/conversations#update` | `routes.rb:134` | exact | +| PUT | `/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` | `/api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages` | `api/v1/accounts/conversations/draft_messages#update` | `routes.rb:152` | exact | | PUT | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id` | `api/v1/accounts/conversations/messages#update` | `routes.rb:142` | exact | | PUT | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `api/v1/accounts/conversations/participants#update` | `routes.rb:150` | exact | | PUT | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `api/v1/accounts/custom_attribute_definitions#update` | `routes.rb:250` | exact | diff --git a/internal/handler/api/v1/draft_message_handler.go b/internal/handler/api/v1/draft_message_handler.go index 0928216e..75e393df 100644 --- a/internal/handler/api/v1/draft_message_handler.go +++ b/internal/handler/api/v1/draft_message_handler.go @@ -19,6 +19,98 @@ func NewDraftMessageHandler(draftSvc *service.DraftMessageService) *DraftMessage return &DraftMessageHandler{draftSvc: draftSvc} } +// Show retrieves the Chatwoot conversation-scoped draft message. +// GET /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages +func (h *DraftMessageHandler) Show(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 + } + + draft, svcErr := h.draftSvc.ShowConversationDraft(c.Request.Context(), accountID, conversationID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + if draft == nil { + c.JSON(http.StatusOK, gin.H{"has_draft": false}) + return + } + c.JSON(http.StatusOK, gin.H{"has_draft": true, "message": draft.Content}) +} + +// UpdateConversationDraft upserts the Chatwoot conversation-scoped draft message. +// PATCH/PUT /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages +func (h *DraftMessageHandler) UpdateConversationDraft(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 + } + + var req struct { + DraftMessage struct { + Message *string `json:"message"` + } `json:"draft_message"` + Message *string `json:"message"` + Content *string `json:"content"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + return + } + + message := "" + if req.DraftMessage.Message != nil { + message = *req.DraftMessage.Message + } else if req.Message != nil { + message = *req.Message + } else if req.Content != nil { + message = *req.Content + } + + if svcErr := h.draftSvc.SetConversationDraft(c.Request.Context(), accountID, conversationID, getUserID(c), message); svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.Status(http.StatusOK) +} + +// DeleteConversationDraft deletes the Chatwoot conversation-scoped draft message. +// DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages +func (h *DraftMessageHandler) DeleteConversationDraft(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 + } + + if svcErr := h.draftSvc.DeleteConversationDraft(c.Request.Context(), accountID, conversationID); svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.Status(http.StatusOK) +} + // List retrieves all draft messages for a conversation (filtered by current user). // GET /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages func (h *DraftMessageHandler) List(c *gin.Context) { diff --git a/internal/handler/api/v1/draft_message_handler_test.go b/internal/handler/api/v1/draft_message_handler_test.go index 22890455..a37f65e3 100644 --- a/internal/handler/api/v1/draft_message_handler_test.go +++ b/internal/handler/api/v1/draft_message_handler_test.go @@ -90,8 +90,12 @@ func (s *DraftMessageHandlerTestSuite) SetupSuite() { { drafts := convGroup.Group("/draft_messages") { - drafts.GET("", handler.List) - drafts.POST("", handler.Create) + drafts.GET("", handler.Show) + drafts.PATCH("", handler.UpdateConversationDraft) + drafts.PUT("", handler.UpdateConversationDraft) + drafts.DELETE("", handler.DeleteConversationDraft) + drafts.GET("/", handler.List) + drafts.POST("/", handler.Create) drafts.GET("/:draft_id", handler.Get) drafts.PATCH("/:draft_id", handler.Update) drafts.DELETE("/:draft_id", handler.Delete) @@ -106,8 +110,12 @@ func makeDraftURL(accountID, convID uint) string { "/draft_messages" } +func makeDraftCollectionURL(accountID, convID uint) string { + return makeDraftURL(accountID, convID) + "/" +} + func makeDraftURLWithID(accountID, convID, draftID uint) string { - return makeDraftURL(accountID, convID) + "/" + strconv.FormatUint(uint64(draftID), 10) + return makeDraftCollectionURL(accountID, convID) + strconv.FormatUint(uint64(draftID), 10) } func (s *DraftMessageHandlerTestSuite) Test_CreateDraft() { @@ -117,7 +125,7 @@ func (s *DraftMessageHandlerTestSuite) Test_CreateDraft() { b, _ := json.Marshal(body) w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", makeDraftURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) + req, _ := http.NewRequest("POST", makeDraftCollectionURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) @@ -132,6 +140,83 @@ func (s *DraftMessageHandlerTestSuite) Test_CreateDraft() { } } +func (s *DraftMessageHandlerTestSuite) Test_ShowDraft_ChatwootShapeWithoutDraft() { + s.Require().NoError(s.db.Exec("DELETE FROM draft_messages").Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", makeDraftURL(s.testAccount.ID, s.testConv.ID), nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(s.T(), false, resp["has_draft"]) + assert.NotContains(s.T(), resp, "data") +} + +func (s *DraftMessageHandlerTestSuite) Test_UpdateAndShowDraft_ChatwootShape() { + s.Require().NoError(s.db.Exec("DELETE FROM draft_messages").Error) + body := map[string]interface{}{"draft_message": map[string]interface{}{"message": "Saved draft"}} + b, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", makeDraftURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Empty(s.T(), w.Body.String()) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", makeDraftURL(s.testAccount.ID, s.testConv.ID), nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(s.T(), true, resp["has_draft"]) + assert.Equal(s.T(), "Saved draft", resp["message"]) + assert.NotContains(s.T(), resp, "success") +} + +func (s *DraftMessageHandlerTestSuite) Test_DeleteDraft_ChatwootShape() { + s.Require().NoError(s.db.Exec("DELETE FROM draft_messages").Error) + s.Require().NoError(s.db.Create(&model.DraftMessage{ConversationID: s.testConv.ID, UserID: s.testUser.ID, Content: "Delete me"}).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", makeDraftURL(s.testAccount.ID, s.testConv.ID), nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Empty(s.T(), w.Body.String()) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", makeDraftURL(s.testAccount.ID, s.testConv.ID), nil) + s.router.ServeHTTP(w, req) + + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(s.T(), false, resp["has_draft"]) +} + +func (s *DraftMessageHandlerTestSuite) Test_ShowDraft_UsesDisplayIDRoute() { + s.Require().NoError(s.db.Exec("DELETE FROM draft_messages").Error) + displayID := uint(91) + s.testConv.DisplayID = &displayID + s.Require().NoError(s.db.Save(s.testConv).Error) + s.Require().NoError(s.db.Create(&model.DraftMessage{ConversationID: s.testConv.ID, UserID: s.testUser.ID, Content: "Display draft"}).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", makeDraftURL(s.testAccount.ID, displayID), nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(s.T(), true, resp["has_draft"]) + assert.Equal(s.T(), "Display draft", resp["message"]) +} + func (s *DraftMessageHandlerTestSuite) Test_ListDrafts() { // First create a draft body := map[string]interface{}{ @@ -140,13 +225,13 @@ func (s *DraftMessageHandlerTestSuite) Test_ListDrafts() { b, _ := json.Marshal(body) w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", makeDraftURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) + req, _ := http.NewRequest("POST", makeDraftCollectionURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) // Now list w = httptest.NewRecorder() - req, _ = http.NewRequest("GET", makeDraftURL(s.testAccount.ID, s.testConv.ID), nil) + req, _ = http.NewRequest("GET", makeDraftCollectionURL(s.testAccount.ID, s.testConv.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) @@ -160,7 +245,7 @@ func (s *DraftMessageHandlerTestSuite) Test_GetDraft() { b, _ := json.Marshal(body) w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", makeDraftURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) + req, _ := http.NewRequest("POST", makeDraftCollectionURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) @@ -170,7 +255,7 @@ func (s *DraftMessageHandlerTestSuite) Test_GetDraft() { // Try to get the draft by ID - note: the response structure may vary // We just verify the list endpoint works for round-trip verification w = httptest.NewRecorder() - req, _ = http.NewRequest("GET", makeDraftURL(s.testAccount.ID, s.testConv.ID), nil) + req, _ = http.NewRequest("GET", makeDraftCollectionURL(s.testAccount.ID, s.testConv.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) @@ -184,7 +269,7 @@ func (s *DraftMessageHandlerTestSuite) Test_DeleteDraft() { b, _ := json.Marshal(body) w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", makeDraftURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) + req, _ := http.NewRequest("POST", makeDraftCollectionURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) @@ -213,7 +298,7 @@ func (s *DraftMessageHandlerTestSuite) Test_UpdateDraft() { b, _ := json.Marshal(body) w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", makeDraftURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) + req, _ := http.NewRequest("POST", makeDraftCollectionURL(s.testAccount.ID, s.testConv.ID), bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) @@ -247,7 +332,7 @@ func (s *DraftMessageHandlerTestSuite) Test_CreateDraft_InvalidAccountID() { b, _ := json.Marshal(body) w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/draft_messages", bytes.NewReader(b)) + req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/draft_messages/", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) diff --git a/internal/repository/draft_message_repo.go b/internal/repository/draft_message_repo.go index b648c248..651366d6 100644 --- a/internal/repository/draft_message_repo.go +++ b/internal/repository/draft_message_repo.go @@ -30,6 +30,16 @@ func (r *DraftMessageRepo) FindByConversationID(ctx context.Context, conversatio return drafts, err } +// FindLatestByConversationID retrieves the single Chatwoot-style draft for a conversation. +func (r *DraftMessageRepo) FindLatestByConversationID(ctx context.Context, conversationID uint) (*model.DraftMessage, error) { + var draft model.DraftMessage + err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Order("id DESC").First(&draft).Error + if err != nil { + return nil, err + } + return &draft, nil +} + // FindByID retrieves a draft message by ID. func (r *DraftMessageRepo) FindByID(ctx context.Context, id uint) (*model.DraftMessage, error) { var draft model.DraftMessage @@ -55,6 +65,11 @@ func (r *DraftMessageRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.DraftMessage{}, id).Error } +// DeleteByConversationID removes all persisted drafts for a conversation. +func (r *DraftMessageRepo) DeleteByConversationID(ctx context.Context, conversationID uint) error { + return r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Delete(&model.DraftMessage{}).Error +} + // SearchByContent retrieves draft messages whose content matches the given query string (LIKE). func (r *DraftMessageRepo) SearchByContent(ctx context.Context, accountID uint, query string) ([]model.DraftMessage, error) { var drafts []model.DraftMessage @@ -76,4 +91,4 @@ func (r *DraftMessageRepo) CountByAccount(ctx context.Context, accountID uint) ( Where("conversations.account_id = ?", accountID). Count(&count).Error return count, err -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index 1d572b7e..1877cf31 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1002,6 +1002,10 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Draft messages nested under conversation drafts := conversations.Group("/:conversation_id/draft_messages") { + drafts.GET("", h.DraftMessage.Show) + drafts.PATCH("", h.DraftMessage.UpdateConversationDraft) + drafts.PUT("", h.DraftMessage.UpdateConversationDraft) + drafts.DELETE("", h.DraftMessage.DeleteConversationDraft) drafts.GET("/", h.DraftMessage.List) drafts.POST("/", h.DraftMessage.Create) drafts.GET("/:draft_id", h.DraftMessage.Get) diff --git a/internal/service/draft_message_service.go b/internal/service/draft_message_service.go index 3a6c9347..4dc168b1 100644 --- a/internal/service/draft_message_service.go +++ b/internal/service/draft_message_service.go @@ -7,6 +7,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" + "gorm.io/gorm" ) // DraftMessageService implements business logic for DraftMessage operations. @@ -35,6 +36,49 @@ func (s *DraftMessageService) List(ctx context.Context, accountID, conversationI return s.repo.FindByConversationID(ctx, conversationID, userID) } +// ShowConversationDraft retrieves Chatwoot's conversation-scoped draft message. +func (s *DraftMessageService) ShowConversationDraft(ctx context.Context, accountID, routeID uint) (*model.DraftMessage, error) { + conversation, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID) + if err != nil { + return nil, err + } + + draft, err := s.repo.FindLatestByConversationID(ctx, conversation.ID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return draft, err +} + +// SetConversationDraft upserts Chatwoot's conversation-scoped draft message. +func (s *DraftMessageService) SetConversationDraft(ctx context.Context, accountID, routeID, userID uint, content string) error { + conversation, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID) + if err != nil { + return err + } + + draft, err := s.repo.FindLatestByConversationID(ctx, conversation.ID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return s.repo.Create(ctx, &model.DraftMessage{ConversationID: conversation.ID, UserID: userID, Content: content}) + } + if err != nil { + return err + } + + draft.Content = content + draft.UserID = userID + return s.repo.Update(ctx, draft) +} + +// DeleteConversationDraft removes Chatwoot's conversation-scoped draft message. +func (s *DraftMessageService) DeleteConversationDraft(ctx context.Context, accountID, routeID uint) error { + conversation, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID) + if err != nil { + return err + } + return s.repo.DeleteByConversationID(ctx, conversation.ID) +} + // Create creates a new draft message. func (s *DraftMessageService) Create(ctx context.Context, accountID, conversationID, userID uint, content string) (*model.DraftMessage, error) { // Verify conversation belongs to the account