From 4a7df91556085b583ffa4df7173e5833e975a603 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 20:36:13 +0800 Subject: [PATCH] feat(reports): align year in review --- cmd/route_parity/main.go | 1 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 18 +- docs/parity/gochat_routes.txt | 3 +- docs/parity/route_parity.md | 3 +- internal/app/bootstrap.go | 2 + .../handler/api/v1/year_in_review_handler.go | 58 +++++ .../api/v1/year_in_review_handler_test.go | 115 ++++++++++ internal/router/router.go | 3 + internal/router/router_test.go | 1 + internal/service/year_in_review_service.go | 202 ++++++++++++++++++ .../service/year_in_review_service_test.go | 107 ++++++++++ 11 files changed, 504 insertions(+), 9 deletions(-) create mode 100644 internal/handler/api/v1/year_in_review_handler.go create mode 100644 internal/handler/api/v1/year_in_review_handler_test.go create mode 100644 internal/service/year_in_review_service.go create mode 100644 internal/service/year_in_review_service_test.go diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 6471bfe9..0f37b109 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -460,6 +460,7 @@ var criticalRoutes = []route{ {Method: "GET", Path: "/api/v2/accounts/:account_id/reports/inbox_label_matrix", Controller: "api/v2/accounts/reports#inbox_label_matrix", Source: "routes.rb:501"}, {Method: "GET", Path: "/api/v2/accounts/:account_id/reports/first_response_time_distribution", Controller: "api/v2/accounts/reports#first_response_time_distribution", Source: "routes.rb:502"}, {Method: "GET", Path: "/api/v2/accounts/:account_id/reports/outgoing_messages_count", Controller: "api/v2/accounts/reports#outgoing_messages_count", Source: "routes.rb:503"}, + {Method: "GET", Path: "/api/v2/accounts/:account_id/year_in_review", Controller: "api/v2/accounts/year_in_reviews#show", Source: "routes.rb:505"}, {Method: "GET", Path: "/api/v2/accounts/:account_id/live_reports/conversation_metrics", Controller: "api/v2/accounts/live_reports#conversation_metrics", Source: "routes.rb:508"}, {Method: "GET", Path: "/api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics", Controller: "api/v2/accounts/live_reports#grouped_conversation_metrics", Source: "routes.rb:509"}, } diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 128af1c4..43881f98 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,16 +49,16 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 WhatsApp calling toggle parity checkpoint, prepared as `feat(channels): align whatsapp calling toggles`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(channels): align whatsapp calling toggles`. +- Current tracking checkpoint: 2026-06-06 API v2 year-in-review parity checkpoint, prepared as `feat(reports): align year in review`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align year in review`. - Latest documentation/tooling checkpoint: this tracker update for P3.24 plus the landed parity tracker history; 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: WhatsApp calling toggle parity from `reference/chatwoot/config/routes.rb:266-267`, `enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb`, `app/models/channel/whatsapp.rb`, `enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb`, `app/services/whatsapp/webhook_setup_service.rb`, `app/policies/inbox_policy.rb`, and reused dashboard `api/inboxes.js` is implemented. GoChat now exposes administrator-gated `POST /api/v1/accounts/:account_id/inboxes/:inbox_id/enable_whatsapp_calling` and `disable_whatsapp_calling`, requires WhatsApp Cloud API support, requires `channel_voice` only for enable, calls Meta phone-number `/settings` with `{ calling: { status: "ENABLED" } }`, persists `provider_config.calling_enabled`, updates inbox `voice_enabled`, re-registers webhook fields with `calls` on enable, best-effort drops `calls` on disable, and returns empty `200 OK` or `422 { error }` like Chatwoot. WhatsApp embedded signup authorization remains implemented from the previous checkpoint. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: API v2 year-in-review parity from `reference/chatwoot/config/routes.rb:505`, `app/controllers/api/v2/accounts/year_in_reviews_controller.rb`, `app/builders/year_in_review_builder.rb`, and reused dashboard `api/yearInReview.js` is implemented. GoChat now exposes `GET /api/v2/accounts/:account_id/year_in_review?year=YYYY`, returns a raw Chatwoot payload without the local `{ success, data }` envelope, defaults missing `year` to `2025`, scopes to the current user/account, reads and writes `Current.user.ui_settings` cache key `year_in_review__`, counts assigned conversations in the selected year, computes the busiest assigned day as `Mon DD`, and truncates average `first_response` reporting-event value into `support_personality.avg_response_time_seconds`. WhatsApp calling toggle parity remains implemented from the previous checkpoint. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence. -- `go test ./...` passes when run outside the restricted socket sandbox; focused Shopify handler/service tests pass in the sandbox. -- Route dump succeeds with `950` registered routes after WhatsApp calling route tracking. +- `go test ./...` passes when run outside the restricted socket sandbox; focused YearInReview service/handler/router tests pass in the sandbox. +- Route dump succeeds with `951` registered routes after API v2 year-in-review route tracking. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 413 Chatwoot routes: 400 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 414 Chatwoot routes: 401 exact, 0 method-compatible, 13 parameter-compatible, 0 missing. The 13 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. - Handler test stability fixes are committed into the baseline before feature parity work continues. - `.codegraph/` is generated indexing output and is not part of tracked product code. @@ -140,6 +140,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.34 API v2 year-in-review parity | Implemented for reused dashboard year-in-review client: `GET /api/v2/accounts/:account_id/year_in_review?year=YYYY` is registered and tracked from `routes.rb:505`, returns raw `{ year, total_conversations, busiest_day, support_personality }`, defaults missing `year` to `2025`, reads/writes the current user's `ui_settings` cache under `year_in_review__`, counts conversations assigned to the current user during the selected year, formats the busiest assigned conversation day as `Mon DD`, and truncates the average `first_response` reporting-event value into seconds. | Keep in Review; reopen only if live reused-dashboard year-in-review smoke exposes timezone, cache-present, invalid-year, or payload-shape drift beyond the inspected controller/builder/frontend contract. | Focused YearInReview service/handler tests, router/route-parity tests, route dump/parity regeneration (`951` routes; `401 exact`, `13 parameter-compatible`, `0 missing out of 414`), full `go test ./...`, and `git diff --check` must pass before commit. | | 0 | P3.33 WhatsApp calling toggle parity | Implemented for reused dashboard inbox voice settings: `POST /api/v1/accounts/:account_id/inboxes/:inbox_id/enable_whatsapp_calling` and `disable_whatsapp_calling` are registered and tracked from `routes.rb:266-267`, administrator-gated like `InboxPolicy`, scoped to WhatsApp Cloud API inboxes, enable requires account feature flag `channel_voice`, enable calls Meta phone-number `/settings` with `ENABLED`, webhook setup subscribes `messages/smb_message_echoes/calls`, provider config persists `calling_enabled: true`, disable persists `calling_enabled: false` without calling Meta settings, and webhook re-registration without `calls` is best-effort. Handlers return empty `200 OK` on success and `422 { error }` for unsupported inbox, missing feature flag, provider setup, or Meta update errors. | Keep in Review; reopen only if live WhatsApp calling settings smoke exposes Meta API version/base URL drift, webhook subscribed-field behavior drift, `voice_enabled` serializer drift, or frontend error/status drift beyond the inspected enterprise controller/model/provider/frontend contract. | Focused WhatsApp calling service/channel/handler tests, router/route-parity tests, route dump/parity regeneration (`950` routes; `400 exact`, `13 parameter-compatible`, `0 missing out of 413`), full `go test ./...`, and `git diff --check` must pass before commit. | | 0 | P3.32 WhatsApp authorization parity | Implemented for reused dashboard WhatsApp embedded signup and reauthorization flow: `POST /api/v1/accounts/:account_id/whatsapp/authorization` is registered and tracked from `routes.rb:339`, authenticated agents and administrators can call it like Chatwoot's controller, missing `code`, `business_id`, and `waba_id` return `422 { success: false, error }`, new authorization exchanges the Meta embedded-signup code, fetches WABA phone info, validates token WABA access, creates a WhatsApp Cloud channel/inbox with embedded-signup provider config, sets up the webhook URL, and returns raw `{ success: true, id, name, channel_type: "whatsapp" }`; reauthorization validates the same phone number, refreshes provider config/access token/phone ID, clears `reauthorization_required`, updates inbox channel config/name, and returns the same raw payload plus the reauthorization success message. | Keep in Review; reopen only if live WhatsApp embedded-signup smoke exposes Meta API version/config-source drift, Graph API error-body wording drift, webhook setup side-effect drift, health-check reauthorization prompting drift, or frontend payload drift beyond the inspected controller/spec/service/frontend contract. | Focused WhatsApp authorization service tests, handler/router/route-parity tests, route dump/parity regeneration (`948` routes; `398 exact`, `13 parameter-compatible`, `0 missing out of 411`), full `go test ./...`, and `git diff --check` must pass before commit. | | 0 | P3.31 Instagram/TikTok authorization parity | Implemented for reused dashboard Instagram and TikTok connect/reauthorization flows: `POST /api/v1/accounts/:account_id/instagram/authorization` and `POST /api/v1/accounts/:account_id/tiktok/authorization` are registered and tracked from `routes.rb:327/331`, both routes are administrator-gated like Chatwoot's shared OAuth authorization controller, responses return raw `{ success: true, url }`, Instagram authorize URLs target `https://api.instagram.com/oauth/authorize` with Chatwoot's required Instagram business scopes, `enable_fb_login=0`, and `force_authentication=1`, TikTok authorize URLs target `https://www.tiktok.com/v2/auth/authorize` with Chatwoot's required TikTok business scopes and `client_key`, and both sign account callback state with optional `return_to`. | Keep in Review; reopen only if live Instagram/TikTok OAuth smoke exposes GlobalID state compatibility requirements beyond signed callback state, provider config-source drift, onboarding return routing drift, or frontend payload drift beyond the inspected authorization controllers/specs/frontend clients. | Focused social authorization handler/router/route tests, route dump/parity regeneration (`947` routes; `397 exact`, `13 parameter-compatible`, `0 missing out of 410`), full `go test ./...`, and `git diff --check` must pass before commit. | @@ -152,7 +153,7 @@ This table is the shortest authoritative handoff view. If an older lower section | 0 | P3.24 Slack integration parity | Implemented for reused dashboard Slack settings flow: no-trailing and trailing singleton routes are registered for create/update/delete, `PUT` and `PATCH` update both work, create accepts frontend `code` and exchanges it for a Slack access token, hooks are persisted with `app_id: slack` and disabled status, update accepts frontend `reference_id`, fetches real private/public Slack channels with pagination, joins public channels, persists `reference_id/settings.channel_name/status`, create/update return raw Chatwoot app payloads with hooks, list-all returns raw channel arrays, invalid channels return Chatwoot's `422 { error }`, and delete returns empty `200 OK`. | Keep in Review; reopen only if live Slack OAuth/channel smoke exposes OAuth redirect, provider error, app serializer, or Slack channel pagination drift beyond the inspected controller/builder/spec/frontend contract. | Focused Slack handler/service tests, route dump/parity regeneration, sandbox focused `go test`, escalated full `go test ./...`, and `git diff --check` passed. | | 0 | P3.23 nested contact inbox creation API | Implemented for Chatwoot nested contact inbox creation: raw JSON/form/query params are accepted, contact and inbox are account-scoped, missing source IDs are generated through Chatwoot channel rules, duplicate contact+inbox+source rows are returned idempotently, `hmac_verified` is persisted on creation, and the response is raw `{ source_id, inbox }` rather than the local model/envelope. | Keep in Review; reopen only if live CRM/new-conversation smoke exposes inbox access-policy, unsupported channel, or serializer drift beyond the inspected controller/builder/Jbuilder contract. | Focused nested ContactInbox handler/service/repository tests, route parity check, full `go test ./...`, and `git diff --check` passed. | | 1 | P3.2a invitation/confirmation mail parity | Implemented for current non-SSO reference behavior: profile resend is no longer a TODO-only log, new invited agents and unconfirmed invited profile resends generate reset-password invitation links, normal unconfirmed profile resends generate confirmation links, `users.unconfirmed_email` is modeled for email-update routing, and fakeable/environment SMTP mailers keep default tests offline. | Keep in Review; reopen only if reused frontend smoke or fresh reference evidence exposes additional Devise confirmation states outside excluded SSO/SAML/LDAP/OIDC variants. | Focused profile and agent invitation tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed. | -| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 413-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Slack routes from `routes.rb:350-352`, account Dyte routes from `routes.rb:357-358`, account Shopify routes from `routes.rb:361-364`, account Linear routes from `routes.rb:365-373`, account Notion authorization/destroy routes from `routes.rb:335/379`, account Twitter/Google/Microsoft/Instagram/TikTok authorization routes from `routes.rb:315/319/323/327/331`, account WhatsApp authorization route from `routes.rb:339`, account inbox WhatsApp calling toggle routes from `routes.rb:266-267`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Slack OAuth/channel payload behavior, account Dyte create/join payload behavior, account Shopify customer-order payload behavior, account Linear GraphQL issue payload behavior, account Notion authorization/destroy behavior, account Twitter/Google/Microsoft/Instagram/TikTok authorization behavior, account WhatsApp embedded-signup creation and reauthorization behavior, account inbox WhatsApp calling toggle behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after WhatsApp calling parity or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | +| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 414-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Slack routes from `routes.rb:350-352`, account Dyte routes from `routes.rb:357-358`, account Shopify routes from `routes.rb:361-364`, account Linear routes from `routes.rb:365-373`, account Notion authorization/destroy routes from `routes.rb:335/379`, account Twitter/Google/Microsoft/Instagram/TikTok authorization routes from `routes.rb:315/319/323/327/331`, account WhatsApp authorization route from `routes.rb:339`, account inbox WhatsApp calling toggle routes from `routes.rb:266-267`, API v2 year-in-review route from `routes.rb:505`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Slack OAuth/channel payload behavior, account Dyte create/join payload behavior, account Shopify customer-order payload behavior, account Linear GraphQL issue payload behavior, account Notion authorization/destroy behavior, account Twitter/Google/Microsoft/Instagram/TikTok authorization behavior, account WhatsApp embedded-signup creation and reauthorization behavior, account inbox WhatsApp calling toggle behavior, API v2 year-in-review raw/cache/aggregate behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after P3.34 year-in-review parity or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 3 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; refreshed `docs/parity/placeholder_audit.md` shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Keep in Review; reopen only if fresh `rg`, route smoke, or B12 finds a frontend-reachable placeholder/stub in account/contact/conversation/message/inbox/widget/public paths. | `rg` placeholder audit and `scripts/parity_frontend_smoke.sh --check` are recorded; no reused-frontend blocker is ownerless. | | 4 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | 5 | P3.10 account webhooks API | Implemented for the reused dashboard Webhooks settings route with PATCH update, Chatwoot `{ payload }` list/mutation serializers, nested `{ webhook: ... }` bodies, generated secret, account-scoped mutations, URL/subscription validation, optional inbox serialization, and empty `200 OK` delete. | Keep in Review; reopen only if live settings smoke exposes audit writer or delivery-signature drift beyond the existing delivery service boundary. | Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | @@ -179,6 +180,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | +| P3.34 API v2 year-in-review parity | `internal/service/year_in_review_service.go`, `internal/handler/api/v1/year_in_review_handler.go`, `internal/router/router.go`, `internal/app/bootstrap.go`, `cmd/route_parity/main.go`, YearInReview service/handler/router tests | `reference/chatwoot/config/routes.rb:505`, `reference/chatwoot/app/controllers/api/v2/accounts/year_in_reviews_controller.rb`, `reference/chatwoot/app/builders/year_in_review_builder.rb`, `reference/chatwoot/app/javascript/dashboard/api/yearInReview.js` | Done. API v2 year-in-review now matches the reused dashboard route and builder boundary: account-scoped `GET /api/v2/accounts/:account_id/year_in_review` is tracked and registered; response is raw JSON; missing `year` defaults to `2025`; the current user/account cache key is persisted in `ui_settings`; assigned yearly conversation totals, busiest day, and average first-response seconds are derived from persisted conversations/reporting events. | Review by `feat(reports): align year in review`; focused service tests cover aggregate payload, busiest day, average truncation, cache storage, cache reuse, and empty personality; handler tests cover raw response shape, auth, and invalid year; router/route-parity tests and route artifacts regenerate to `TOTAL: 951` and `401 exact, 13 parameter-compatible, 0 missing out of 414`; full `go test ./...` and `git diff --check` must pass. | | P3.33 WhatsApp calling toggle parity | `internal/service/inbox_service.go`, `internal/channel/whatsapp/service.go`, `internal/handler/api/v1/inbox_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, WhatsApp calling service/channel/handler tests | `reference/chatwoot/config/routes.rb:266-267`, `reference/chatwoot/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb`, `reference/chatwoot/app/models/channel/whatsapp.rb`, `reference/chatwoot/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb`, `reference/chatwoot/app/services/whatsapp/webhook_setup_service.rb`, `reference/chatwoot/app/services/whatsapp/facebook_api_client.rb`, `reference/chatwoot/app/policies/inbox_policy.rb`, `reference/chatwoot/app/javascript/dashboard/api/inboxes.js` | Done. WhatsApp calling enable/disable now matches the reused dashboard and enterprise backend boundary: member routes are tracked and registered; administrator policy is enforced at the router; unsupported non-WhatsApp/non-Cloud inboxes return Chatwoot's `422` error; enable requires `channel_voice`, calls Meta `/settings` with status `ENABLED`, registers webhook fields including `calls`, and persists `calling_enabled`; disable persists `calling_enabled: false`, updates inbox `voice_enabled`, and best-effort re-registers only `messages/smb_message_echoes`. | Review by `feat(channels): align whatsapp calling toggles`; focused service tests cover enable persistence/webhook, unsupported/feature gates, and disable best-effort behavior; channel provider tests cover Meta settings request/error parsing; handler tests cover bad params and empty `200 OK`; route dump/parity regenerated to `TOTAL: 950` and `400 exact, 13 parameter-compatible, 0 missing out of 413`; full `go test ./...` and `git diff --check` must pass. | | P3.32 WhatsApp authorization parity | `internal/service/whatsapp_authorization_service.go`, `internal/handler/api/v1/inbox_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, WhatsApp authorization service tests | `reference/chatwoot/config/routes.rb:338-339`, `reference/chatwoot/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb`, `reference/chatwoot/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb`, `reference/chatwoot/app/services/whatsapp/embedded_signup_service.rb`, `reference/chatwoot/app/services/whatsapp/channel_creation_service.rb`, `reference/chatwoot/app/services/whatsapp/reauthorization_service.rb`, `reference/chatwoot/app/services/whatsapp/token_exchange_service.rb`, `reference/chatwoot/app/services/whatsapp/phone_info_service.rb`, `reference/chatwoot/app/services/whatsapp/token_validation_service.rb`, `reference/chatwoot/app/javascript/dashboard/api/channel/whatsappChannel.js` | Done. WhatsApp embedded signup now matches the reused dashboard boundary: the account route is tracked and registered; authenticated agents/admins can create or reauthorize; missing required params return Chatwoot-shaped `422` errors; Meta code exchange, phone info, and token WABA-access checks run through a fakeable HTTP boundary; new channels create WhatsApp Cloud inboxes with embedded-signup provider config and webhook setup; reauthorization validates phone-number continuity, refreshes access token/phone/provider config, clears `reauthorization_required`, updates inbox channel config/name, and returns raw `{ success, id, name, channel_type, message? }` payloads. | Review by `feat(channels): align whatsapp authorization`; focused WhatsApp authorization service tests cover create, reauthorize, validation, and provider failures; handler/router/route-parity focused tests pass; route dump/parity regenerated to `TOTAL: 948` and `398 exact, 13 parameter-compatible, 0 missing out of 411`; full `go test ./...` and `git diff --check` must pass. | | P3.24 Slack integration parity | `internal/handler/api/v1/slack_integration_handler.go`, `internal/service/slack_integration_service.go`, `internal/repository/integration_hook_repo.go`, `internal/handler/api/v1/integration_hook_handler.go`, `cmd/route_parity/main.go`, Slack handler/service tests | `reference/chatwoot/config/routes.rb:350-352`, `reference/chatwoot/app/controllers/api/v1/accounts/integrations/slack_controller.rb`, `reference/chatwoot/lib/integrations/slack/hook_builder.rb`, `reference/chatwoot/lib/integrations/slack/channel_builder.rb`, `reference/chatwoot/app/views/api/v1/accounts/integrations/slack/create.json.jbuilder`, `reference/chatwoot/app/views/api/v1/accounts/integrations/slack/update.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_app.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_hook.json.jbuilder`, `reference/chatwoot/spec/requests/api/v1/accounts/integrations/slack_request_spec.rb`, `reference/chatwoot/app/javascript/dashboard/api/integrations.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/integrations.js` | Done. Chatwoot Slack singleton behavior is implemented for the reused dashboard flow: frontend no-trailing `POST/PATCH/DELETE` routes and Rails `PUT` update are registered alongside trailing aliases; create accepts `code`/`inbox_id`, exchanges OAuth against Slack, persists disabled `app_id: slack` hooks with access tokens; update accepts `reference_id`, fetches private/public Slack channels with cursor pagination, joins public channels, persists `reference_id`, `settings.channel_name`, and enabled status only when the channel exists; create/update return the raw app partial with hooks; list-all returns raw Slack channel arrays; invalid channel returns `422 { error: "Invalid slack channel. Please try again" }`; delete returns empty `200 OK`. | Review by `feat(integrations): align slack parity`; focused handler tests cover no-trailing create raw app payload, empty delete, and PUT route availability; service tests cover OAuth exchange redirect/body, disabled hook creation, channel fetch/update/join, invalid/not-found, and raw channel lists through a fake Slack client; route parity is `379 exact, 13 parameter-compatible, 0 missing out of 392`; escalated full `go test ./...` and `git diff --check` passed. | @@ -250,6 +252,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(reports): align year in review` | Advances P3.34 API v2 year-in-review parity by matching Chatwoot `Api::V2::Accounts::YearInReviewsController#show`, `YearInReviewBuilder`, route `505`, current-user `ui_settings` caching, and reused dashboard `api/yearInReview.js`. GoChat now exposes `GET /api/v2/accounts/:account_id/year_in_review`, returns raw `{ year, total_conversations, busiest_day, support_personality }`, defaults missing `year` to `2025`, stores cached data under `year_in_review__`, counts current-user assigned conversations in the selected year, formats the busiest day as `Mon DD`, and truncates average `first_response` seconds. | `go test ./internal/service -run YearInReview -count=1`; `go test ./internal/handler/api/v1 -run YearInReview -count=1`; `go test ./internal/router ./cmd/route_parity -run 'RegisterRoutes\|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: 951`; tracked route parity is `401 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 414`. | P3.34 moves to Review for current API v2 year-in-review evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(channels): align whatsapp calling toggles` | Advances P3.33 WhatsApp calling toggle parity by matching Chatwoot enterprise inbox member actions, WhatsApp channel model voice gates, Meta calling status provider call, webhook subscribed-field behavior, inbox policy, routes `266-267`, and reused dashboard `api/inboxes.js`. GoChat now exposes administrator-gated enable/disable member routes, supports only WhatsApp Cloud API inboxes, requires `channel_voice` for enable, calls Meta phone-number `/settings` with `ENABLED`, persists `provider_config.calling_enabled`, mirrors `voice_enabled` into the inbox serializer config, and best-effort removes the `calls` webhook field on disable. | `go test ./internal/service -run 'WhatsAppCalling\|WhatsAppAuthorization\|InboxService_(RegisterWebhook\|SyncTemplates)' -count=1`; `go test ./internal/channel/whatsapp -run 'UpdateCallingStatus\|FetchHealthStatus' -count=1`; `go test ./internal/handler/api/v1 -run 'InboxWhatsAppCalling\|Inbox(RegisterWebhook\|SyncTemplates)' -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: 950`; tracked route parity is `400 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 413`. | P3.33 moves to Review for current WhatsApp calling toggle evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(channels): align whatsapp authorization` | Advances P3.32 WhatsApp embedded signup authorization parity by matching Chatwoot `Api::V1::Accounts::Whatsapp::AuthorizationsController#create`, `Whatsapp::EmbeddedSignupService`, channel creation/reauthorization/token/phone validation services, route `339`, request specs, and reused dashboard `whatsappChannel.js`. GoChat now exposes authenticated-agent/admin `POST /api/v1/accounts/:account_id/whatsapp/authorization`, returns raw `{ success, id, name, channel_type, message? }` payloads, creates WhatsApp Cloud inboxes from embedded signup, validates token access to the requested WABA, and reauthorizes existing WhatsApp Cloud inboxes by refreshing provider config and clearing `reauthorization_required`. | `go test ./internal/service -run 'WhatsAppAuthorization' -count=1`; `go test ./internal/handler/api/v1 ./internal/router ./cmd/route_parity -run 'WhatsAppAuthorization\|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: 948`; tracked route parity is `398 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 411`. | P3.32 moves to Review for current WhatsApp embedded-signup evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(channels): align social authorization` | Advances P3.31 Instagram/TikTok authorization parity by matching Chatwoot `Api::V1::Accounts::Instagram::AuthorizationsController#create`, `Api::V1::Accounts::Tiktok::AuthorizationsController#create`, shared OAuth authorization behavior, routes `327/331`, request specs, callback return hints, and reused dashboard `instagramClient.js`/`tiktokClient.js`. GoChat now exposes administrator-gated `POST /api/v1/accounts/:account_id/instagram/authorization` and `POST /api/v1/accounts/:account_id/tiktok/authorization`, returns raw `{ success: true, url }`, builds Chatwoot-scoped provider authorize URLs with frontend callback redirects, and signs account state with optional `return_to` for the existing social callback paths. | `go test ./internal/handler/api/v1 -run 'InstagramAuthorization\|TikTokAuthorization\|SocialAuthorization' -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: 947`; tracked route parity is `397 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 410`. | P3.31 moves to Review for current Instagram/TikTok authorization evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2466,3 +2469,4 @@ Verification milestone gates: - 2026-06-06: P3.31 Instagram/TikTok authorization checkpoint prepared as `feat(channels): align social authorization`; audited Chatwoot Instagram and TikTok account authorization controllers/specs, shared OAuth authorization behavior, routes `327/331`, callback return hints, and reused dashboard `instagramClient.js`/`tiktokClient.js`. GoChat now exposes administrator-gated `POST /api/v1/accounts/:account_id/instagram/authorization` and `POST /api/v1/accounts/:account_id/tiktok/authorization`, returns raw `{ success: true, url }`, builds Chatwoot provider authorize URLs with exact frontend callback redirects and required social scopes, includes Instagram `enable_fb_login=0` plus `force_authentication=1`, includes TikTok `client_key`, signs account callback state with optional `return_to`, and tracks both account authorization routes in route parity. Focused social authorization handler/router tests, route dump/parity regeneration (`947` routes; `397 exact`, `13 parameter-compatible`, `0 missing out of 410`), full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: P3.32 WhatsApp authorization checkpoint prepared as `feat(channels): align whatsapp authorization`; audited Chatwoot WhatsApp authorization controller/specs, embedded signup/channel creation/reauthorization/token/phone validation services, route `339`, and reused dashboard `whatsappChannel.js`. GoChat now exposes authenticated-agent/admin `POST /api/v1/accounts/:account_id/whatsapp/authorization`, validates embedded signup params, exchanges Meta codes, fetches phone info, validates WABA token access, creates WhatsApp Cloud inboxes with embedded-signup provider config, reauthorizes existing inboxes with same-phone validation and `reauthorization_required` clearing, and returns raw `{ success, id, name, channel_type, message? }`. Focused WhatsApp authorization service tests, handler/router/route-parity tests, route dump/parity regeneration (`948` routes; `398 exact`, `13 parameter-compatible`, `0 missing out of 411`), full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: P3.33 WhatsApp calling toggle checkpoint prepared as `feat(channels): align whatsapp calling toggles`; audited Chatwoot enterprise inbox calling actions, WhatsApp channel voice gates, Meta calling-status provider, webhook setup subscribed fields, inbox policy, routes `266-267`, and reused dashboard `api/inboxes.js`. GoChat now exposes administrator-gated `POST /api/v1/accounts/:account_id/inboxes/:inbox_id/enable_whatsapp_calling` and `disable_whatsapp_calling`, supports only WhatsApp Cloud API inboxes, requires `channel_voice` for enable, calls Meta `/settings` with `ENABLED`, persists `provider_config.calling_enabled`, mirrors `voice_enabled` into inbox config, and best-effort removes `calls` from webhook fields on disable. Focused service/channel/handler/router tests, route dump/parity regeneration (`950` routes; `400 exact`, `13 parameter-compatible`, `0 missing out of 413`), full `go test ./...`, and `git diff --check` must pass before commit; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. +- 2026-06-06: P3.34 API v2 year-in-review checkpoint prepared as `feat(reports): align year in review`; audited Chatwoot year-in-review route/controller/builder and reused dashboard `api/yearInReview.js`. GoChat now exposes and tracks `GET /api/v2/accounts/:account_id/year_in_review`, returns raw `{ year, total_conversations, busiest_day, support_personality }`, defaults missing `year` to `2025`, caches per current user in `ui_settings`, counts selected-year assigned conversations, formats busiest day as `Mon DD`, and truncates average `first_response` seconds from reporting events. Focused YearInReview service/handler tests, router/route-parity tests, route dump/parity regeneration (`951` routes; `401 exact`, `13 parameter-compatible`, `0 missing out of 414`), full `go test ./...`, and `git diff --check` must pass before commit; 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 7eafb992..6d83bc9f 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -435,6 +435,7 @@ GET /api/v2/accounts/:account_id/summary_reports/channel GET /api/v2/accounts/:account_id/summary_reports/inbox GET /api/v2/accounts/:account_id/summary_reports/label GET /api/v2/accounts/:account_id/summary_reports/team +GET /api/v2/accounts/:account_id/year_in_review GET /app GET /app/*params GET /auth/validate_token @@ -948,4 +949,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: 950 +TOTAL: 951 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 9e946907..ce377d6b 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: 400 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 413 tracked critical routes. +Summary: 401 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 414 tracked critical routes. ## Missing Critical Routes @@ -236,6 +236,7 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v2/accounts/:account_id/summary_reports/inbox` | `/api/v2/accounts/:account_id/summary_reports/inbox` | `api/v2/accounts/summary_reports#inbox` | `routes.rb:483` | exact | | GET | `/api/v2/accounts/:account_id/summary_reports/label` | `/api/v2/accounts/:account_id/summary_reports/label` | `api/v2/accounts/summary_reports#label` | `routes.rb:484` | exact | | GET | `/api/v2/accounts/:account_id/summary_reports/team` | `/api/v2/accounts/:account_id/summary_reports/team` | `api/v2/accounts/summary_reports#team` | `routes.rb:482` | exact | +| GET | `/api/v2/accounts/:account_id/year_in_review` | `/api/v2/accounts/:account_id/year_in_review` | `api/v2/accounts/year_in_reviews#show` | `routes.rb:505` | exact | | GET | `/app` | `/app` | `dashboard#index` | `routes.rb:19` | exact | | GET | `/app/*params` | `/app/*params` | `dashboard#index` | `routes.rb:20` | exact | | GET | `/google/callback` | `/google/callback` | `google/callbacks#show` | `routes.rb:650` | exact | diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index a0434b90..ab564445 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -609,6 +609,7 @@ func Bootstrap(env string) (*App, error) { // Analytics services (P11 — Reports/Analytics) analyticsService := service.NewAnalyticsService(reportingEventRepo, reportingEventsRollupRepo) analyticsService.SetWorkerPool(workerPool) + yearInReviewService := service.NewYearInReviewService(db) summaryReportService := service.NewSummaryReportService(reportingEventsRollupRepo) dashboardAppService := service.NewDashboardAppService(dashboardAppRepo) platformAppService := service.NewPlatformAppService(platformAppRepo, accessTokenRepo, permissibleRepo) @@ -772,6 +773,7 @@ func Bootstrap(env string) (*App, error) { Copilot: v1.NewCopilotHandler(copilotService), Analytics: v1.NewAnalyticsHandler(analyticsService), LiveReport: v1.NewLiveReportHandler(analyticsService), + YearInReview: v1.NewYearInReviewHandler(yearInReviewService), DashboardApp: v1.NewDashboardAppHandler(dashboardAppService), Portal: v1.NewPortalHandler(portalService), Category: v1.NewCategoryHandler(categoryService, portalService), diff --git a/internal/handler/api/v1/year_in_review_handler.go b/internal/handler/api/v1/year_in_review_handler.go new file mode 100644 index 00000000..0c2b18e6 --- /dev/null +++ b/internal/handler/api/v1/year_in_review_handler.go @@ -0,0 +1,58 @@ +package v1 + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/pkg/response" + "gorm.io/gorm" +) + +// YearInReviewHandler serves Chatwoot API v2 year_in_review. +// Reference: app/controllers/api/v2/accounts/year_in_reviews_controller.rb. +type YearInReviewHandler struct { + svc *service.YearInReviewService +} + +func NewYearInReviewHandler(svc *service.YearInReviewService) *YearInReviewHandler { + return &YearInReviewHandler{svc: svc} +} + +// Show returns the current user's cached or freshly built yearly review payload. +// GET /api/v2/accounts/:account_id/year_in_review?year=YYYY +func (h *YearInReviewHandler) Show(c *gin.Context) { + accountID, ok := parseAccountID(c) + if !ok { + return + } + userID := getUserID(c) + if userID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated") + return + } + + year := service.DefaultYearInReviewYear() + if rawYear := c.Query("year"); rawYear != "" { + parsed, err := strconv.Atoi(rawYear) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid year") + return + } + year = parsed + } + + data, err := h.svc.Show(c.Request.Context(), accountID, userID, year) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "record not found") + return + } + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to build year in review") + return + } + + c.JSON(http.StatusOK, data) +} diff --git a/internal/handler/api/v1/year_in_review_handler_test.go b/internal/handler/api/v1/year_in_review_handler_test.go new file mode 100644 index 00000000..b0a5a657 --- /dev/null +++ b/internal/handler/api/v1/year_in_review_handler_test.go @@ -0,0 +1,115 @@ +package v1 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/service" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestYearInReviewShowReturnsRawChatwootPayload(t *testing.T) { + db, handler, account, user, inbox, contact := setupYearInReviewHandlerTest(t) + createdAt := time.Date(2026, time.April, 4, 8, 0, 0, 0, time.UTC) + require.NoError(t, db.Create(&model.Conversation{Base: model.Base{CreatedAt: createdAt}, AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}).Error) + require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: createdAt}, AccountID: account.ID, Name: model.MetricNameFirstResponse, UserID: &user.ID, Value: 91.8}).Error) + + router := yearInReviewTestRouter(handler, user.ID) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/"+strconv.Itoa(int(account.ID))+"/year_in_review?year=2026", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.NotContains(t, body, "success") + require.Equal(t, float64(2026), body["year"]) + require.Equal(t, float64(1), body["total_conversations"]) + require.Equal(t, "Apr 04", body["busiest_day"].(map[string]any)["date"]) + require.Equal(t, float64(91), body["support_personality"].(map[string]any)["avg_response_time_seconds"]) +} + +func TestYearInReviewShowDefaultsMissingYearTo2025(t *testing.T) { + db, handler, account, user, inbox, contact := setupYearInReviewHandlerTest(t) + createdAt := time.Date(2025, time.December, 31, 8, 0, 0, 0, time.UTC) + require.NoError(t, db.Create(&model.Conversation{Base: model.Base{CreatedAt: createdAt}, AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}).Error) + + router := yearInReviewTestRouter(handler, user.ID) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/"+strconv.Itoa(int(account.ID))+"/year_in_review", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, float64(2025), body["year"]) + require.Equal(t, float64(1), body["total_conversations"]) +} + +func TestYearInReviewShowRequiresUser(t *testing.T) { + _, handler, account, _, _, _ := setupYearInReviewHandlerTest(t) + + router := yearInReviewTestRouter(handler, 0) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/"+strconv.Itoa(int(account.ID))+"/year_in_review", nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestYearInReviewShowRejectsInvalidYear(t *testing.T) { + _, handler, account, user, _, _ := setupYearInReviewHandlerTest(t) + + router := yearInReviewTestRouter(handler, user.ID) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/"+strconv.Itoa(int(account.ID))+"/year_in_review?year=nope", nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func setupYearInReviewHandlerTest(t *testing.T) (*gorm.DB, *YearInReviewHandler, *model.Account, *model.User, *model.Inbox, *model.Contact) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Account{}, + &model.User{}, + &model.Inbox{}, + &model.Contact{}, + &model.Conversation{}, + &model.ReportingEvent{}, + )) + t.Cleanup(func() { + sqlDB, _ := db.DB() + _ = sqlDB.Close() + }) + account := &model.Account{Name: "Review API", Timezone: "UTC"} + require.NoError(t, db.Create(account).Error) + user := &model.User{AccountID: account.ID, Name: "Agent", Email: "agent-year-api@example.com", Password: "secret", Active: true} + require.NoError(t, db.Create(user).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Customer", Email: "customer-year-api@example.com"} + require.NoError(t, db.Create(contact).Error) + return db, NewYearInReviewHandler(service.NewYearInReviewService(db)), account, user, inbox, contact +} + +func yearInReviewTestRouter(handler *YearInReviewHandler, userID uint) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + if userID != 0 { + c.Set("user_id", userID) + } + c.Next() + }) + r.GET("/api/v2/accounts/:account_id/year_in_review", handler.Show) + return r +} diff --git a/internal/router/router.go b/internal/router/router.go index 815ee070..7c15475e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -68,6 +68,7 @@ type Handlers struct { WebWidgetOffline *v1.WebWidgetOfflineHandler Analytics *v1.AnalyticsHandler LiveReport *v1.LiveReportHandler + YearInReview *v1.YearInReviewHandler DashboardApp *v1.DashboardAppHandler Portal *v1.PortalHandler Category *v1.CategoryHandler @@ -1877,6 +1878,8 @@ func registerV2Routes(g *gin.RouterGroup, h *Handlers) { liveReports.GET("/conversation_metrics", h.LiveReport.ConversationMetrics) liveReports.GET("/grouped_conversation_metrics", h.LiveReport.GroupedConversationMetrics) } + + accountScoped.GET("/year_in_review", h.YearInReview.Show) } } } diff --git a/internal/router/router_test.go b/internal/router/router_test.go index d599976b..fed0b2cb 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -72,6 +72,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "GET /hc/:slug/:locale/categories/:category_slug", "GET /hc/:slug/articles/:article_slug", "GET /api/v2/accounts/:account_id/reports/summary", + "GET /api/v2/accounts/:account_id/year_in_review", "GET /api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics", "GET /webhooks/twitter", "POST /webhooks/twitter", diff --git a/internal/service/year_in_review_service.go b/internal/service/year_in_review_service.go new file mode 100644 index 00000000..f05e4ee4 --- /dev/null +++ b/internal/service/year_in_review_service.go @@ -0,0 +1,202 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "time" + + "github.com/gochat/gochat/internal/model" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +const defaultYearInReviewYear = 2025 + +// YearInReviewData mirrors Chatwoot's YearInReviewBuilder response shape. +type YearInReviewData struct { + Year int `json:"year"` + TotalConversations int64 `json:"total_conversations"` + BusiestDay *YearInReviewBusiestDay `json:"busiest_day"` + SupportPersonality YearInReviewSupportPerson `json:"support_personality"` +} + +type YearInReviewBusiestDay struct { + Date string `json:"date"` + Count int64 `json:"count"` +} + +type YearInReviewSupportPerson struct { + AvgResponseTimeSeconds int64 `json:"avg_response_time_seconds"` +} + +// YearInReviewService builds and caches the API v2 year-in-review payload. +type YearInReviewService struct { + db *gorm.DB +} + +func NewYearInReviewService(db *gorm.DB) *YearInReviewService { + return &YearInReviewService{db: db} +} + +func DefaultYearInReviewYear() int { return defaultYearInReviewYear } + +// Show returns cached Current.user.ui_settings data when present, otherwise builds +// the same aggregate payload as Chatwoot's YearInReviewBuilder and stores it. +func (s *YearInReviewService) Show(ctx context.Context, accountID, userID uint, year int) (*YearInReviewData, error) { + if s == nil || s.db == nil { + return nil, errors.New("year in review service unavailable") + } + if year == 0 { + year = defaultYearInReviewYear + } + + var user model.User + if err := s.db.WithContext(ctx).Where("id = ? AND account_id = ?", userID, accountID).First(&user).Error; err != nil { + return nil, err + } + + cacheKey := fmt.Sprintf("year_in_review_%d_%d", accountID, year) + uiSettings, err := decodeUISettings(user.UISettings) + if err != nil { + return nil, err + } + if cached, ok := uiSettings[cacheKey]; ok && cached != nil { + var data YearInReviewData + encoded, err := json.Marshal(cached) + if err != nil { + return nil, err + } + if err := json.Unmarshal(encoded, &data); err != nil { + return nil, err + } + return &data, nil + } + + data, err := s.Build(ctx, accountID, userID, year) + if err != nil { + return nil, err + } + uiSettings[cacheKey] = data + encoded, err := json.Marshal(uiSettings) + if err != nil { + return nil, err + } + if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ? AND account_id = ?", userID, accountID).Update("ui_settings", datatypes.JSON(encoded)).Error; err != nil { + return nil, err + } + return data, nil +} + +func (s *YearInReviewService) Build(ctx context.Context, accountID, userID uint, year int) (*YearInReviewData, error) { + loc, err := s.accountLocation(ctx, accountID) + if err != nil { + return nil, err + } + start, end := yearBoundsUTC(year, loc) + + var conversations []model.Conversation + if err := s.db.WithContext(ctx). + Where("account_id = ? AND assignee_id = ? AND created_at >= ? AND created_at < ?", accountID, userID, start, end). + Find(&conversations).Error; err != nil { + return nil, err + } + + avg, err := s.averageFirstResponse(ctx, accountID, userID, start, end) + if err != nil { + return nil, err + } + + return &YearInReviewData{ + Year: year, + TotalConversations: int64(len(conversations)), + BusiestDay: busiestDay(conversations, loc), + SupportPersonality: YearInReviewSupportPerson{AvgResponseTimeSeconds: int64(avg)}, + }, nil +} + +func (s *YearInReviewService) accountLocation(ctx context.Context, accountID uint) (*time.Location, error) { + var account model.Account + if err := s.db.WithContext(ctx).Select("id", "timezone").Where("id = ?", accountID).First(&account).Error; err != nil { + return nil, err + } + if account.Timezone == "" { + return time.UTC, nil + } + loc, err := time.LoadLocation(account.Timezone) + if err != nil { + return time.UTC, nil + } + return loc, nil +} + +func (s *YearInReviewService) averageFirstResponse(ctx context.Context, accountID, userID uint, start, end time.Time) (float64, error) { + var avg sql.NullFloat64 + if err := s.db.WithContext(ctx). + Model(&model.ReportingEvent{}). + Where("account_id = ? AND name = ? AND user_id = ? AND created_at >= ? AND created_at < ?", accountID, model.MetricNameFirstResponse, userID, start, end). + Select("AVG(value)"). + Scan(&avg).Error; err != nil { + return 0, err + } + if !avg.Valid { + return 0, nil + } + return avg.Float64, nil +} + +func yearBoundsUTC(year int, loc *time.Location) (time.Time, time.Time) { + if loc == nil { + loc = time.UTC + } + start := time.Date(year, time.January, 1, 0, 0, 0, 0, loc) + end := start.AddDate(1, 0, 0) + return start.UTC(), end.UTC() +} + +func busiestDay(conversations []model.Conversation, loc *time.Location) *YearInReviewBusiestDay { + if len(conversations) == 0 { + return nil + } + counts := make(map[time.Time]int64) + for _, conversation := range conversations { + localDay := conversation.CreatedAt.In(loc) + bucket := time.Date(localDay.Year(), localDay.Month(), localDay.Day(), 0, 0, 0, 0, loc) + counts[bucket]++ + } + days := make([]time.Time, 0, len(counts)) + for day := range counts { + days = append(days, day) + } + sort.Slice(days, func(i, j int) bool { return days[i].Before(days[j]) }) + + var busiest time.Time + var max int64 + for _, day := range days { + if counts[day] > max { + busiest = day + max = counts[day] + } + } + if max == 0 { + return nil + } + return &YearInReviewBusiestDay{Date: busiest.Format("Jan 02"), Count: max} +} + +func decodeUISettings(raw datatypes.JSON) (map[string]any, error) { + settings := map[string]any{} + if len(raw) == 0 || string(raw) == "null" { + return settings, nil + } + if err := json.Unmarshal(raw, &settings); err != nil { + return nil, err + } + if settings == nil { + settings = map[string]any{} + } + return settings, nil +} diff --git a/internal/service/year_in_review_service_test.go b/internal/service/year_in_review_service_test.go new file mode 100644 index 00000000..8eeca48c --- /dev/null +++ b/internal/service/year_in_review_service_test.go @@ -0,0 +1,107 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/gochat/gochat/internal/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupYearInReviewServiceTest(t *testing.T) (*gorm.DB, *YearInReviewService, *model.Account, *model.User, *model.Inbox, *model.Contact) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Account{}, + &model.User{}, + &model.Inbox{}, + &model.Contact{}, + &model.Conversation{}, + &model.ReportingEvent{}, + )) + t.Cleanup(func() { + sqlDB, _ := db.DB() + _ = sqlDB.Close() + }) + + account := &model.Account{Name: "Review", Timezone: "UTC"} + require.NoError(t, db.Create(account).Error) + user := &model.User{AccountID: account.ID, Name: "Agent", Email: "agent-review@example.com", Password: "secret", Active: true} + require.NoError(t, db.Create(user).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Customer", Email: "customer-review@example.com"} + require.NoError(t, db.Create(contact).Error) + + return db, NewYearInReviewService(db), account, user, inbox, contact +} + +func TestYearInReviewBuildsAndCachesChatwootPayload(t *testing.T) { + db, svc, account, user, inbox, contact := setupYearInReviewServiceTest(t) + dayOne := time.Date(2026, time.January, 2, 10, 0, 0, 0, time.UTC) + dayTwo := time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC) + otherYear := time.Date(2025, time.January, 2, 10, 0, 0, 0, time.UTC) + otherUserID := user.ID + 99 + + createReviewConversation(t, db, account.ID, inbox.ID, contact.ID, user.ID, dayOne) + createReviewConversation(t, db, account.ID, inbox.ID, contact.ID, user.ID, dayOne.Add(2*time.Hour)) + createReviewConversation(t, db, account.ID, inbox.ID, contact.ID, user.ID, dayTwo) + createReviewConversation(t, db, account.ID, inbox.ID, contact.ID, user.ID, otherYear) + createReviewConversation(t, db, account.ID, inbox.ID, contact.ID, otherUserID, dayOne) + + require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: dayOne}, AccountID: account.ID, Name: model.MetricNameFirstResponse, UserID: &user.ID, Value: 120.9}).Error) + require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: dayTwo}, AccountID: account.ID, Name: model.MetricNameFirstResponse, UserID: &user.ID, Value: 240.1}).Error) + require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: otherYear}, AccountID: account.ID, Name: model.MetricNameFirstResponse, UserID: &user.ID, Value: 999}).Error) + + data, err := svc.Show(context.Background(), account.ID, user.ID, 2026) + require.NoError(t, err) + require.NotNil(t, data) + assert.Equal(t, 2026, data.Year) + assert.Equal(t, int64(3), data.TotalConversations) + require.NotNil(t, data.BusiestDay) + assert.Equal(t, "Jan 02", data.BusiestDay.Date) + assert.Equal(t, int64(2), data.BusiestDay.Count) + assert.Equal(t, int64(180), data.SupportPersonality.AvgResponseTimeSeconds) + + var refreshed model.User + require.NoError(t, db.First(&refreshed, user.ID).Error) + var uiSettings map[string]any + require.NoError(t, json.Unmarshal(refreshed.UISettings, &uiSettings)) + assert.Contains(t, uiSettings, "year_in_review_1_2026") + + createReviewConversation(t, db, account.ID, inbox.ID, contact.ID, user.ID, dayTwo.Add(time.Hour)) + cached, err := svc.Show(context.Background(), account.ID, user.ID, 2026) + require.NoError(t, err) + assert.Equal(t, int64(3), cached.TotalConversations) +} + +func TestYearInReviewReturnsEmptyPersonalityWithoutEvents(t *testing.T) { + _, svc, account, user, _, _ := setupYearInReviewServiceTest(t) + + data, err := svc.Show(context.Background(), account.ID, user.ID, 2026) + require.NoError(t, err) + assert.Equal(t, int64(0), data.TotalConversations) + assert.Nil(t, data.BusiestDay) + assert.Equal(t, int64(0), data.SupportPersonality.AvgResponseTimeSeconds) +} + +func createReviewConversation(t *testing.T, db *gorm.DB, accountID, inboxID, contactID, assigneeID uint, createdAt time.Time) { + t.Helper() + conversation := &model.Conversation{ + Base: model.Base{CreatedAt: createdAt}, + AccountID: accountID, + InboxID: inboxID, + ContactID: contactID, + AssigneeID: &assigneeID, + Status: string(model.ConversationStatusOpen), + ChannelType: "web_widget", + Channel: "web_widget", + } + require.NoError(t, db.Create(conversation).Error) +}