feat(notifications): align subscription payloads

This commit is contained in:
2026-06-06 13:58:35 +08:00
parent 218eec92ac
commit 0ed1cfdee5
9 changed files with 265 additions and 44 deletions
+3
View File
@@ -344,6 +344,9 @@ var criticalRoutes = []route{
{Method: "POST", Path: "/api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/", Controller: "api/v1/accounts/inboxes/assignment_policy#create", Source: "routes.rb:311"},
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/", Controller: "api/v1/accounts/inboxes/assignment_policy#destroy", Source: "routes.rb:311"},
{Method: "POST", Path: "/api/v1/notification_subscriptions", Controller: "api/v1/notification_subscriptions#create", Source: "routes.rb:440"},
{Method: "DELETE", Path: "/api/v1/notification_subscriptions", Controller: "api/v1/notification_subscriptions#destroy", Source: "routes.rb:440"},
{Method: "POST", Path: "/api/v1/widget/direct_uploads", Controller: "api/v1/widget/direct_uploads#create", Source: "routes.rb:443"},
{Method: "POST", Path: "/api/v1/widget/config", Controller: "api/v1/widget/config#create", Source: "routes.rb:444"},
{Method: "GET", Path: "/api/v1/widget/campaigns", Controller: "api/v1/widget/campaigns#index", Source: "routes.rb:445"},
+13 -9
View File
@@ -49,15 +49,15 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-06 canned responses implementation checkpoint, prepared as `feat(canned-responses): align chatwoot payloads`.
- Latest committed implementation checkpoint: `4e0113a feat(canned-responses): align chatwoot payloads`.
- Current tracking checkpoint: 2026-06-06 notification subscriptions implementation checkpoint, prepared as `feat(notifications): align subscription payloads`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(notifications): align subscription payloads`.
- 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: canned responses from `reference/chatwoot/config/routes.rb:114` are implemented for the reused dashboard canned-response settings flow. `GET/POST/PATCH/PUT/DELETE /api/v1/accounts/:account_id/canned_responses` now accept raw frontend bodies plus Rails-style wrappers, return raw arrays/objects, support `search=` ranking by short-code prefix/contains/content, scope show/update/delete by account, and return empty `200 OK` on delete. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. The next active slice returns to Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or a fresh reference/smoke finding.
- Worktree status at this implementation checkpoint: notification subscriptions from `reference/chatwoot/config/routes.rb:440` are implemented for the reused dashboard push helper. `POST/DELETE /api/v1/notification_subscriptions` now track the Rails singular resource route shape, accept raw frontend bodies plus Rails-style wrappers, derive identifiers from browser push endpoints or FCM device/push tokens, return raw subscription objects, and make destroy an empty `200 OK` no-op when no matching subscription exists. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 910` after adding canned response no-trailing-slash frontend aliases and `PATCH` update.
- Route dump succeeds with `TOTAL: 912` after adding the notification subscription singular `DELETE` route plus the existing compatibility aliases.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
- Tracked frontend-critical route audit covers 371 Chatwoot routes: 358 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 373 Chatwoot routes: 360 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.
@@ -148,10 +148,11 @@ This table is the shortest authoritative handoff view. If an older lower section
| 7 | P3.12 account Dyte integration API | Implemented for the reused dashboard VideoCallButton/Dyte flow: account-scoped create meeting and add-participant routes are registered, route parity tracks `routes.rb:357-358`, Dyte credentials are loaded from account integration hooks, `create_a_meeting` creates a Chatwoot `integrations` outgoing message with meeting content attributes, and add-participant returns Dyte token payloads while rejecting non-integration messages with the reference error. | Keep in Review; reopen if live smoke exposes provider payload/status-code drift or frontend message serializer gaps beyond the fakeable Dyte boundary. | Focused Dyte service and handler tests, combined handler/service/router/app/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 8 | P3.13 dashboard apps API | Implemented for the reused dashboard integration settings route: dashboard app CRUD routes are tracked, raw frontend bodies and Rails-style wrappers are accepted, list/show/create/update return the Chatwoot dashboard-app partial fields only, show/update/delete are account-scoped, content validation requires at least one `{ type: "frame", url: "http(s)://..." }`, and delete returns `204`. | Keep in Review; reopen if live settings smoke exposes error-body wording or iframe content validation drift beyond the current reference schema. | Focused dashboard app handler/service tests, combined handler/service/repository/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 9 | P3.14 canned responses API | Implemented for the reused dashboard canned-response settings flow: raw frontend bodies and Rails-style wrappers are accepted, no-trailing-slash frontend aliases and `PATCH` update are registered, list/search return raw arrays, create/update/show return raw Chatwoot fields, search uses `search=` and Chatwoot ranking, update/delete are account-scoped, and delete returns empty `200 OK`. | Keep in Review; reopen only if live settings smoke exposes validation/error wording or non-frontend compatibility alias drift beyond the current reference contract. | Focused canned handler/service tests, combined handler/canned/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 10 | 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. |
| 11 | 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. |
| 12 | 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. |
| 13 | 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. |
| 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 | 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. |
| 12 | 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. |
| 13 | 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. |
| 14 | 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
@@ -177,6 +178,7 @@ These rows are the executable development plan from this point forward. A checkp
| P3.12 account Dyte integration API parity | `internal/router/router.go`, `internal/handler/api/v1/dyte_integration_handler.go`, `internal/service/dyte_integration_service.go`, `internal/service/message_service.go`, Dyte handler/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:357-358`, `reference/chatwoot/app/controllers/api/v1/accounts/integrations/dyte_controller.rb`, `reference/chatwoot/lib/integrations/dyte/processor_service.rb`, `reference/chatwoot/lib/dyte.rb`, `reference/chatwoot/app/javascript/dashboard/api/integrations/dyte.js`, `reference/chatwoot/app/javascript/dashboard/components/widgets/VideoCallButton.vue`, `reference/chatwoot/app/javascript/dashboard/components-next/message/bubbles/Dyte.vue` | Account Dyte APIs now match the reused dashboard video-call boundary: `POST /integrations/dyte/create_a_meeting` and `POST /integrations/dyte/add_participant_to_meeting` are registered under account integrations and tracked in route parity; create resolves conversations by account display ID, enforces inbox access, loads account Dyte hook credentials, calls a fakeable Dyte backend, persists an outgoing `integrations` message with `content_attributes.type=dyte` and `data.meeting_id`, and returns the normal Chatwoot message serializer; add-participant resolves account-scoped messages, rejects non-`integrations` messages with `422 { error: { error: "Invalid message type. Action not permitted" } }`, and returns the Dyte participant token payload. | Review by `feat(integrations): align dyte meeting routes`; focused Dyte service tests, focused Dyte handler tests, combined handler/service/router/app/route-parity tests, route dump/parity regenerated to `TOTAL: 907` and `355 exact, 9 parameter-compatible, 0 missing out of 364`, full `go test ./...`, and `git diff --check` passed. |
| P3.13 dashboard apps API parity | `internal/router/router.go`, `internal/handler/api/v1/dashboard_app_handler.go`, `internal/service/dashboard_app_service.go`, `internal/model/dashboard_app.go`, `internal/repository/dashboard_app_repo.go`, dashboard app handler/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:130`, `reference/chatwoot/app/controllers/api/v1/accounts/dashboard_apps_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/dashboard_apps/*.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_dashboard_app.json.jbuilder`, `reference/chatwoot/app/models/dashboard_app.rb`, `reference/chatwoot/app/javascript/dashboard/api/dashboardApps.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/dashboardApps.js`, dashboard integrations DashboardApps modal/index components | Dashboard app APIs now match the reused settings flow: the full Chatwoot resource route set is tracked; create/update accept raw frontend bodies and Rails-style `{ dashboard_app: ... }` wrappers; create returns `200 OK`; list returns a raw array; show/create/update return raw dashboard app objects containing only `id`, `title`, `content`, and `created_at`; show/update/delete are scoped by current account; delete returns `204`; and content validation requires at least one frame widget with an HTTP(S) URL. | Review by `feat(dashboard-apps): align chatwoot payloads`; focused dashboard app handler/service tests, combined handler/service/repository/router/route-parity tests, route dump/parity regenerated to `TOTAL: 907` and `357 exact, 13 parameter-compatible, 0 missing out of 370`, full `go test ./...`, and `git diff --check` passed. |
| P3.14 canned responses API parity | `internal/router/router.go`, `internal/handler/api/v1/canned_response_handler.go`, `internal/canned/service.go`, `internal/canned/model.go`, canned handler/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:114`, `reference/chatwoot/app/controllers/api/v1/accounts/canned_responses_controller.rb`, `reference/chatwoot/app/models/canned_response.rb`, `reference/chatwoot/db/schema.rb`, `reference/chatwoot/app/javascript/dashboard/api/cannedResponse.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/cannedResponse.js` | Canned responses now match the reused dashboard API: frontend `PATCH /api/v1/accounts/:account_id/canned_responses/:id` and no-trailing-slash index/create aliases are registered; the tracked critical route set includes Rails `PATCH`; create/update accept raw frontend bodies plus Rails-style `{ canned_response: ... }`; list/search return raw arrays; `search=` filters and ranks by short-code prefix, short-code contains, then content contains; create/update/show return raw objects with `id`, `account_id`, `short_code`, `content`, `created_at`, and `updated_at`; update/delete are account-scoped; delete returns empty `200 OK`; legacy `q` and `/search` remain only compatibility aliases. | Review by `feat(canned-responses): align chatwoot payloads`; focused canned handler tests cover list/search/raw create/PATCH update/delete/cross-account `404`, service tests cover account-scoped get/update/delete and ranking, route dump/parity regenerated to `TOTAL: 910` and `358 exact, 13 parameter-compatible, 0 missing out of 371`, full `go test ./...`, and `git diff --check` passed. |
| 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. |
| 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. |
@@ -218,6 +220,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `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. |
| `4e0113a feat(canned-responses): align chatwoot payloads` | Advances P3.14 canned response parity by matching Chatwoot `CannedResponsesController`, `CannedResponse#order_by_search`, schema fields, and reused dashboard `cannedResponse` API/store. GoChat now registers frontend no-trailing-slash index/create aliases plus `PATCH` update, accepts raw frontend bodies and Rails-style wrappers, returns raw arrays/objects with Chatwoot fields instead of local `{ success, data }` envelopes, supports `search=` ranking by short-code prefix/contains/content, scopes show/update/delete by account, and returns empty `200 OK` deletes while keeping legacy `q` and `/search` as compatibility aliases only. | `go test ./internal/handler/api/v1 -run CannedResponse -count=1`; `go test ./internal/canned -count=1`; `go test ./internal/handler/api/v1 ./internal/canned ./internal/router ./cmd/route_parity -run 'CannedResponse\|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: 910`; tracked route parity is `358 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 371`. | P3.14 moves to Review for current canned response settings evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `docs: track canned response parity` | Documentation-only checkpoint requested before continuing implementation. It records the already-clean `75f0e80 feat(dashboard-apps): align chatwoot payloads` baseline and lands fresh canned-response reference/frontend drift as P3.14, with owner files, reference files, required request/response/status contracts, route artifact rules, and test gates. | `git diff --check`. No Go code changed and no route artifacts changed. | Start `feat(canned-responses): align chatwoot payloads`, then continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from concrete reference/smoke evidence. |
| `feat(dashboard-apps): align chatwoot payloads` | Advances P3.13 dashboard app parity by matching Chatwoot `DashboardAppsController`, dashboard app Jbuilder partials, model content schema, and reused dashboard integrations settings store/API. GoChat now tracks the dashboard app resource routes, accepts the raw frontend create/update bodies plus Rails-style wrappers, scopes show/update/delete by account, returns raw dashboard app partial payloads instead of local success/data envelopes or extended model fields, returns `200 OK` for create and `204` for delete, and validates content as at least one frame widget with an HTTP(S) URL. | `go test ./internal/handler/api/v1 -run DashboardApp -count=1`; `go test ./internal/service -run DashboardApp -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router ./cmd/route_parity -run 'DashboardApp\|Router\|Route' -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: 907`; tracked route parity is `357 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 370`. | P3.13 moves to Review for current dashboard integrations settings evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
@@ -2397,3 +2400,4 @@ Verification milestone gates:
- 2026-06-06: Documentation checkpoint prepared as `docs: track canned response parity`; worktree was clean at `75f0e80 feat(dashboard-apps): align chatwoot payloads`, and fresh reference/frontend inspection is now landed as P3.14. The tracker records canned response owner files, Chatwoot controller/model/schema/frontend references, raw payload and serializer contracts, `PATCH`/`search=`/account-scope/delete-status drift, required tests, and route artifact rules. Verification for this docs-only checkpoint: `git diff --check`; next slice is `feat(canned-responses): align chatwoot payloads`.
- 2026-06-06: P3.14 canned response checkpoint prepared as `feat(canned-responses): align chatwoot payloads`; audited Chatwoot canned responses controller/model/schema and reused dashboard API/store. GoChat now exposes no-trailing-slash index/create aliases and `PATCH` update, accepts raw and wrapped bodies, returns raw Chatwoot arrays/objects, supports `search=` with Chatwoot ranking, scopes mutations by account, and returns empty `200 OK` delete. Focused canned handler/service tests, combined handler/canned/router/route-parity tests, route dump/parity regeneration (`TOTAL: 910`, `358 exact`, `13 parameter-compatible`, `0 missing out of 371`), 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: Documentation checkpoint prepared as `docs: record committed parity tracker`; confirms `4e0113a feat(canned-responses): align chatwoot payloads` is the current committed implementation baseline, marks this master plan as the landed tracker for known Hermes plans and user-confirmed scope, and keeps the next executable queue limited to Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. Verification for this docs-only checkpoint: `git diff --check`.
- 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.
+3 -1
View File
@@ -76,6 +76,7 @@ DELETE /api/v1/accounts/:account_id/integrations/slack/
DELETE /api/v1/accounts/:account_id/labels/:tag_id
DELETE /api/v1/accounts/:account_id/macros/:macro_id
DELETE /api/v1/accounts/:account_id/microsoft_channels/:ms_id
DELETE /api/v1/accounts/:account_id/notification_subscriptions/
DELETE /api/v1/accounts/:account_id/notification_subscriptions/:identifier
DELETE /api/v1/accounts/:account_id/notifications/:notification_id
DELETE /api/v1/accounts/:account_id/notifications/destroy_all
@@ -98,6 +99,7 @@ DELETE /api/v1/accounts/:account_id/twitter_channels/:twitter_id
DELETE /api/v1/accounts/:account_id/users/:user_id
DELETE /api/v1/accounts/:account_id/webhooks/:webhook_id
DELETE /api/v1/auth/logout
DELETE /api/v1/notification_subscriptions
DELETE /api/v1/notification_subscriptions/:identifier
DELETE /api/v1/notifications/:id
DELETE /api/v1/notifications/destroy_all
@@ -908,4 +910,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: 910
TOTAL: 912
+3 -1
View File
@@ -7,7 +7,7 @@ Generated from:
This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`.
Summary: 358 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 371 tracked critical routes.
Summary: 360 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 373 tracked critical routes.
## Missing Critical Routes
@@ -87,6 +87,7 @@ These routes exist with equivalent method and path shape but different parameter
| DELETE | `/api/v1/accounts/:account_id/teams/:team_id` | `/api/v1/accounts/:account_id/teams/:team_id` | `api/v1/accounts/teams#destroy` | `routes.rb:296` | exact |
| DELETE | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#destroy` | `routes.rb:299` | exact |
| DELETE | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `api/v1/accounts/webhooks#destroy` | `routes.rb:342` | exact |
| DELETE | `/api/v1/notification_subscriptions` | `/api/v1/notification_subscriptions` | `api/v1/notification_subscriptions#destroy` | `routes.rb:440` | exact |
| DELETE | `/api/v1/widget/labels/:label_id` | `/api/v1/widget/labels/:label_id` | `api/v1/widget/labels#destroy` | `routes.rb:464` | exact |
| GET | `/.well-known/apple-app-site-association` | `/.well-known/apple-app-site-association` | `apple_app#site_association` | `routes.rb:658` | exact |
| GET | `/.well-known/assetlinks.json` | `/.well-known/assetlinks.json` | `android_app#assetlinks` | `routes.rb:657` | exact |
@@ -344,6 +345,7 @@ These routes exist with equivalent method and path shape but different parameter
| POST | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#create` | `routes.rb:297` | exact |
| POST | `/api/v1/accounts/:account_id/update_active_at` | `/api/v1/accounts/:account_id/update_active_at` | `api/v1/accounts#update_active_at` | `routes.rb:49` | exact |
| POST | `/api/v1/accounts/:account_id/webhooks` | `/api/v1/accounts/:account_id/webhooks` | `api/v1/accounts/webhooks#create` | `routes.rb:342` | exact |
| POST | `/api/v1/notification_subscriptions` | `/api/v1/notification_subscriptions` | `api/v1/notification_subscriptions#create` | `routes.rb:440` | exact |
| POST | `/api/v1/widget/config` | `/api/v1/widget/config` | `api/v1/widget/config#create` | `routes.rb:444` | exact |
| POST | `/api/v1/widget/contact/destroy_custom_attributes` | `/api/v1/widget/contact/destroy_custom_attributes` | `api/v1/widget/contact#destroy_custom_attributes` | `routes.rb:460` | exact |
| POST | `/api/v1/widget/conversations` | `/api/v1/widget/conversations` | `api/v1/widget/conversations#create` | `routes.rb:448` | exact |
@@ -1,9 +1,12 @@
package v1
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
)
@@ -22,14 +25,14 @@ func NewNotificationSubscriptionHandler(svc *service.NotificationSubscriptionSer
// POST /api/v1/notification_subscriptions
// Chatwoot: requires identifier, subscription_attributes, subscription_type
func (h *NotificationSubscriptionHandler) Create(c *gin.Context) {
userID, exists := c.Get("current_user_id")
if !exists {
userID := getUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var req service.CreateSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err := bindJSONWrappedOrRaw(c, "notification_subscription", &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -40,35 +43,62 @@ func (h *NotificationSubscriptionHandler) Create(c *gin.Context) {
return
}
sub, err := h.svc.Create(c.Request.Context(), userID.(uint), &req)
sub, err := h.svc.Create(c.Request.Context(), userID, &req)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, sub)
c.JSON(http.StatusOK, notificationSubscriptionPayloadFromModel(sub))
}
// Destroy removes a notification subscription.
// DELETE /api/v1/notification_subscriptions/:identifier
// Chatwoot: finds by identifier and deletes
func (h *NotificationSubscriptionHandler) Destroy(c *gin.Context) {
userID, exists := c.Get("current_user_id")
if !exists {
userID := getUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
identifier := c.Param("identifier")
if identifier == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "identifier is required"})
return
pushToken := c.Query("push_token")
if pushToken == "" {
pushToken = c.Param("identifier")
}
if pushToken == "" {
pushToken = c.PostForm("push_token")
}
if pushToken == "" && c.Request.Body != nil {
var body struct {
PushToken string `json:"push_token"`
}
_ = c.ShouldBindJSON(&body)
pushToken = body.PushToken
}
if err := h.svc.Destroy(c.Request.Context(), userID.(uint), identifier); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
_ = h.svc.Destroy(c.Request.Context(), userID, pushToken)
c.Status(http.StatusOK)
}
c.JSON(http.StatusOK, gin.H{})
}
type notificationSubscriptionDTO struct {
ID uint `json:"id"`
Identifier string `json:"identifier"`
SubscriptionAttributes json.RawMessage `json:"subscription_attributes"`
SubscriptionType string `json:"subscription_type"`
UserID uint `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func notificationSubscriptionPayloadFromModel(sub *model.NotificationSubscription) notificationSubscriptionDTO {
return notificationSubscriptionDTO{
ID: sub.ID,
Identifier: sub.Identifier,
SubscriptionAttributes: sub.SubscriptionAttributes,
SubscriptionType: sub.SubscriptionType.String(),
UserID: sub.UserID,
CreatedAt: sub.CreatedAt,
UpdatedAt: sub.UpdatedAt,
}
}
@@ -0,0 +1,115 @@
package v1
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupNotificationSubscriptionHandlerTest(t *testing.T, userID uint) (*gin.Engine, *gorm.DB) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.NotificationSubscription{}))
handler := NewNotificationSubscriptionHandler(service.NewNotificationSubscriptionService(repository.NewNotificationSubscriptionRepo(db)))
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("user_id", userID)
c.Next()
})
router.POST("/api/v1/notification_subscriptions", handler.Create)
router.DELETE("/api/v1/notification_subscriptions", handler.Destroy)
router.DELETE("/api/v1/notification_subscriptions/:identifier", handler.Destroy)
return router, db
}
func TestNotificationSubscriptionCreateAcceptsFrontendPayload(t *testing.T) {
router, db := setupNotificationSubscriptionHandlerTest(t, 7)
body := `{"subscription_type":"browser_push","subscription_attributes":{"endpoint":"https://push.example/sub","p256dh":"key","auth":"secret"}}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/notification_subscriptions", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload), w.Body.String())
require.NotContains(t, payload, "success")
require.NotContains(t, payload, "data")
require.Equal(t, "https://push.example/sub", payload["identifier"])
require.Equal(t, "browser_push", payload["subscription_type"])
require.Equal(t, float64(7), payload["user_id"])
var sub model.NotificationSubscription
require.NoError(t, db.First(&sub).Error)
require.Equal(t, uint(7), sub.UserID)
require.Equal(t, "https://push.example/sub", sub.Identifier)
}
func TestNotificationSubscriptionCreateAcceptsRailsWrapperAndUpdatesExisting(t *testing.T) {
router, db := setupNotificationSubscriptionHandlerTest(t, 11)
existing := model.NotificationSubscription{
Identifier: "device-1",
UserID: 2,
SubscriptionType: model.NotificationSubFCM,
SubscriptionAttributes: json.RawMessage(`{"device_id":"device-1","push_token":"old"}`),
}
require.NoError(t, db.Create(&existing).Error)
body := `{"notification_subscription":{"subscription_type":"fcm","subscription_attributes":{"device_id":"device-1","push_token":"new"}}}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/notification_subscriptions", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var sub model.NotificationSubscription
require.NoError(t, db.First(&sub, existing.ID).Error)
require.Equal(t, uint(11), sub.UserID)
require.JSONEq(t, `{"device_id":"device-1","push_token":"new"}`, string(sub.SubscriptionAttributes))
}
func TestNotificationSubscriptionDestroyUsesPushTokenAndReturnsEmptyOK(t *testing.T) {
router, db := setupNotificationSubscriptionHandlerTest(t, 7)
sub := model.NotificationSubscription{
Identifier: "https://push.example/sub",
UserID: 7,
SubscriptionType: model.NotificationSubBrowserPush,
SubscriptionAttributes: json.RawMessage(`{"endpoint":"https://push.example/sub","p256dh":"key","auth":"secret"}`),
}
require.NoError(t, db.Create(&sub).Error)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/notification_subscriptions?push_token=https%3A%2F%2Fpush.example%2Fsub", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Empty(t, w.Body.String())
var count int64
require.NoError(t, db.Model(&model.NotificationSubscription{}).Where("id = ?", sub.ID).Count(&count).Error)
require.Equal(t, int64(0), count)
}
func TestNotificationSubscriptionDestroyMissingTokenStillOK(t *testing.T) {
router, _ := setupNotificationSubscriptionHandlerTest(t, 7)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/notification_subscriptions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Empty(t, w.Body.String())
}
@@ -2,6 +2,7 @@ package repository
import (
"context"
"encoding/json"
"github.com/gochat/gochat/internal/model"
"gorm.io/gorm"
@@ -21,6 +22,11 @@ func (r *NotificationSubscriptionRepo) Create(ctx context.Context, sub *model.No
return r.db.WithContext(ctx).Create(sub).Error
}
// Save inserts or updates a subscription.
func (r *NotificationSubscriptionRepo) Save(ctx context.Context, sub *model.NotificationSubscription) error {
return r.db.WithContext(ctx).Save(sub).Error
}
// FindByID retrieves a subscription by ID.
func (r *NotificationSubscriptionRepo) FindByID(ctx context.Context, id uint) (*model.NotificationSubscription, error) {
var sub model.NotificationSubscription
@@ -35,6 +41,29 @@ func (r *NotificationSubscriptionRepo) FindByUserID(ctx context.Context, userID
return subs, err
}
// FindByUserIDAndPushToken finds the Chatwoot destroy target by push_token.
func (r *NotificationSubscriptionRepo) FindByUserIDAndPushToken(ctx context.Context, userID uint, pushToken string) (*model.NotificationSubscription, error) {
subs, err := r.FindByUserID(ctx, userID)
if err != nil {
return nil, err
}
for i := range subs {
if subs[i].Identifier == pushToken {
return &subs[i], nil
}
var attrs map[string]any
if err := json.Unmarshal(subs[i].SubscriptionAttributes, &attrs); err == nil {
if value, _ := attrs["push_token"].(string); value == pushToken {
return &subs[i], nil
}
if value, _ := attrs["endpoint"].(string); value == pushToken {
return &subs[i], nil
}
}
}
return nil, gorm.ErrRecordNotFound
}
// FindByIdentifier retrieves a subscription by its unique identifier.
func (r *NotificationSubscriptionRepo) FindByIdentifier(ctx context.Context, identifier string) (*model.NotificationSubscription, error) {
var sub model.NotificationSubscription
@@ -52,4 +81,4 @@ func (r *NotificationSubscriptionRepo) DeleteByUserIDAndIdentifier(ctx context.C
return r.db.WithContext(ctx).
Where("user_id = ? AND identifier = ?", userID, identifier).
Delete(&model.NotificationSubscription{}).Error
}
}
+2
View File
@@ -609,6 +609,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
// Notification subscription routes — user-scoped, browser_push/fcm subscription management
// Reference: Chatwoot resource :notification_subscriptions, only: [:create, :destroy]
g.POST("/notification_subscriptions", h.NotificationSubscription.Create)
g.DELETE("/notification_subscriptions", h.NotificationSubscription.Destroy)
g.DELETE("/notification_subscriptions/:identifier", h.NotificationSubscription.Destroy)
// SSO session management routes — user-scoped, authenticated (M13)
@@ -1632,6 +1633,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
notifSubs := accountScoped.Group("/notification_subscriptions")
{
notifSubs.POST("/", h.NotificationSubscription.Create)
notifSubs.DELETE("/", h.NotificationSubscription.Destroy)
notifSubs.DELETE("/:identifier", h.NotificationSubscription.Destroy)
}
@@ -23,14 +23,26 @@ func NewNotificationSubscriptionService(repo *repository.NotificationSubscriptio
// CreateSubscriptionRequest matches Chatwoot's create action payload.
type CreateSubscriptionRequest struct {
Identifier string `json:"identifier" binding:"required"`
SubscriptionAttributes json.RawMessage `json:"subscription_attributes" binding:"required"`
SubscriptionType string `json:"subscription_type" binding:"required"` // "browser_push" or "fcm"
Identifier string `json:"identifier"`
SubscriptionAttributes json.RawMessage `json:"subscription_attributes"`
SubscriptionType string `json:"subscription_type"` // "browser_push" or "fcm"
}
// Create adds a new notification subscription for a user.
// Chatwoot behavior: validates identifier uniqueness, creates subscription with type enum.
func (s *NotificationSubscriptionService) Create(ctx context.Context, userID uint, req *CreateSubscriptionRequest) (*model.NotificationSubscription, error) {
if req.SubscriptionType == "" {
return nil, fmt.Errorf("subscription_type is required")
}
if len(req.SubscriptionAttributes) == 0 {
return nil, fmt.Errorf("subscription_attributes is required")
}
identifier, err := notificationSubscriptionIdentifier(req)
if err != nil {
return nil, err
}
req.Identifier = identifier
// Check for duplicate identifier
existing, err := s.repo.FindByIdentifier(ctx, req.Identifier)
if err == nil && existing != nil {
@@ -38,7 +50,7 @@ func (s *NotificationSubscriptionService) Create(ctx context.Context, userID uin
existing.SubscriptionAttributes = req.SubscriptionAttributes
existing.SubscriptionType = model.NotificationSubscriptionTypeFromString(req.SubscriptionType)
existing.UserID = userID
if err := s.repo.Create(ctx, existing); err != nil {
if err := s.repo.Save(ctx, existing); err != nil {
applogger.L().Errorf("NotificationSubscription Create update: %v", err)
return nil, err
}
@@ -47,10 +59,10 @@ func (s *NotificationSubscriptionService) Create(ctx context.Context, userID uin
subType := model.NotificationSubscriptionTypeFromString(req.SubscriptionType)
sub := &model.NotificationSubscription{
Identifier: req.Identifier,
Identifier: req.Identifier,
SubscriptionAttributes: req.SubscriptionAttributes,
SubscriptionType: subType,
UserID: userID,
SubscriptionType: subType,
UserID: userID,
}
if err := s.repo.Create(ctx, sub); err != nil {
@@ -63,14 +75,12 @@ func (s *NotificationSubscriptionService) Create(ctx context.Context, userID uin
// Destroy removes a notification subscription.
// Chatwoot behavior: finds by identifier and deletes.
func (s *NotificationSubscriptionService) Destroy(ctx context.Context, userID uint, identifier string) error {
// Chatwoot finds by identifier first, then deletes
sub, err := s.repo.FindByIdentifier(ctx, identifier)
sub, err := s.repo.FindByUserIDAndPushToken(ctx, userID, identifier)
if err != nil {
applogger.L().Errorf("NotificationSubscription Destroy find: %v", err)
return err
return nil
}
if sub == nil || sub.UserID != userID {
return fmt.Errorf("notification subscription not found")
if sub == nil {
return nil
}
return s.repo.Delete(ctx, sub.ID)
}
@@ -94,9 +104,33 @@ func ValidateSubscriptionAttributes(subType string, attrs json.RawMessage) error
return fmt.Errorf("missing required field: endpoint, p256dh, auth")
}
case "fcm":
if m["token"] == nil {
if m["token"] == nil && m["device_id"] == nil && m["push_token"] == nil {
return fmt.Errorf("missing required field: token")
}
}
return nil
}
}
func notificationSubscriptionIdentifier(req *CreateSubscriptionRequest) (string, error) {
if req.Identifier != "" {
return req.Identifier, nil
}
var attrs map[string]any
if err := json.Unmarshal(req.SubscriptionAttributes, &attrs); err != nil {
return "", err
}
switch req.SubscriptionType {
case "browser_push":
if endpoint, _ := attrs["endpoint"].(string); endpoint != "" {
return endpoint, nil
}
case "fcm":
if deviceID, _ := attrs["device_id"].(string); deviceID != "" {
return deviceID, nil
}
if pushToken, _ := attrs["push_token"].(string); pushToken != "" {
return pushToken, nil
}
}
return "", fmt.Errorf("identifier is required")
}