feat(reports): derive analytics aggregates

This commit is contained in:
2026-06-05 20:53:30 +08:00
parent 37bdac422a
commit a48b9cda8e
8 changed files with 726 additions and 72 deletions
+33 -10
View File
@@ -16,10 +16,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
## Current Baseline
- Current tracking checkpoint: 2026-06-05 after `dd6d019 feat(captain): queue response embedding jobs`, with this implementation checkpoint prepared as `feat(captain): queue copilot response jobs`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(captain): queue copilot response jobs`.
- Current tracking checkpoint: 2026-06-05 after `37bdac4 feat(captain): queue copilot response jobs`, with this implementation checkpoint prepared as `feat(reports): derive analytics aggregates`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): derive analytics aggregates`.
- Latest documentation-only checkpoint: `2923aae docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs. Next active implementation slice is P5.13 analytics aggregation.
- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations. Next active implementation slice is P5.13b scheduled/cached report rollups and timeseries index parity.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 832` after adding the Chatwoot-compatible Twilio delivery-status route plus the legacy namespaced alias.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
@@ -45,7 +45,7 @@ Next ordered checkpoints:
| Order | Slice | Required outcome | Primary verification |
| --- | --- | --- | --- |
| 1 | P5.13 analytics aggregation | Replace frontend-visible placeholder report values with scheduled or cached real aggregations. | Report service/handler fixtures and freshness/idempotency tests. |
| 1 | P5.13b scheduled/cached analytics | Wire scheduled or lazy cached report rollups and the `/reports` timeseries index path after the P5.13a placeholder burn-down. | Report worker/service fixtures for freshness, idempotency, and timeseries values. |
| 2 | B9.3 delayed automation actions | Confirm current-reference delayed automation params and queue any still-synchronous scheduled action execution. | Automation worker fixtures for schedule time, retry, idempotency, and observable failure. |
| 3 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. |
| 4 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. |
@@ -54,7 +54,7 @@ Next ordered checkpoints:
This checkpoint is intended to make the development plan complete enough to track without reading Hermes notes first.
- The next active implementation slice is P5.13 analytics aggregation. P5.11 Captain/Copilot response jobs are now in Review, and B12 has repeatable API/browser/enterprise smoke harnesses in Review; optional live failures should be converted into named slices instead of blocking Phase 5 job work.
- The next active implementation slice is P5.13b scheduled/cached analytics. P5.13a replaced the most visible report placeholders with persisted aggregations; remaining analytics work is report rollup freshness/idempotency and `/reports` timeseries index parity. B12 has repeatable API/browser/enterprise smoke harnesses in Review; optional live failures should be converted into named slices instead of blocking Phase 5 job work.
- The Hermes search plan is fully represented by Phase 1/B6. Future search changes must be Meilisearch-first and must not reintroduce production DB fallback.
- The Hermes automation/macro/CSAT plan is fully represented by B8/B9 and Phase 5. Durable delayed execution remains visible Phase 5/B9.3 work if the current reference exposes explicit delayed action params; channel-specific template delivery is in Review.
- Enterprise scope is fixed: SLA, Audit, CustomRole, AgentCapacity, Captain/Copilot, CSAT, InboxLimit, automation, macros, assignment policies, and related limits/workflows are in scope; SSO/SAML/LDAP/OIDC are out of scope.
@@ -64,7 +64,7 @@ Open work after the current checkpoint:
| Area | Next concrete action | Tracking location | Done boundary |
| --- | --- | --- | --- |
| Phase 5 jobs | Finish analytics aggregation and the B9.3 delayed automation action check on top of the committed durable worker. | `Phase 5: Background Jobs And Integrations` | Report and automation tests prove real aggregation/scheduling, freshness/idempotency, and no frontend-visible placeholder values. |
| Phase 5 jobs | Finish P5.13b scheduled/cached analytics and the B9.3 delayed automation action check on top of the committed durable worker. | `Phase 5: Background Jobs And Integrations` | Report and automation tests prove real aggregation/scheduling, freshness/idempotency, and no frontend-visible placeholder values. |
| B12 smoke | Run optional live API/browser/enterprise smoke in a full PostgreSQL/Redis/Meilisearch/Vite environment and convert failures into named slices. | `B12 reused frontend verification breakdown` | `docs/parity/frontend_smoke_report.md` records checked pass/fail results and maps failures to slices. |
| Phase 2/3 drift | Expand tracked route/serializer fixtures when B12 exposes frontend-critical gaps. | `Phase 2`, `Phase 3`, `docs/parity/` | Route parity remains 0 missing for tracked frontend routes; serializers have reference fixtures. |
| Phase 6 placeholders | Re-run placeholder audit and burn down any frontend-reachable stub. | `Phase 6: Core Product Placeholder Burn-down` | Stub list has no reused-frontend critical path without a named owner. |
@@ -78,7 +78,7 @@ Open work after the current checkpoint:
| Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback |
| Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based |
| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, B10, and B11 are in Review; B12 reused frontend smoke harnesses exist and optional live runs can expose follow-up slices |
| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.9/P5.10/P5.11/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT survey/template, SLA scan, contact export, inbound webhook persistence, outbound/provider delivery status, Captain document sync/crawl/response/embedding/Copilot/conversation responses, conversation maintenance, message status update, and account bulk-action cores are in Review; analytics aggregation and the B9.3 delayed automation action check remain open |
| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.9/P5.10/P5.11/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT survey/template, SLA scan, contact export, inbound webhook persistence, outbound/provider delivery status, Captain document sync/crawl/response/embedding/Copilot/conversation responses, conversation maintenance, message status update, and account bulk-action cores are in Review; P5.13a placeholder aggregation is in Review, while scheduled/cached rollups, timeseries index parity, and the B9.3 delayed automation action check remain open |
| Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad |
| Phase 7 | Verification harness | Review | B12.1 boot/readiness, B12.2a API assertions, B12.2b browser smoke harness, B12.3a enterprise API assertions, and B12.3b enterprise browser route navigation exist; optional live Meilisearch/full-browser runs remain environment-dependent |
@@ -88,7 +88,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 1 | P5.13 reports/analytics | Frontend smoke harness exists; `analytics_service` still has placeholder aggregation paths. | Replace frontend-visible report placeholders with real scheduled/cached aggregations. | Report fixtures verify values, cache/freshness behavior, and no hidden placeholder JSON. |
| 1 | P5.13 reports/analytics | P5.13a derives live reports, bot reports, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message counts from persisted rows; scheduled/cached rollups and `/reports` timeseries index parity remain. | Add scheduled or lazy cached rollup freshness/idempotency and route `GET /reports` to metric timeseries instead of summary. | Report fixtures verify timeseries values, cache/freshness behavior, and no hidden placeholder JSON. |
| 2 | B9.3 delayed automation actions | Macro fan-out, webhook/transcript delivery, and CSAT jobs are durable; current-reference delayed automation action params still need a final check. | Queue any still-synchronous delayed automation action execution or close the row with reference evidence if no explicit delayed params exist. | Automation worker fixtures verify schedule time, retry, idempotency, and observable failure. |
| 3 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts and add endpoint-family fixture tests. |
| 4 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; account/contact/conversation/message/inbox audit remains broad. | Run a fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` audit result is recorded and no reused-frontend blocker is ownerless. |
@@ -103,7 +103,8 @@ These rows are the executable development plan from this point forward. A checkp
| P5.11a Captain document crawl/schedule | `internal/service/captain_document_service.go`, `internal/service/captain_document_worker.go`, `internal/app/bootstrap.go` | `reference/chatwoot/enterprise/app/jobs/captain/documents/crawl_job.rb`, `schedule_syncs_job.rb`, `perform_sync_job.rb`, Firecrawl/simple parser jobs | Durable schedule/crawl producers and handlers with fakeable crawl/parser boundaries. Missing provider config is a failed `crawl_disabled` state, not placeholder success. | Review by `feat(captain): queue document crawl jobs`; focused worker tests prove enqueue, replay, account scope, idempotent scheduler, and disabled/failure states. |
| P5.11b Captain response/embedding fan-out | Captain document/assistant-response services and repositories, Meilisearch/embedding boundaries | `response_builder_job.rb`, `enterprise/app/jobs/captain/llm/update_embedding_job.rb`, FAQ generator/embedding services | Queue FAQ response generation after successful document content changes, reset unedited responses, create/update assistant responses, and fan out embedding update work behind fakeable LLM gates. | Review by `feat(captain): queue response embedding jobs`; tests cover response reset/create, embedding-disabled retry, fake embedding success, account scope, and no external network in default tests. |
| P5.11c Copilot and conversation response jobs | `internal/service/copilot_service.go`, `internal/service/copilot_response_worker.go`, `internal/service/captain_conversation_service.go`, `internal/service/message_service.go`, `internal/app/bootstrap.go` | `enterprise/app/jobs/captain/copilot/response_job.rb`, `enterprise/app/jobs/captain/conversation/response_builder_job.rb`, `enterprise/app/services/captain/copilot/chat_service.rb`, `enterprise/app/services/enterprise/message_templates/hook_execution_service.rb`, `enterprise/app/models/copilot_message.rb` | Queue assistant replies after Copilot user messages and Captain pending-conversation triggers. Persist assistant messages, enqueue Captain conversation replies/handoff messages, open handoff conversations, and keep fakeable provider disabled/failure states observable through durable retry. | Review by `feat(captain): queue copilot response jobs`; focused tests cover Copilot enqueue/persist/fallback/retry, Captain conversation enqueue/handoff/retry/non-pending skip, and service/worker/app package replay. |
| P5.13 analytics aggregation | `internal/service/analytics_service.go`, report handlers/services, worker bootstrap | Chatwoot report controllers/services used by dashboard analytics, CSAT/SLA reporting views | Replace frontend-visible placeholder report values with real scheduled or cached aggregations. Define freshness/idempotency rules for expensive rollups. | Report fixtures prove values are derived from persisted conversations/messages/CSAT/SLA rows; `rg` finds no frontend-visible placeholder report JSON. |
| P5.13a analytics placeholder burn-down | `internal/service/analytics_service.go`, `internal/service/analytics_query_helpers.go`, live/report handlers | Chatwoot `live_reports_controller.rb`, `reports_controller.rb`, `BotMetricsBuilder`, `InboxLabelMatrixBuilder`, `FirstResponseTimeDistributionBuilder`, `OutgoingMessagesCountBuilder` | Replace frontend-visible zero/empty placeholder responses for live conversations, grouped live conversations, bot summary/metrics, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message counts with persisted conversation/message/reporting-event queries. | Review by `feat(reports): derive analytics aggregates`; focused service/handler tests prove non-zero values from persisted rows and `rg` finds no placeholder TODOs in these methods. |
| P5.13b scheduled/cached analytics | `internal/service/analytics_service.go`, `internal/service/reporting_rollup_service.go`, report handlers/services, worker bootstrap | Chatwoot report controllers/services used by dashboard analytics, `Reports::DataSource`, reporting rollup/backfill jobs | Wire scheduled or lazy cached rollup freshness/idempotency and route `GET /reports` to metric timeseries instead of the summary handler. Define freshness rules for expensive rollups. | Report fixtures prove timeseries values are derived from persisted conversations/messages/reporting events, rollups refresh idempotently, and no hidden placeholder report JSON remains. |
| Phase 2/3 drift audit | `cmd/route_parity`, `docs/parity/*`, serializer tests | `reference/chatwoot/config/routes.rb`, controller Jbuilder views, reused frontend API clients | Convert any smoke/reference mismatch into a named route, controller, or serializer slice. Static route extraction remains acceptable until Ruby/Bundler is available. | Regenerated route parity shows 0 missing tracked frontend routes; new serializer fixtures cover the drift. |
| Phase 6 placeholder burn-down | Account/contact/conversation/message/inbox handlers and services | Matching reference controllers/Jbuilder views plus reused frontend screens | Re-run placeholder audit and assign every frontend-reachable stub to a specific owner. Burn down the highest-impact stubs before broad feature expansion. | `rg` placeholder audit is recorded here; no reused-frontend critical path is ownerless. |
| B12 live smoke | `scripts/parity_frontend_smoke.sh`, `docs/parity/frontend_smoke_report.md`, `cmd/gochat` | Reused `reference/chatwoot` Vite frontend, dashboard route/API clients | Run optional live API/browser/enterprise smoke with PostgreSQL, Redis, Meilisearch, GoChat, Vite, and Chrome. Convert failures into named rows above. | Smoke report records command, environment, pass/fail, artifacts, and linked follow-up owners. |
@@ -139,6 +140,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(reports): derive analytics aggregates` | Advances P5.13a by replacing frontend-visible analytics placeholder responses with persisted aggregations. Live report conversation metrics now count open/unattended/unassigned/pending conversations with team filtering; grouped live reports return assignee/team grouped counts; bot summary/metrics, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message counts are derived from conversations, messages, labels, agent-bot bindings, and reporting events instead of fixed zero/empty JSON. | `go test ./internal/service -run 'Analytics' -count=1`; `go test ./internal/handler/api/v1 -run 'Analytics\|LiveReport' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.13 section. | P5.13a moves to Review; continue P5.13b scheduled/cached rollup freshness and `/reports` timeseries index parity, then B9.3 delayed automation action check. |
| `feat(captain): queue copilot response jobs` | Completes P5.11c with durable Chatwoot `Captain::Copilot::ResponseJob` and `Captain::Conversation::ResponseBuilderJob` equivalents. Copilot thread/message creation now persists the user message and enqueues `captain:copilot_response` when a WorkerPool is configured, while worker replay reloads the account/user/thread/message scope and persists assistant replies through a fakeable backend or the existing no-provider fallback. Incoming pending conversation messages for Captain-enabled inboxes now enqueue `captain:conversation_response_builder`; replay collects public incoming/outgoing history, creates Captain outgoing replies, enqueues provider send-reply, and opens the conversation with a handoff message when the backend requests handoff. | `go test ./internal/service -run 'CopilotResponse\|CaptainConversation\|MessageService' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.11 section. | P5.11 moves to Review; continue P5.13 analytics aggregation, then Phase 2/3 drift and Phase 6 placeholder audits as smoke exposes gaps. |
| `feat(captain): queue response embedding jobs` | Advances P5.11b with durable Captain document response building and embedding update fan-out. Successful document sync/parser content updates enqueue `captain:document_response_builder`, worker replay resets only unedited document responses, preserves edited responses, creates approved `Captain::Document` assistant responses from a fakeable FAQ backend, and enqueues `captain:llm_update_embedding` jobs for created responses. Embedding replay reloads account-scoped responses, uses a fakeable embedding backend or configured LLM provider, and surfaces missing provider config as retryable worker failures. | `go test ./internal/service -run 'CaptainDocumentService\|EnqueueCaptainDocumentScheduleSyncs' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.11 section. | P5.11b moves to Review; continue P5.11c Copilot/conversation response jobs, then P5.13 analytics aggregation. |
| `feat(captain): queue document crawl jobs` | Advances P5.11a with durable Captain document crawl, simple-page parser, and auto-sync scheduler jobs. Document create/crawl requests enqueue `captain:document_crawl`, crawl replay uses a fakeable crawl backend to enqueue normalized `captain:document_page_crawl_parse` jobs, parser replay creates or updates account-scoped document content through a fakeable parser backend, and the `captain:documents_schedule_syncs` root job scans stale synced/failed/syncing documents for accounts with `captain_document_auto_sync` enabled before enqueueing `captain:document_sync` work with daily idempotency. Bootstrap seeds the scheduler after Captain document services are wired. | `go test ./internal/service -run 'CaptainDocumentService\|EnqueueCaptainDocumentScheduleSyncs' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.11 section. | P5.11a moves to Review; continue P5.11b response/embedding fan-out, then P5.11c Copilot/conversation response jobs. |
@@ -1672,7 +1674,7 @@ Tracking table:
| P5.10 | Queue outbound message delivery and delivery-status updates. | `send_reply_job.rb`, provider delivery/status jobs | message send/channel services, delivery status handler | Outgoing message creation and provider delivery are separated; retries update message/delivery status exactly once. | Review by `feat(messages): queue send replies` and `feat(messages): queue delivery statuses` |
| P5.11 | Queue Captain document sync, crawl, response building, embeddings, and Copilot responses. | Captain document/crawl/response/embedding/Copilot/conversation jobs | `internal/service/captain_document_service.go`, `internal/service/copilot_service.go`, `internal/service/captain_conversation_service.go`, Captain/Copilot services | Existing fakeable disabled/failure gates run under durable jobs; document statuses, Copilot message persistence, and Captain conversation replies survive worker restart. | Review by `feat(captain): queue document syncs`, `feat(captain): queue document crawl jobs`, `feat(captain): queue response embedding jobs`, and `feat(captain): queue copilot response jobs` |
| P5.12 | Queue conversation maintenance jobs. | `trigger_scheduled_items_job.rb`, `campaigns/trigger_oneoff_campaign_job.rb`, `conversations/resolution_job.rb`, `reopen_snoozed_conversations_job.rb`, `update_message_status_job.rb`, `bulk_actions_job.rb` | conversation service/handlers | Auto-resolution, snooze reopen, status updates, and bulk actions are scheduled/retryable with idempotent tests. | Review by `feat(conversations): queue maintenance jobs`, `feat(conversations): queue message status updates`, and `feat(conversations): queue bulk actions` |
| P5.13 | Replace placeholder analytics/report builders that need background aggregation. | reporting jobs/services and report controllers | `internal/service/analytics_service.go`, reporting services | Frontend-visible reports no longer use placeholder values; any expensive aggregation is scheduled or cached with freshness rules. | Todo |
| P5.13 | Replace placeholder analytics/report builders that need background aggregation. | reporting jobs/services and report controllers | `internal/service/analytics_service.go`, `internal/service/analytics_query_helpers.go`, reporting services | P5.13a derives live/bot/conversation summary/matrix/distribution/outgoing-count report values from persisted rows; remaining expensive aggregation should be scheduled or cached with freshness rules. | Doing: placeholder burn-down Review by `feat(reports): derive analytics aggregates`; scheduled/cached rollups and `/reports` timeseries index parity Todo |
P5.1 current checkpoint:
@@ -1898,6 +1900,26 @@ env TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp GOCACHE=/tmp/gochat-gocache
git diff --check
```
P5.13 current checkpoint:
- P5.13a replaces the most visible analytics/report placeholder values with persisted queries instead of fixed zero or empty JSON.
- Live conversation metrics now count `open`, `unattended`, `unassigned`, and `pending` from `conversations`, including Chatwoot-style unattended semantics and `team_id` filtering.
- Grouped live reports now group open conversations by `assignee_id` or `team_id` and return the matching Chatwoot key plus open/unattended/unassigned counts.
- Bot summary and bot metrics now derive bot resolutions/handoffs from `reporting_events`, exclude handoff conversations from bot resolutions, and calculate bot conversation/message counts and rates from active `agent_bot_inboxes`.
- Conversation summary now derives conversation, incoming-message, outgoing-message, resolution, first-response, resolution-time, and reply-time values from persisted conversations/messages/reporting events.
- Inbox-label matrix now uses `inboxes`, `tags`, and `conversation_labels`; first-response distribution buckets `first_response` events by inbox channel type; outgoing-message counts group by agent, team, inbox, or label.
- Remaining P5.13 work: scheduled or lazy cached rollup freshness/idempotency and `/api/v2/accounts/:account_id/reports` timeseries index parity.
P5.13 verification:
```bash
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'Analytics' -count=1
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'Analytics\|LiveReport' -count=1
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1
env TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...
git diff --check
```
## Phase 6: Core Product Placeholder Burn-down
Status: doing.
@@ -1999,6 +2021,7 @@ Verification milestone gates:
## Progress Log
- 2026-06-05: P5.13a analytics placeholder burn-down checkpoint prepared as `feat(reports): derive analytics aggregates`; live report conversation metrics and grouped metrics now read persisted conversations, bot summary/metrics read reporting events plus active agent-bot inbox bindings, conversation summary reads conversations/messages/reporting events, inbox-label matrix reads inbox/tag/conversation-label rows, first-response distribution buckets reporting events by channel, and outgoing-message counts group by agent/team/inbox/label. Focused analytics service tests, analytics/live handler tests, service/handler/worker/app package tests, full `go test ./...`, and `git diff --check` passed. Remaining P5.13 follow-up is scheduled/cached rollup freshness and `/reports` timeseries index parity.
- 2026-06-05: P5.11c Copilot/conversation response checkpoint prepared as `feat(captain): queue copilot response jobs`; Copilot thread/message creation now enqueues `captain:copilot_response` after persisting the user message, worker replay persists assistant replies through a fakeable backend or the existing no-provider fallback, and backend failures surface as retryable jobs. Pending Captain-enabled incoming conversations now enqueue `captain:conversation_response_builder`, replay creates Captain outgoing replies, queues provider send-reply, and opens conversations on handoff. Focused Copilot/Captain conversation worker tests, service/worker/app package tests, full `go test ./...`, and `git diff --check` passed. P5.11 moves to Review; next slice is P5.13 analytics aggregation.
- 2026-06-05: P5.11b Captain response/embedding checkpoint prepared as `feat(captain): queue response embedding jobs`; successful document sync/parser content updates now enqueue `captain:document_response_builder`, response-builder replay resets only unedited document responses, preserves edited responses, creates approved `Captain::Document` assistant responses through a fakeable FAQ backend, and fans out `captain:llm_update_embedding` jobs. Embedding replay reloads account-scoped responses and uses a fakeable embedding backend or configured LLM provider, with missing providers surfacing as retryable worker failures. Focused Captain document worker tests, service/worker/app package tests, full `go test ./...`, and `git diff --check` passed. Remaining P5.11 follow-up is Copilot/conversation response jobs.
- 2026-06-05: P5.11a Captain document crawl/schedule checkpoint prepared as `feat(captain): queue document crawl jobs`; document create/crawl requests now enqueue `captain:document_crawl`, crawl replay fans out normalized `captain:document_page_crawl_parse` parser jobs through fakeable crawl/parser boundaries, parser replay creates or updates account-scoped documents with synced content/fingerprints, and `captain:documents_schedule_syncs` scans stale completed documents for `captain_document_auto_sync` accounts to enqueue `captain:document_sync` jobs with daily idempotency. Focused Captain document worker tests, service/worker/app package tests, full `go test ./...`, and `git diff --check` passed. Remaining P5.11 follow-up is now Copilot/conversation response jobs after P5.11b.
+8 -7
View File
@@ -187,6 +187,7 @@ func (h *AnalyticsHandler) ConversationTraffic(c *gin.Context) {
response.OK(c, result)
}
// BotSummary returns bot-level summary metrics.
// GET /api/v1/accounts/:account_id/reports/bot_summary
// Reference: Chatwoot reports#bot_summary
@@ -226,12 +227,7 @@ func (h *AnalyticsHandler) Conversations(c *gin.Context) {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
result, err := h.svc.GetConversationsByType(c.Request.Context(), accountID, reportType, since, until)
result, err := h.svc.GetConversationsByType(c.Request.Context(), accountID, reportType, time.Time{}, time.Time{})
if err != nil {
applogger.L().Errorf("Conversations report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversations report")
@@ -341,8 +337,13 @@ func (h *AnalyticsHandler) OutgoingMessagesCount(c *gin.Context) {
if !ok {
return
}
groupBy := c.Query("group_by")
if groupBy != "agent" && groupBy != "team" && groupBy != "inbox" && groupBy != "label" {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "invalid group_by")
return
}
result, err := h.svc.GetOutgoingMessagesCount(c.Request.Context(), accountID, since, until)
result, err := h.svc.GetOutgoingMessagesCountGrouped(c.Request.Context(), accountID, since, until, groupBy)
if err != nil {
applogger.L().Errorf("Outgoing messages count report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate outgoing messages count")
+12 -7
View File
@@ -2,6 +2,7 @@ package v1
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
@@ -30,19 +31,23 @@ func (h *LiveReportHandler) ConversationMetrics(c *gin.Context) {
return
}
// Chatwoot supports team_id filter
teamIDStr := c.Query("team_id")
teamID := uint(0)
if teamIDStr := c.Query("team_id"); teamIDStr != "" {
parsed, err := strconv.ParseUint(teamIDStr, 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team_id")
return
}
teamID = uint(parsed)
}
result, err := h.svc.GetConversationMetrics(c.Request.Context(), accountID)
result, err := h.svc.GetConversationMetricsForTeam(c.Request.Context(), accountID, teamID)
if err != nil {
applogger.L().Errorf("Live conversation metrics: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get conversation metrics")
return
}
// Chatwoot also filters by team_id if provided — TODO: implement team filter
_ = teamIDStr
response.OK(c, result)
}
@@ -72,4 +77,4 @@ func (h *LiveReportHandler) GroupedConversationMetrics(c *gin.Context) {
}
response.OK(c, result)
}
}
+8 -1
View File
@@ -17,6 +17,13 @@ func NewReportingEventRepo(db *gorm.DB) *ReportingEventRepo {
return &ReportingEventRepo{db: db}
}
func (r *ReportingEventRepo) DB() *gorm.DB {
if r == nil {
return nil
}
return r.db
}
func (r *ReportingEventRepo) Create(ctx context.Context, event *model.ReportingEvent) error {
return r.db.WithContext(ctx).Create(event).Error
}
@@ -75,4 +82,4 @@ func (r *ReportingEventRepo) FindByAccountIDAndTimeRange(ctx context.Context, ac
Where("account_id = ? AND event_start_time >= ? AND event_end_time <= ?", accountID, since, until).
Find(&events).Error
return events, err
}
}
@@ -17,6 +17,13 @@ func NewReportingEventsRollupRepo(db *gorm.DB) *ReportingEventsRollupRepo {
return &ReportingEventsRollupRepo{db: db}
}
func (r *ReportingEventsRollupRepo) DB() *gorm.DB {
if r == nil {
return nil
}
return r.db
}
func (r *ReportingEventsRollupRepo) Create(ctx context.Context, rollup *model.ReportingEventsRollup) error {
return r.db.WithContext(ctx).Create(rollup).Error
}
@@ -82,4 +89,4 @@ func (r *ReportingEventsRollupRepo) FindByAccountAndDateRangeWithDimension(ctx c
}
err := query.Find(&rollups).Error
return rollups, err
}
}
+149
View File
@@ -0,0 +1,149 @@
package service
import (
"context"
"testing"
"time"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupAnalyticsP513Test(t *testing.T) (*gorm.DB, *AnalyticsService, *model.Account, *model.Inbox, *model.Contact, *model.User, *model.Team) {
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.AccountUser{},
&model.Team{},
&model.Inbox{},
&model.Contact{},
&model.Conversation{},
&model.Message{},
&model.ReportingEvent{},
&model.ReportingEventsRollup{},
&model.AgentBot{},
&model.AgentBotInbox{},
&model.Tag{},
&model.ConversationLabel{},
))
t.Cleanup(func() {
sqlDB, _ := db.DB()
sqlDB.Close()
})
account := &model.Account{Name: "Analytics"}
require.NoError(t, db.Create(account).Error)
user := &model.User{AccountID: account.ID, Name: "Agent", Email: "agent@example.com", Password: "secret", Active: true}
require.NoError(t, db.Create(user).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: string(model.AccountUserRoleAgent)}).Error)
team := &model.Team{AccountID: account.ID, Name: "Support"}
require.NoError(t, db.Create(team).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@example.com"}
require.NoError(t, db.Create(contact).Error)
svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db))
return db, svc, account, inbox, contact, user, team
}
func TestAnalyticsLiveConversationMetricsAreDerivedFromConversations(t *testing.T) {
db, svc, account, inbox, contact, user, team := setupAnalyticsP513Test(t)
now := int64(1760000000)
firstReply := now - 60
assigned := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, TeamID: &team.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
unassigned := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, TeamID: &team.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType, FirstReplyCreatedAt: &firstReply}
pending := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusPending), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
otherAccount := &model.Account{Name: "Other"}
require.NoError(t, db.Create(otherAccount).Error)
other := &model.Conversation{AccountID: otherAccount.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
require.NoError(t, db.Create(assigned).Error)
require.NoError(t, db.Create(unassigned).Error)
require.NoError(t, db.Create(pending).Error)
require.NoError(t, db.Create(other).Error)
metrics, err := svc.GetConversationMetrics(context.Background(), account.ID)
require.NoError(t, err)
assert.Equal(t, int64(2), metrics.OpenCount)
assert.Equal(t, int64(1), metrics.UnattendedCount)
assert.Equal(t, int64(1), metrics.UnassignedCount)
assert.Equal(t, int64(1), metrics.PendingCount)
teamMetrics, err := svc.GetConversationMetricsForTeam(context.Background(), account.ID, team.ID)
require.NoError(t, err)
assert.Equal(t, int64(2), teamMetrics.OpenCount)
assert.Equal(t, int64(0), teamMetrics.PendingCount)
grouped, err := svc.GetGroupedConversationMetrics(context.Background(), account.ID, "assignee_id")
require.NoError(t, err)
require.Len(t, grouped, 2)
assert.Equal(t, int64(1), grouped[0]["open"])
assert.Nil(t, grouped[0]["assignee_id"])
assert.Equal(t, user.ID, grouped[1]["assignee_id"])
assert.Equal(t, int64(1), grouped[1]["open"])
}
func TestAnalyticsReportsUsePersistedConversationMessageAndEventRows(t *testing.T) {
db, svc, account, inbox, contact, user, team := setupAnalyticsP513Test(t)
since := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
until := since.Add(24 * time.Hour)
resolvedAt := since.Add(3 * time.Hour)
conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, TeamID: &team.ID, Status: string(model.ConversationStatusResolved), ResolvedAt: &resolvedAt, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
require.NoError(t, db.Create(conv).Error)
require.NoError(t, db.Model(conv).Updates(map[string]interface{}{"created_at": since.Add(time.Hour)}).Error)
senderID := user.ID
require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(2 * time.Hour)}, AccountID: account.ID, ConversationID: conv.ID, InboxID: inbox.ID, SenderID: &senderID, SenderType: "User", MessageType: string(model.MessageTypeOutgoing), Content: "hello"}).Error)
require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(90 * time.Minute)}, AccountID: account.ID, ConversationID: conv.ID, InboxID: inbox.ID, MessageType: string(model.MessageTypeIncoming), Content: "hi"}).Error)
bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot"}
require.NoError(t, db.Create(bot).Error)
require.NoError(t, db.Create(&model.AgentBotInbox{AccountID: &account.ID, AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error)
tag := &model.Tag{AccountID: account.ID, Name: "vip"}
require.NoError(t, db.Create(tag).Error)
require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: conv.ID, TagID: tag.ID}).Error)
require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(30 * time.Minute)}, AccountID: account.ID, Name: model.MetricNameFirstResponse, Value: 1800, ConversationID: &conv.ID, InboxID: &inbox.ID, UserID: &user.ID, EventStartTime: since, EventEndTime: since.Add(30 * time.Minute)}).Error)
require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(time.Hour)}, AccountID: account.ID, Name: "conversation_bot_resolved", Value: 1, ConversationID: &conv.ID, InboxID: &inbox.ID, EventStartTime: since, EventEndTime: since.Add(time.Hour)}).Error)
summary, err := svc.GetConversationsSummary(context.Background(), account.ID, since, until)
require.NoError(t, err)
summaryMap := summary.(map[string]interface{})
assert.Equal(t, int64(1), summaryMap["conversations_count"])
assert.Equal(t, int64(1), summaryMap["incoming_messages_count"])
assert.Equal(t, int64(1), summaryMap["outgoing_messages_count"])
assert.Equal(t, int64(1), summaryMap["resolutions_count"])
assert.Equal(t, 1800.0, summaryMap["avg_first_response_time"])
botSummary, err := svc.GetBotSummary(context.Background(), account.ID, since, until)
require.NoError(t, err)
assert.Equal(t, int64(1), botSummary.BotResolutionsCount)
assert.Equal(t, int64(0), botSummary.BotHandoffsCount)
assert.NotNil(t, botSummary.Previous)
botMetrics, err := svc.GetBotMetrics(context.Background(), account.ID, since, until)
require.NoError(t, err)
botMap := botMetrics.(map[string]interface{})
assert.Equal(t, int64(1), botMap["conversation_count"])
assert.Equal(t, int64(1), botMap["message_count"])
assert.Equal(t, 100, botMap["resolution_rate"])
distribution, err := svc.GetFirstResponseTimeDistribution(context.Background(), account.ID, since, until)
require.NoError(t, err)
distMap := distribution.(map[string]map[string]int64)
assert.Equal(t, int64(1), distMap["web_widget"]["0-1h"])
outgoing, err := svc.GetOutgoingMessagesCountGrouped(context.Background(), account.ID, since, until, "inbox")
require.NoError(t, err)
outRows := outgoing.([]map[string]interface{})
require.Len(t, outRows, 1)
assert.Equal(t, inbox.ID, outRows[0]["id"])
assert.Equal(t, int64(1), outRows[0]["outgoing_messages_count"])
matrix, err := svc.GetInboxLabelMatrix(context.Background(), account.ID)
require.NoError(t, err)
matrixMap := matrix.(map[string]interface{})
assert.Equal(t, [][]int64{{1}}, matrixMap["matrix"])
}
+452
View File
@@ -0,0 +1,452 @@
package service
import (
"context"
"errors"
"sort"
"strings"
"time"
"github.com/gochat/gochat/internal/model"
"gorm.io/gorm"
)
func (s *AnalyticsService) analyticsDB() (*gorm.DB, error) {
if s == nil || s.db == nil {
return nil, errors.New("analytics database is required")
}
return s.db, nil
}
func (s *AnalyticsService) liveConversationMetrics(ctx context.Context, accountID uint, teamID uint) (*ConversationMetrics, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
base := func() *gorm.DB {
q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID)
if teamID > 0 {
q = q.Where("team_id = ?", teamID)
}
return q
}
var result ConversationMetrics
if err := base().Where("status = ?", string(model.ConversationStatusOpen)).Count(&result.OpenCount).Error; err != nil {
return nil, err
}
if err := base().Where("status = ? AND (first_reply_created_at IS NULL OR waiting_since IS NOT NULL)", string(model.ConversationStatusOpen)).Count(&result.UnattendedCount).Error; err != nil {
return nil, err
}
if err := base().Where("status = ? AND assignee_id IS NULL", string(model.ConversationStatusOpen)).Count(&result.UnassignedCount).Error; err != nil {
return nil, err
}
if err := base().Where("status = ?", string(model.ConversationStatusPending)).Count(&result.PendingCount).Error; err != nil {
return nil, err
}
return &result, nil
}
func (s *AnalyticsService) groupedLiveConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]map[string]interface{}, error) {
if groupBy != "team_id" && groupBy != "assignee_id" {
return nil, errors.New("invalid group_by")
}
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
type row struct {
GroupID *uint
Count int64
}
load := func(where string) (map[uint]int64, error) {
var rows []row
if err := db.WithContext(ctx).Model(&model.Conversation{}).
Select(groupBy+" AS group_id, COUNT(*) AS count").
Where("account_id = ? AND status = ? "+where, accountID, string(model.ConversationStatusOpen)).
Group(groupBy).
Scan(&rows).Error; err != nil {
return nil, err
}
counts := map[uint]int64{}
for _, row := range rows {
if row.GroupID == nil {
counts[0] += row.Count
continue
}
counts[*row.GroupID] += row.Count
}
return counts, nil
}
open, err := load("")
if err != nil {
return nil, err
}
unattended, err := load("AND (first_reply_created_at IS NULL OR waiting_since IS NOT NULL)")
if err != nil {
return nil, err
}
unassigned, err := load("AND assignee_id IS NULL")
if err != nil {
return nil, err
}
ids := make([]uint, 0, len(open))
for id := range open {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
result := make([]map[string]interface{}, 0, len(ids))
for _, id := range ids {
metric := map[string]interface{}{
groupBy: id,
"open": open[id],
"unattended": unattended[id],
"unassigned": unassigned[id],
}
if id == 0 {
metric[groupBy] = nil
}
result = append(result, metric)
}
return result, nil
}
func (s *AnalyticsService) botSummaryCounts(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
var handoffIDs []uint
if err := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until).
Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil {
return nil, err
}
result := &BotSummaryResponse{}
handoffQ := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until)
if err := handoffQ.Distinct("conversation_id").Count(&result.BotHandoffsCount).Error; err != nil {
return nil, err
}
resolvedQ := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, since, until)
if len(handoffIDs) > 0 {
resolvedQ = resolvedQ.Where("conversation_id IS NULL OR conversation_id NOT IN ?", handoffIDs)
}
if err := resolvedQ.Distinct("conversation_id").Count(&result.BotResolutionsCount).Error; err != nil {
return nil, err
}
return result, nil
}
func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accountID uint, reportType string) (interface{}, error) {
switch reportType {
case "account":
return s.liveConversationMetrics(ctx, accountID, 0)
case "agent":
return s.groupedLiveConversationMetrics(ctx, accountID, "assignee_id")
case "team":
return s.groupedLiveConversationMetrics(ctx, accountID, "team_id")
default:
return s.liveConversationMetrics(ctx, accountID, 0)
}
}
func (s *AnalyticsService) conversationSummary(ctx context.Context, accountID uint, since, until time.Time) (map[string]interface{}, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
countConversations := func(where string, args ...interface{}) (int64, error) {
var count int64
q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID).Where(where, args...)
err := q.Count(&count).Error
return count, err
}
countMessages := func(messageType model.MessageType) (int64, error) {
var count int64
err := db.WithContext(ctx).Model(&model.Message{}).
Where("account_id = ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, string(messageType), since, until).
Count(&count).Error
return count, err
}
conversations, err := countConversations("created_at >= ? AND created_at < ?", since, until)
if err != nil {
return nil, err
}
incoming, err := countMessages(model.MessageTypeIncoming)
if err != nil {
return nil, err
}
outgoing, err := countMessages(model.MessageTypeOutgoing)
if err != nil {
return nil, err
}
resolutions, err := countConversations("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until)
if err != nil {
return nil, err
}
firstResponse, _ := s.averageEventValue(ctx, accountID, []string{model.MetricNameFirstResponse}, since, until)
resolutionTime, _ := s.averageEventValue(ctx, accountID, []string{"conversation_resolved", model.MetricNameResolutionTime}, since, until)
replyTime, _ := s.averageEventValue(ctx, accountID, []string{model.MetricNameReplyTime}, since, until)
return map[string]interface{}{
"conversations_count": conversations,
"incoming_messages_count": incoming,
"outgoing_messages_count": outgoing,
"avg_first_response_time": firstResponse,
"avg_resolution_time": resolutionTime,
"resolutions_count": resolutions,
"reply_time": replyTime,
}, nil
}
func (s *AnalyticsService) botMetrics(ctx context.Context, accountID uint, since, until time.Time) (map[string]interface{}, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
var inboxIDs []uint
if db.Migrator().HasTable(&model.AgentBotInbox{}) {
if err := db.WithContext(ctx).Model(&model.AgentBotInbox{}).
Where("status = ?", model.AgentBotInboxActive).
Where("account_id = ? OR account_id IS NULL", accountID).
Distinct("inbox_id").Pluck("inbox_id", &inboxIDs).Error; err != nil {
return nil, err
}
}
conversationCount := int64(0)
messageCount := int64(0)
if len(inboxIDs) > 0 {
if err := db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND inbox_id IN ? AND created_at >= ? AND created_at < ?", accountID, inboxIDs, since, until).
Count(&conversationCount).Error; err != nil {
return nil, err
}
if err := db.WithContext(ctx).Model(&model.Message{}).
Where("account_id = ? AND inbox_id IN ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, inboxIDs, string(model.MessageTypeOutgoing), since, until).
Count(&messageCount).Error; err != nil {
return nil, err
}
}
summary, err := s.botSummaryCounts(ctx, accountID, since, until)
if err != nil {
return nil, err
}
resolutionRate := 0
handoffRate := 0
if conversationCount > 0 {
resolutionRate = int(float64(summary.BotResolutionsCount) / float64(conversationCount) * 100)
handoffRate = int(float64(summary.BotHandoffsCount) / float64(conversationCount) * 100)
}
return map[string]interface{}{
"conversation_count": conversationCount,
"message_count": messageCount,
"resolution_rate": resolutionRate,
"handoff_rate": handoffRate,
}, nil
}
func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint) (map[string]interface{}, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
var inboxes []model.Inbox
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&inboxes).Error; err != nil {
return nil, err
}
var tags []model.Tag
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&tags).Error; err != nil {
return nil, err
}
type countRow struct {
InboxID uint
TagID uint
Count int64
}
var rows []countRow
if len(inboxes) > 0 && len(tags) > 0 {
if err := db.WithContext(ctx).Table("conversation_labels").
Select("conversations.inbox_id AS inbox_id, conversation_labels.tag_id AS tag_id, COUNT(*) AS count").
Joins("INNER JOIN conversations ON conversations.id = conversation_labels.conversation_id").
Where("conversation_labels.account_id = ?", accountID).
Group("conversations.inbox_id, conversation_labels.tag_id").
Scan(&rows).Error; err != nil {
return nil, err
}
}
counts := map[uint]map[uint]int64{}
for _, row := range rows {
if counts[row.InboxID] == nil {
counts[row.InboxID] = map[uint]int64{}
}
counts[row.InboxID][row.TagID] = row.Count
}
inboxPayload := make([]map[string]interface{}, 0, len(inboxes))
for _, inbox := range inboxes {
inboxPayload = append(inboxPayload, map[string]interface{}{"id": inbox.ID, "name": inbox.Name})
}
labelPayload := make([]map[string]interface{}, 0, len(tags))
for _, tag := range tags {
labelPayload = append(labelPayload, map[string]interface{}{"id": tag.ID, "title": tag.Name})
}
matrix := make([][]int64, 0, len(inboxes))
for _, inbox := range inboxes {
row := make([]int64, 0, len(tags))
for _, tag := range tags {
row = append(row, counts[inbox.ID][tag.ID])
}
matrix = append(matrix, row)
}
return map[string]interface{}{"inboxes": inboxPayload, "labels": labelPayload, "matrix": matrix}, nil
}
func (s *AnalyticsService) firstResponseTimeDistribution(ctx context.Context, accountID uint, since, until time.Time) (map[string]map[string]int64, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
var events []model.ReportingEvent
if err := db.WithContext(ctx).Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ?", accountID, model.MetricNameFirstResponse, since, until).Find(&events).Error; err != nil {
return nil, err
}
inboxIDs := make([]uint, 0, len(events))
for _, event := range events {
if event.InboxID != nil {
inboxIDs = append(inboxIDs, *event.InboxID)
}
}
var inboxes []model.Inbox
if len(inboxIDs) > 0 {
if err := db.WithContext(ctx).Where("account_id = ? AND id IN ?", accountID, inboxIDs).Find(&inboxes).Error; err != nil {
return nil, err
}
}
channelTypes := map[uint]string{}
for _, inbox := range inboxes {
channelTypes[inbox.ID] = inbox.ChannelType
}
result := map[string]map[string]int64{}
for _, event := range events {
if event.InboxID == nil {
continue
}
channelType := channelTypes[*event.InboxID]
if channelType == "" {
continue
}
if result[channelType] == nil {
result[channelType] = map[string]int64{"0-1h": 0, "1-4h": 0, "4-8h": 0, "8-24h": 0, "24h+": 0}
}
switch {
case event.Value < 3600:
result[channelType]["0-1h"]++
case event.Value < 14400:
result[channelType]["1-4h"]++
case event.Value < 28800:
result[channelType]["4-8h"]++
case event.Value < 86400:
result[channelType]["8-24h"]++
default:
result[channelType]["24h+"]++
}
}
return result, nil
}
func (s *AnalyticsService) outgoingMessagesCount(ctx context.Context, accountID uint, since, until time.Time, groupBy string) ([]map[string]interface{}, error) {
if groupBy == "" {
groupBy = "agent"
}
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
switch groupBy {
case "agent":
return s.outgoingMessagesByAgent(ctx, db, accountID, since, until)
case "team":
return s.outgoingMessagesByConversationField(ctx, db, accountID, since, until, "team_id", "teams")
case "inbox":
return s.outgoingMessagesByInbox(ctx, db, accountID, since, until)
case "label":
return s.outgoingMessagesByLabel(ctx, db, accountID, since, until)
default:
return nil, errors.New("invalid group_by")
}
}
func (s *AnalyticsService) averageEventValue(ctx context.Context, accountID uint, names []string, since, until time.Time) (float64, error) {
db, err := s.analyticsDB()
if err != nil {
return 0, err
}
var row struct {
Value float64
Count int64
}
err = db.WithContext(ctx).Model(&model.ReportingEvent{}).
Select("COALESCE(SUM(value), 0) AS value, COUNT(*) AS count").
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until).
Scan(&row).Error
if err != nil || row.Count == 0 {
return 0, err
}
return row.Value / float64(row.Count), nil
}
type outgoingCountRow struct {
ID uint
Name string
Count int64
}
func (s *AnalyticsService) outgoingMessagesByAgent(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) {
var rows []outgoingCountRow
err := db.WithContext(ctx).Table("messages").
Select("messages.sender_id AS id, users.name AS name, COUNT(*) AS count").
Joins("LEFT JOIN users ON users.id = messages.sender_id").
Where("messages.account_id = ? AND messages.message_type = ? AND messages.sender_type = ? AND messages.sender_id IS NOT NULL AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), "User", since, until).
Group("messages.sender_id, users.name").Scan(&rows).Error
return outgoingRows(rows, "agent"), err
}
func (s *AnalyticsService) outgoingMessagesByConversationField(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time, field, table string) ([]map[string]interface{}, error) {
var rows []outgoingCountRow
err := db.WithContext(ctx).Table("messages").
Select("conversations."+field+" AS id, "+table+".name AS name, COUNT(*) AS count").
Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id").
Joins("LEFT JOIN "+table+" ON "+table+".id = conversations."+field).
Where("messages.account_id = ? AND messages.message_type = ? AND conversations."+field+" IS NOT NULL AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until).
Group("conversations." + field + ", " + table + ".name").Scan(&rows).Error
return outgoingRows(rows, strings.TrimSuffix(field, "_id")), err
}
func (s *AnalyticsService) outgoingMessagesByInbox(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) {
var rows []outgoingCountRow
err := db.WithContext(ctx).Table("messages").
Select("messages.inbox_id AS id, inboxes.name AS name, COUNT(*) AS count").
Joins("LEFT JOIN inboxes ON inboxes.id = messages.inbox_id").
Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until).
Group("messages.inbox_id, inboxes.name").Scan(&rows).Error
return outgoingRows(rows, "inbox"), err
}
func (s *AnalyticsService) outgoingMessagesByLabel(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) {
var rows []outgoingCountRow
err := db.WithContext(ctx).Table("messages").
Select("tags.id AS id, tags.name AS name, COUNT(*) AS count").
Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id").
Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = conversations.id").
Joins("INNER JOIN tags ON tags.id = conversation_labels.tag_id").
Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until).
Group("tags.id, tags.name").Scan(&rows).Error
return outgoingRows(rows, "label"), err
}
func outgoingRows(rows []outgoingCountRow, _ string) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
result = append(result, map[string]interface{}{"id": row.ID, "name": row.Name, "outgoing_messages_count": row.Count})
}
return result
}
+56 -46
View File
@@ -4,11 +4,10 @@ import (
"context"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
"gorm.io/gorm"
)
// AnalyticsService implements business logic for reporting/analytics.
@@ -16,15 +15,24 @@ import (
type AnalyticsService struct {
eventRepo *repository.ReportingEventRepo
rollupRepo *repository.ReportingEventsRollupRepo
db *gorm.DB
}
func NewAnalyticsService(
eventRepo *repository.ReportingEventRepo,
rollupRepo *repository.ReportingEventsRollupRepo,
) *AnalyticsService {
var db *gorm.DB
if rollupRepo != nil {
db = rollupRepo.DB()
}
if db == nil && eventRepo != nil {
db = eventRepo.DB()
}
return &AnalyticsService{
eventRepo: eventRepo,
rollupRepo: rollupRepo,
db: db,
}
}
@@ -32,9 +40,9 @@ func NewAnalyticsService(
// MetricSummary holds aggregated metric data.
type MetricSummary struct {
Metric string `json:"metric"`
Count int64 `json:"count"`
AverageValue float64 `json:"average_value"`
Metric string `json:"metric"`
Count int64 `json:"count"`
AverageValue float64 `json:"average_value"`
AverageBusinessHours float64 `json:"average_business_hours"`
}
@@ -52,8 +60,8 @@ type DimensionMetrics struct {
// ConversationTrafficPoint holds a single time-series point.
type ConversationTrafficPoint struct {
Date time.Time `json:"date"`
Count int64 `json:"count"`
Date time.Time `json:"date"`
Count int64 `json:"count"`
}
// ConversationMetrics holds real-time conversation counts.
@@ -92,9 +100,9 @@ func (s *AnalyticsService) GetSummary(ctx context.Context, accountID uint, since
m.AverageBusinessHours += r.SumValueBusinessHours
} else {
metricMap[key] = &MetricSummary{
Metric: key,
Count: r.Count,
AverageValue: r.SumValue,
Metric: key,
Count: r.Count,
AverageValue: r.SumValue,
AverageBusinessHours: r.SumValueBusinessHours,
}
}
@@ -146,9 +154,9 @@ func (s *AnalyticsService) getDimensionMetrics(ctx context.Context, accountID ui
for _, r := range rollups {
if dm, ok := grouped[r.DimensionID]; ok {
ms := MetricSummary{
Metric: string(r.Metric),
Count: r.Count,
AverageValue: r.SumValue,
Metric: string(r.Metric),
Count: r.Count,
AverageValue: r.SumValue,
AverageBusinessHours: r.SumValueBusinessHours,
}
if r.Count > 0 {
@@ -158,9 +166,9 @@ func (s *AnalyticsService) getDimensionMetrics(ctx context.Context, accountID ui
dm.Metrics = append(dm.Metrics, ms)
} else {
ms := MetricSummary{
Metric: string(r.Metric),
Count: r.Count,
AverageValue: r.SumValue,
Metric: string(r.Metric),
Count: r.Count,
AverageValue: r.SumValue,
AverageBusinessHours: r.SumValueBusinessHours,
}
if r.Count > 0 {
@@ -204,22 +212,17 @@ func (s *AnalyticsService) GetConversationTraffic(ctx context.Context, accountID
// GetConversationMetrics returns real-time conversation counts.
// Reference: Chatwoot live_reports#conversation_metrics — open, unattended, unassigned, pending
func (s *AnalyticsService) GetConversationMetrics(ctx context.Context, accountID uint) (*ConversationMetrics, error) {
// TODO: implement real-time open/unattended/unassigned/pending counts from conversations table
// Currently returns placeholder data until conversation queries are implemented
return &ConversationMetrics{
OpenCount: 0,
UnattendedCount: 0,
UnassignedCount: 0,
PendingCount: 0,
}, nil
return s.GetConversationMetricsForTeam(ctx, accountID, 0)
}
func (s *AnalyticsService) GetConversationMetricsForTeam(ctx context.Context, accountID uint, teamID uint) (*ConversationMetrics, error) {
return s.liveConversationMetrics(ctx, accountID, teamID)
}
// GetGroupedConversationMetrics returns conversation metrics grouped by team_id or assignee_id.
// Reference: Chatwoot live_reports#grouped_conversation_metrics
func (s *AnalyticsService) GetGroupedConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]GroupedConversationMetric, error) {
// TODO: implement grouped conversation metrics from conversations table
// Currently returns empty until group-by queries are implemented
return []GroupedConversationMetric{}, nil
func (s *AnalyticsService) GetGroupedConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]map[string]interface{}, error) {
return s.groupedLiveConversationMetrics(ctx, accountID, groupBy)
}
// RecordEvent records a new reporting event.
@@ -338,59 +341,66 @@ func (s *AnalyticsService) groupEventsByDimension(events []model.ReportingEvent,
}
return grouped
}
// GetBotSummary returns bot-level summary metrics.
// Reference: Chatwoot reports#bot_summary
func (s *AnalyticsService) GetBotSummary(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) {
// TODO: implement bot summary using V2::Reports::BotSummaryBuilder logic
return &BotSummaryResponse{}, nil
current, err := s.botSummaryCounts(ctx, accountID, since, until)
if err != nil {
return nil, err
}
previousSince := since.Add(-(until.Sub(since)))
previous, err := s.botSummaryCounts(ctx, accountID, previousSince, since)
if err != nil {
return nil, err
}
current.Previous = previous
return current, nil
}
// BotSummaryResponse holds bot summary metrics.
type BotSummaryResponse struct {
BotResolutions int64 `json:"bot_resolutions"`
BotHandoffs int64 `json:"bot_handoffs"`
SelfService int64 `json:"self_service"`
HumanService int64 `json:"human_service"`
BotResolutionsCount int64 `json:"bot_resolutions_count"`
BotHandoffsCount int64 `json:"bot_handoffs_count"`
Previous *BotSummaryResponse `json:"previous,omitempty"`
}
// GetConversationsByType returns conversation metrics filtered by report type.
// Reference: Chatwoot reports#conversations — type param is required
func (s *AnalyticsService) GetConversationsByType(ctx context.Context, accountID uint, reportType string, since, until time.Time) (interface{}, error) {
// TODO: implement conversation metrics by type using V2::Reports::Conversations::ConversationMetricsService
return gin.H{}, nil
return s.conversationMetricsByType(ctx, accountID, reportType)
}
// GetConversationsSummary returns conversations summary report.
// Reference: Chatwoot reports#conversations_summary
func (s *AnalyticsService) GetConversationsSummary(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
// TODO: implement conversations summary using V2::Reports::Conversations::SummaryBuilder
return gin.H{}, nil
return s.conversationSummary(ctx, accountID, since, until)
}
// GetBotMetrics returns bot metrics.
// Reference: Chatwoot reports#bot_metrics — V2::Reports::BotMetricsBuilder
func (s *AnalyticsService) GetBotMetrics(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
// TODO: implement bot metrics
return gin.H{}, nil
return s.botMetrics(ctx, accountID, since, until)
}
// GetInboxLabelMatrix returns inbox-label matrix data.
// Reference: Chatwoot reports#inbox_label_matrix — V2::Reports::InboxLabelMatrixBuilder
func (s *AnalyticsService) GetInboxLabelMatrix(ctx context.Context, accountID uint) (interface{}, error) {
// TODO: implement inbox label matrix
return gin.H{}, nil
return s.inboxLabelMatrix(ctx, accountID)
}
// GetFirstResponseTimeDistribution returns first response time distribution.
// Reference: Chatwoot reports#first_response_time_distribution
func (s *AnalyticsService) GetFirstResponseTimeDistribution(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
// TODO: implement FRT distribution
return gin.H{}, nil
return s.firstResponseTimeDistribution(ctx, accountID, since, until)
}
// GetOutgoingMessagesCount returns outgoing message count metrics.
// Reference: Chatwoot reports#outgoing_messages_count
func (s *AnalyticsService) GetOutgoingMessagesCount(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
// TODO: implement outgoing messages count
return gin.H{}, nil
return s.outgoingMessagesCount(ctx, accountID, since, until, "")
}
func (s *AnalyticsService) GetOutgoingMessagesCountGrouped(ctx context.Context, accountID uint, since, until time.Time, groupBy string) (interface{}, error) {
return s.outgoingMessagesCount(ctx, accountID, since, until, groupBy)
}