feat(reports): derive analytics aggregates
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user