From 7554a7542750140c7c4ed05b1d4903390e3f6622 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 14:50:14 +0800 Subject: [PATCH] feat(conversations): align direct upload routes --- cmd/route_parity/main.go | 1 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 24 +- docs/parity/gochat_routes.txt | 4 +- docs/parity/route_parity.md | 3 +- internal/app/bootstrap.go | 4 +- internal/handler/api/v1/upload_handler.go | 53 +++++ .../handler/api/v1/upload_handler_test.go | 81 +++++++ internal/router/router.go | 2 + internal/service/upload_service.go | 207 ++++++++++++------ 9 files changed, 300 insertions(+), 79 deletions(-) diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 7cf9b14e..b0901b70 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -96,6 +96,7 @@ var criticalRoutes = []route{ {Method: "PATCH", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/participants", Controller: "api/v1/accounts/conversations/participants#update", Source: "routes.rb:150"}, {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: "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 b7b68301..860c9327 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 participants checkpoint, prepared as `feat(conversations): align participant payloads`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align participant payloads`. +- Current tracking checkpoint: 2026-06-06 conversation direct uploads checkpoint, prepared as `feat(conversations): align direct upload routes`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align direct upload routes`. - Latest documentation/tooling checkpoint: this tracker landing update plus `e8d08bb docs: track canned response parity`; 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 participants from `reference/chatwoot/config/routes.rb:150`, `ParticipantsController`, participant Jbuilder views, and reused dashboard `api/inbox/conversation.js` are implemented for the frontend route and payload shape. `GET/POST/PATCH/PUT/DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/participants` are now tracked and routed without the trailing slash, participant create/update return raw Chatwoot agent arrays, update treats `user_ids` as the final participant set, and destroy returns an empty `200 OK`. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: conversation direct uploads from `reference/chatwoot/config/routes.rb:151`, `DirectUploadsController`, and reused dashboard `useFileUpload.js`/`fileUploadMixin.js` are implemented for the ActiveStorage metadata + PUT upload flow. `POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads` is now tracked and routed, the local `PUT /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads/:upload_uuid` target stores the bytes, uploads are account-scoped, and conversation lookup follows Chatwoot display-ID semantics with legacy ID fallback. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 925` after adding frontend conversation participant aliases and the singleton `PUT/DELETE` participant routes. +- Route dump succeeds with `TOTAL: 927` after adding conversation nested direct upload create and local PUT upload target routes. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 379 Chatwoot routes: 366 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 380 Chatwoot routes: 367 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 379-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`, and conversation participant routes from `routes.rb:150` 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, 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 participant 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 380-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`, and conversation direct upload route from `routes.rb:151` 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, 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 direct upload 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. | @@ -151,10 +151,11 @@ This table is the shortest authoritative handoff view. If an older lower section | 10 | P3.15 notification subscriptions API | Implemented for the reused dashboard push helper: user-scoped singular `POST/DELETE /api/v1/notification_subscriptions` is tracked, raw frontend bodies plus `{ notification_subscription: ... }` wrappers are accepted, browser push identifiers derive from `subscription_attributes.endpoint`, FCM identifiers derive from `device_id`/`push_token`, existing identifiers move/update to the current user, create returns raw Chatwoot subscription JSON with string `subscription_type`, and destroy returns empty `200 OK` whether or not a matching `push_token` exists. | Keep in Review; reopen only if live browser push smoke exposes service-worker registration or mobile FCM attribute drift beyond the current reference builder/controller contract. | Focused notification subscription handler tests, service/repository/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | 11 | P3.16 teams frontend routes | Implemented for reused dashboard team settings: no-trailing-slash `GET/POST /teams` and `GET/POST/PATCH/DELETE /teams/:team_id/team_members` aliases are registered, and frontend `PATCH /teams/:team_id` update now reaches the Chatwoot team serializer response. | Keep in Review; reopen only if live team settings smoke exposes team show/store payload drift or team-member authorization/status-code differences beyond the inspected controller/Jbuilder contract. | Focused TeamHandler PATCH test, router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | 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 | 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. | -| 14 | 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. | -| 15 | 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. | -| 16 | 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. | +| 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 | 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. | +| 15 | 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. | +| 16 | 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. | +| 17 | 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 @@ -183,6 +184,7 @@ These rows are the executable development plan from this point forward. A checkp | P3.15 notification subscriptions API parity | `internal/router/router.go`, `internal/handler/api/v1/notification_subscription_handler.go`, `internal/service/notification_subscription_service.go`, `internal/repository/notification_subscription_repo.go`, notification subscription handler tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:440`, `reference/chatwoot/app/controllers/api/v1/notification_subscriptions_controller.rb`, `reference/chatwoot/app/builders/notification_subscription_builder.rb`, `reference/chatwoot/app/models/notification_subscription.rb`, `reference/chatwoot/db/schema.rb`, dashboard `api/notificationSubscription.js`, `helper/pushHelper.js` | Notification subscriptions now match the reused dashboard push registration boundary: singular user-scoped `POST/DELETE /api/v1/notification_subscriptions` routes are tracked; create accepts raw frontend bodies plus Rails-style wrappers; browser push identifiers derive from `endpoint`; FCM identifiers derive from `device_id` or `push_token`; duplicate identifiers update/move to the current user; create serializes raw Chatwoot subscription fields with string enum values; and destroy deletes by `push_token`/endpoint while returning empty `200 OK` for missing matches. | Review by `feat(notifications): align subscription payloads`; focused notification subscription handler tests, combined handler/service/repository/router/route-parity tests, route dump/parity regeneration to `TOTAL: 912` and `360 exact, 13 parameter-compatible, 0 missing out of 373`, full `go test ./...`, and `git diff --check` passed. | | P3.16 teams frontend route parity | `internal/router/router.go`, `internal/handler/api/v1/team_handler.go`, team handler/router tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:296-300`, `reference/chatwoot/app/controllers/api/v1/accounts/teams_controller.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/team_members_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/teams/*.json.jbuilder`, `reference/chatwoot/app/views/api/v1/accounts/team_members/*.json.jbuilder`, dashboard `api/teams.js`, `store/modules/teams/actions.js`, `store/modules/teamMembers.js` | Team routes now match reused dashboard call sites: no-trailing-slash `GET/POST /teams` aliases are registered for `CacheEnabledApiClient`, frontend `PATCH /teams/:team_id` update is registered alongside Rails `PUT`, and no-trailing-slash team-member list/create/update/delete aliases are registered for `TeamsAPI.getAgents/addAgents/updateAgents`. Team update continues to accept raw frontend bodies and `{ team: ... }` wrappers and returns raw Chatwoot team fields. | Review by `feat(teams): align frontend update routes`; focused TeamHandler PATCH test, router/route-parity tests, route dump/parity regeneration to `TOTAL: 919` and `361 exact, 13 parameter-compatible, 0 missing out of 374`, full `go test ./...`, and `git diff --check` passed. | | 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. | | 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. | @@ -224,6 +226,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `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. | | `feat(teams): align frontend update routes` | Advances P3.16 team route parity by matching Chatwoot `TeamsController`, `TeamMembersController`, their Jbuilder serializers, routes `296-300`, and reused dashboard `api/teams.js`/team store callers. GoChat now registers frontend no-trailing-slash aliases for team index/create and team-member list/create/update/delete, tracks the Rails `PATCH /api/v1/accounts/:account_id/teams/:team_id` route, routes frontend team updates through the existing Chatwoot-shaped update serializer, and keeps raw frontend bodies plus `{ team: ... }` wrappers accepted. | `go test ./internal/handler/api/v1 -run TeamHandler -count=1`; `go test ./internal/handler/api/v1 ./internal/router ./cmd/route_parity -run 'TeamHandler\|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: 919`; tracked route parity is `361 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 374`. | P3.16 moves to Review for current team settings route evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(notifications): align subscription payloads` | Advances P3.15 notification subscription parity by matching Chatwoot `NotificationSubscriptionsController`, `NotificationSubscriptionBuilder`, the `NotificationSubscription` enum model, schema fields, and reused dashboard `pushHelper`/`notificationSubscription` API. GoChat now registers the singular user-scoped `DELETE /api/v1/notification_subscriptions`, tracks `POST/DELETE` route parity, accepts raw frontend bodies and Rails-style wrappers, derives browser push identifiers from `subscription_attributes.endpoint`, derives FCM identifiers from `device_id` or `push_token`, updates/moves duplicate identifiers to the current user, returns raw subscription objects with string `subscription_type`, and makes destroy an empty `200 OK` no-op for missing matches like the reference controller. | `go test ./internal/handler/api/v1 -run NotificationSubscription -count=1`; `go test ./internal/service ./internal/repository -run NotificationSubscription -count=1`; `go test ./internal/router ./cmd/route_parity -run '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: 912`; tracked route parity is `360 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 373`. | P3.15 moves to Review for current browser push registration evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2409,3 +2412,4 @@ Verification milestone gates: - 2026-06-06: P3.15 notification subscription checkpoint prepared as `feat(notifications): align subscription payloads`; audited Chatwoot notification subscriptions controller, builder, model/schema, and reused dashboard push helper/API. GoChat now exposes the singular user-scoped `DELETE /api/v1/notification_subscriptions`, tracks `POST/DELETE` route parity, accepts raw and wrapped create bodies, derives identifiers from browser endpoints or FCM device/push tokens, updates/moves duplicate identifiers to the current user, returns raw subscription JSON with string enum values, and makes destroy an empty `200 OK` no-op for missing matches. Focused notification subscription handler tests, service/repository/router/route-parity tests, route dump/parity regeneration (`TOTAL: 912`, `360 exact`, `13 parameter-compatible`, `0 missing out of 373`), 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.16 teams frontend route checkpoint prepared as `feat(teams): align frontend update routes`; audited Chatwoot teams/team-members controllers, Jbuilder serializers, routes `296-300`, and reused dashboard teams API/store callers. GoChat now exposes the frontend `PATCH /api/v1/accounts/:account_id/teams/:team_id` update path, registers no-trailing-slash team index/create aliases, and registers no-trailing-slash team-member list/create/update/delete aliases used by the dashboard. Focused TeamHandler PATCH test, router/route-parity tests, route dump/parity regeneration (`TOTAL: 919`, `361 exact`, `13 parameter-compatible`, `0 missing out of 374`), 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.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. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index ffb97c5b..c385b993 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -641,6 +641,7 @@ POST /api/v1/accounts/:account_id/conversations/:conversation_id/assign POST /api/v1/accounts/:account_id/conversations/:conversation_id/assignments POST /api/v1/accounts/:account_id/conversations/:conversation_id/custom_attributes POST /api/v1/accounts/:account_id/conversations/:conversation_id/custom_attributes/ +POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads POST /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/ POST /api/v1/accounts/:account_id/conversations/:conversation_id/labels POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages/ @@ -867,6 +868,7 @@ PUT /api/v1/accounts/:account_id/companies/:company_id 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/messages/:message_id PUT /api/v1/accounts/:account_id/conversations/:conversation_id/participants PUT /api/v1/accounts/:account_id/conversations/:conversation_id/participants/ @@ -923,4 +925,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: 925 +TOTAL: 927 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index b51800b3..442cab62 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: 366 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 379 tracked critical routes. +Summary: 367 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 380 tracked critical routes. ## Missing Critical Routes @@ -308,6 +308,7 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/api/v1/accounts/:account_id/contacts/import` | `/api/v1/accounts/:account_id/contacts/import` | `api/v1/accounts/contacts#import` | `routes.rb:203` | exact | | POST | `/api/v1/accounts/:account_id/conversations/` | `/api/v1/accounts/:account_id/conversations/` | `api/v1/accounts/conversations#create` | `routes.rb:134` | exact | | POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/assignments` | `/api/v1/accounts/:account_id/conversations/:conversation_id/assignments` | `api/v1/accounts/conversations/assignments#create` | `routes.rb:148` | exact | +| POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads` | `/api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads` | `api/v1/accounts/conversations/direct_uploads#create` | `routes.rb:151` | exact | | POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/labels` | `/api/v1/accounts/:account_id/conversations/:conversation_id/labels` | `api/v1/accounts/conversations/labels#create` | `routes.rb:149` | exact | | POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/` | `api/v1/accounts/conversations/messages#create` | `routes.rb:142` | exact | | POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/retry` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/retry` | `api/v1/accounts/conversations/messages#retry` | `routes.rb:145` | exact | diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 75b9ef2a..a0434b90 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -724,7 +724,9 @@ func Bootstrap(env string) (*App, error) { // Upload: DirectUpload repo + service + handler (account-level + widget direct uploads) directUploadRepo := repository.NewDirectUploadRepo(db) - uploadService := service.NewUploadService(directUploadRepo, cfg).WithWidgetAuth(inboxRepo, contactInboxRepo) + uploadService := service.NewUploadService(directUploadRepo, cfg). + WithWidgetAuth(inboxRepo, contactInboxRepo). + WithConversationRepo(conversationRepo) uploadHandler := v1.NewUploadHandler(uploadService) // Step 9: Wire handlers (HTTP presentation layer) diff --git a/internal/handler/api/v1/upload_handler.go b/internal/handler/api/v1/upload_handler.go index 9a278450..cbdcb166 100644 --- a/internal/handler/api/v1/upload_handler.go +++ b/internal/handler/api/v1/upload_handler.go @@ -118,3 +118,56 @@ func (h *UploadHandler) AccountDirectUpload(c *gin.Context) { response.OK(c, result) } + +// ConversationDirectUpload handles Chatwoot's nested conversation direct upload +// endpoint used by the reused dashboard message composer. +// Reference: POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads +func (h *UploadHandler) ConversationDirectUpload(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "account_id is required") + return + } + conversationID, err := parseUintParam(c, "conversation_id") + if err != nil || conversationID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id") + return + } + + if !strings.Contains(c.GetHeader("Content-Type"), "application/json") { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid direct upload metadata") + return + } + + var req service.ActiveStorageDirectUploadRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid direct upload metadata") + return + } + result, svcErr := h.svc.CreateConversationDirectUpload(c.Request.Context(), accountID, conversationID, req) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *UploadHandler) CompleteConversationDirectUpload(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "account_id is required") + return + } + conversationID, err := parseUintParam(c, "conversation_id") + if err != nil || conversationID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id") + return + } + + result, svcErr := h.svc.CompleteConversationDirectUpload(c.Request.Context(), accountID, conversationID, c.Param("upload_uuid"), c.Request.Body) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + response.OK(c, result) +} diff --git a/internal/handler/api/v1/upload_handler_test.go b/internal/handler/api/v1/upload_handler_test.go index c39e075e..7465eed7 100644 --- a/internal/handler/api/v1/upload_handler_test.go +++ b/internal/handler/api/v1/upload_handler_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "testing" "github.com/gin-gonic/gin" @@ -37,6 +38,8 @@ func setupUploadHandlerRouter(h *UploadHandler) *gin.Engine { // Account direct upload route api.POST("/direct_uploads", h.AccountDirectUpload) + api.POST("/conversations/:conversation_id/direct_uploads", h.ConversationDirectUpload) + api.PUT("/conversations/:conversation_id/direct_uploads/:upload_uuid", h.CompleteConversationDirectUpload) // Widget direct upload route widget := r.Group("/widget") @@ -163,6 +166,84 @@ func TestUploadHandler_WidgetActiveStorageDirectUploadFlow(t *testing.T) { assert.Equal(t, []byte("hello image"), storedBytes) } +func TestUploadHandler_ConversationActiveStorageDirectUploadFlow(t *testing.T) { + gin.SetMode(gin.TestMode) + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Account{}, + &model.Inbox{}, + &model.Contact{}, + &model.Conversation{}, + &model.DirectUpload{}, + )) + + account := &model.Account{Name: "Conversation Upload Org", Status: "active"} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Conversation Upload Inbox", ChannelType: "web_widget", Enabled: true} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Composer"} + require.NoError(t, db.Create(contact).Error) + displayID := uint(44) + conversation := &model.Conversation{ + AccountID: account.ID, + InboxID: inbox.ID, + ContactID: contact.ID, + DisplayID: &displayID, + Status: "open", + ChannelType: "web_widget", + Channel: "web_widget", + } + require.NoError(t, db.Create(conversation).Error) + + uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{ + Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}, + }).WithConversationRepo(repository.NewConversationRepo(db)) + router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc)) + + metadataBody, err := json.Marshal(map[string]any{ + "blob": map[string]any{ + "filename": "agent-note.pdf", + "byte_size": 12, + "checksum": "pdf-checksum", + "content_type": "application/pdf", + "metadata": map[string]any{"identified": true}, + }, + }) + require.NoError(t, err) + + createPath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/conversations/44/direct_uploads" + wCreate := httptest.NewRecorder() + reqCreate, _ := http.NewRequest("POST", createPath, bytes.NewReader(metadataBody)) + reqCreate.Header.Set("Content-Type", "application/json") + router.ServeHTTP(wCreate, reqCreate) + require.Equal(t, http.StatusOK, wCreate.Code) + + var createResp map[string]any + require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp)) + signedID, ok := createResp["signed_id"].(string) + require.True(t, ok) + require.NotEmpty(t, signedID) + assert.Equal(t, "agent-note.pdf", createResp["filename"]) + directUpload := createResp["direct_upload"].(map[string]any) + assert.Equal(t, createPath+"/"+signedID, directUpload["url"]) + + wPut := httptest.NewRecorder() + reqPut, _ := http.NewRequest("PUT", directUpload["url"].(string), bytes.NewReader([]byte("hello report"))) + reqPut.Header.Set("Content-Type", "application/pdf") + router.ServeHTTP(wPut, reqPut) + require.Equal(t, http.StatusOK, wPut.Code) + + var upload model.DirectUpload + require.NoError(t, db.Where("upload_uuid = ?", signedID).First(&upload).Error) + assert.Equal(t, account.ID, upload.AccountID) + assert.Equal(t, model.DirectUploadSourceAccount, upload.Source) + storedBytes, err := os.ReadFile(filepath.Join(tmpDir, "account", strconv.FormatUint(uint64(account.ID), 10), signedID+".pdf")) + require.NoError(t, err) + assert.Equal(t, []byte("hello report"), storedBytes) +} + func TestUploadHandler_AccountDirectUpload_NoFile(t *testing.T) { // Create handler with nil service — we only test validation before service call h := &UploadHandler{svc: nil} diff --git a/internal/router/router.go b/internal/router/router.go index 06c00d79..ac20c0be 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -976,6 +976,8 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Additional Conversation endpoints matching Chatwoot member routes conversations.GET("/:conversation_id/attachments", h.Conversation.ListAttachments) + conversations.POST("/:conversation_id/direct_uploads", h.Upload.ConversationDirectUpload) + conversations.PUT("/:conversation_id/direct_uploads/:upload_uuid", h.Upload.CompleteConversationDirectUpload) conversations.POST("/:conversation_id/toggle_typing_status", h.Conversation.ToggleTyping) conversations.POST("/:conversation_id/update_last_seen", h.Conversation.UpdateLastSeen) conversations.POST("/:conversation_id/assignments", h.Conversation.AssignTeam) diff --git a/internal/service/upload_service.go b/internal/service/upload_service.go index 598f4c4e..c82c7ad3 100644 --- a/internal/service/upload_service.go +++ b/internal/service/upload_service.go @@ -25,6 +25,7 @@ import ( // UploadService handles file uploads for both account-level and widget direct uploads. type UploadService struct { directUploadRepo *repository.DirectUploadRepo + conversationRepo *repository.ConversationRepo inboxRepo *repository.InboxRepo contactInboxRepo *repository.ContactInboxRepo cfg *config.Config @@ -46,6 +47,13 @@ func (s *UploadService) WithWidgetAuth(inboxRepo *repository.InboxRepo, contactI return s } +// WithConversationRepo wires the account-scoped conversation lookup needed by +// Chatwoot's nested conversation direct upload endpoint. +func (s *UploadService) WithConversationRepo(conversationRepo *repository.ConversationRepo) *UploadService { + s.conversationRepo = conversationRepo + return s +} + // --- DTOs --- // AccountUploadRequest is the DTO for account-level file upload. @@ -161,77 +169,25 @@ func (s *UploadService) CreateWidgetDirectUpload(ctx context.Context, req Active if err != nil { return nil, err } - if req.Blob.Filename == "" { - return nil, errors.New("filename is required") + return s.createActiveStorageDirectUpload(ctx, accountID, 0, model.DirectUploadSourceWidget, req, "/api/v1/widget/direct_uploads/") +} + +func (s *UploadService) CreateConversationDirectUpload(ctx context.Context, accountID, conversationID uint, req ActiveStorageDirectUploadRequest) (*ActiveStorageDirectUploadResponse, error) { + if accountID == 0 { + return nil, errors.New("account_id is required") } - if req.Blob.ByteSize <= 0 { - return nil, errors.New("byte_size is required") + if conversationID == 0 { + return nil, errors.New("conversation_id is required") } - mimeType := req.Blob.ContentType - if mimeType == "" || mimeType == "application/octet-stream" { - mimeType = detectUploadMIMEFromFilename(req.Blob.Filename) + if s.conversationRepo == nil { + return nil, errors.New("conversation repository is not configured") } - fileCategory := categorizeUploadMIME(mimeType) - if fileCategory == "" { - return nil, fmt.Errorf("unsupported file type: %s", mimeType) - } - if !isUploadMIMEAllowed(fileCategory, mimeType) { - return nil, fmt.Errorf("MIME type %s is not allowed for category %s", mimeType, fileCategory) - } - maxSize := model.WidgetUploadMaxSizeByType[fileCategory] - if maxSize == 0 { - maxSize = int64(s.cfg.Storage.MaxFileSize) - } - if req.Blob.ByteSize > maxSize { - return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", req.Blob.ByteSize, maxSize, fileCategory) + if _, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, conversationID); err != nil { + return nil, fmt.Errorf("conversation not found: %w", err) } - uploadUUID := uuid.New().String() - fileURL, thumbURL := s.directUploadURL(model.DirectUploadSourceWidget, 0, uploadUUID, req.Blob.Filename) - metadata := req.Blob.Metadata - if metadata == nil { - metadata = map[string]any{} - } - metadata["checksum"] = req.Blob.Checksum - metadata["active_storage_key"] = randomStorageKey() - metadataJSON, _ := json.Marshal(metadata) - - upload := &model.DirectUpload{ - UploadUUID: uploadUUID, - AccountID: accountID, - Status: model.DirectUploadStatusPending, - Source: model.DirectUploadSourceWidget, - OriginalName: req.Blob.Filename, - FileType: fileCategory, - MimeType: mimeType, - FileSize: req.Blob.ByteSize, - FileURL: fileURL, - ThumbURL: thumbURL, - Metadata: metadataJSON, - ExpiresAt: time.Now().Add(24 * time.Hour), - } - if err := s.directUploadRepo.Create(ctx, upload); err != nil { - return nil, fmt.Errorf("failed to create direct upload: %w", err) - } - - return &ActiveStorageDirectUploadResponse{ - ID: upload.ID, - Key: fmt.Sprint(metadata["active_storage_key"]), - Filename: upload.OriginalName, - ContentType: upload.MimeType, - Metadata: metadata, - ServiceName: "gochat_local", - ByteSize: upload.FileSize, - Checksum: req.Blob.Checksum, - CreatedAt: upload.CreatedAt, - SignedID: upload.UploadUUID, - DirectUpload: ActiveStorageUploadURL{ - URL: "/api/v1/widget/direct_uploads/" + upload.UploadUUID, - Headers: map[string]string{ - "Content-Type": upload.MimeType, - }, - }, - }, nil + urlPrefix := fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/direct_uploads/", accountID, conversationID) + return s.createActiveStorageDirectUpload(ctx, accountID, accountID, model.DirectUploadSourceAccount, req, urlPrefix) } func (s *UploadService) validateWidgetUploadSession(ctx context.Context, websiteToken, authToken string) (uint, error) { @@ -294,8 +250,127 @@ func (s *UploadService) CompleteWidgetDirectUpload(ctx context.Context, uploadUU }, nil } +func (s *UploadService) CompleteConversationDirectUpload(ctx context.Context, accountID, conversationID uint, uploadUUID string, body io.Reader) (*UploadResponse, error) { + if accountID == 0 { + return nil, errors.New("account_id is required") + } + if conversationID == 0 { + return nil, errors.New("conversation_id is required") + } + if s.conversationRepo == nil { + return nil, errors.New("conversation repository is not configured") + } + if _, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, conversationID); err != nil { + return nil, fmt.Errorf("conversation not found: %w", err) + } + if uploadUUID == "" { + return nil, errors.New("upload_uuid is required") + } + upload, err := s.directUploadRepo.FindByUUID(ctx, uploadUUID) + if err != nil { + return nil, fmt.Errorf("direct upload not found: %w", err) + } + if upload.Source != model.DirectUploadSourceAccount || upload.AccountID != accountID { + return nil, errors.New("direct upload source mismatch") + } + if time.Now().After(upload.ExpiresAt) { + upload.Status = model.DirectUploadStatusExpired + _ = s.directUploadRepo.Update(ctx, upload) + return nil, errors.New("direct upload has expired") + } + if err := s.saveReaderToDisk(upload.FileURL, body); err != nil { + return nil, fmt.Errorf("failed to save direct upload: %w", err) + } + return &UploadResponse{ + UploadID: upload.ID, + UploadUUID: upload.UploadUUID, + OriginalName: upload.OriginalName, + FileType: upload.FileType, + MimeType: upload.MimeType, + FileSize: upload.FileSize, + FileURL: upload.FileURL, + ThumbURL: upload.ThumbURL, + Status: string(upload.Status), + ExpiresAt: upload.ExpiresAt, + }, nil +} + // --- Internal helpers --- +func (s *UploadService) createActiveStorageDirectUpload(ctx context.Context, accountID, storageAccountID uint, source model.DirectUploadSource, req ActiveStorageDirectUploadRequest, directUploadURLPrefix string) (*ActiveStorageDirectUploadResponse, error) { + if req.Blob.Filename == "" { + return nil, errors.New("filename is required") + } + if req.Blob.ByteSize <= 0 { + return nil, errors.New("byte_size is required") + } + mimeType := req.Blob.ContentType + if mimeType == "" || mimeType == "application/octet-stream" { + mimeType = detectUploadMIMEFromFilename(req.Blob.Filename) + } + fileCategory := categorizeUploadMIME(mimeType) + if fileCategory == "" { + return nil, fmt.Errorf("unsupported file type: %s", mimeType) + } + if !isUploadMIMEAllowed(fileCategory, mimeType) { + return nil, fmt.Errorf("MIME type %s is not allowed for category %s", mimeType, fileCategory) + } + maxSize := model.WidgetUploadMaxSizeByType[fileCategory] + if maxSize == 0 { + maxSize = int64(s.cfg.Storage.MaxFileSize) + } + if req.Blob.ByteSize > maxSize { + return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", req.Blob.ByteSize, maxSize, fileCategory) + } + + uploadUUID := uuid.New().String() + fileURL, thumbURL := s.directUploadURL(source, storageAccountID, uploadUUID, req.Blob.Filename) + metadata := req.Blob.Metadata + if metadata == nil { + metadata = map[string]any{} + } + metadata["checksum"] = req.Blob.Checksum + metadata["active_storage_key"] = randomStorageKey() + metadataJSON, _ := json.Marshal(metadata) + + upload := &model.DirectUpload{ + UploadUUID: uploadUUID, + AccountID: accountID, + Status: model.DirectUploadStatusPending, + Source: source, + OriginalName: req.Blob.Filename, + FileType: fileCategory, + MimeType: mimeType, + FileSize: req.Blob.ByteSize, + FileURL: fileURL, + ThumbURL: thumbURL, + Metadata: metadataJSON, + ExpiresAt: time.Now().Add(24 * time.Hour), + } + if err := s.directUploadRepo.Create(ctx, upload); err != nil { + return nil, fmt.Errorf("failed to create direct upload: %w", err) + } + + return &ActiveStorageDirectUploadResponse{ + ID: upload.ID, + Key: fmt.Sprint(metadata["active_storage_key"]), + Filename: upload.OriginalName, + ContentType: upload.MimeType, + Metadata: metadata, + ServiceName: "gochat_local", + ByteSize: upload.FileSize, + Checksum: req.Blob.Checksum, + CreatedAt: upload.CreatedAt, + SignedID: upload.UploadUUID, + DirectUpload: ActiveStorageUploadURL{ + URL: directUploadURLPrefix + upload.UploadUUID, + Headers: map[string]string{ + "Content-Type": upload.MimeType, + }, + }, + }, nil +} + func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileHeader *multipart.FileHeader, source model.DirectUploadSource) (*UploadResponse, error) { // Step 1: Validate file mimeType := fileHeader.Header.Get("Content-Type")