diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 7f8988e3..7492e7d5 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,12 +16,12 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Current tracking checkpoint: 2026-06-05 after `9db1336 feat(inboxes): queue template sync jobs`, with this implementation checkpoint prepared as `feat(auth): align password reset flows`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(auth): align password reset flows`. +- Current tracking checkpoint: 2026-06-05 after `ad5d864 feat(auth): align password reset flows`, with this implementation checkpoint prepared as `feat(crm): align shared attachment payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(crm): align shared attachment payloads`. - Latest documentation-only checkpoint: `2923aae docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations; P5.13b routes `GET /reports` to Chatwoot-style metric timeseries, adds lazy rollup freshness/idempotency, registers durable `reporting:rollup_day` jobs, and makes rollup replacement hard-delete soft-deleted rows before recompute; B9.3 verifies the current reference exposes no delayed automation action params, preserves scheduled-item coverage under P5.12, and completes remaining automation action side effects by queueing `send_email_to_team` plus applying enterprise `add_sla`; Phase 2/3 drift audit now aligns account notifications with Chatwoot `NotificationFinder` list envelopes, `includes` filters, unread counts, primary-actor `read_all`, raw action responses, and `destroy_all` read/all semantics; P6.5 now aligns WhatsApp Cloud inbox health/register-webhook endpoints with Chatwoot raw payloads, Cloud-only guards, Graph health fields, default callback URL construction, and no-body register behavior; P6.5 also aligns `sync_templates` with Chatwoot's async low-priority template sync job and `{ message: "Template sync initiated successfully" }` response; Phase 2/3 auth now aligns Chatwoot Devise password reset/confirmation token columns, `POST/PUT /auth/password`, and `POST /auth/confirmation` payload/auth-header behavior. Next active implementation slice is continued Phase 6 placeholder audit, followed by further Phase 2/3 drift from smoke/reference evidence. +- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations; P5.13b routes `GET /reports` to Chatwoot-style metric timeseries, adds lazy rollup freshness/idempotency, registers durable `reporting:rollup_day` jobs, and makes rollup replacement hard-delete soft-deleted rows before recompute; B9.3 verifies the current reference exposes no delayed automation action params, preserves scheduled-item coverage under P5.12, and completes remaining automation action side effects by queueing `send_email_to_team` plus applying enterprise `add_sla`; Phase 2/3 drift audit now aligns account notifications with Chatwoot `NotificationFinder` list envelopes, `includes` filters, unread counts, primary-actor `read_all`, raw action responses, and `destroy_all` read/all semantics; P6.5 now aligns WhatsApp Cloud inbox health/register-webhook endpoints with Chatwoot raw payloads, Cloud-only guards, Graph health fields, default callback URL construction, and no-body register behavior; P6.5 also aligns `sync_templates` with Chatwoot's async low-priority template sync job and `{ message: "Template sync initiated successfully" }` response; Phase 2/3 auth now aligns Chatwoot Devise password reset/confirmation token columns, `POST/PUT /auth/password`, and `POST /auth/confirmation` payload/auth-header behavior; Phase 3/6 CRM attachments now aligns conversation shared-file payloads and adds Chatwoot contact shared-file attachments with meta totals, serialized attachment fields, sender, created_at, and display conversation IDs. Next active implementation slice is continued Phase 6 placeholder audit, followed by further Phase 2/3 drift from smoke/reference evidence. - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 833` after adding `GET /api/v1/accounts/:account_id/reports` for the Chatwoot reports index path. +- Route dump succeeds with `TOTAL: 837` after adding Chatwoot auth reset/confirmation routes and `GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments`. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. - Tracked frontend-critical route audit covers 277 Chatwoot routes: 270 exact, 0 method-compatible, 7 parameter-compatible, 0 missing. The 7 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits; the external URL shape is equivalent. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. @@ -87,7 +87,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; notification list/action serializers and Devise password reset/confirmation payloads now match Chatwoot's frontend contract; broader serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | +| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; notification list/action serializers, Devise password reset/confirmation payloads, and CRM shared attachment payloads now match Chatwoot's frontend contract; broader serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; account/contact/conversation/message/inbox audit remains broad. | Continue fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. | | 3 | 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. | | 4 | 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. | @@ -139,6 +139,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(crm): align shared attachment payloads` | Advances Phase 3/6 CRM serializer parity by adding Chatwoot's `GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments` shared-file route and aligning conversation/contact attachment responses with `contacts/attachments#index`, `conversations#attachments`, and `_attachment.json.jbuilder`. Attachment lists now return `{ meta: { total_count }, payload }`, order newest first, serialize `data_url`, `thumb_url`, `file_size`, `file_type`, `extension`, dimensions, `created_at`, sender data, and contact attachment `conversation_id` as the Chatwoot display ID. | `go test ./internal/service -run 'MessageService\|ContactService' -count=1`; `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUD\|ConversationHandler' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/router -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. | P3.5 remains Doing for broader contact/company drift; continue Phase 6 placeholder audit and CRM serializer fixture expansion from reference/smoke evidence. | | `feat(auth): align password reset flows` | Advances Phase 2/3 auth/session parity by replacing the password reset and confirmation placeholders with Chatwoot Devise-compatible token columns and frontend routes. `POST /auth/password` now returns Chatwoot's reset request message while storing a reset token digest; `PUT /auth/password` validates `reset_password_token`, updates and confirms the user password, clears reset/confirmation tokens, emits Devise auth headers, and returns `{ data: profile }`; `POST /auth/confirmation` confirms by `confirmation_token`, clears it, emits auth headers, and returns `{ data: profile }`. | `go test ./internal/service -run AuthService -count=1`; `go test ./internal/handler/api/v1 -run ChatwootAuth -count=1`; `go test ./...`; `git diff --check`. | Auth/session/profile remains Done for current reused frontend flow; future auth drift should come from B12 smoke or reference evidence. | | `feat(inboxes): queue template sync jobs` | Advances P6.5 inbox API parity by aligning `POST /inboxes/:id/sync_templates` with `Api::V1::Accounts::Concerns::WhatsappHealthManagement#sync_templates`. The endpoint no longer blocks on live provider template fetches or returns a local `templates` envelope; it validates WhatsApp inboxes, queues durable low-priority `inbox:sync_templates` jobs, replays the existing WhatsApp template fetch/update path in the worker, and returns Chatwoot `{ message: "Template sync initiated successfully" }`. | `go test ./internal/service -run 'InboxService.*SyncTemplates\|InboxService.*Health\|InboxService.*RegisterWebhook' -count=1`; `go test ./internal/handler/api/v1 -run InboxSyncTemplates -count=1`; `go test ./internal/app -count=1`; `go test ./...`; `git diff --check`. | P6.5 remains Doing for broader inbox/channel settings parity; continue Phase 6 placeholder audit across account/contact/conversation/message/inbox surfaces. | | `feat(inboxes): align whatsapp health endpoints` | Advances P6.5 inbox API parity by replacing the generic inbox health result with Chatwoot's WhatsApp Cloud-only `health` contract and aligning `register_webhook` with the reference concern. Health now returns raw Graph phone-number fields plus `expected_webhook_url` and `business_id`; non-WhatsApp/non-Cloud inboxes return the Chatwoot `400` error; register webhook accepts an empty body, builds the default callback from `FRONTEND_URL`, and returns `Webhook registered successfully`. WhatsApp Graph health reads now use `WHATSAPP_API_VERSION` default `v22.0`. | `go test ./internal/channel/whatsapp -count=1`; `go test ./internal/service -run InboxService -count=1`; `go test ./internal/handler/api/v1 -run Inbox -count=1`; `go test ./...`; `git diff --check`. | P6.5 remains Doing for broader inbox/channel settings parity; continue Phase 6 placeholder audit across account/contact/conversation/message/inbox surfaces. | @@ -1541,7 +1542,7 @@ Frontend-critical API groups to audit first: | P3.2 | Accounts/users/teams | Account settings, users, agents, teams, invitations, roles, permissions. | Todo | | P3.3 | Inboxes/channels | Inbox CRUD, assignable agents, avatars, channel config, business hours, widget config. | Done | | P3.4 | Conversations/messages | List filters, status changes, assignment, labels, private notes, attachments, drafts, typing/read events. | Review | -| P3.5 | Contacts/companies | CRUD, merge, labels, notes, custom attributes, import/export, conversations relation. | Doing | +| P3.5 | Contacts/companies | CRUD, merge, labels, notes, custom attributes, import/export, conversations relation, and shared attachments. | Doing | | P3.6 | Labels/custom attributes/custom filters | Create/update/list behavior and exact response shapes. | Todo | | P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Doing; notifications and reports now have focused Chatwoot payload fixtures, help center/campaigns remain broader audit scope. | | P3.8 | Widget/public APIs | Widget init, campaigns, config, contact, conversations, messages, direct uploads, public inbox flow, public CSAT. | Doing | @@ -1553,7 +1554,7 @@ Serializer parity work plan: | --- | --- | --- | --- | --- | | S1 | Auth and profile | `reference/chatwoot/app/controllers/api/v1/profile*`, frontend auth API usage | fixture tests for current user/profile payloads | Done | | S2 | Conversations and messages | `reference/chatwoot/app/controllers/api/v1/accounts/conversations*`, serializers/entities | fixture tests for index/show/message create/update | Review | -| S3 | Contacts and companies | `reference/chatwoot/app/controllers/api/v1/accounts/contacts*`, `companies*` | fixture tests for list/show/search/merge/relation payloads | Doing | +| S3 | Contacts and companies | `reference/chatwoot/app/controllers/api/v1/accounts/contacts*`, `companies*` | fixture tests for list/show/search/merge/relation/shared attachment payloads | Doing; contact shared attachments and conversation shared-file payloads now have focused Chatwoot-style coverage. | | S4 | Inboxes and channels | `reference/chatwoot/app/controllers/api/v1/accounts/inboxes*`, channel controllers | fixture tests for inbox CRUD, channel settings, widget config | Done | | S5 | Notifications and settings | `reference/chatwoot/app/controllers/api/v1/accounts/notifications*` | fixture tests for notification list/actions/settings | Review; list envelope, includes filters, unread counts, read_all, unread/snooze/update raw responses, and destroy_all read/all are covered. | | S6 | Reports and CSAT | `reference/chatwoot/app/controllers/api/v1/accounts/reports*`, `csat_survey_responses*` | fixture tests for report filters and CSAT metrics/list | Doing | @@ -2052,6 +2053,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-05: Phase 3/6 CRM attachment checkpoint prepared as `feat(crm): align shared attachment payloads`; audited Chatwoot `Contacts::AttachmentsController#index`, `conversations#attachments`, `_attachment.json.jbuilder`, and the reused dashboard shared-files store/API. Added `GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments`, aligned conversation attachment lists to `{ meta: { total_count }, payload }`, serialized shared-file fields through the Chatwoot attachment shape with sender and `created_at`, returned contact attachment `conversation_id` as display ID, regenerated `docs/parity/gochat_routes.txt` to `TOTAL: 837`, and reran route parity. Focused service/handler/router tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-05: Phase 2/3 auth drift checkpoint prepared as `feat(auth): align password reset flows`; audited Chatwoot `DeviseOverrides::PasswordsController`, `DeviseOverrides::ConfirmationsController`, `Auth::ResendConfirmationsController`, and the reused v3 auth frontend. Added Chatwoot user token columns/migration, replaced password reset confirmation placeholders, aligned `POST /auth/password` reset request message, added `PUT /auth/password` reset-token password update with auth headers and `{ data: profile }`, and added `POST /auth/confirmation` confirmation-token sign-in behavior. Focused auth service/handler tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-05: P6.5 inbox sync-template checkpoint prepared as `feat(inboxes): queue template sync jobs`; audited Chatwoot `WhatsappHealthManagement#sync_templates`, `Channels::Whatsapp::TemplatesSyncJob`, and the reused frontend inbox API/store action. `POST /inboxes/:id/sync_templates` now validates WhatsApp inboxes, enqueues durable low-priority `inbox:sync_templates` jobs instead of synchronously fetching provider templates, replays the existing WhatsApp template fetch/update path in the worker, and returns Chatwoot `{ message: "Template sync initiated successfully" }` without a local `templates` envelope. Focused inbox service/handler/app tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-05: P6.5 inbox placeholder/drift checkpoint prepared as `feat(inboxes): align whatsapp health endpoints`; audited `reference/chatwoot/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb`, `Whatsapp::HealthService`, `Whatsapp::WebhookSetupService`, and the reused frontend inbox health client/component. `GET /inboxes/:id/health` now returns the raw WhatsApp Cloud Graph health payload with `expected_webhook_url` and `business_id`, rejects non-WhatsApp/non-Cloud inboxes with Chatwoot's `400` error, and drops the generic `healthy/status/details` placeholder shape. `POST /inboxes/:id/register_webhook` now accepts an empty body, builds the default callback from `FRONTEND_URL`, returns `Webhook registered successfully`, and uses the same Cloud-only guard. Focused WhatsApp, inbox service, inbox handler tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index d6398fb4..00a1a70e 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -186,6 +186,7 @@ GET /api/v1/accounts/:account_id/companies/search GET /api/v1/accounts/:account_id/contact_inboxes/filter GET /api/v1/accounts/:account_id/contacts/ GET /api/v1/accounts/:account_id/contacts/:contact_id +GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments GET /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes GET /api/v1/accounts/:account_id/contacts/:contact_id/contactable_inboxes GET /api/v1/accounts/:account_id/contacts/:contact_id/conversations @@ -712,6 +713,7 @@ POST /api/v1/widget/integrations/dyte/add_participant_to_meeting POST /api/v1/widget/labels POST /api/v1/widget/messages POST /api/v2/accounts/ +POST /auth/confirmation POST /auth/password POST /auth/sign_in POST /platform/api/v1/accounts @@ -813,6 +815,7 @@ PUT /api/v1/accounts/:account_id/teams/:team_id PUT /api/v1/accounts/:account_id/web_widgets/offline_messages/:offline_message_id/convert PUT /api/v1/accounts/:account_id/web_widgets/offline_messages/:offline_message_id/dismiss PUT /api/v1/accounts/:account_id/webhooks/:webhook_id +PUT /api/v1/auth/reset_password PUT /api/v1/ldap/config PUT /api/v1/notifications/:id PUT /api/v1/oidc/config @@ -822,6 +825,7 @@ PUT /api/v1/profile/set_active_account PUT /api/v1/widget/contact PUT /api/v1/widget/direct_uploads/:upload_uuid PUT /api/v1/widget/messages/:message_id +PUT /auth/password PUT /platform/api/v1/agent_bots/:id PUT /platform/api/v1/agent_bots/:id/avatar PUT /platform/api/v1/apps/:id @@ -831,4 +835,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: 833 +TOTAL: 837 diff --git a/internal/handler/api/v1/contact_handler.go b/internal/handler/api/v1/contact_handler.go index 6dd62128..66c7b5b4 100644 --- a/internal/handler/api/v1/contact_handler.go +++ b/internal/handler/api/v1/contact_handler.go @@ -832,6 +832,36 @@ func (h *ContactHandler) ContactableInboxes(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"payload": payload}) } +// ListAttachments returns a contact's shared files across all visible conversations. +// GET /api/v1/accounts/:id/contacts/:contact_id/attachments +// Reference: Chatwoot Api::V1::Accounts::Contacts::AttachmentsController#index +func (h *ContactHandler) ListAttachments(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) + return + } + + contactID, err := parseUintParam(c, "contact_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"}) + return + } + + p := pagination.Parse(c) + attachments, total, svcErr := h.svc.ListAttachments(c.Request.Context(), accountID, contactID, p.Offset, p.PerPage) + if svcErr != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contact attachments"}) + return + } + + payload := make([]any, 0, len(attachments)) + for i := range attachments { + payload = append(payload, serializeAttachmentWithConversation(c.Request.Context(), h.svc.DB(), &attachments[i])) + } + c.JSON(http.StatusOK, gin.H{"meta": gin.H{"total_count": total}, "payload": payload}) +} + // DeleteCustomAttributes removes all custom attributes from a contact. // DELETE /api/v1/accounts/:id/contacts/:contact_id/custom_attributes // Reference: Chatwoot contacts#destroy_custom_attributes diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index f25e17d8..a10596c7 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -96,6 +96,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() { s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Delete) s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id/avatar", s.handler.DeleteAvatar) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/conversations", s.handler.ListConversations) + s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/attachments", s.handler.ListAttachments) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.ListLabels) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.UpdateLabels) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.ListContactInboxes) @@ -578,6 +579,39 @@ func (s *ContactHandlerCRUDTestSuite) TestDeleteAvatar_Success() { s.Equal("", found.AvatarURL) } +func (s *ContactHandlerCRUDTestSuite) TestListAttachmentsReturnsChatwootPayload() { + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Shared Files", ChannelType: "web_widget"} + s.Require().NoError(s.db.Create(inbox).Error) + displayID := uint(42) + conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(conversation).Error) + message := &model.Message{AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &s.contact.ID, SenderType: "contact", Content: "image", MessageType: "incoming", ContentType: "text", Status: "sent"} + s.Require().NoError(s.db.Create(message).Error) + attachment := &model.Attachment{AccountID: s.account.ID, MessageID: message.ID, FileType: "image", FileURL: "https://cdn.example.com/image.png", ThumbURL: "https://cdn.example.com/thumb.png", FileName: "image.png", FileSize: 1234, Width: 640, Height: 480} + s.Require().NoError(s.db.Create(attachment).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/attachments", s.account.ID, s.contact.ID), nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code, w.Body.String()) + var resp map[string]any + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + meta := resp["meta"].(map[string]any) + s.Equal(float64(1), meta["total_count"]) + payload := resp["payload"].([]any) + s.Len(payload, 1) + item := payload[0].(map[string]any) + s.Equal(float64(attachment.ID), item["id"]) + s.Equal(float64(message.ID), item["message_id"]) + s.Equal("image", item["file_type"]) + s.Equal("https://cdn.example.com/image.png", item["data_url"]) + s.Equal("png", item["extension"]) + s.Equal(float64(displayID), item["conversation_id"]) + s.Contains(item, "created_at") + s.Contains(item, "sender") +} + func (s *ContactHandlerCRUDTestSuite) TestMerge_ChatwootActionsPathReturnsRawContact() { base := &model.Contact{AccountID: s.account.ID, Name: "Base Contact", Email: "base@example.com"} mergee := &model.Contact{AccountID: s.account.ID, Name: "Mergee Contact", PhoneNumber: "+12212345"} diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index 7cd634f7..0614f45e 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -764,13 +764,17 @@ func (h *ConversationHandler) ListAttachments(c *gin.Context) { if !ok { return } - attachments, _, svcErr := h.messageSvc.ListAttachments(c.Request.Context(), accountID, conversation.ID, p.Offset, p.PerPage) + attachments, total, svcErr := h.messageSvc.ListAttachments(c.Request.Context(), accountID, conversation.ID, p.Offset, p.PerPage) if svcErr != nil { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, gin.H{"payload": attachments}) + payload := make([]any, 0, len(attachments)) + for i := range attachments { + payload = append(payload, serializeAttachment(c.Request.Context(), h.conversationSvc.DB(), &attachments[i])) + } + c.JSON(http.StatusOK, gin.H{"meta": gin.H{"total_count": total}, "payload": payload}) } // ToggleTyping toggles the typing status for an agent in a conversation. diff --git a/internal/handler/api/v1/conversation_handler_test.go b/internal/handler/api/v1/conversation_handler_test.go index e27d41d8..232f0cc1 100644 --- a/internal/handler/api/v1/conversation_handler_test.go +++ b/internal/handler/api/v1/conversation_handler_test.go @@ -68,6 +68,7 @@ func (s *ConversationHandlerTestSuite) SetupSuite() { &model.Conversation{}, &model.ConversationParticipant{}, &model.Message{}, + &model.Attachment{}, &model.InboxMember{}, &model.Tag{}, &model.ConversationLabel{}, @@ -134,6 +135,7 @@ func (s *ConversationHandlerTestSuite) TearDownSuite() { } func (s *ConversationHandlerTestSuite) TearDownTest() { + s.db.Exec("DELETE FROM attachments") s.db.Exec("DELETE FROM conversations") s.db.Exec("DELETE FROM contact_inboxes") s.db.Exec("DELETE FROM contacts") @@ -420,6 +422,31 @@ func (s *ConversationHandlerTestSuite) TestListAttachments_InvalidConversationID assert.Equal(s.T(), http.StatusBadRequest, w.Code) } +func (s *ConversationHandlerTestSuite) TestListAttachmentsReturnsChatwootPayload() { + message := &model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: s.testConv.ID, Content: "file", MessageType: "incoming", ContentType: "text", Status: "sent"} + s.Require().NoError(s.db.Create(message).Error) + attachment := &model.Attachment{AccountID: s.testAccount.ID, MessageID: message.ID, FileType: "file", FileURL: "https://cdn.example.com/report.pdf", FileName: "report.pdf", FileSize: 2048} + s.Require().NoError(s.db.Create(attachment).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/attachments", 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.Equal(s.T(), float64(1), resp["meta"].(map[string]any)["total_count"]) + payload := resp["payload"].([]any) + assert.Len(s.T(), payload, 1) + item := payload[0].(map[string]any) + assert.Equal(s.T(), float64(attachment.ID), item["id"]) + assert.Equal(s.T(), float64(message.ID), item["message_id"]) + assert.Equal(s.T(), "file", item["file_type"]) + assert.Equal(s.T(), "https://cdn.example.com/report.pdf", item["data_url"]) + assert.Equal(s.T(), "pdf", item["extension"]) + assert.Contains(s.T(), item, "created_at") +} + 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"}`)) diff --git a/internal/handler/api/v1/conversation_serializer.go b/internal/handler/api/v1/conversation_serializer.go index 932306b5..3862b170 100644 --- a/internal/handler/api/v1/conversation_serializer.go +++ b/internal/handler/api/v1/conversation_serializer.go @@ -304,17 +304,17 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message, if err := db.WithContext(ctx).Where("message_id = ?", message.ID).Order("id ASC").Find(&attachments).Error; err == nil && len(attachments) > 0 { payload.Attachments = make([]any, 0, len(attachments)) for i := range attachments { - payload.Attachments = append(payload.Attachments, serializeAttachment(&attachments[i])) + payload.Attachments = append(payload.Attachments, serializeAttachment(ctx, db, &attachments[i])) } } } return payload } -func serializeAttachment(attachment *model.Attachment) map[string]any { +func serializeAttachment(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any { extension := strings.TrimPrefix(filepath.Ext(attachment.FileName), ".") dataURL := nonEmpty(attachment.FileURL, attachment.ExternalURL) - return map[string]any{ + payload := map[string]any{ "id": attachment.ID, "message_id": attachment.MessageID, "file_type": attachment.FileType, @@ -326,6 +326,52 @@ func serializeAttachment(attachment *model.Attachment) map[string]any { "width": attachment.Width, "height": attachment.Height, } + + message := attachment.Message + if message.ID == 0 && db != nil && attachment.MessageID != 0 { + _ = db.WithContext(ctx).First(&message, attachment.MessageID).Error + } + if !message.CreatedAt.IsZero() { + payload["created_at"] = message.CreatedAt.Unix() + } + if sender := serializeMessageSender(ctx, db, &message); sender != nil { + payload["sender"] = sender + } + return payload +} + +func serializeAttachmentWithConversation(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any { + payload := serializeAttachment(ctx, db, attachment) + message := attachment.Message + if message.ID == 0 && db != nil && attachment.MessageID != 0 { + _ = db.WithContext(ctx).First(&message, attachment.MessageID).Error + } + if db != nil && message.ConversationID != 0 { + var conversation model.Conversation + if err := db.WithContext(ctx).First(&conversation, message.ConversationID).Error; err == nil { + payload["conversation_id"] = conversationDisplayID(&conversation) + } + } + return payload +} + +func serializeMessageSender(ctx context.Context, db *gorm.DB, message *model.Message) map[string]any { + if db == nil || message == nil || message.SenderID == nil || *message.SenderID == 0 { + return nil + } + senderType := strings.ToLower(message.SenderType) + if senderType == "contact" { + var contact model.Contact + if err := db.WithContext(ctx).First(&contact, *message.SenderID).Error; err == nil { + return serializeContact(&contact) + } + return nil + } + var user model.User + if err := db.WithContext(ctx).First(&user, *message.SenderID).Error; err == nil { + return serializeUser(&user, message.AccountID) + } + return nil } func serializeContact(contact *model.Contact) map[string]any { diff --git a/internal/router/router.go b/internal/router/router.go index 74cbddd8..576f4960 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1004,9 +1004,10 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { contacts.DELETE("/:contact_id", h.Contact.Delete) contacts.DELETE("/:contact_id/avatar", h.Contact.DeleteAvatar) - // M4 G3: Contact extension routes (active, export, import, contactable_inboxes, custom_attributes) + // M4 G3: Contact extension routes (active, export, import, contactable_inboxes, attachments, custom_attributes) contacts.GET("/:contact_id/conversations", h.Contact.ListConversations) contacts.GET("/:contact_id/contactable_inboxes", h.Contact.ContactableInboxes) + contacts.GET("/:contact_id/attachments", h.Contact.ListAttachments) contacts.GET("/:contact_id/labels", h.Contact.ListLabels) contacts.GET("/:contact_id/labels/", h.Contact.ListLabels) contacts.POST("/:contact_id/labels", h.Contact.UpdateLabels) diff --git a/internal/service/contact_service.go b/internal/service/contact_service.go index 4375bf58..674d2cf3 100644 --- a/internal/service/contact_service.go +++ b/internal/service/contact_service.go @@ -1230,3 +1230,35 @@ func (s *ContactService) GetContactableInboxes(ctx context.Context, accountID, c return result, nil } + +// ListAttachments returns a contact's message attachments across account-scoped conversations. +// Reference: Chatwoot Api::V1::Accounts::Contacts::AttachmentsController#index. +func (s *ContactService) ListAttachments(ctx context.Context, accountID, contactID uint, offset, limit int) ([]model.Attachment, int64, error) { + if _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID); err != nil { + return nil, 0, errors.New("contact not found") + } + + if limit <= 0 { + limit = 100 + } + db := s.repo.DB() + scope := db.WithContext(ctx). + Where("account_id = ? AND message_id IN (SELECT messages.id FROM messages JOIN conversations ON conversations.id = messages.conversation_id WHERE conversations.account_id = ? AND conversations.contact_id = ?)", accountID, accountID, contactID) + + var total int64 + if err := scope.Model(&model.Attachment{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + var attachments []model.Attachment + if err := scope. + Preload("Message"). + Order("created_at DESC, id DESC"). + Offset(offset). + Limit(limit). + Find(&attachments).Error; err != nil { + return nil, 0, err + } + + return attachments, total, nil +} diff --git a/internal/service/message_service.go b/internal/service/message_service.go index 8c66aab0..4e2a5467 100644 --- a/internal/service/message_service.go +++ b/internal/service/message_service.go @@ -529,23 +529,22 @@ func (s *MessageService) ListAttachments(ctx context.Context, accountID, convers var total int64 db := s.repo.DB() - err := db.WithContext(ctx). - Where("account_id = ? AND message_id IN (SELECT id FROM messages WHERE conversation_id = ?)", accountID, conversationID). - Order("id ASC"). - Find(&attachments).Error - if err != nil { + scope := db.WithContext(ctx). + Where("account_id = ? AND message_id IN (SELECT id FROM messages WHERE conversation_id = ?)", accountID, conversationID) + if err := scope.Model(&model.Attachment{}).Count(&total).Error; err != nil { + return nil, 0, err + } + if limit <= 0 { + limit = 100 + } + if err := scope. + Preload("Message"). + Order("created_at DESC, id DESC"). + Offset(offset). + Limit(limit). + Find(&attachments).Error; err != nil { return nil, 0, err } - total = int64(len(attachments)) - start := offset - if start > len(attachments) { - start = len(attachments) - } - end := start + limit - if end > len(attachments) { - end = len(attachments) - } - - return attachments[start:end], total, nil + return attachments, total, nil }