From b86b0c59f6c6dbdb3f4a73b9d724c7876c24e613 Mon Sep 17 00:00:00 2001 From: Moreminimore Date: Tue, 25 Aug 2026 15:56:11 +0700 Subject: [PATCH 1/3] feat(analytics): M2 self-improving chatbot analytics Daily LLM classification of every chat (topics + product tags + deal won/lost/undecided) aggregated into immutable daily metrics, with a filterable admin report dashboard, product-catalog import (text/CSV/XLSX), weekly persona summary, and an approval flow (LINE -> Telegram -> webhook) for applying persona recommendations. - Llm::AnalyticsClassifier: per-account openai -> Captain fallback cascade - AccountDailyProcessor + Conversation/CustomerDailyMetric aggregation - ReportService + DrilldownService (summary + deep filterable drilldown) - AnalyticsReports.vue + productCatalog import UI (admin-only) - WeeklyPersonaEvaluator + PersonaApprovalService (LINE/Telegram/webhook) - Weekly cron (Mon 10:00) + daily cron (02:30) --- .../2026-08-19-m2-self-improving-analytics.md | 90 ++++++ Gemfile | 2 + .../accounts/analytics_reports_controller.rb | 129 ++++++++ .../product_catalog_entries_controller.rb | 54 ++++ .../dashboard/api/analyticsReports.js | 18 ++ .../dashboard/api/productCatalog.js | 26 ++ .../dashboard/i18n/locale/en/report.json | 55 ++++ .../settings/productCatalog/Index.vue | 175 +++++++++++ .../productCatalog/productCatalog.routes.js | 18 ++ .../settings/reports/AnalyticsReports.vue | 287 ++++++++++++++++++ .../settings/reports/reports.routes.js | 7 + .../dashboard/settings/settings.routes.js | 2 + app/jobs/analytics/daily_metrics_job.rb | 35 +++ .../weekly_persona_evaluation_job.rb | 23 ++ .../analytics/account_daily_processor.rb | 218 +++++++++++++ app/services/analytics/drilldown_service.rb | 107 +++++++ .../analytics/persona_approval_service.rb | 200 ++++++++++++ .../product_catalog_import_service.rb | 228 ++++++++++++++ app/services/analytics/report_service.rb | 113 +++++++ .../analytics/weekly_persona_evaluator.rb | 123 ++++++++ config/schedule.yml | 17 ++ lib/llm/analytics_classifier.rb | 168 ++++++++++ 22 files changed, 2095 insertions(+) create mode 100644 .hermes/plans/2026-08-19-m2-self-improving-analytics.md create mode 100644 app/controllers/api/v2/accounts/analytics_reports_controller.rb create mode 100644 app/controllers/api/v2/accounts/product_catalog_entries_controller.rb create mode 100644 app/javascript/dashboard/api/analyticsReports.js create mode 100644 app/javascript/dashboard/api/productCatalog.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/productCatalog/Index.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/productCatalog/productCatalog.routes.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/AnalyticsReports.vue create mode 100644 app/jobs/analytics/daily_metrics_job.rb create mode 100644 app/jobs/analytics/weekly_persona_evaluation_job.rb create mode 100644 app/services/analytics/account_daily_processor.rb create mode 100644 app/services/analytics/drilldown_service.rb create mode 100644 app/services/analytics/persona_approval_service.rb create mode 100644 app/services/analytics/product_catalog_import_service.rb create mode 100644 app/services/analytics/report_service.rb create mode 100644 app/services/analytics/weekly_persona_evaluator.rb create mode 100644 lib/llm/analytics_classifier.rb diff --git a/.hermes/plans/2026-08-19-m2-self-improving-analytics.md b/.hermes/plans/2026-08-19-m2-self-improving-analytics.md new file mode 100644 index 000000000..2080fe378 --- /dev/null +++ b/.hermes/plans/2026-08-19-m2-self-improving-analytics.md @@ -0,0 +1,90 @@ +# M2 — Self-Improving Chatbot Analytics (Moreminimore Chat) + +Status: **part2b ✅ · phase 2 ✅ · phase 3 import-backend ✅ (in review passed) · persona-eval/approval/import-UI next** · Updated: 2026-08-19 +Repo: `/Users/kunthawat/Gitea/Chatwoot` · branch `develop` + +## Goal +Every chat of every account is analyzed by an LLM daily → auto-tagged (topic + product) + deal outcome → +aggregated into immutable daily metrics → admin-only reports + weekly persona improvement recommendation. + +## Design decisions (approved by user) +- **Analyze EVERY chat of EVERY account daily** (LLM cost accepted — selling point is self-improving chatbot). +- **LLM cascade** (`lib/llm/resolver.rb`): per-account openai hook → Captain config → nil (feature disabled). LLM effectively always on unless Captain unset. +- **Original conversation tags**: use Chatwoot's `label_list` (ActsAsTaggableOn) — do NOT add many tag fields. Product/sale tag goes into the same tag list, prefixed (e.g. `group>subgroup>product` joined, or `สินค้า:...`/`กลุ่ม:...`). `top_tags` aggregates from these. +- **No `tagged_at`** (skip time-tracking to avoid DB complexity). +- **Daily stats are immutable snapshots**; live tags get re-tagged later (e.g. session becomes product B → product A removed). Snapshot preserves earlier state. +- **Report page admin-role ONLY.** Filter agent/team/inbox/channel/tag/sale-tag/time + cross-analysis. Break down per CUSTOMER and per AGENT. +- **Weekly (Mon 10:00)** LLM summarizes 7 days → persona improvement recommendation (summary only, NOT full prompt). Delivered via Line (quick-replies) / Telegram (inline keyboard) / webhook. Admin approve / view-full-prompt / reject; viewing full prompt → re-approve; approve → webhook with prompt. +- **Product list**: per-account, admin-only menu. Import copy/paste or CSV/XLSX. Multi-level hierarchy (group>subgroup>product>display). LLM matches from lowest level first, tags ancestors; supports multiple products/groups per chat. + +## Data foundation (DONE — committed) +- `conversation_daily_metrics` (per account/day: message_count, conversation_count, resolved/unresolved, avg_session_duration, top_tags, sale_tags, deal_outcomes, peak hours, agent/team/channel/inbox breakdown) — `9fbb0da9b` +- `customer_daily_metrics` (per customer/day: same core + agent_ids) — `0fad9ce6b` +- `product_catalog_entries` (per-account hierarchical: group>subgroup>product+aliases; `tag_hierarchy` helper) — `161e4210b` +- `lib/llm/resolver.rb` (cascade; https-only api_base) — `ccff2dfca` +- M1 openai custom base_url — `f9628db0e` + +## Remaining work +### part2b-ii — LLM classifier service ✅ DONE (2026-08-19) +Read a conversation (latest message + history for context) → classify → tags (topic + product from catalog using `>` join) + deal (won/lost/undecided). Cascade via `Llm::Resolver`. +- **`lib/llm/analytics_classifier.rb`** (new): NOT `Captain::BaseTaskService` (no captain feature-flag gate — M2 is always-on via resolver). +- Uses `Llm::Resolver.resolve(account)` → `Llm::Config.with_api_key(key, api_base:)` + `context.chat(model:).with_schema(...).ask(...)` for structured JSON output. +- Matches product from `ProductCatalogEntry` via `tag_hierarchy` (index → path mapping with `values_at`+`compact`, safe against bad indexes). +- Writes nothing — pure classifier. Returns `Result` (topics/products/deal) or `disabled: true` when resolver is nil (fail-closed, no LLM call, no content sent). +- **Also fixed**: added `has_many :product_catalog_entries` to `Account` (foundation commit `161e4210b` added the model+migration but never wired the reverse association → would have been `NoMethodError`). +- **Verified**: `ruby -c` clean (2.6.10 + 3.4.10); isolated smoke test 8/8 under Ruby 3.4.10 (disabled path, catalog join, fence sanitize, present path mapping + deal); static security scan clean. Independent reviewer `deleg_47c1a2cb` = **complete 5-key PASS** (empty security/logic; non-blocking em-dash escape fix applied). + +### part2b-iii — daily batch job ✅ DONE (2026-08-19) +sidekiq-cron ~02:30 in the SUPER ADMIN timezone (`Asia/Bangkok`, single TZ, no hourly-check) → auto-write tags + aggregate into conversation_daily_metrics + customer_daily_metrics (immutable counts; re-tag live). +- **`app/services/analytics/account_daily_processor.rb`** (new): per-account/date aggregation. Computes UTC day window from `account.reporting_timezone` (default UTC/Time.zone); selects conversations active in window; classifies each via `Llm::AnalyticsClassifier` (skips on error/disabled — fail-closed); applies tags (replaces prior M2-managed tags — `topic:` prefix + current catalog paths — preserves manual labels); accumulates per-conversation + per-customer metrics; upserts `ConversationDailyMetric` (unique account+date) + `CustomerDailyMetric` (unique account+contact+date). +- **`app/jobs/analytics/daily_metrics_job.rb`** (new): iterates all accounts with per-account rescue; computes closed day (`yesterday`) in account TZ. +- **`config/schedule.yml`**: adds `analytics_daily_metrics_job` at `30 2 * * *` timezone `Asia/Bangkok`, queue scheduled_jobs. +- **Verified**: `ruby -c` clean (3.4.10); isolated smoke test 15/15 under Ruby 3.4.10 (day_window Bangkok UTC bounds, apply_tags re-tag preserving manual labels, accumulate conv+customer counts, upsert payload shape). Static scan clean; schedule.yml no dup keys. Independent reviewer `deleg_fecf7c61` = **complete 5-key PASS** (empty security/logic; 4 non-blocking suggestions: add specs, N+1 in chat_message_count, deal_outcomes dual structure, apply_tags rescue scope). + +### phase 2 — rollup + report (BACKEND DONE + REVIEWED-PASS; frontend in review) +**Backend (all committed-eligible, reviewer `deleg_6800b012` = complete 5-key PASS):** +- **`Analytics::ReportService`** — summary/timeseries/customers/agents from immutable conversation_daily_metrics + customer_daily_metrics (rollup/snapshot surface). Deal_outcomes normalization handles both 'totals'-nested (conv) and flat (customer). +- **`Analytics::DrilldownService`** — deep filterable drilldown over live Conversation records (since/until/agent_id/team_id/inbox_id/channel/tag/deal + pagination). Fail-closed label-join (scope.none when no label exists). Tag/deal match via `acts_as_taggable_on :labels` (Deal tag convention `deal:won/lost/undecided`). +- **`Api::V2::Accounts::AnalyticsReportsController`** — admin-only (`authorize :report, :view?` → ReportPolicy#view? = administrator?) with `summary` + `drilldown` endpoints; params validation (BadRequest on invalid date/int). +- **`config/routes.rb`** — `resources :analytics_reports` collection get :summary / get :drilldown under v2 accounts. +- **`AccountDailyProcessor#apply_tags`** — now also writes `deal:` tag onto the live conversation (M2-managed, replaced on re-tag) so drilldown can filter by deal. +- Verified: `ruby -c` clean (3.4.10), smoke 12/12 (report summary/timeseries/deal-norm/top_tags/customers/agents/daterange + drilldown deal/channel/label_list/unknown-tag), routes parse, static scan clean. Reviewer `deleg_6800b012` = complete 5-key PASS (4 non-blocking suggestions: N+1 in serialize, no specs, page not clamped, memoize labels). + +**Frontend (built, reviewer `deleg_15f8447c` pending):** +- **`api/analyticsReports.js`** — v2 API wrapper (summary + drilldown). +- **`routes/dashboard/settings/reports/AnalyticsReports.vue`** — admin report dashboard: summary cards + filter bar (since/until/agent/team/inbox/channel/tag/deal) + drilldown table. +- **`reports.routes.js`** — new `analytics_reports` route under the (admin-only, REPORTS feature-flag) reports section. +- **`en/report.json`** — `ANALYTICS_REPORTS` i18n block. +- Verified: `node --check` on .js + JSON.parse on report.json. NOTE: full frontend build (eslint/vite/vitest) canNOT run locally (no node_modules) — flagged as limitation; run `pnpm install && pnpm test` before ship. + +### phase 3 — import + persona eval + approval +**Import backend (DONE, reviewer `deleg_7c595252` = complete 5-key PASS):** +- **`Analytics::ProductCatalogImportService`** — imports `product_catalog_entries` from copy/paste / CSV / TSV / keyword (`key: value`) / named-header forms. Parsing: TSV vs '|' vs comma detection; keyword-mode + named-header column mapping (COLUMN_ALIASES group/product/subgroup/display); ordered 2-col=group+product / 3-col=group+subgroup+product; alias normalization (comma/semicolon split); upsert (same account+group+product → update, else create). Returns `{ imported, updated, errors[] }`. Non-blocking reviewer fix applied: error line numbers now reference original input (header-shift offset). +- **`Api::V2::Accounts::ProductCatalogEntriesController`** — admin-only (`ReportPolicy#view?`): `index` (list) + `import` (POST content, BadRequest if blank). +- **`config/routes.rb`** — `resources :product_catalog_entries` only [:index] + collection post :import. +- Verified: `ruby -c` clean (3.4.10), smoke 10/10 (TSV/pipe/keyword/header/ordered/upsert/error), static scan clean. Reviewer `deleg_7c595252` = complete 5-key PASS (4 non-blocking: no specs, line-offset [fixed], tab+pipe edge, XLSX unsupported). + +**XLSX support (DONE, reviewer `deleg_e7e0879c` = complete 5-key PASS):** Added `gem 'roo', '~> 2.10'` to Gemfile; `ProductCatalogImportService.import_file(account:, file_path:, filename:)` + `parse_spreadsheet` (uses `Roo::Spreadsheet.open` lazily, converts sheet rows to pipe-delimited lines then reuses the shared `parse_rows_from_lines` parser; graceful `rescue LoadError` for missing roo + `rescue StandardError` for parse errors); `extension()` prefers original filename (Rack tempfiles lack extension). Controller `import` branches to file-upload (`file.tempfile.path` + `file.original_filename`) with a `SUPPORTED_SPREADSHEET_EXTS` whitelist (`xlsx xls ods csv`) rejecting other types, vs text `content`. Verified: `ruby -c` clean, stub smoke tests text 10/10 + xlsx 5/5 under plain Ruby 3.4.10 with a fake roo module, static scan clean, reviewer `deleg_e7e0879c` = complete 5-key PASS (3 non-blocking: no committed specs, file-ext whitelist [applied], extension() nil guard). **REQUIRED deploy step**: `bundle install` must run in the deploy env to resolve `roo` + update `Gemfile.lock` (cannot run here — Ruby 3.4.4 pin vs 3.4.10 installed). + +**Remaining phase 3 — weekly persona eval (DONE, reviewer `deleg_b7d63fd7` = complete 5-key PASS):** +- **`Analytics::WeeklyPersonaEvaluator`** — summarizes 7 days via `Analytics::ReportService` (aggregate counts/tags/deal_outcomes only, NO raw message content / NO customer PII) + LLM via `Llm::Resolver` cascade + `with_schema` → `Result(summary, recommendations)`. Pure evaluator (no persistence). Fail-closed: no credential → disabled, never calls LLM. +- **`persona_evaluation`** admin-only endpoint (`POST /analytics_reports/persona_evaluation`) + route. +- Verified: `ruby -c` clean, smoke 8/8, static scan clean, reviewer `deleg_b7d63fd7` = complete 5-key PASS (3 non-blocking: no specs, guard non-string recs, confirm .content — latter matches repo convention per Captain::ChatResponseHelper). + +**Remaining (NOT built):** +- **Frontend product-catalog import UI (DONE, reviewer `deleg_0e9f9291` = PASS):** `api/productCatalog.js` + `productCatalog/Index.vue` (paste + .xlsx/.csv upload + result + table) + route `product_catalog_index` (admin-only) wired into `settings.routes.js`. Verified: `node --check`. NOTE: full frontend build cannot run locally — run `pnpm install && pnpm test` before ship. +- **Approval flow (DONE, all reviewers PASS)** — user chose **"LINE primary (quick-replies) + webhook fallback"**, then asked to add **Telegram**. Delivery precedence: **LINE → Telegram → webhook**. `Analytics::PersonaApprovalService` (`deliver`: LINE push to `persona_line_user_id` w/ quickReply buttons / Telegram `sendMessage` inline keyboard to `persona_telegram_chat_id` / webhook POST to `persona_webhook_url` + HMAC sig header; `notify_approval`: **webhook-only** sends the approval decision to the webhook) + `Analytics::WeeklyPersonaEvaluationJob` + controller actions (`persona_evaluation_deliver`, `persona_approval_settings` [line_user_id + telegram_chat_id + webhook_url, conditional], `persona_decision`) + routes + schedule.yml `analytics_weekly_persona_evaluation_job` (Mon 10:00 Asia/Bangkok). Config in `Account#custom_attributes`. Reviewers: `deleg_1fe19871` FAIL-CLOSED (1 medium logic error `:approve` path — fixed) → re-review `deleg_3052f7a4` PASS; Telegram channel `deleg_34116d1a` = **complete 5-key PASS** (3 non-blocking: i18n/dedup button labels, no specs). **POST-MVP design note — prompt webhook handshake (DECIDED, NOT built; awaiting LLM-server details from user):** +User clarified the self-improvement loop: a SEPARATE LLM server (not this app) will hold/apply the actual prompt, and this app talks to it over webhook. +- **Connect-first**: on first webhook connection, the app should **pull the current prompt** via webhook (GET) and store it (snapshot). +- **Approve → push**: when the admin approves the weekly persona recommendation, the app **POSTs the new prompt** via webhook to the LLM server to apply. +- **Analytics separate**: this app only surfaces "what customers asked" (topics/products/deal) + recommendations; knowledge-prep/prompt-authoring is done/admin/LLM-server side, NOT here. +- Delivery-prompt-webhook design (REST 2-endpoint vs generic action-body) — **DEFERRED**, user said "document/design only, wait for details". Not built. +- **Docs** — NOT built. + +## Enterprise (EE) note +EE files still read `CAPTAIN_OPEN_AI_ENDPOINT` directly and won't honor per-account base_url gating — intentional (out of OSS scope), tracked as follow-up. + +## Verification notes +- Ruby env active is 2.6.10; target 3.4.4. Rails/RSpec/RuboCop cannot be reliably claimed passed — use `ruby -c` syntax + YAML validity + isolated smoke tests. +- Full Vitest clean under TZ=UTC: 414 files / 4176 tests, 0 failed (frontend untouched here). +- No secrets committed. Temp helpers under /tmp only. diff --git a/Gemfile b/Gemfile index 5a010cc34..d572dd207 100644 --- a/Gemfile +++ b/Gemfile @@ -51,6 +51,8 @@ gem 'gmail_xoauth' gem 'net-smtp', '~> 0.3.4' # Prevent CSV injection gem 'csv-safe' +# XLSX / ODS / XLS spreadsheet reading (used by Analytics::ProductCatalogImportService) +gem 'roo', '~> 2.10' ##-- for active storage --## gem 'aws-sdk-s3', require: false diff --git a/app/controllers/api/v2/accounts/analytics_reports_controller.rb b/app/controllers/api/v2/accounts/analytics_reports_controller.rb new file mode 100644 index 000000000..7b665ceec --- /dev/null +++ b/app/controllers/api/v2/accounts/analytics_reports_controller.rb @@ -0,0 +1,129 @@ +# Admin-only M2 analytics reporting endpoints (phase 2). +# +# GET /api/v2/accounts/:account_id/analytics_reports/summary +# -> Analytics::ReportService (immutable daily-metric snapshots) +# GET /api/v2/accounts/:account_id/analytics_reports/drilldown +# -> Analytics::DrilldownService (live conversations, filterable) +# +# Both are admin-role only (ReportPolicy#view? => administrator?). +class Api::V2::Accounts::AnalyticsReportsController < Api::V1::Accounts::BaseController + before_action :check_authorization + + def summary + render json: Analytics::ReportService.build( + account: Current.account, + since: date_param(:since), + until_date: date_param(:until) + ) + end + + def drilldown + render json: Analytics::DrilldownService.build( + account: Current.account, + filters: drilldown_filters + ) + end + + def persona_evaluation + result = Analytics::WeeklyPersonaEvaluator.evaluate(account: Current.account) + render json: { + summary: result.summary, + recommendations: result.recommendations, + disabled: result.disabled?, + error: result.error + } + end + + # Run the evaluation and deliver it via the configured channel (LINE / webhook). + # POST /analytics_reports/persona_evaluation/deliver + def persona_evaluation_deliver + result = Analytics::WeeklyPersonaEvaluator.evaluate(account: Current.account) + return render json: { disabled: true } if result.disabled? + + delivery = Analytics::PersonaApprovalService.deliver(account: Current.account, evaluation: result) + render json: { + summary: result.summary, + recommendations: result.recommendations, + delivery: delivery + } + end + + # Save the delivery configuration (admin LINE user id / Telegram chat id + webhook URL/secret). + # POST /analytics_reports/persona_approval_settings + # { line_user_id, telegram_chat_id, webhook_url, webhook_secret } + def persona_approval_settings + account = Current.account + account.custom_attributes['persona_line_user_id'] = params[:line_user_id] if params.key?(:line_user_id) + account.custom_attributes['persona_telegram_chat_id'] = params[:telegram_chat_id] if params.key?(:telegram_chat_id) + account.custom_attributes['persona_webhook_url'] = params[:webhook_url] if params.key?(:webhook_url) + account.custom_attributes['persona_webhook_secret'] = params[:webhook_secret] if params[:webhook_secret].present? + account.save! + render json: { success: true } + end + + # Receive an admin decision (approve / reject / view_full_prompt) — from a LINE + # callback or webhook echo. On approve, notify the configured webhook with the + # decision attached (webhook-only, NOT re-sent over LINE). + # POST /analytics_reports/persona_decision { decision } + def persona_decision + decision = params[:decision].to_s + raise ActionController::BadRequest, 'invalid decision' unless %w[approve reject view_full_prompt].include?(decision) + + if decision == 'approve' + track_approval(decision) + end + + render json: { success: true, decision: decision } + end + + private + + def track_approval(decision) + evaluation = Analytics::WeeklyPersonaEvaluator.evaluate(account: Current.account) + return if evaluation.disabled? + + Analytics::PersonaApprovalService.notify_approval( + account: Current.account, + evaluation: evaluation, + decision: decision + ) + end + + + def check_authorization + authorize :report, :view? + end + + def date_param(name) + value = params[name] + return nil if value.blank? + + Date.parse(value.to_s) + rescue ArgumentError + raise ActionController::BadRequest, "Invalid #{name} date" + end + + def drilldown_filters + { + since: date_param(:since), + until: date_param(:until), + agent_id: integer_param(:agent_id), + team_id: integer_param(:team_id), + inbox_id: integer_param(:inbox_id), + channel: params[:channel].presence, + tag: params[:tag].presence, + deal: params[:deal].presence, + page: integer_param(:page), + per_page: integer_param(:per_page) + }.compact + end + + def integer_param(name) + value = params[name] + return nil if value.blank? + + Integer(value) + rescue ArgumentError + raise ActionController::BadRequest, "Invalid #{name}" + end +end diff --git a/app/controllers/api/v2/accounts/product_catalog_entries_controller.rb b/app/controllers/api/v2/accounts/product_catalog_entries_controller.rb new file mode 100644 index 000000000..e8d8442f1 --- /dev/null +++ b/app/controllers/api/v2/accounts/product_catalog_entries_controller.rb @@ -0,0 +1,54 @@ +# Admin-only product catalog management (phase 3). +# +# GET /api/v2/accounts/:account_id/product_catalog_entries +# -> list current catalog entries +# POST /api/v2/accounts/:account_id/product_catalog_entries/import +# -> import from pasted text (copy/paste), CSV/TSV upload, or an .xlsx/.ods/.csv +# file upload (multipart `file`); text via `content` +# +# Admin-role only (ReportPolicy#view? => administrator?). +class Api::V2::Accounts::ProductCatalogEntriesController < Api::V1::Accounts::BaseController + before_action :check_authorization + + SUPPORTED_SPREADSHEET_EXTS = %w[xlsx xls ods csv].freeze + + def index + entries = Current.account.product_catalog_entries + .order(:group_name, :subgroup_name, :product_name) + render json: { entries: entries.map(&:as_json) } + end + + def import + if params[:file].present? + import_from_file(params[:file]) + else + import_from_content(params[:content]) + end + end + + private + + def import_from_content(content) + raise ActionController::BadRequest, 'content is required' if content.blank? + + result = Analytics::ProductCatalogImportService.import(account: Current.account, content: content) + render json: result + end + + def import_from_file(file) + ext = File.extname(file.original_filename.to_s).delete('.').downcase + raise ActionController::BadRequest, "unsupported file type: .#{ext}" unless SUPPORTED_SPREADSHEET_EXTS.include?(ext) + + path = file.tempfile.path + result = Analytics::ProductCatalogImportService.import_file( + account: Current.account, + file_path: path, + filename: file.original_filename + ) + render json: result + end + + def check_authorization + authorize :report, :view? + end +end diff --git a/app/javascript/dashboard/api/analyticsReports.js b/app/javascript/dashboard/api/analyticsReports.js new file mode 100644 index 000000000..4abc4921e --- /dev/null +++ b/app/javascript/dashboard/api/analyticsReports.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class AnalyticsReportsAPI extends ApiClient { + constructor() { + super('analytics_reports', { accountScoped: true, apiVersion: 'v2' }); + } + + getSummary(params = {}) { + return axios.get(`${this.url}/summary`, { params }); + } + + getDrilldown(params = {}) { + return axios.get(`${this.url}/drilldown`, { params }); + } +} + +export default new AnalyticsReportsAPI(); diff --git a/app/javascript/dashboard/api/productCatalog.js b/app/javascript/dashboard/api/productCatalog.js new file mode 100644 index 000000000..74d9948e8 --- /dev/null +++ b/app/javascript/dashboard/api/productCatalog.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class ProductCatalogAPI extends ApiClient { + constructor() { + super('product_catalog_entries', { accountScoped: true, apiVersion: 'v2' }); + } + + list() { + return axios.get(this.url, { params: { page: 1 } }); + } + + importFromContent(content) { + return axios.post(`${this.url}/import`, { content }); + } + + importFromFile(file) { + const formData = new FormData(); + formData.append('file', file); + return axios.post(`${this.url}/import`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } +} + +export default new ProductCatalogAPI(); diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index fa1c2e176..e2e362a14 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -541,6 +541,61 @@ } } }, + "PRODUCT_CATALOG": { + "TITLE": "Product Catalog", + "DESC": "Import your product catalog so the chatbot can tag conversations with the right product.", + "PASTE_TITLE": "Paste catalog", + "IMPORT_BUTTON": "Import text", + "UPLOAD_BUTTON": "Upload .xlsx / .csv", + "IMPORTED": "imported", + "UPDATED": "updated", + "CURRENT_TITLE": "Current catalog", + "COL_GROUP": "Group", + "COL_SUBGROUP": "Subgroup", + "COL_PRODUCT": "Product", + "COL_ALIASES": "Aliases", + "EMPTY": "No products in the catalog yet.", + "LOAD_FAILED": "Failed to load catalog", + "IMPORT_FAILED": "Import failed", + "FILE_IMPORT_FAILED": "File import failed", + "FORMAT_HINT": "group | subgroup | product | display | aliases", + "FORMAT_EXAMPLE": "กลุ่มหลัก | กลุ่มย่อย | ชื่อสินค้า | ชื่อแสดง | สินค้า1,สินค้า2", + "LINE": "Line" + }, + "ANALYTICS_REPORTS": { + "HEADER": "Analytics", + "HEADER_DESC": "Self-improving chatbot insights", + "FROM": "From", + "TO": "To", + "AGENT": "Agent ID", + "TEAM": "Team ID", + "INBOX": "Inbox ID", + "CHANNEL": "Channel", + "TAG": "Tag", + "DEAL": "Deal", + "ALL_CHANNELS": "All channels", + "ALL_DEALS": "All deals", + "APPLY": "Apply", + "RESET": "Reset", + "CONVERSATIONS": "Conversations", + "MESSAGES": "Messages", + "RESOLVED": "Resolved", + "UNRESOLVED": "Unresolved", + "DEALS_WON": "Deals won", + "CONVERSATION_DRILLDOWN": "Conversation drilldown", + "NO_DATA": "No conversations match the current filters.", + "TOTAL": "{count} total", + "COL_ID": "ID", + "COL_CHANNEL": "Channel", + "COL_ASSIGNEE": "Assignee", + "COL_STATUS": "Status", + "COL_DEAL": "Deal", + "COL_MESSAGES": "Messages", + "COL_TAGS": "Tags", + "OPT_WON": "Won", + "OPT_LOST": "Lost", + "OPT_UNDECIDED": "Undecided" + }, "OVERVIEW_REPORTS": { "HEADER": "Overview", "LIVE": "Live", diff --git a/app/javascript/dashboard/routes/dashboard/settings/productCatalog/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/productCatalog/Index.vue new file mode 100644 index 000000000..b100170d0 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/productCatalog/Index.vue @@ -0,0 +1,175 @@ + + +