From 0ed1cfdee50e47b07d646612c93375c7a4b338f7 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 13:58:35 +0800 Subject: [PATCH] feat(notifications): align subscription payloads --- cmd/route_parity/main.go | 3 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 22 ++-- docs/parity/gochat_routes.txt | 4 +- docs/parity/route_parity.md | 4 +- .../v1/notification_subscription_handler.go | 64 +++++++--- .../notification_subscription_handler_test.go | 115 ++++++++++++++++++ .../notification_subscription_repo.go | 31 ++++- internal/router/router.go | 2 + .../notification_subscription_service.go | 64 +++++++--- 9 files changed, 265 insertions(+), 44 deletions(-) create mode 100644 internal/handler/api/v1/notification_subscription_handler_test.go diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index ec5dfafa..5b1d279a 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -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"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 8bea94a2..7953708b 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,15 +49,15 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 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. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index fddfe76b..a5d13e48 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -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 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 6160d426..4b01b81a 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: 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 | diff --git a/internal/handler/api/v1/notification_subscription_handler.go b/internal/handler/api/v1/notification_subscription_handler.go index 9984ed5b..68eb0ddc 100644 --- a/internal/handler/api/v1/notification_subscription_handler.go +++ b/internal/handler/api/v1/notification_subscription_handler.go @@ -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{}) -} \ No newline at end of file +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, + } +} diff --git a/internal/handler/api/v1/notification_subscription_handler_test.go b/internal/handler/api/v1/notification_subscription_handler_test.go new file mode 100644 index 00000000..9c61d949 --- /dev/null +++ b/internal/handler/api/v1/notification_subscription_handler_test.go @@ -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()) +} diff --git a/internal/repository/notification_subscription_repo.go b/internal/repository/notification_subscription_repo.go index 39532684..9a7f3824 100644 --- a/internal/repository/notification_subscription_repo.go +++ b/internal/repository/notification_subscription_repo.go @@ -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 -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index cba2aff5..6eff8feb 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) } diff --git a/internal/service/notification_subscription_service.go b/internal/service/notification_subscription_service.go index 55e95204..463f15f7 100644 --- a/internal/service/notification_subscription_service.go +++ b/internal/service/notification_subscription_service.go @@ -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 -} \ No newline at end of file +} + +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") +}