From 85819489b9319436735b777f83613f5f73622801 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 09:57:47 +0800 Subject: [PATCH] feat(integrations): expose oauth callbacks --- cmd/route_parity/main.go | 3 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 14 +- docs/parity/gochat_routes.txt | 5 +- docs/parity/route_parity.md | 5 +- .../api/v1/shopify_integration_handler.go | 6 +- internal/router/integration_callbacks.go | 311 ++++++++++++++++++ internal/router/router.go | 6 + internal/router/router_test.go | 174 ++++++++++ .../service/shopify_integration_service.go | 43 +++ .../shopify_integration_service_test.go | 25 +- 10 files changed, 580 insertions(+), 12 deletions(-) create mode 100644 internal/router/integration_callbacks.go diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 7a6cca74..e01ed77f 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -35,6 +35,9 @@ var criticalRoutes = []route{ {Method: "POST", Path: "/twilio/voice/status/:phone", Controller: "twilio/voice#status", Source: "routes.rb:644"}, {Method: "POST", Path: "/twilio/voice/conference_status/:phone", Controller: "twilio/voice#conference_status", Source: "routes.rb:645"}, {Method: "POST", Path: "/twilio/voice/recording_status/:phone", Controller: "twilio/voice#recording_status", Source: "routes.rb:646"}, + {Method: "GET", Path: "/linear/callback", Controller: "linear/callbacks#show", Source: "routes.rb:630"}, + {Method: "GET", Path: "/shopify/callback", Controller: "shopify/callbacks#show", Source: "routes.rb:634"}, + {Method: "GET", Path: "/notion/callback", Controller: "notion/callbacks#show", Source: "routes.rb:654"}, {Method: "POST", Path: "/api/v1/accounts/", Controller: "api/v1/accounts#create", Source: "routes.rb:47"}, {Method: "GET", Path: "/api/v1/accounts/:account_id", Controller: "api/v1/accounts#show", Source: "routes.rb:47"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index ba994958..96dc7062 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,14 +49,14 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(twilio): expose root callback`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(twilio): expose root callback`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(integrations): expose oauth callbacks`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(integrations): expose oauth callbacks`. - Latest documentation/tooling checkpoint: `docs: land full parity tracking plan`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: P2/P3 external service verification route drift is now implemented for Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, `CustomDomainsController#verify`, and `routes.rb:657-660`; Twilio callback route drift is implemented for `Twilio::CallbackController`, `Twilio::DeliveryStatusController`, and `routes.rb:639-640`; enterprise Twilio voice callback route drift is implemented for `Twilio::VoiceController` and `routes.rb:643-646`. GoChat registers `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, `/.well-known/microsoft-identity-association.json`, `/.well-known/cf-custom-hostname-challenge/:id`, `/twilio/callback`, `/twilio/delivery_status`, plus `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, and `/twilio/voice/recording_status/:phone`. Twilio root callbacks acknowledge with `204 No Content`, resolve channels by `To` or `MessagingServiceSid`, and persist inbound messages through the existing provider incoming boundary; Twilio voice callbacks resolve the `Channel::TwilioSms` phone/inbox, enforce `voice_enabled`, return conference TwiML, and persist status/conference/recording payloads against stored Twilio call rows. P2/P3 dashboard deep-link route drift is in Review. P3.8/P6.6l public help-center sitemap drift is in Review. P6.6g public article lists, P6.6h public categories, P6.6i public portal show, P6.6j public portal search, P6.6k public article show/markdown/tracking, P3.5 company avatar form/pagination drift, P3.11 account integrations apps/hooks API, P3.10 account webhook API, P3.9 account agent-bot API, P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 2/3 callback drift slice, Phase 6 placeholder burn-down item, or B12 live smoke finding. +- Worktree status at this implementation checkpoint: P2/P3 external service verification route drift is now implemented for Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, `CustomDomainsController#verify`, and `routes.rb:657-660`; Twilio callback route drift is implemented for `Twilio::CallbackController`, `Twilio::DeliveryStatusController`, and `routes.rb:639-640`; enterprise Twilio voice callback route drift is implemented for `Twilio::VoiceController` and `routes.rb:643-646`; root integration callback drift is implemented for `Linear::CallbacksController`, `Shopify::CallbacksController`, `Notion::CallbacksController`, and `routes.rb:630/634/654`. GoChat registers `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, `/.well-known/microsoft-identity-association.json`, `/.well-known/cf-custom-hostname-challenge/:id`, `/twilio/callback`, `/twilio/delivery_status`, `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, `/twilio/voice/recording_status/:phone`, `/linear/callback`, `/shopify/callback`, and `/notion/callback`. Linear, Shopify, and Notion callbacks verify account state, exchange OAuth codes through provider token endpoints, persist Chatwoot-style account integration hooks, and redirect to reused frontend integration settings; Shopify account integration auth now returns the Chatwoot `redirect_url` authorization payload instead of creating credentials directly. P2/P3 dashboard deep-link route drift is in Review. P3.8/P6.6l public help-center sitemap drift is in Review. P6.6g public article lists, P6.6h public categories, P6.6i public portal show, P6.6j public portal search, P6.6k public article show/markdown/tracking, P3.5 company avatar form/pagination drift, P3.11 account integrations apps/hooks API, P3.10 account webhook API, P3.9 account agent-bot API, P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder burn-down, or B12 live smoke finding. - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 892` after adding the Chatwoot root Twilio callback route. +- Route dump succeeds with `TOTAL: 895` after adding Chatwoot root Linear, Shopify, and Notion callback routes. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 354 Chatwoot routes: 345 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. +- Tracked frontend-critical route audit covers 357 Chatwoot routes: 348 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits 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. @@ -138,7 +138,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 354-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`, 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`, and account integration app/hook routes from `routes.rb:345-348` 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, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, Twilio inbound/status callbacks, enterprise Twilio voice 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. | Run the next evidence-backed route/controller/serializer drift audit. | 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 357-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`, 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`, and account integration app/hook routes from `routes.rb:345-348` 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, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, 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 remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok or the next evidence-backed route/controller/serializer 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; fresh `rg` audit shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Continue targeted account/contact/conversation/message/inbox drift from reference/smoke evidence. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. | | 3 | 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. | | 4 | 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. | @@ -207,6 +207,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(integrations): expose oauth callbacks` | Advances Phase 2/3 root integration callback parity by matching Chatwoot `Linear::CallbacksController#show`, `Shopify::CallbacksController#show`, `Notion::CallbacksController#show`, account Shopify auth behavior, and `routes.rb:630/634/654`. GoChat now registers `/linear/callback`, `/shopify/callback`, and `/notion/callback`; verifies account state, exchanges OAuth codes through configurable provider token endpoints, persists account integration hooks with Chatwoot app IDs/access tokens/settings/reference IDs, redirects to reused frontend integration settings, and returns Shopify auth `redirect_url` authorization payloads. | `go test ./internal/router -run 'IntegrationCallbacks\|RegisterRoutes' -count=1`; `go test ./internal/service -run ShopifyIntegrationService -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`. Route dump is `TOTAL: 895`; tracked route parity is `348 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 357`. | Linear/Shopify/Notion root callbacks move to Review for current reference evidence; continue the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder audit, or B12 live smoke. | | `feat(twilio): expose root callback` | Advances Phase 2/3 Twilio ingress parity by matching Chatwoot `Twilio::CallbackController#create`, `Twilio::DeliveryStatusController#create`, and `routes.rb:639-640`. GoChat now registers the exact `/twilio/callback` route, tracks `/twilio/delivery_status`, acknowledges root callbacks with `204 No Content`, resolves inbound callbacks by `To` or `MessagingServiceSid`, normalizes phone numbers to stored E.164 channel rows, and persists inbound SMS/MMS through the existing Twilio incoming-message pipeline and durable persister boundary. | `go test ./internal/handler/webhook -run 'TwilioCallback\|TwilioWebhook\|TwilioDeliveryStatus' -count=1`; `go test ./internal/router -run 'RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 892`; tracked route parity is `345 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 354`. | Twilio root callback ingress moves to Review for current reference evidence; continue Phase 2/3 callback route audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(twilio): expose voice callbacks` | Advances Phase 2/3 enterprise voice parity by matching Chatwoot enterprise `Twilio::VoiceController#call_twiml/#status/#conference_status/#recording_status` and `routes.rb:643-646`. GoChat now registers `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, and `/twilio/voice/recording_status/:phone`; voice callbacks resolve the Twilio phone to a voice-enabled inbox, return conference TwiML for existing call rows, map Twilio call/conference events to persisted call state, and retain status/conference/recording callback payloads in call attributes. | `go test ./internal/router -run 'TwilioVoice\|RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 891`; tracked route parity is `343 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 352`. | Enterprise Twilio voice callbacks move to Review for current reference evidence; continue Phase 2/3 drift audit, callback route audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(custom-domains): expose cloudflare challenge` | Advances Phase 2/3 public custom-domain parity by matching Chatwoot enterprise `CustomDomainsController#verify`, Cloudflare SSL setting keys from `Cloudflare::BaseCloudflareZoneService`, and `routes.rb:660`. GoChat now registers `/.well-known/cf-custom-hostname-challenge/:id`, resolves the request host against `portals.custom_domain`, returns `Domain not found` for unknown domains, returns `Challenge ID not found` for mismatched `ssl_settings.cf_verification_id`, and serves the stored `cf_verification_body` as plain text on success. | `go test ./internal/router -run 'CustomDomain\|WellKnown\|RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 887`; tracked route parity is `339 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 348`. | Cloudflare custom hostname verification moves to Review for current reference evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2355,3 +2356,4 @@ Verification milestone gates: - 2026-06-06: Cloudflare custom-domain checkpoint prepared as `feat(custom-domains): expose cloudflare challenge`; audited Chatwoot enterprise `CustomDomainsController#verify`, `Cloudflare::BaseCloudflareZoneService`, and `routes.rb:660`. GoChat now exposes `/.well-known/cf-custom-hostname-challenge/:id`, resolves `request.host` against `portals.custom_domain`, checks `ssl_settings.cf_verification_id`, returns Chatwoot plain-text 404 bodies for unknown domains and mismatched challenges, and serves `ssl_settings.cf_verification_body` on success. Focused router tests, route-parity tests, route dump/parity regeneration, 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: Twilio voice callback checkpoint prepared as `feat(twilio): expose voice callbacks`; audited Chatwoot enterprise `Twilio::VoiceController`, voice callback routes `routes.rb:643-646`, and existing Go contact-call persistence. GoChat now exposes `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, and `/twilio/voice/recording_status/:phone`; callbacks resolve `Channel::TwilioSms` phones to voice-enabled inboxes, return conference TwiML for persisted call rows, map Twilio status/conference events to call state, and persist callback payloads/recording metadata. Focused router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 callback audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: Twilio root callback checkpoint prepared as `feat(twilio): expose root callback`; audited Chatwoot `Twilio::CallbackController#create`, `Twilio::DeliveryStatusController#create`, and routes `639-640`. GoChat now exposes `/twilio/callback`, tracks `/twilio/delivery_status`, acknowledges root callbacks with `204 No Content`, resolves inbound events by `To` or `MessagingServiceSid`, normalizes phone numbers to the stored Twilio channel rows, and persists inbound SMS/MMS through the existing Twilio incoming-message pipeline and persister. Focused webhook/router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 callback audit, Phase 6 placeholder audit, or B12 live smoke. +- 2026-06-06: Root integration callback checkpoint prepared as `feat(integrations): expose oauth callbacks`; audited Chatwoot `Linear::CallbacksController`, `Shopify::CallbacksController`, `Notion::CallbacksController`, Shopify account auth, and routes `630/634/654`. GoChat now exposes `/linear/callback`, `/shopify/callback`, and `/notion/callback`, verifies state, exchanges OAuth codes via configurable token endpoints, persists account hooks with Chatwoot app IDs/access tokens/settings/reference IDs, redirects to reused frontend integration settings, and returns Shopify account auth `redirect_url`. Focused router/service/route-parity tests and route dump/parity regeneration passed; continue the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder audit, or B12 live smoke. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index c76622a3..466b07a8 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -433,6 +433,8 @@ GET /hc/:slug/:locale/search GET /hc/:slug/articles/:article_slug GET /hc/:slug/sitemap.xml GET /health +GET /linear/callback +GET /notion/callback GET /platform/api/v1/accounts GET /platform/api/v1/accounts/:account_id GET /platform/api/v1/accounts/:account_id/account_users @@ -460,6 +462,7 @@ GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages +GET /shopify/callback GET /webhooks/instagram GET /webhooks/tiktok/:business_id GET /webhooks/twitter @@ -890,4 +893,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: 892 +TOTAL: 895 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 5a40b324..214754d8 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: 345 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 354 tracked critical routes. +Summary: 348 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 357 tracked critical routes. ## Missing Critical Routes @@ -227,11 +227,14 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/hc/:slug/:locale/search` | `/hc/:slug/:locale/search` | `public/api/v1/portals/search#index` | `routes.rb:593` | exact | | GET | `/hc/:slug/articles/:article_slug` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#show` | `routes.rb:601` | exact | | GET | `/hc/:slug/sitemap.xml` | `/hc/:slug/sitemap.xml` | `public/api/v1/portals#sitemap` | `routes.rb:591` | exact | +| GET | `/linear/callback` | `/linear/callback` | `linear/callbacks#show` | `routes.rb:630` | exact | +| GET | `/notion/callback` | `/notion/callback` | `notion/callbacks#show` | `routes.rb:654` | exact | | GET | `/public/api/v1/csat_survey/:id` | `/public/api/v1/csat_survey/:id` | `public/api/v1/csat_survey#show` | `routes.rb:585` | exact | | GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id` | `public/api/v1/inboxes/contacts#show` | `routes.rb:572` | exact | | GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `public/api/v1/inboxes/conversations#index` | `routes.rb:573` | exact | | GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id` | `public/api/v1/inboxes/conversations#show` | `routes.rb:573` | exact | | GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `public/api/v1/inboxes/messages#index` | `routes.rb:580` | exact | +| GET | `/shopify/callback` | `/shopify/callback` | `shopify/callbacks#show` | `routes.rb:634` | exact | | PATCH | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `api/v1/accounts/agent_bots#update` | `routes.rb:94` | exact | | PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#update` | `routes.rb:123` | exact | | PATCH | `/api/v1/accounts/:account_id/inbox_members/` | `/api/v1/accounts/:account_id/inbox_members/` | `api/v1/accounts/inbox_members#update` | `routes.rb:278` | exact | diff --git a/internal/handler/api/v1/shopify_integration_handler.go b/internal/handler/api/v1/shopify_integration_handler.go index 35023cdf..7005cdc2 100644 --- a/internal/handler/api/v1/shopify_integration_handler.go +++ b/internal/handler/api/v1/shopify_integration_handler.go @@ -36,7 +36,7 @@ func (h *ShopifyIntegrationHandler) Delete(c *gin.Context) { response.OK(c, gin.H{"message": "Shopify integration deleted"}) } -// Auth creates/updates Shopify OAuth credentials. +// Auth returns the Shopify OAuth authorize URL. // POST /api/v1/accounts/:account_id/integrations/shopify/auth func (h *ShopifyIntegrationHandler) Auth(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") @@ -51,12 +51,12 @@ func (h *ShopifyIntegrationHandler) Auth(c *gin.Context) { return } - hook, svcErr := h.svc.Auth(c.Request.Context(), accountID, req) + redirect, svcErr := h.svc.BuildAuthRedirect(c.Request.Context(), accountID, req) if svcErr != nil { handleServiceError(c, svcErr) return } - response.OK(c, hook) + response.OK(c, redirect) } // GetOrders retrieves Shopify orders for an account. diff --git a/internal/router/integration_callbacks.go b/internal/router/integration_callbacks.go new file mode 100644 index 00000000..2b36bbeb --- /dev/null +++ b/internal/router/integration_callbacks.go @@ -0,0 +1,311 @@ +package router + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" + "github.com/golang-jwt/jwt/v5" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +func linearIntegrationCallback(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("LINEAR_CLIENT_SECRET")) + redirectURL := integrationRedirectURL("linear", accountID) + if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) { + c.Redirect(http.StatusFound, redirectURL) + return + } + + body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ + TokenURL: envOrDefault("LINEAR_OAUTH_TOKEN_URL", "https://api.linear.app/oauth/token"), + ClientID: os.Getenv("LINEAR_CLIENT_ID"), + ClientSecret: os.Getenv("LINEAR_CLIENT_SECRET"), + Code: c.Query("code"), + RedirectURI: frontendBaseURL() + "/linear/callback", + }) + if err != nil || strings.TrimSpace(body["access_token"]) == "" { + c.Redirect(http.StatusFound, redirectURL) + return + } + + settings := map[string]any{ + "token_type": body["token_type"], + "scope": body["scope"], + "refresh_token": body["refresh_token"], + } + if body["expires_in"] != "" { + settings["expires_in"] = body["expires_in"] + if seconds, err := strconv.Atoi(body["expires_in"]); err == nil { + settings["expires_on"] = time.Now().UTC().Add(time.Duration(seconds) * time.Second).Format(time.RFC3339) + } + } + if err := upsertIntegrationHook(c, db, accountID, model.HookTypeLinear, "", body["access_token"], settings); err != nil { + c.Redirect(http.StatusFound, redirectURL) + return + } + c.Redirect(http.StatusFound, redirectURL) + } +} + +func shopifyIntegrationCallback(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("SHOPIFY_CLIENT_SECRET")) + redirectURL := integrationRedirectURL("shopify", accountID) + shop := strings.TrimSpace(c.Query("shop")) + if !ok || db == nil || c.Query("code") == "" || shop == "" || !accountExists(c, db, accountID) { + c.Redirect(http.StatusFound, redirectWithError(redirectURL)) + return + } + + tokenURL := os.Getenv("SHOPIFY_OAUTH_TOKEN_URL") + if tokenURL == "" { + tokenURL = fmt.Sprintf("https://%s/admin/oauth/access_token", shop) + } + body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ + TokenURL: tokenURL, + ClientID: os.Getenv("SHOPIFY_CLIENT_ID"), + ClientSecret: os.Getenv("SHOPIFY_CLIENT_SECRET"), + Code: c.Query("code"), + RedirectURI: frontendBaseURL() + "/shopify/callback", + }) + if err != nil || strings.TrimSpace(body["access_token"]) == "" { + c.Redirect(http.StatusFound, redirectWithError(redirectURL)) + return + } + + if err := createIntegrationHook(c, db, accountID, model.HookTypeShopify, shop, body["access_token"], map[string]any{"scope": body["scope"]}); err != nil { + c.Redirect(http.StatusFound, redirectWithError(redirectURL)) + return + } + c.Redirect(http.StatusFound, redirectURL) + } +} + +func notionIntegrationCallback(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("NOTION_CLIENT_SECRET")) + redirectURL := integrationRedirectURL("notion", accountID) + if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) { + c.Redirect(http.StatusFound, frontendBaseURL()) + return + } + + body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ + TokenURL: envOrDefault("NOTION_OAUTH_TOKEN_URL", "https://api.notion.com/v1/oauth/token"), + ClientID: os.Getenv("NOTION_CLIENT_ID"), + ClientSecret: os.Getenv("NOTION_CLIENT_SECRET"), + Code: c.Query("code"), + RedirectURI: frontendBaseURL() + "/notion/callback", + BasicAuth: true, + }) + if err != nil || strings.TrimSpace(body["access_token"]) == "" { + c.Redirect(http.StatusFound, frontendBaseURL()) + return + } + + settings := map[string]any{ + "token_type": body["token_type"], + "workspace_name": body["workspace_name"], + "workspace_id": body["workspace_id"], + "workspace_icon": body["workspace_icon"], + "bot_id": body["bot_id"], + } + if owner := strings.TrimSpace(body["owner"]); owner != "" { + settings["owner"] = owner + } + if err := createIntegrationHook(c, db, accountID, model.HookTypeNotion, "", body["access_token"], settings); err != nil { + c.Redirect(http.StatusFound, frontendBaseURL()) + return + } + c.Redirect(http.StatusFound, redirectURL) + } +} + +type oauthTokenExchangeRequest struct { + TokenURL string + ClientID string + ClientSecret string + Code string + RedirectURI string + BasicAuth bool +} + +func exchangeOAuthToken(c *gin.Context, req oauthTokenExchangeRequest) (map[string]string, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", req.Code) + form.Set("redirect_uri", req.RedirectURI) + if !req.BasicAuth { + form.Set("client_id", req.ClientID) + form.Set("client_secret", req.ClientSecret) + } + + httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, req.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + httpReq.Header.Set("Accept", "application/json") + if req.BasicAuth { + httpReq.SetBasicAuth(req.ClientID, req.ClientSecret) + } + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + _, _ = io.Copy(io.Discard, resp.Body) + return nil, fmt.Errorf("oauth token exchange failed: %s", resp.Status) + } + + var raw map[string]any + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, err + } + result := make(map[string]string, len(raw)) + for key, value := range raw { + switch typed := value.(type) { + case string: + result[key] = typed + case float64: + result[key] = strconv.FormatInt(int64(typed), 10) + case nil: + result[key] = "" + default: + encoded, _ := json.Marshal(typed) + result[key] = string(encoded) + } + } + return result, nil +} + +func callbackAccountID(state string, secret string) (uint, bool) { + state = strings.TrimSpace(state) + if state == "" { + return 0, false + } + if id, err := strconv.ParseUint(state, 10, 64); err == nil && id > 0 { + return uint(id), true + } + if secret == "" { + return 0, false + } + + claims := jwt.MapClaims{} + token, err := jwt.ParseWithClaims(state, claims, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(secret), nil + }) + if err != nil || !token.Valid { + return 0, false + } + if sub, ok := claims["sub"].(float64); ok && sub > 0 { + return uint(sub), true + } + if sub, ok := claims["sub"].(string); ok { + id, err := strconv.ParseUint(sub, 10, 64) + return uint(id), err == nil && id > 0 + } + return 0, false +} + +func accountExists(c *gin.Context, db *gorm.DB, accountID uint) bool { + var count int64 + if err := db.WithContext(c.Request.Context()).Model(&model.Account{}).Where("id = ?", accountID).Count(&count).Error; err != nil { + return false + } + return count > 0 +} + +func upsertIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error { + settingsJSON, err := json.Marshal(compactMap(settings)) + if err != nil { + return err + } + + var hook model.IntegrationHook + err = db.WithContext(c.Request.Context()). + Where("account_id = ? AND (app_id = ? OR hook_type = ?)", accountID, string(hookType), hookType). + First(&hook).Error + if err == nil { + hook.AppID = string(hookType) + hook.HookType = hookType + hook.Status = model.HookStatusActive + hook.ReferenceID = referenceID + hook.AccessToken = accessToken + hook.Settings = datatypes.JSON(settingsJSON) + return db.WithContext(c.Request.Context()).Save(&hook).Error + } + if err != gorm.ErrRecordNotFound { + return err + } + return createIntegrationHook(c, db, accountID, hookType, referenceID, accessToken, settings) +} + +func createIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error { + settingsJSON, err := json.Marshal(compactMap(settings)) + if err != nil { + return err + } + hook := &model.IntegrationHook{ + AccountID: accountID, + AppID: string(hookType), + HookType: hookType, + Status: model.HookStatusActive, + AccessToken: accessToken, + ReferenceID: referenceID, + Settings: datatypes.JSON(settingsJSON), + } + return db.WithContext(c.Request.Context()).Create(hook).Error +} + +func compactMap(values map[string]any) map[string]any { + result := make(map[string]any, len(values)) + for key, value := range values { + if value == nil || value == "" { + continue + } + result[key] = value + } + return result +} + +func integrationRedirectURL(app string, accountID uint) string { + if accountID == 0 { + return frontendBaseURL() + } + return fmt.Sprintf("%s/app/accounts/%d/settings/integrations/%s", frontendBaseURL(), accountID, app) +} + +func redirectWithError(location string) string { + if strings.Contains(location, "?") { + return location + "&error=true" + } + return location + "?error=true" +} + +func frontendBaseURL() string { + return strings.TrimRight(envOrDefault("FRONTEND_URL", "http://localhost:3000"), "/") +} + +func envOrDefault(key string, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} diff --git a/internal/router/router.go b/internal/router/router.go index e4e1c4f7..b8349224 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -207,6 +207,12 @@ func RegisterRoutes( engine.GET("/.well-known/microsoft-identity-association.json", microsoftIdentityAssociation) engine.GET("/.well-known/cf-custom-hostname-challenge/:id", customDomainChallenge(db)) + // Root OAuth callbacks used by Chatwoot integration app redirects. + // Reference: Chatwoot routes.rb linear/shopify/notion callback routes. + engine.GET("/linear/callback", linearIntegrationCallback(db)) + engine.GET("/shopify/callback", shopifyIntegrationCallback(db)) + engine.GET("/notion/callback", notionIntegrationCallback(db)) + // Dashboard shell routes used by Chatwoot mailer and push deep links. // Reference: Chatwoot routes.rb `get '/app'`, `get '/app/*params'` -> DashboardController#index. engine.GET("/app", dashboardIndex) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 48fb1216..532891cf 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -2,17 +2,20 @@ package router import ( "encoding/json" + "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/golang-jwt/jwt/v5" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -45,6 +48,9 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "GET /.well-known/apple-app-site-association", "GET /.well-known/microsoft-identity-association.json", "GET /.well-known/cf-custom-hostname-challenge/:id", + "GET /linear/callback", + "GET /shopify/callback", + "GET /notion/callback", "GET /app", "GET /app/*params", "GET /api/v1/accounts/:account_id/captain/assistants/tools", @@ -206,6 +212,106 @@ func TestCustomDomainChallengeMatchesChatwootVerification(t *testing.T) { } } +func TestIntegrationCallbacksCreateHooksAndRedirect(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FRONTEND_URL", "https://app.example.test") + t.Setenv("LINEAR_CLIENT_ID", "linear-client") + t.Setenv("LINEAR_CLIENT_SECRET", "linear-secret") + t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client") + t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret") + t.Setenv("NOTION_CLIENT_ID", "notion-client") + t.Setenv("NOTION_CLIENT_SECRET", "notion-secret") + + t.Setenv("LINEAR_OAUTH_TOKEN_URL", "https://oauth.example.test/linear") + t.Setenv("SHOPIFY_OAUTH_TOKEN_URL", "https://oauth.example.test/shopify") + t.Setenv("NOTION_OAUTH_TOKEN_URL", "https://oauth.example.test/notion") + withFakeOAuthTransport(t, map[string]map[string]any{ + "/linear": {"access_token": "linear-token", "refresh_token": "linear-refresh", "token_type": "bearer", "expires_in": 3600, "scope": "read,write"}, + "/shopify": {"access_token": "shopify-token", "scope": "read_orders"}, + "/notion": {"access_token": "notion-token", "token_type": "bearer", "workspace_name": "Docs", "workspace_id": "workspace-1", "bot_id": "bot-1"}, + }) + + db, account := setupRouterIntegrationCallbackDB(t) + engine := gin.New() + engine.GET("/linear/callback", linearIntegrationCallback(db)) + engine.GET("/shopify/callback", shopifyIntegrationCallback(db)) + engine.GET("/notion/callback", notionIntegrationCallback(db)) + + linearState := signedCallbackState(t, account.ID, "linear-secret") + linear := performGet(engine, "/linear/callback?code=linear-code&state="+url.QueryEscape(linearState)) + if linear.Code != http.StatusFound || linear.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/linear" { + t.Fatalf("expected linear integration redirect, got %d %q", linear.Code, linear.Header().Get("Location")) + } + + shopifyState := signedCallbackState(t, account.ID, "shopify-secret") + shopify := performGet(engine, "/shopify/callback?code=shopify-code&shop=store.myshopify.com&state="+url.QueryEscape(shopifyState)) + if shopify.Code != http.StatusFound || shopify.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/shopify" { + t.Fatalf("expected shopify integration redirect, got %d %q", shopify.Code, shopify.Header().Get("Location")) + } + + notionState := signedCallbackState(t, account.ID, "notion-secret") + notion := performGet(engine, "/notion/callback?code=notion-code&state="+url.QueryEscape(notionState)) + if notion.Code != http.StatusFound || notion.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/notion" { + t.Fatalf("expected notion integration redirect, got %d %q", notion.Code, notion.Header().Get("Location")) + } + + var hooks []model.IntegrationHook + if err := db.Order("app_id ASC").Find(&hooks).Error; err != nil { + t.Fatalf("failed to load hooks: %v", err) + } + if len(hooks) != 3 { + t.Fatalf("expected three integration hooks, got %d", len(hooks)) + } + + seen := map[string]model.IntegrationHook{} + for _, hook := range hooks { + seen[hook.AppID] = hook + } + if seen["linear"].AccessToken != "linear-token" || seen["linear"].Status != model.HookStatusActive { + t.Fatalf("expected active linear hook with token, got %+v", seen["linear"]) + } + if seen["shopify"].AccessToken != "shopify-token" || seen["shopify"].ReferenceID != "store.myshopify.com" { + t.Fatalf("expected shopify hook with reference shop, got %+v", seen["shopify"]) + } + if seen["notion"].AccessToken != "notion-token" || !strings.Contains(string(seen["notion"].Settings), "workspace_name") { + t.Fatalf("expected notion hook with workspace settings, got %+v settings=%s", seen["notion"], string(seen["notion"].Settings)) + } +} + +func TestIntegrationCallbacksRedirectSafelyOnInvalidState(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FRONTEND_URL", "https://app.example.test") + t.Setenv("LINEAR_CLIENT_SECRET", "linear-secret") + t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret") + db, _ := setupRouterIntegrationCallbackDB(t) + + engine := gin.New() + engine.GET("/linear/callback", linearIntegrationCallback(db)) + engine.GET("/shopify/callback", shopifyIntegrationCallback(db)) + engine.GET("/notion/callback", notionIntegrationCallback(db)) + + linear := performGet(engine, "/linear/callback?code=linear-code&state=bad-state") + if linear.Code != http.StatusFound || linear.Header().Get("Location") != "https://app.example.test" { + t.Fatalf("expected linear safe redirect, got %d %q", linear.Code, linear.Header().Get("Location")) + } + shopify := performGet(engine, "/shopify/callback?code=shopify-code&shop=store.myshopify.com&state=bad-state") + if shopify.Code != http.StatusFound || shopify.Header().Get("Location") != "https://app.example.test?error=true" { + t.Fatalf("expected shopify error redirect, got %d %q", shopify.Code, shopify.Header().Get("Location")) + } + notion := performGet(engine, "/notion/callback?code=notion-code&state=bad-state") + if notion.Code != http.StatusFound || notion.Header().Get("Location") != "https://app.example.test" { + t.Fatalf("expected notion safe redirect, got %d %q", notion.Code, notion.Header().Get("Location")) + } + + var count int64 + if err := db.Model(&model.IntegrationHook{}).Count(&count).Error; err != nil { + t.Fatalf("failed to count hooks: %v", err) + } + if count != 0 { + t.Fatalf("expected invalid callbacks not to create hooks, got %d", count) + } +} + func TestWellKnownRoutesServeMobileAssociationPayloads(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("ANDROID_BUNDLE_ID", "com.example.gochat") @@ -331,6 +437,74 @@ func setupRouterPortalDB(t *testing.T) *gorm.DB { return db } +func setupRouterIntegrationCallbackDB(t *testing.T) (*gorm.DB, *model.Account) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("failed to open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Account{}, &model.IntegrationHook{}); err != nil { + t.Fatalf("failed to migrate integration callback models: %v", err) + } + account := &model.Account{Name: "Integration Account", Locale: "en"} + if err := db.Create(account).Error; err != nil { + t.Fatalf("failed to create account: %v", err) + } + return db, account +} + +func signedCallbackState(t *testing.T, accountID uint, secret string) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": accountID, + "iat": time.Now().Unix(), + }) + signed, err := token.SignedString([]byte(secret)) + if err != nil { + t.Fatalf("failed to sign callback state: %v", err) + } + return signed +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func withFakeOAuthTransport(t *testing.T, payloads map[string]map[string]any) { + t.Helper() + original := http.DefaultClient + http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + t.Fatalf("expected token exchange POST, got %s", req.Method) + } + if err := req.ParseForm(); err != nil { + t.Fatalf("failed to parse token exchange form: %v", err) + } + if req.Form.Get("code") == "" || req.Form.Get("redirect_uri") == "" { + t.Fatalf("expected code and redirect_uri in token exchange form: %v", req.Form) + } + payload, ok := payloads[req.URL.Path] + if !ok { + t.Fatalf("unexpected OAuth token URL path: %s", req.URL.Path) + } + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to encode fake OAuth payload: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(string(encoded))), + Request: req, + }, nil + })} + t.Cleanup(func() { http.DefaultClient = original }) +} + func setupRouterTwilioVoiceDB(t *testing.T) (*gorm.DB, *model.Call) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{ diff --git a/internal/service/shopify_integration_service.go b/internal/service/shopify_integration_service.go index 3e3cae8e..ccbb80d6 100644 --- a/internal/service/shopify_integration_service.go +++ b/internal/service/shopify_integration_service.go @@ -4,10 +4,15 @@ import ( "context" "encoding/json" "fmt" + "net/url" + "os" + "strings" + "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" + "github.com/golang-jwt/jwt/v5" ) // ShopifyIntegrationService implements Shopify integration business logic. @@ -28,6 +33,10 @@ type CreateShopifyAuthRequest struct { AccessToken string `json:"access_token,omitempty"` } +type ShopifyAuthResponse struct { + RedirectURL string `json:"redirect_url"` +} + // Delete removes a Shopify integration hook for an account. func (s *ShopifyIntegrationService) Delete(ctx context.Context, accountID uint) error { hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeShopify) @@ -93,6 +102,40 @@ func (s *ShopifyIntegrationService) Auth(ctx context.Context, accountID uint, re return hook, nil } +// BuildAuthRedirect returns the Chatwoot-compatible Shopify OAuth authorize URL. +// Reference: Api::V1::Accounts::Integrations::ShopifyController#auth. +func (s *ShopifyIntegrationService) BuildAuthRedirect(_ context.Context, accountID uint, req CreateShopifyAuthRequest) (*ShopifyAuthResponse, error) { + shopDomain := strings.TrimSpace(req.ShopDomain) + if shopDomain == "" { + return nil, fmt.Errorf("Shop domain is required") + } + clientID := strings.TrimSpace(os.Getenv("SHOPIFY_CLIENT_ID")) + clientSecret := strings.TrimSpace(os.Getenv("SHOPIFY_CLIENT_SECRET")) + if clientID == "" || clientSecret == "" { + return nil, fmt.Errorf("Shopify OAuth is not configured") + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": accountID, + "iat": time.Now().Unix(), + }) + state, err := token.SignedString([]byte(clientSecret)) + if err != nil { + return nil, fmt.Errorf("failed to generate Shopify state: %w", err) + } + + frontendURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") + if frontendURL == "" { + frontendURL = "http://localhost:3000" + } + params := url.Values{} + params.Set("client_id", clientID) + params.Set("scope", "read_customers,read_orders,read_fulfillments") + params.Set("redirect_uri", frontendURL+"/shopify/callback") + params.Set("state", state) + return &ShopifyAuthResponse{RedirectURL: fmt.Sprintf("https://%s/admin/oauth/authorize?%s", shopDomain, params.Encode())}, nil +} + // GetOrders retrieves Shopify orders for an account (proxy to Shopify API). // GET /api/v1/accounts/:account_id/integrations/shopify/orders func (s *ShopifyIntegrationService) GetOrders(ctx context.Context, accountID uint) ([]map[string]interface{}, error) { diff --git a/internal/service/shopify_integration_service_test.go b/internal/service/shopify_integration_service_test.go index c0714a22..448e585e 100644 --- a/internal/service/shopify_integration_service_test.go +++ b/internal/service/shopify_integration_service_test.go @@ -3,6 +3,8 @@ package service import ( "context" "encoding/json" + "net/url" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -87,6 +89,27 @@ func TestShopifyIntegrationService_Auth_CreateNew(t *testing.T) { assert.Len(t, hooks, 1, "one Shopify hook should exist for the account") } +func TestShopifyIntegrationService_BuildAuthRedirect_ChatwootPayload(t *testing.T) { + svc, db := setupShopifyIntegrationService(t) + accountID := seedShopifyAccount(db, t) + t.Setenv("FRONTEND_URL", "https://app.example.test/") + t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client") + t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret") + + result, err := svc.BuildAuthRedirect(context.Background(), accountID, CreateShopifyAuthRequest{ShopDomain: "my-store.myshopify.com"}) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, strings.HasPrefix(result.RedirectURL, "https://my-store.myshopify.com/admin/oauth/authorize?")) + + parsed, err := url.Parse(result.RedirectURL) + require.NoError(t, err) + query := parsed.Query() + assert.Equal(t, "shopify-client", query.Get("client_id")) + assert.Equal(t, "read_customers,read_orders,read_fulfillments", query.Get("scope")) + assert.Equal(t, "https://app.example.test/shopify/callback", query.Get("redirect_uri")) + assert.NotEmpty(t, query.Get("state")) +} + func TestShopifyIntegrationService_Auth_UpdateExisting(t *testing.T) { svc, db := setupShopifyIntegrationService(t) accountID := seedShopifyAccount(db, t) @@ -167,4 +190,4 @@ func TestShopifyIntegrationService_Delete_NotFound(t *testing.T) { err := svc.Delete(ctx, accountID) assert.Error(t, err, "Delete should fail when no Shopify integration exists") assert.Contains(t, err.Error(), "not found", "error should indicate integration not found") -} \ No newline at end of file +}