Phase 4: Sheet-initiated pull integration (Apps Script) + template import
Rearchitects the Sheet integration per updated CLAUDE.md §15: a stateless Flask backend has no way to reach into whichever Sheet a PM has open, so the Sheet pulls its latest results on demand rather than Flask ever pushing to it. This is also the simpler path for non-technical users — no Sheet ID to find/paste, no service-account sharing, just one button in the sidebar. Apps Script (appscript/, new): - appsscript.json — Editor Add-on manifest, spreadsheets.currentonly scope (touches only whichever Sheet is open, nothing else in Drive) - Code.gs — onOpen/onInstall/onHomepage, opens the sidebar - Validation.gs — validateLicence() (6hr cache, POST /validate-email), plus markOnboardingStepOnce_() shared helper - Sync.gs — pullLatestResults()/getWorkbookTabs()/detectColumns(), with a real header-alias map for every STANDARD_FIELDS column (majorityPrice matches "Rack Rate"/"Standard Price"/etc, not just one exact label) so silent write failures don't happen from a PM naming a column slightly differently than expected - Sidebar.html — tab selector + Pull latest results + last-pull status Backend: - GET /research/history/latest (email-resolved) replaces the removed POST /sheet/push and GET /sheet/tabs - GET /account/template now reads TEMPLATE_SHEET_URL instead of a hardcoded placeholder - PATCH /account/onboarding now accepts either a JWT session (dashboard) or an email in the body (Apps Script has no JWT, only the PM's Google email) — this was a real gap: Sync.gs's onboarding-flag calls would have 401'd against the JWT-only version - onboarding_service.DEFAULT_CHECKLIST corrected to the current schema (template_imported/sidebar_installed/sync_from_sheet, not the stale push_to_sheet/download_template) - Fixed the same stale field names in CLAUDE.md §32's onboarding runbook example payload, so it doesn't teach a future tenant-creation script the wrong schema Frontend: - PushToSheet.vue removed, replaced by SyncToSheet.vue — a clipboard copy button + instructions to use the sidebar, no tab selector (tab selection lives in the sidebar, where the workbook is actually open) - AccountPage.vue's template section rebuilt as three items: open template, two-step import instructions + sidebar install link, and a deliberately no-op verification input (never validated/fetched/ stored — the confirmation is purely the PM telling themselves it's done, per explicit instruction not to create a dependency on their workbook) - useOnboardingTour.js step 5 updated to match 220 backend tests passing. Frontend production build verified clean. Apps Script (.gs/.html) cannot be unit-tested in this environment — no Google Apps Script runtime available locally; verified only by careful cross-file consistency review (function names/signatures matching across Code.gs/Validation.gs/Sync.gs/Sidebar.html). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c0a77a1a65
commit
d4f2c7d96b
|
|
@ -58,6 +58,10 @@ SHEET_ID=
|
||||||
STANDARD_TAB=
|
STANDARD_TAB=
|
||||||
NGS_TAB=
|
NGS_TAB=
|
||||||
|
|
||||||
|
# Bettersight template Sheet — hosted share URL, served via GET
|
||||||
|
# /account/template (CLAUDE.md §15 Template Import)
|
||||||
|
TEMPLATE_SHEET_URL=
|
||||||
|
|
||||||
# Frontend (Vite)
|
# Frontend (Vite)
|
||||||
VITE_SENTRY_DSN=
|
VITE_SENTRY_DSN=
|
||||||
VITE_ENV=development
|
VITE_ENV=development
|
||||||
|
|
|
||||||
292
CLAUDE.md
292
CLAUDE.md
|
|
@ -190,13 +190,23 @@ Bettersight Dashboard — PRIMARY INTERFACE (app.bettersight.io)
|
||||||
Path 2 REFRESH — last_scraped > 24hrs OR change_detected = true
|
Path 2 REFRESH — last_scraped > 24hrs OR change_detected = true
|
||||||
→ RQ job → app.py Playwright scrape → PocketBase → LLM (~2 mins)
|
→ RQ job → app.py Playwright scrape → PocketBase → LLM (~2 mins)
|
||||||
→ Results displayed in dashboard side-by-side comparison view
|
→ Results displayed in dashboard side-by-side comparison view
|
||||||
→ PM clicks "Push to Sheet" → Apps Script writes to selected workbook tab
|
→ PM copies rows to clipboard, or opens Sheet + Bettersight sidebar to pull
|
||||||
|
|
||||||
Apps Script (standalone — export integration only)
|
Apps Script (standalone — Sheet-initiated pull integration)
|
||||||
→ getWorkbookTabs() — returns PM's workbook tab list for dashboard dropdown
|
→ getWorkbookTabs() — returns tab list for the sidebar's own selector
|
||||||
→ pushToSheet(results, tabName) — writes dashboard results to Sheet on demand
|
→ pullLatestResults(tabName) — Sheet-initiated fetch from Flask + write
|
||||||
→ validateLicence() — confirms PM is authorised
|
→ validateLicence() — confirms PM is authorised
|
||||||
→ Three functions only. No analysis UI in the script.
|
→ Three functions only. Flask never pushes to the Sheet.
|
||||||
|
|
||||||
|
Template setup (one-time per PM, via AccountPage.vue)
|
||||||
|
→ PM opens hosted template Sheet from AccountPage
|
||||||
|
→ PM copies Bettersight Analysis tab into their existing workbook
|
||||||
|
via Google Sheets "Copy to → Existing spreadsheet"
|
||||||
|
→ PM optionally renames the imported tab to anything they want
|
||||||
|
→ PM installs standalone Apps Script (binds to Google account, not workbook)
|
||||||
|
→ From that point: every workbook the PM opens has Extensions →
|
||||||
|
Bettersight available, and pullLatestResults() writes into whichever
|
||||||
|
workbook and whichever tab is selected in the sidebar dropdown
|
||||||
|
|
||||||
Firecrawl Monitor (event-driven, runs continuously)
|
Firecrawl Monitor (event-driven, runs continuously)
|
||||||
→ Watches all active competitor URLs
|
→ Watches all active competitor URLs
|
||||||
|
|
@ -469,7 +479,7 @@ bettersight/
|
||||||
│ │ │ │ ├── JobProgress.vue # Per-competitor real-time status during job
|
│ │ │ │ ├── JobProgress.vue # Per-competitor real-time status during job
|
||||||
│ │ │ │ ├── ResultsTable.vue # Side-by-side competitor comparison
|
│ │ │ │ ├── ResultsTable.vue # Side-by-side competitor comparison
|
||||||
│ │ │ │ ├── ConfidenceBar.vue # Per-competitor confidence indicator
|
│ │ │ │ ├── ConfidenceBar.vue # Per-competitor confidence indicator
|
||||||
│ │ │ │ ├── PushToSheet.vue # Tab selector + push button
|
│ │ │ │ ├── SyncToSheet.vue # Copy rows button + pull instruction card
|
||||||
│ │ │ │ └── RunHistory.vue # Last 5 runs with restore option
|
│ │ │ │ └── RunHistory.vue # Last 5 runs with restore option
|
||||||
│ │ │ ├── activity/
|
│ │ │ ├── activity/
|
||||||
│ │ │ │ ├── FeedItem.vue # Single activity feed row with dot grid
|
│ │ │ │ ├── FeedItem.vue # Single activity feed row with dot grid
|
||||||
|
|
@ -506,11 +516,11 @@ bettersight/
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ ├── vite.config.js
|
│ ├── vite.config.js
|
||||||
│ └── index.html
|
│ └── index.html
|
||||||
├── appscript/ # Export integration only — 3 files
|
├── appscript/ # Sheet-initiated pull integration — 3 files
|
||||||
│ ├── Code.gs # onOpen(), install entry point
|
│ ├── Code.gs # onOpen(), install entry point
|
||||||
│ ├── Validation.gs # validateLicence() — email only
|
│ ├── Validation.gs # validateLicence() — email only
|
||||||
│ ├── Export.gs # pushToSheet(), getWorkbookTabs(), detectColumns()
|
│ ├── Sync.gs # pullLatestResults(), getWorkbookTabs(), detectColumns()
|
||||||
│ └── Sidebar.html # Minimal UI — tab selector + push button only
|
│ └── Sidebar.html # Tab selector + pull button + last-pull status
|
||||||
├── n8n/
|
├── n8n/
|
||||||
│ └── workflows/ # n8n workflow JSON exports
|
│ └── workflows/ # n8n workflow JSON exports
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
|
|
@ -640,7 +650,22 @@ Services (src/services/)
|
||||||
→ [Upgrade now] creates a Stripe checkout session for the next tier
|
→ [Upgrade now] creates a Stripe checkout session for the next tier
|
||||||
→ On successful upgrade: PocketBase tier updated via Stripe webhook
|
→ On successful upgrade: PocketBase tier updated via Stripe webhook
|
||||||
→ Upgrade card hidden for Intelligence and Enterprise clients
|
→ Upgrade card hidden for Intelligence and Enterprise clients
|
||||||
- Download Sheet template button
|
- Template import section — three items:
|
||||||
|
1. [Open template ↗] button that opens the hosted template Sheet
|
||||||
|
in a new tab (GET /account/template returns the Drive URL)
|
||||||
|
2. Two-step import instruction card:
|
||||||
|
```
|
||||||
|
Import the Bettersight tab into your workbook:
|
||||||
|
1. Right-click any tab in the template → Copy to →
|
||||||
|
Existing spreadsheet → select your workbook
|
||||||
|
2. Rename the tab to anything you want (optional)
|
||||||
|
3. Install the Bettersight sidebar (one click):
|
||||||
|
[Install sidebar ↗]
|
||||||
|
```
|
||||||
|
3. Verification helper — after import, PM can paste their workbook
|
||||||
|
URL into an "It worked" confirmation input which marks
|
||||||
|
onboarding_checklist.template_imported = true and dismisses this
|
||||||
|
section. Purely a completion signal — no data leaves the browser.
|
||||||
- Seat management table (add/remove seats)
|
- Seat management table (add/remove seats)
|
||||||
- Stripe customer portal link (manage billing, cancel)
|
- Stripe customer portal link (manage billing, cancel)
|
||||||
- Onboarding checklist (tracked in PocketBase)
|
- Onboarding checklist (tracked in PocketBase)
|
||||||
|
|
@ -671,7 +696,13 @@ Services (src/services/)
|
||||||
→ If primary market currency not extracted, fall back to USD only
|
→ If primary market currency not extracted, fall back to USD only
|
||||||
- ConfidenceBar — per-competitor AI confidence with low-confidence warning
|
- ConfidenceBar — per-competitor AI confidence with low-confidence warning
|
||||||
- RunHistory — last 5 runs, restore any run to current results view
|
- RunHistory — last 5 runs, restore any run to current results view
|
||||||
- PushToSheet — tab selector dropdown + push button (calls /sheet/push)
|
- SyncToSheet — post-analysis affordance replacing the removed PushToSheet:
|
||||||
|
→ [Copy rows] button — puts tab-separated results on the clipboard
|
||||||
|
→ Instruction card below: "Your results are saved. To pull them into
|
||||||
|
your Sheet: 1) Open your workbook 2) Extensions → Bettersight →
|
||||||
|
Pull latest results 3) Select the tab to write to."
|
||||||
|
→ No tab selector on this page — tab selection lives inside the sidebar
|
||||||
|
where the workbook is actually open
|
||||||
|
|
||||||
**ActivityPage.vue**
|
**ActivityPage.vue**
|
||||||
- Stat cards (price changes, drops, new products, competitors tracked)
|
- Stat cards (price changes, drops, new products, competitors tracked)
|
||||||
|
|
@ -1229,6 +1260,11 @@ GET /research/history
|
||||||
Query: email, limit (default 5)
|
Query: email, limit (default 5)
|
||||||
Returns: last N runs for tenant
|
Returns: last N runs for tenant
|
||||||
|
|
||||||
|
GET /research/history/latest
|
||||||
|
Query: email
|
||||||
|
Returns: full stored results for the PM's most recent completed job
|
||||||
|
Used by Apps Script sidebar for [Pull latest results]
|
||||||
|
|
||||||
GET /research/history/<job_id>
|
GET /research/history/<job_id>
|
||||||
Returns: full stored results for a specific run
|
Returns: full stored results for a specific run
|
||||||
|
|
||||||
|
|
@ -1253,17 +1289,27 @@ POST /comparable-trips
|
||||||
similarity_score, price_difference, duration_difference } ]
|
similarity_score, price_difference, duration_difference } ]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Apps Script Export Integration
|
### Apps Script Sheet Sync
|
||||||
```
|
|
||||||
POST /sheet/push
|
No dedicated routes. The Sheet-initiated pull uses the existing
|
||||||
Body: { email, job_id, tab_name }
|
research history endpoints:
|
||||||
Triggers Apps Script pushToSheet() with stored results for job_id
|
|
||||||
Returns: { success, rows_written }
|
|
||||||
|
|
||||||
GET /sheet/tabs
|
|
||||||
Header: X-User-Email
|
|
||||||
Returns list of available tabs in PM's registered workbook
|
|
||||||
```
|
```
|
||||||
|
GET /research/history/latest?email={email}
|
||||||
|
Returns: full stored results for the PM's most recent completed job
|
||||||
|
Called by the Apps Script sidebar's [Pull latest results] button
|
||||||
|
See Section 15 — Google Apps Script for the full pull flow
|
||||||
|
|
||||||
|
GET /research/history/<job_id>
|
||||||
|
Returns: full stored results for a specific run (used by dashboard
|
||||||
|
history panel — see Trip-Intent Matching + Research routes above)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: A stateless Flask backend has no way to reach into whichever
|
||||||
|
Sheet the PM currently has open. Only the Apps Script itself, running
|
||||||
|
inside that open Sheet, can write to it. So the Sheet pulls from Flask
|
||||||
|
rather than Flask pushing to the Sheet. `getWorkbookTabs()` runs inside
|
||||||
|
the script and needs no Flask counterpart.
|
||||||
|
|
||||||
### Dashboard Account
|
### Dashboard Account
|
||||||
```
|
```
|
||||||
|
|
@ -1869,18 +1915,30 @@ def send_weekly_brief(tenant_id: str):
|
||||||
|
|
||||||
## 15. Google Apps Script
|
## 15. Google Apps Script
|
||||||
|
|
||||||
### Role — Export Integration Only
|
### Role — Sheet-Initiated Pull Integration
|
||||||
|
|
||||||
The Apps Script is no longer the primary interface. The dashboard is.
|
The Apps Script is not the primary interface. The dashboard is.
|
||||||
The script's only job is to push analysis results from the dashboard
|
The script's only job is to **pull** completed analysis results from
|
||||||
into the PM's Google Sheet workbook on demand.
|
Flask into the PM's currently open Sheet on demand.
|
||||||
|
|
||||||
|
**Architectural rule that governs this section:**
|
||||||
|
|
||||||
|
A standalone Apps Script cannot be invoked by an external server.
|
||||||
|
Google's execution model requires the script to run in response to
|
||||||
|
a user action inside the Sheet itself. Therefore Flask never pushes
|
||||||
|
to the Sheet — the Sheet pulls from Flask.
|
||||||
|
|
||||||
|
The dashboard's post-analysis affordance is a "Copy rows" button
|
||||||
|
(tab-separated to clipboard) plus a short instruction reminding the
|
||||||
|
PM to open their Sheet and click "Pull latest results" in the
|
||||||
|
Bettersight sidebar when they want the data written directly.
|
||||||
|
|
||||||
**Three functions only:**
|
**Three functions only:**
|
||||||
|
|
||||||
```
|
```
|
||||||
pushToSheet(results, tabName) — writes dashboard results to selected tab
|
pullLatestResults(tabName) — Sheet-initiated fetch + write for the most recent job
|
||||||
getWorkbookTabs() — returns tab list for dashboard push dropdown
|
getWorkbookTabs() — returns tab list for the sidebar's own tab selector
|
||||||
validateLicence() — confirms PM is authorised (email check only)
|
validateLicence() — confirms PM is authorised (email check only)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deployment Model — Standalone Script
|
### Deployment Model — Standalone Script
|
||||||
|
|
@ -1889,38 +1947,112 @@ Deployed as a standalone script, not container-bound to any workbook.
|
||||||
PM installs once via a shareable link from app.bettersight.io/account.
|
PM installs once via a shareable link from app.bettersight.io/account.
|
||||||
Works in any Google Sheet workbook they open from that point forward.
|
Works in any Google Sheet workbook they open from that point forward.
|
||||||
|
|
||||||
|
### Template Import — The PM's One-Time Setup
|
||||||
|
|
||||||
|
The PM never moves their existing stage-gate workbook. They import a
|
||||||
|
single tab from our template into their workbook and continue using
|
||||||
|
their workbook exactly as before.
|
||||||
|
|
||||||
|
**Two-step import performed once:**
|
||||||
|
|
||||||
|
1. From app.bettersight.io/account, click [Open template ↗] to open
|
||||||
|
the Bettersight template Sheet we host on Google Drive
|
||||||
|
2. In their existing stage-gate workbook: right-click any tab at
|
||||||
|
the bottom of the page → "Copy to" → "Existing spreadsheet" →
|
||||||
|
select their workbook
|
||||||
|
3. The Bettersight Analysis tab is added alongside their existing tabs
|
||||||
|
|
||||||
|
**The PM can rename the imported tab to anything.**
|
||||||
|
The sidebar's tab selector dropdown reads live tab names via
|
||||||
|
getWorkbookTabs() every time it opens, and detectColumns() maps
|
||||||
|
data to columns by header name — never by tab name or column position.
|
||||||
|
Rename the tab "Comp Intel", "Peru Research 2026", or leave it as
|
||||||
|
"Bettersight Analysis" — pullLatestResults() writes correctly either
|
||||||
|
way as long as the PM selects the intended tab from the dropdown.
|
||||||
|
|
||||||
|
**After import:**
|
||||||
|
|
||||||
|
- Their existing stage-gate formulas can reference the new tab like any
|
||||||
|
other tab: ='Bettersight Analysis'!B5 or ='Whatever They Renamed It'!B5
|
||||||
|
- The standalone Apps Script (installed once against their Google account)
|
||||||
|
appears in Extensions → Bettersight in every workbook they open,
|
||||||
|
including this one
|
||||||
|
- When they click Pull latest results the script uses
|
||||||
|
SpreadsheetApp.getActiveSpreadsheet() to write into whichever workbook
|
||||||
|
is currently open — no configuration required per workbook
|
||||||
|
|
||||||
|
**What we do not do:**
|
||||||
|
|
||||||
|
- We do not ask the PM to migrate their stage-gate workbook into our template
|
||||||
|
- We do not require them to duplicate their existing workbook
|
||||||
|
- We do not bind the script to a specific spreadsheet ID
|
||||||
|
- We do not depend on a specific tab name
|
||||||
|
- We do not host the PM's stage-gate data — only the imported Bettersight
|
||||||
|
tab holds any Bettersight data, and that tab lives inside their own
|
||||||
|
workbook on their own Drive
|
||||||
|
|
||||||
|
### Template File Structure
|
||||||
|
|
||||||
|
The template Sheet we host on Google Drive contains a single tab
|
||||||
|
named Bettersight Analysis by default (PM can rename after import).
|
||||||
|
|
||||||
|
**Column structure:** Row 1 is a bold header row with column names
|
||||||
|
matching STANDARD_FIELDS in the exact order the extractor produces.
|
||||||
|
Rows 2+ are empty and will be populated by pullLatestResults() based
|
||||||
|
on header-name column detection.
|
||||||
|
|
||||||
|
**Header resilience:**
|
||||||
|
- If the PM renames a column header, detectColumns() still finds it
|
||||||
|
as long as the header name is one of the recognised aliases in
|
||||||
|
detectColumns()'s header alias map
|
||||||
|
- If the PM inserts new columns, reorders columns, or deletes optional
|
||||||
|
columns, detectColumns() adapts — never hardcoded column positions
|
||||||
|
- If detectColumns() cannot find a required header, it logs a warning
|
||||||
|
to the sidebar and falls back to appending the missing column at the
|
||||||
|
end of the existing data range with the standard name
|
||||||
|
|
||||||
|
**Template hosting:**
|
||||||
|
The template is a single Google Sheets file on Drive at a stable share
|
||||||
|
URL. The URL is stored as an env var TEMPLATE_SHEET_URL and served via
|
||||||
|
GET /account/template. Any updates to the template columns are made
|
||||||
|
in-place on the source file — no versioning needed since the PM's
|
||||||
|
copy is a snapshot at their moment of import.
|
||||||
|
|
||||||
### File Structure
|
### File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
appscript/
|
appscript/
|
||||||
Code.gs — onOpen(), install entry point
|
Code.gs — onOpen(), install entry point
|
||||||
Validation.gs — validateLicence() email-only
|
Validation.gs — validateLicence() email-only
|
||||||
Export.gs — pushToSheet(), getWorkbookTabs()
|
Sync.gs — pullLatestResults(), getWorkbookTabs(), detectColumns()
|
||||||
Sidebar.html — minimal UI: tab selector + push button only
|
Sidebar.html — tab selector + pull button + last-pull status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Export Flow
|
### Pull Flow
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
/**
|
/**
|
||||||
* Pushes analysis results from the dashboard to the PM's selected
|
* Fetches the most recent completed analysis for the PM from Flask
|
||||||
* workbook tab. Called via the dashboard "Push to Sheet" button
|
* and writes it to the selected tab of the currently open workbook.
|
||||||
* which hits Flask POST /sheet/push, which calls this function.
|
* Sheet-initiated only — cannot be triggered by any external server.
|
||||||
*
|
*
|
||||||
* Writes results using header-based column detection — never
|
* Writes results using header-based column detection — never
|
||||||
* hardcoded column positions. Resilient to any sheet restructuring.
|
* hardcoded column positions. Resilient to any sheet restructuring.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* job_id → Flask /research/history/{job_id} → stored results →
|
* PM clicks [Pull latest results] in sidebar →
|
||||||
* getWorkbookTabs() shows tab selector in dashboard dropdown →
|
* validateLicence() confirms authorisation →
|
||||||
* PM selects tab → POST /sheet/push →
|
* GET Flask /research/history/latest?email={sessionEmail} →
|
||||||
* detectColumns(sheet) → writeResults(results, columnMap, sheet)
|
* stored results returned →
|
||||||
|
* detectColumns(sheet) builds { field: column } map →
|
||||||
|
* writeResults(results, columnMap, sheet) writes into selected tab →
|
||||||
|
* sidebar shows "Pulled: Today 9:14am · 5 competitors · Peru"
|
||||||
*/
|
*/
|
||||||
function pushToSheet(results, tabName) { ... }
|
function pullLatestResults(tabName) { ... }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns list of all tab names in the PM's current workbook.
|
* Returns list of all tab names in the PM's currently open workbook.
|
||||||
* Used by the dashboard to populate the "Push to Sheet" tab selector.
|
* Used by the sidebar (not the dashboard) to populate its own tab selector.
|
||||||
*/
|
*/
|
||||||
function getWorkbookTabs() {
|
function getWorkbookTabs() {
|
||||||
return SpreadsheetApp.getActiveSpreadsheet()
|
return SpreadsheetApp.getActiveSpreadsheet()
|
||||||
|
|
@ -1936,26 +2068,26 @@ function getWorkbookTabs() {
|
||||||
function detectColumns(sheet) { ... }
|
function detectColumns(sheet) { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sidebar UI (minimal)
|
### Sidebar UI
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────┐
|
┌─────────────────────────────┐
|
||||||
│ 🔵 Bettersight │
|
│ 🔵 Bettersight │
|
||||||
│ Export results to Sheet │
|
│ Pull results into Sheet │
|
||||||
│ ───────────────────────── │
|
│ ───────────────────────── │
|
||||||
│ Push to tab: │
|
│ Write to tab: │
|
||||||
│ [Bettersight Analysis ▾] │
|
│ [Bettersight Analysis ▾] │
|
||||||
│ │
|
│ │
|
||||||
│ [ Push last analysis ] │
|
│ [ Pull latest results ] │
|
||||||
│ ───────────────────────── │
|
│ ───────────────────────── │
|
||||||
│ Last push: Today 9:14am │
|
│ Pulled: Today 9:14am │
|
||||||
│ 5 competitors · Peru │
|
│ 5 competitors · Peru │
|
||||||
└─────────────────────────────┘
|
└─────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
The full analysis workflow — competitor selection, trip-intent matching,
|
The full analysis workflow — competitor selection, trip-intent matching,
|
||||||
job status, results view, history, confidence indicators — all live in
|
job status, results view, history, confidence indicators — all live in
|
||||||
the dashboard. The sidebar is a push button only.
|
the dashboard. The sidebar is a pull button only.
|
||||||
|
|
||||||
The Apps Script is deployed as a **standalone script**, NOT container-bound
|
The Apps Script is deployed as a **standalone script**, NOT container-bound
|
||||||
to a specific Google Sheet file. This is the most important architectural
|
to a specific Google Sheet file. This is the most important architectural
|
||||||
|
|
@ -2725,13 +2857,13 @@ Phase 3 — Dashboard (PRIMARY — build before Apps Script)
|
||||||
35. Tour trigger on ActivityPage.vue onMounted
|
35. Tour trigger on ActivityPage.vue onMounted
|
||||||
36. Tour replay link on AccountPage.vue
|
36. Tour replay link on AccountPage.vue
|
||||||
|
|
||||||
Phase 4 — Apps Script (export integration — simple)
|
Phase 4 — Apps Script (Sheet-initiated pull — simple)
|
||||||
31. Deploy as standalone Apps Script
|
31. Deploy as standalone Apps Script — not container-bound to any workbook
|
||||||
32. Validation.gs — validateLicence() email-only
|
32. Validation.gs — validateLicence() email-only
|
||||||
33. Export.gs — pushToSheet(), getWorkbookTabs(), detectColumns()
|
33. Sync.gs — pullLatestResults(), getWorkbookTabs(), detectColumns()
|
||||||
34. Sidebar.html — minimal: tab selector + push button only
|
34. Sidebar.html — tab selector + [Pull latest results] + last-pull status
|
||||||
35. Sheet template tab — importable into any existing workbook
|
35. Sheet template tab — importable into any existing workbook
|
||||||
36. Flask POST /sheet/push + GET /sheet/tabs routes
|
36. Flask GET /research/history/latest route (reuses history handler)
|
||||||
|
|
||||||
Phase 5 — Automation
|
Phase 5 — Automation
|
||||||
37. n8n daily scrape cron — changed/stale competitors only
|
37. n8n daily scrape cron — changed/stale competitors only
|
||||||
|
|
@ -2977,13 +3109,14 @@ Updated automatically as each action is completed. Collapses when all steps done
|
||||||
```
|
```
|
||||||
onboarding_checklist (JSON field on tenants)
|
onboarding_checklist (JSON field on tenants)
|
||||||
{
|
{
|
||||||
"add_competitor": false, // set true when first competitor saved
|
"add_competitor": false, // set true when first competitor is created
|
||||||
"run_analysis": false, // set true when first job completes
|
"run_analysis": false, // set true when first job completes
|
||||||
"push_to_sheet": false, // set true when first push to Sheet runs
|
"template_imported": false, // set true from AccountPage confirmation
|
||||||
"download_template": false, // set true when template downloaded
|
"sidebar_installed": false, // set true first time validateLicence() succeeds
|
||||||
"tour_completed": false, // set true when tour reaches final step
|
"sync_from_sheet": false, // set true first time pullLatestResults() succeeds
|
||||||
"tour_skipped": false, // set true when PM clicks Skip
|
"tour_completed": false, // set true when guided tour reaches final step
|
||||||
"tour_step": 0 // last step reached — for resume on return
|
"tour_skipped": false, // set true when PM clicks Skip
|
||||||
|
"tour_step": 0 // last step reached — for resume on return
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -3064,13 +3197,13 @@ Step 4 of 5 — Run analysis (anchors to Analyse sidebar nav item)
|
||||||
└─────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────┘
|
||||||
→ On [Go to Analyse →]: router.push('/analyse')
|
→ On [Go to Analyse →]: router.push('/analyse')
|
||||||
|
|
||||||
Step 5 of 5 — Push to Sheet (anchors to PushToSheet component)
|
Step 5 of 5 — Sync to Sheet (anchors to SyncToSheet component)
|
||||||
┌─────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────┐
|
||||||
│ 📊 Push results to your Sheet │
|
│ 📊 Get results into your Sheet │
|
||||||
│ │
|
│ │
|
||||||
│ Once your analysis is complete, push the │
|
│ Once your analysis is complete, copy rows or │
|
||||||
│ results directly into your Google Sheet │
|
│ open your workbook and click Bettersight → │
|
||||||
│ with one click. Select which tab to write to. │
|
│ Pull latest results in the sidebar. │
|
||||||
│ │
|
│ │
|
||||||
│ [← Back] [Done — let's go ✓] │
|
│ [← Back] [Done — let's go ✓] │
|
||||||
└─────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────┘
|
||||||
|
|
@ -3195,13 +3328,13 @@ export function useOnboardingTour() {
|
||||||
})
|
})
|
||||||
|
|
||||||
tour.addStep({
|
tour.addStep({
|
||||||
id: 'push-to-sheet',
|
id: 'sync-to-sheet',
|
||||||
attachTo: { element: '[data-tour="push-to-sheet"]',
|
attachTo: { element: '[data-tour="sync-to-sheet"]',
|
||||||
on: 'top' },
|
on: 'top' },
|
||||||
text: `
|
text: `
|
||||||
<strong>Push results to your Sheet 📊</strong><br><br>
|
<strong>Get results into your Sheet 📊</strong><br><br>
|
||||||
Once your analysis completes, push results directly into
|
Copy rows to the clipboard from here, or open your workbook
|
||||||
your Google Sheet with one click. Select which tab to write to.
|
and click Bettersight → Pull latest results in the sidebar.
|
||||||
`,
|
`,
|
||||||
buttons: [
|
buttons: [
|
||||||
{ text: '← Back', action: tour.back,
|
{ text: '← Back', action: tour.back,
|
||||||
|
|
@ -3275,8 +3408,8 @@ onMounted(() => {
|
||||||
<!-- CompetitorForm.vue -->
|
<!-- CompetitorForm.vue -->
|
||||||
<USelect data-tour="primary-market-field" ... />
|
<USelect data-tour="primary-market-field" ... />
|
||||||
|
|
||||||
<!-- PushToSheet.vue -->
|
<!-- SyncToSheet.vue -->
|
||||||
<div data-tour="push-to-sheet" ...>
|
<div data-tour="sync-to-sheet" ...>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Custom tour styles — extend Bettersight design system:**
|
**Custom tour styles — extend Bettersight design system:**
|
||||||
|
|
@ -5188,9 +5321,9 @@ def build_json_schema(is_ngs: bool, custom_fields: list) -> dict:
|
||||||
return schema
|
return schema
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sheet Push Handling
|
### Sheet Sync Handling
|
||||||
|
|
||||||
`detectColumns()` in Export.gs handles custom fields automatically
|
`detectColumns()` in Sync.gs handles custom fields automatically
|
||||||
by reading header names from row 1. No code change needed in the
|
by reading header names from row 1. No code change needed in the
|
||||||
Apps Script for new custom fields.
|
Apps Script for new custom fields.
|
||||||
|
|
||||||
|
|
@ -5734,13 +5867,14 @@ POST /api/collections/tenants/records
|
||||||
"stripe_customer_id": "cus_xxx",
|
"stripe_customer_id": "cus_xxx",
|
||||||
"stripe_subscription_id": "sub_xxx",
|
"stripe_subscription_id": "sub_xxx",
|
||||||
"onboarding_checklist": {
|
"onboarding_checklist": {
|
||||||
"add_competitor": false,
|
"add_competitor": false,
|
||||||
"run_analysis": false,
|
"run_analysis": false,
|
||||||
"push_to_sheet": false,
|
"template_imported": false,
|
||||||
"download_template": false,
|
"sidebar_installed": false,
|
||||||
"tour_completed": false,
|
"sync_from_sheet": false,
|
||||||
"tour_skipped": false,
|
"tour_completed": false,
|
||||||
"tour_step": 0
|
"tour_skipped": false,
|
||||||
|
"tour_step": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* Entry points for the Bettersight Sheets add-on. This script is
|
||||||
|
* genuinely minimal — three functions total across the whole project
|
||||||
|
* (validateLicence, pullLatestResults, getWorkbookTabs) plus the
|
||||||
|
* install/menu plumbing here. No analysis UI lives in this script;
|
||||||
|
* the dashboard at app.bettersight.io is the only place a PM
|
||||||
|
* configures or runs an analysis. This file only ever gets the
|
||||||
|
* PM to the sidebar.
|
||||||
|
*
|
||||||
|
* Data flow:
|
||||||
|
* PM opens any Sheet after installing the add-on →
|
||||||
|
* onOpen(e) fires automatically (Google's Add-on framework handles
|
||||||
|
* this per-file once installed — no per-sheet setup needed) →
|
||||||
|
* adds a "Bettersight" item under Extensions →
|
||||||
|
* PM clicks it → showSidebar() renders Sidebar.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple trigger — fires automatically when the PM opens a Sheet,
|
||||||
|
* once the add-on is installed on their account. Adds the Extensions
|
||||||
|
* menu entry that opens the sidebar.
|
||||||
|
*/
|
||||||
|
function onOpen(e) {
|
||||||
|
SpreadsheetApp.getUi()
|
||||||
|
.createAddonMenu()
|
||||||
|
.addItem('Open Bettersight', 'showSidebar')
|
||||||
|
.addToUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires once, the moment the PM installs the add-on. Simply reuses
|
||||||
|
* onOpen() — Google's own recommended pattern for Editor Add-ons —
|
||||||
|
* so the menu appears immediately without waiting for the next
|
||||||
|
* natural onOpen (e.g. a fresh page load).
|
||||||
|
*/
|
||||||
|
function onInstall(e) {
|
||||||
|
onOpen(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sheets homepage card — shown in the Extensions side panel before
|
||||||
|
* the PM has opened the sidebar. A single button is enough; the
|
||||||
|
* sidebar itself carries the real UI.
|
||||||
|
*/
|
||||||
|
function onHomepage(e) {
|
||||||
|
const button = CardService.newTextButton()
|
||||||
|
.setText('Open Bettersight')
|
||||||
|
.setOnClickAction(CardService.newAction().setFunctionName('showSidebar'));
|
||||||
|
|
||||||
|
const section = CardService.newCardSection().addWidget(button);
|
||||||
|
|
||||||
|
return CardService.newCardBuilder()
|
||||||
|
.setHeader(CardService.newCardHeader().setTitle('Bettersight'))
|
||||||
|
.addSection(section)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders Sidebar.html into the Sheets UI sidebar panel.
|
||||||
|
*
|
||||||
|
* Data flow:
|
||||||
|
* showSidebar() → HtmlService renders Sidebar.html →
|
||||||
|
* sidebar's own JS calls validateLicence() then getWorkbookTabs()
|
||||||
|
* on load, and pullLatestResults(tabName) when the PM clicks Pull.
|
||||||
|
*/
|
||||||
|
function showSidebar() {
|
||||||
|
const html = HtmlService.createHtmlOutputFromFile('Sidebar')
|
||||||
|
.setTitle('Bettersight')
|
||||||
|
.setWidth(320);
|
||||||
|
SpreadsheetApp.getUi().showSidebar(html);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<base target="_top">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', Arial, sans-serif;
|
||||||
|
color: #0D1526;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.logo { font-size: 15px; font-weight: 800; margin-bottom: 4px; }
|
||||||
|
.logo em { color: #00D4FF; font-style: normal; }
|
||||||
|
.subtitle { font-size: 11.5px; color: #6B7A99; margin-bottom: 18px; }
|
||||||
|
label { display: block; font-size: 11px; font-weight: 700; color: #6B7A99;
|
||||||
|
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
|
||||||
|
select {
|
||||||
|
width: 100%; padding: 7px 10px; border-radius: 8px; border: 1px solid rgba(28,56,121,.14);
|
||||||
|
font-size: 13px; margin-bottom: 14px; font-family: inherit;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%; padding: 9px 14px; border-radius: 9px; border: none;
|
||||||
|
background: linear-gradient(135deg, #1B8EF8, #0B7AE8); color: #fff;
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.status { font-size: 11.5px; color: #6B7A99; margin-top: 14px; line-height: 1.5; }
|
||||||
|
.status.error { color: #F04438; }
|
||||||
|
.divider { height: 1px; background: rgba(28,56,121,.09); margin: 16px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="logo">Better<em>sight</em></div>
|
||||||
|
<div class="subtitle">Pull results into Sheet</div>
|
||||||
|
|
||||||
|
<label for="tab-select">Write to tab</label>
|
||||||
|
<select id="tab-select"></select>
|
||||||
|
|
||||||
|
<button id="pull-button" onclick="pullResults()">Pull latest results</button>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div id="status" class="status">Loading…</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(populateTabs)
|
||||||
|
.withFailureHandler(function (err) { showStatus(err.message, true); })
|
||||||
|
.getWorkbookTabs();
|
||||||
|
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(function (status) {
|
||||||
|
if (status) showStatus(status, false);
|
||||||
|
else document.getElementById('status').textContent = '';
|
||||||
|
})
|
||||||
|
.getLastPullStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function populateTabs(tabs) {
|
||||||
|
const select = document.getElementById('tab-select');
|
||||||
|
select.innerHTML = '';
|
||||||
|
tabs.forEach(function (tab) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = tab;
|
||||||
|
option.textContent = tab;
|
||||||
|
if (tab === 'Bettersight Analysis') option.selected = true;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
if (!tabs.includes('Bettersight Analysis')) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = 'Bettersight Analysis';
|
||||||
|
option.textContent = 'Bettersight Analysis (new tab)';
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pullResults() {
|
||||||
|
const button = document.getElementById('pull-button');
|
||||||
|
const tabName = document.getElementById('tab-select').value;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Pulling…';
|
||||||
|
showStatus('Fetching your latest analysis…', false);
|
||||||
|
|
||||||
|
google.script.run
|
||||||
|
.withSuccessHandler(function (result) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = 'Pull latest results';
|
||||||
|
showStatus(result.message || result.summary, !result.success);
|
||||||
|
})
|
||||||
|
.withFailureHandler(function (err) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = 'Pull latest results';
|
||||||
|
showStatus(err.message, true);
|
||||||
|
})
|
||||||
|
.pullLatestResults(tabName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(text, isError) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = isError ? 'status error' : 'status';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
/**
|
||||||
|
* Sheet-initiated pull integration. This is the only place data ever
|
||||||
|
* moves between Bettersight and a PM's spreadsheet — always initiated
|
||||||
|
* from inside the open Sheet (the sidebar's [Pull latest results]
|
||||||
|
* button), never from the dashboard or Flask, since a stateless
|
||||||
|
* backend has no way to reach into whichever Sheet a PM has open
|
||||||
|
* (CLAUDE.md §15).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_TAB_NAME = 'Bettersight Analysis';
|
||||||
|
|
||||||
|
// Every STANDARD_FIELDS column's recognised header aliases, written
|
||||||
|
// out explicitly rather than guessed at mid-scrape. The first alias
|
||||||
|
// in each list is the canonical label used when writing a brand-new
|
||||||
|
// header row. Matching is case- and punctuation-insensitive (see
|
||||||
|
// normaliseHeader_), so "Rack Rate", "rack rate", and "RACK-RATE" all
|
||||||
|
// match the same entry.
|
||||||
|
const FIELD_ALIASES = {
|
||||||
|
company: ['Company', 'Competitor', 'Competitor Name', 'Operator'],
|
||||||
|
link: ['Link', 'URL', 'Page URL', 'Trip URL', 'Web Link'],
|
||||||
|
tripName: ['Trip Name', 'Trip', 'Product Name', 'Tour Name'],
|
||||||
|
tripCode: ['Trip Code', 'Code', 'Product Code', 'SKU'],
|
||||||
|
serviceLevel: ['Service Level', 'Style', 'Accommodation Level', 'Class'],
|
||||||
|
groupSize: ['Group Size', 'Max Group Size', 'Group', 'Party Size'],
|
||||||
|
duration: ['Duration', 'Days', 'Length', 'Trip Length', 'Number of Days'],
|
||||||
|
meals: ['Meals', 'Included Meals', 'Total Meals', 'Meal Count'],
|
||||||
|
startLocation: ['Start Location', 'Departure City', 'Starts', 'Start City', 'Start Point'],
|
||||||
|
endLocation: ['End Location', 'End City', 'Ends', 'Finish City', 'End Point'],
|
||||||
|
departures: ['Departures', 'Departures Per Year', 'Frequency', 'Number of Departures'],
|
||||||
|
seasonality: ['Seasonality', 'Season', 'Price Bands', 'Seasonal Pricing'],
|
||||||
|
startDays: ['Start Days', 'Departure Days', 'Days of Week'],
|
||||||
|
targetAudience: ['Target Audience', 'Audience', 'Traveller Type', 'Traveler Type'],
|
||||||
|
hotels: ['Hotels', 'Accommodation', 'Hotel Type', 'Lodging'],
|
||||||
|
activities: ['Activities', 'Included Activities', 'Key Activities', 'Highlights'],
|
||||||
|
relevancy: ['Relevancy', 'Relevance', 'Match Score', 'Relevance Score'],
|
||||||
|
majorityPrice: ['Majority Price', 'Standard Price', 'Rack Rate', 'Price', 'Price USD', 'Adult Price'],
|
||||||
|
priceLow: ['Price Low', 'Low Price', 'Min Price', 'Lowest Price', 'Price From'],
|
||||||
|
priceHigh: ['Price High', 'High Price', 'Max Price', 'Highest Price', 'Price To'],
|
||||||
|
comments: ['Comments', 'Analysis', 'Notes', 'Observations'],
|
||||||
|
dateUsed: ['Date Used', 'Departure Date', 'Date', 'Sample Date'],
|
||||||
|
audPrice: ['AUD Price', 'AUD', 'Price AUD', 'Australian Dollar Price'],
|
||||||
|
cadPrice: ['CAD Price', 'CAD', 'Price CAD', 'Canadian Dollar Price'],
|
||||||
|
eurPrice: ['EUR Price', 'EUR', 'Price EUR', 'Euro Price'],
|
||||||
|
gbpPrice: ['GBP Price', 'GBP', 'Price GBP', 'British Pound Price', 'Pound Price']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback column positions, used only when row 1 has no recognisable
|
||||||
|
// headers at all (e.g. a brand-new blank tab). Ordered left to right
|
||||||
|
// starting at column 1 — logs a warning when this path is taken.
|
||||||
|
const STANDARD_FIELDS_ORDER = Object.keys(FIELD_ALIASES);
|
||||||
|
|
||||||
|
function normaliseHeader_(text) {
|
||||||
|
return String(text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalLabel_(field) {
|
||||||
|
return FIELD_ALIASES[field][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns every tab name in the currently open workbook — used by the
|
||||||
|
* sidebar to populate its own tab selector. Never called by Flask;
|
||||||
|
* this only ever runs inside the open Sheet.
|
||||||
|
*/
|
||||||
|
function getWorkbookTabs() {
|
||||||
|
return SpreadsheetApp.getActiveSpreadsheet().getSheets().map(function (s) {
|
||||||
|
return s.getName();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the column index for each field by matching header names in
|
||||||
|
* row 1 of the target sheet against FIELD_ALIASES — never hardcoded
|
||||||
|
* column positions. Falls back to STANDARD_FIELDS_ORDER's left-to-right
|
||||||
|
* positions only when row 1 has no recognisable headers at all — logs
|
||||||
|
* a warning when that fallback is used.
|
||||||
|
*
|
||||||
|
* Data flow:
|
||||||
|
* sheet → read row 1 → normalise each header cell →
|
||||||
|
* for each field, check every alias in FIELD_ALIASES against every
|
||||||
|
* header cell → first match wins → column index →
|
||||||
|
* { fieldName: columnIndex } map returned
|
||||||
|
*/
|
||||||
|
function detectColumns(sheet) {
|
||||||
|
const lastColumn = Math.max(sheet.getLastColumn(), 1);
|
||||||
|
const headerRow = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
|
||||||
|
const normalisedHeaders = headerRow.map(normaliseHeader_);
|
||||||
|
|
||||||
|
const columnMap = {};
|
||||||
|
Object.keys(FIELD_ALIASES).forEach(function (field) {
|
||||||
|
const normalisedAliases = FIELD_ALIASES[field].map(normaliseHeader_);
|
||||||
|
for (let col = 0; col < normalisedHeaders.length; col++) {
|
||||||
|
if (normalisedHeaders[col] && normalisedAliases.indexOf(normalisedHeaders[col]) !== -1) {
|
||||||
|
columnMap[field] = col + 1; // 1-indexed columns
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundAnyHeader = Object.keys(columnMap).length > 0;
|
||||||
|
if (!foundAnyHeader) {
|
||||||
|
Logger.log('detectColumns: no recognisable headers in row 1 — falling back to STANDARD_FIELDS_ORDER positions');
|
||||||
|
STANDARD_FIELDS_ORDER.forEach(function (field, i) {
|
||||||
|
columnMap[field] = i + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return columnMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the header row (if the sheet is empty) and appends one row
|
||||||
|
* per competitor result, using columnMap from detectColumns(). Only
|
||||||
|
* writes fields present in columnMap.
|
||||||
|
*/
|
||||||
|
function writeResultsToSheet_(results, columnMap, sheet) {
|
||||||
|
if (sheet.getLastRow() === 0) {
|
||||||
|
const headerCells = [];
|
||||||
|
Object.keys(columnMap).forEach(function (field) {
|
||||||
|
headerCells[columnMap[field] - 1] = canonicalLabel_(field);
|
||||||
|
});
|
||||||
|
sheet.getRange(1, 1, 1, headerCells.length).setValues([headerCells.map(function (h) { return h || ''; })]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextRow = sheet.getLastRow() + 1;
|
||||||
|
const successItems = (results && results.success) || [];
|
||||||
|
|
||||||
|
successItems.forEach(function (item) {
|
||||||
|
const extracted = item.result || {};
|
||||||
|
const rowValues = {};
|
||||||
|
rowValues.company = item.name;
|
||||||
|
rowValues.link = extracted.link || (item.product && item.product.url) || '';
|
||||||
|
|
||||||
|
Object.keys(FIELD_ALIASES).forEach(function (field) {
|
||||||
|
if (field === 'company' || field === 'link') return;
|
||||||
|
const value = extracted[field];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
rowValues[field] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(rowValues).forEach(function (field) {
|
||||||
|
const col = columnMap[field];
|
||||||
|
if (col) {
|
||||||
|
sheet.getRange(nextRow, col).setValue(rowValues[field]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextRow += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return successItems.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the PM's most recent completed analysis from Flask and
|
||||||
|
* writes it into the selected tab of the currently open workbook.
|
||||||
|
* Sheet-initiated only — cannot be triggered by any external server.
|
||||||
|
*
|
||||||
|
* Data flow:
|
||||||
|
* validateLicence() → confirms authorisation →
|
||||||
|
* GET {BETTERSIGHT_API_BASE_URL}/research/history/latest?email=... →
|
||||||
|
* stored results returned → target tab resolved (creates it if
|
||||||
|
* missing) → detectColumns(sheet) → writeResultsToSheet_() →
|
||||||
|
* last-pull status saved to UserProperties, sync_from_sheet
|
||||||
|
* onboarding flag flipped on Flask on first success →
|
||||||
|
* { success, rowsWritten, summary } returned to the sidebar
|
||||||
|
*/
|
||||||
|
function pullLatestResults(tabName) {
|
||||||
|
if (!validateLicence()) {
|
||||||
|
return { success: false, message: 'Licence check failed — see the alert for details.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = Session.getActiveUser().getEmail();
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = UrlFetchApp.fetch(
|
||||||
|
`${BETTERSIGHT_API_BASE_URL}/research/history/latest?email=${encodeURIComponent(email)}`,
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
headers: { 'X-API-Key': BETTERSIGHT_API_KEY },
|
||||||
|
muteHttpExceptions: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, message: 'Could not reach Bettersight — check your connection and try again.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.getResponseCode() === 404) {
|
||||||
|
return { success: false, message: 'No completed analysis runs yet — run one from the dashboard first.' };
|
||||||
|
}
|
||||||
|
if (response.getResponseCode() >= 400) {
|
||||||
|
return { success: false, message: 'Bettersight returned an error fetching your latest results.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = JSON.parse(response.getContentText());
|
||||||
|
|
||||||
|
const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
|
||||||
|
const targetTabName = tabName || DEFAULT_TAB_NAME;
|
||||||
|
let sheet = spreadsheet.getSheetByName(targetTabName);
|
||||||
|
if (!sheet) {
|
||||||
|
sheet = spreadsheet.insertSheet(targetTabName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnMap = detectColumns(sheet);
|
||||||
|
const rowsWritten = writeResultsToSheet_(run.results, columnMap, sheet);
|
||||||
|
|
||||||
|
const summary = `${rowsWritten} competitor${rowsWritten === 1 ? '' : 's'}`;
|
||||||
|
const timestamp = new Date().toLocaleString();
|
||||||
|
const statusLine = `Pulled: ${timestamp} · ${summary}`;
|
||||||
|
PropertiesService.getUserProperties().setProperty('LAST_PULL_STATUS', statusLine);
|
||||||
|
|
||||||
|
markOnboardingStepOnce_('sync_from_sheet');
|
||||||
|
|
||||||
|
return { success: true, rowsWritten: rowsWritten, summary: statusLine };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the last-pull status line saved by pullLatestResults(), for
|
||||||
|
* the sidebar to show immediately on open without waiting for a pull.
|
||||||
|
*/
|
||||||
|
function getLastPullStatus() {
|
||||||
|
return PropertiesService.getUserProperties().getProperty('LAST_PULL_STATUS') || '';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* Shared config — same values as the dashboard frontend's
|
||||||
|
* VITE_API_BASE_URL / VITE_API_KEY (CLAUDE.md §9: every route requires
|
||||||
|
* X-API-Key). This is a single app-wide key baked into the published
|
||||||
|
* script, not a per-PM secret — same tradeoff as embedding it in the
|
||||||
|
* frontend bundle. Real per-PM authorization is the email check inside
|
||||||
|
* validateLicence() below, not this header.
|
||||||
|
*
|
||||||
|
* Replace these two values before publishing the add-on.
|
||||||
|
*/
|
||||||
|
const BETTERSIGHT_API_BASE_URL = 'https://api.bettersight.io';
|
||||||
|
const BETTERSIGHT_API_KEY = 'REPLACE_WITH_PRODUCTION_API_KEY';
|
||||||
|
|
||||||
|
const CACHE_KEY_VALID = 'BETTERSIGHT_VALID';
|
||||||
|
const CACHE_KEY_TIER = 'BETTERSIGHT_TIER';
|
||||||
|
const CACHE_DURATION_SECONDS = 21600; // 6 hours
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flips a single onboarding_checklist flag to true on the PM's tenant,
|
||||||
|
* exactly once per install (tracked locally via UserProperties so a
|
||||||
|
* repeat success — e.g. every pull, or every cache expiry — doesn't
|
||||||
|
* re-send the same PATCH). The Apps Script has no PocketBase JWT, so
|
||||||
|
* it authenticates this call by email only, same as validateLicence()
|
||||||
|
* — the backend's PATCH /account/onboarding accepts either.
|
||||||
|
*
|
||||||
|
* A failure here is a side-effect failure only: it never blocks the
|
||||||
|
* primary action (validating a licence, pulling results) that
|
||||||
|
* triggered it.
|
||||||
|
*/
|
||||||
|
function markOnboardingStepOnce_(flagName) {
|
||||||
|
const properties = PropertiesService.getUserProperties();
|
||||||
|
const sentKey = 'ONBOARDING_SENT_' + flagName;
|
||||||
|
if (properties.getProperty(sentKey) === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = Session.getActiveUser().getEmail();
|
||||||
|
if (!email) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = { email: email };
|
||||||
|
payload[flagName] = true;
|
||||||
|
UrlFetchApp.fetch(`${BETTERSIGHT_API_BASE_URL}/account/onboarding`, {
|
||||||
|
method: 'patch',
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: { 'X-API-Key': BETTERSIGHT_API_KEY },
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
muteHttpExceptions: true
|
||||||
|
});
|
||||||
|
properties.setProperty(sentKey, 'true');
|
||||||
|
} catch (e) {
|
||||||
|
Logger.log('markOnboardingStepOnce_ failed for ' + flagName + ': ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the current user's licence against the Bettersight API.
|
||||||
|
* Standalone script — validates by email only, no sheet binding of
|
||||||
|
* any kind (CLAUDE.md §15).
|
||||||
|
*
|
||||||
|
* Data flow:
|
||||||
|
* check 6-hour cache → cache hit: return true immediately →
|
||||||
|
* cache miss: Session.getActiveUser().getEmail() →
|
||||||
|
* POST {BETTERSIGHT_API_BASE_URL}/validate-email →
|
||||||
|
* valid: cache result + tier for 6 hours, return true →
|
||||||
|
* invalid: show a UI alert with the rejection reason, return false
|
||||||
|
*/
|
||||||
|
function validateLicence() {
|
||||||
|
const cache = CacheService.getUserCache();
|
||||||
|
const cachedValid = cache.get(CACHE_KEY_VALID);
|
||||||
|
if (cachedValid === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = Session.getActiveUser().getEmail();
|
||||||
|
if (!email) {
|
||||||
|
SpreadsheetApp.getUi().alert('Could not determine your Google account email. Please try again.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = UrlFetchApp.fetch(`${BETTERSIGHT_API_BASE_URL}/validate-email`, {
|
||||||
|
method: 'post',
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: { 'X-API-Key': BETTERSIGHT_API_KEY },
|
||||||
|
payload: JSON.stringify({ email: email }),
|
||||||
|
muteHttpExceptions: true
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
SpreadsheetApp.getUi().alert('Could not reach Bettersight — check your connection and try again.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.parse(response.getContentText());
|
||||||
|
|
||||||
|
if (body.valid) {
|
||||||
|
cache.put(CACHE_KEY_VALID, 'true', CACHE_DURATION_SECONDS);
|
||||||
|
cache.put(CACHE_KEY_TIER, body.tier || '', CACHE_DURATION_SECONDS);
|
||||||
|
markOnboardingStepOnce_('sidebar_installed');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
SpreadsheetApp.getUi().alert(`Bettersight access denied: ${body.reason || 'Unknown reason'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"timeZone": "Etc/UTC",
|
||||||
|
"exceptionLogging": "STACKDRIVER",
|
||||||
|
"runtimeVersion": "V8",
|
||||||
|
"oauthScopes": [
|
||||||
|
"https://www.googleapis.com/auth/spreadsheets.currentonly",
|
||||||
|
"https://www.googleapis.com/auth/script.external_request",
|
||||||
|
"https://www.googleapis.com/auth/script.container.ui",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email"
|
||||||
|
],
|
||||||
|
"addOns": {
|
||||||
|
"common": {
|
||||||
|
"name": "Bettersight",
|
||||||
|
"logoUrl": "https://app.bettersight.io/logo.svg",
|
||||||
|
"layoutProperties": {
|
||||||
|
"primaryColor": "#1B8EF8",
|
||||||
|
"secondaryColor": "#00D4FF"
|
||||||
|
},
|
||||||
|
"homepageTrigger": {
|
||||||
|
"runFunction": "onHomepage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sheets": {
|
||||||
|
"homepageTrigger": {
|
||||||
|
"runFunction": "onHomepage",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
backend/api.py
105
backend/api.py
|
|
@ -34,6 +34,7 @@ api_bp = Blueprint('api', __name__)
|
||||||
|
|
||||||
API_SECRET_KEY = os.getenv('API_SECRET_KEY')
|
API_SECRET_KEY = os.getenv('API_SECRET_KEY')
|
||||||
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET')
|
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET')
|
||||||
|
TEMPLATE_SHEET_URL = os.getenv('TEMPLATE_SHEET_URL')
|
||||||
FIRECRAWL_URL = os.getenv('FIRECRAWL_URL', 'http://firecrawl:3002')
|
FIRECRAWL_URL = os.getenv('FIRECRAWL_URL', 'http://firecrawl:3002')
|
||||||
POCKETBASE_URL = os.getenv('POCKETBASE_URL', 'http://pocketbase:8090')
|
POCKETBASE_URL = os.getenv('POCKETBASE_URL', 'http://pocketbase:8090')
|
||||||
|
|
||||||
|
|
@ -302,6 +303,26 @@ def research_history():
|
||||||
return jsonify(scrape_repository.list_recent_for_tenant(tenant['id'], limit=limit))
|
return jsonify(scrape_repository.list_recent_for_tenant(tenant['id'], limit=limit))
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/research/history/latest', methods=['GET'])
|
||||||
|
@require_api_key
|
||||||
|
def research_history_latest():
|
||||||
|
"""
|
||||||
|
Returns the PM's most recent COMPLETED job — called by the Apps
|
||||||
|
Script sidebar's [Pull latest results] button (CLAUDE.md §15). The
|
||||||
|
Sheet pulls this on demand rather than Flask ever pushing to a
|
||||||
|
Sheet, since a stateless backend has no way to reach into whichever
|
||||||
|
workbook a PM currently has open.
|
||||||
|
"""
|
||||||
|
email = request.args.get('email')
|
||||||
|
tenant = _resolve_tenant_by_email(email)
|
||||||
|
if not tenant:
|
||||||
|
return error_response('not_found', 'No tenant for this email', 404)
|
||||||
|
run = scrape_repository.get_latest_complete_for_tenant(tenant['id'])
|
||||||
|
if not run:
|
||||||
|
return error_response('not_found', 'No completed analysis runs yet', 404)
|
||||||
|
return jsonify(run)
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/research/history/<job_id>', methods=['GET'])
|
@api_bp.route('/research/history/<job_id>', methods=['GET'])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def research_history_detail(job_id):
|
def research_history_detail(job_id):
|
||||||
|
|
@ -419,51 +440,14 @@ def mark_alert_read(alert_id):
|
||||||
return jsonify(alert_repository.mark_read(alert_id))
|
return jsonify(alert_repository.mark_read(alert_id))
|
||||||
|
|
||||||
|
|
||||||
# ── APPS SCRIPT EXPORT INTEGRATION ────────────────────────────────────
|
# ── APPS SCRIPT SHEET SYNC ──────────────────────────────────────────
|
||||||
# NOTE: the Apps Script is a standalone script running in the PM's own
|
# No dedicated routes here per CLAUDE.md §15 — the Sheet-initiated pull
|
||||||
# Google account (CLAUDE.md §15) — it is not deployed or addressable by
|
# uses GET /research/history/latest (above) and GET
|
||||||
# this backend until Phase 4 defines its Web App URL / install model.
|
# /research/history/<job_id> (below). A stateless Flask backend has no
|
||||||
# These two routes have a correct, working contract against PocketBase
|
# way to reach into whichever Sheet a PM currently has open, so the
|
||||||
# today; the actual "push the bytes into the PM's open Sheet" step is a
|
# Sheet pulls from Flask rather than Flask pushing to the Sheet;
|
||||||
# Phase 4 dependency and is clearly marked below rather than guessed at.
|
# getWorkbookTabs() runs entirely inside the script and needs no Flask
|
||||||
|
# counterpart.
|
||||||
@api_bp.route('/sheet/push', methods=['POST'])
|
|
||||||
@require_api_key
|
|
||||||
def sheet_push():
|
|
||||||
data = request.json or {}
|
|
||||||
tenant = _resolve_tenant_by_email(data.get('email'))
|
|
||||||
if not tenant:
|
|
||||||
return error_response('not_found', 'No tenant for this email', 404)
|
|
||||||
|
|
||||||
run = scrape_repository.get_by_id(data.get('job_id', ''))
|
|
||||||
if not run:
|
|
||||||
return error_response('not_found', 'Unknown job_id', 404)
|
|
||||||
|
|
||||||
# Phase 4 TODO: invoke the PM's installed Apps Script (via its Web
|
|
||||||
# App URL, once that deployment model is built) with run['results']
|
|
||||||
# and data['tab_name']. Until then this confirms the data exists and
|
|
||||||
# is ready to push, without a transport to actually deliver it.
|
|
||||||
return error_response(
|
|
||||||
'not_yet_available',
|
|
||||||
'Push to Sheet requires the Apps Script plugin (Phase 4) — not yet deployed.',
|
|
||||||
501
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/sheet/tabs', methods=['GET'])
|
|
||||||
@require_api_key
|
|
||||||
def sheet_tabs():
|
|
||||||
email = request.headers.get('X-User-Email')
|
|
||||||
tenant = _resolve_tenant_by_email(email)
|
|
||||||
if not tenant:
|
|
||||||
return error_response('not_found', 'No tenant for this email', 404)
|
|
||||||
|
|
||||||
# Phase 4 TODO: same dependency as /sheet/push above.
|
|
||||||
return error_response(
|
|
||||||
'not_yet_available',
|
|
||||||
'Sheet tab listing requires the Apps Script plugin (Phase 4) — not yet deployed.',
|
|
||||||
501
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── DASHBOARD ACCOUNT ──────────────────────────────────────────────────
|
# ── DASHBOARD ACCOUNT ──────────────────────────────────────────────────
|
||||||
|
|
@ -624,18 +608,39 @@ def delete_competitor(competitor_id):
|
||||||
@api_bp.route('/account/template', methods=['GET'])
|
@api_bp.route('/account/template', methods=['GET'])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def account_template():
|
def account_template():
|
||||||
# Sheet template is a static Google Sheets link hosted outside this
|
"""
|
||||||
# backend — returned as a redirect target for the frontend to open.
|
Returns the hosted Bettersight template Sheet's share URL, per
|
||||||
return jsonify({'template_url': 'https://docs.google.com/spreadsheets/d/TEMPLATE_SHEET_ID/copy'})
|
CLAUDE.md §15 Template Import — the PM opens this and copies its
|
||||||
|
one tab into their existing workbook via Google Sheets'
|
||||||
|
"Copy to → Existing spreadsheet", never migrating their own data.
|
||||||
|
"""
|
||||||
|
return jsonify({'template_url': TEMPLATE_SHEET_URL})
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/account/onboarding', methods=['PATCH'])
|
@api_bp.route('/account/onboarding', methods=['PATCH'])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def update_onboarding():
|
def update_onboarding():
|
||||||
|
"""
|
||||||
|
Merges partial onboarding_checklist updates. Two callers, two
|
||||||
|
resolution paths: the dashboard has a PocketBase JWT session
|
||||||
|
(resolved via auth_service); the Apps Script has no JWT at all —
|
||||||
|
only the PM's Google email (Session.getActiveUser()) — so it
|
||||||
|
passes `email` in the body instead, resolved the same way
|
||||||
|
/validate-email resolves a tenant. Either is sufficient; JWT is
|
||||||
|
tried first since it is the stronger signal.
|
||||||
|
"""
|
||||||
|
updates = request.json or {}
|
||||||
|
|
||||||
tenant_id = auth_service.get_tenant_id_from_request(request)
|
tenant_id = auth_service.get_tenant_id_from_request(request)
|
||||||
if not tenant_id:
|
if not tenant_id:
|
||||||
return error_response('unauthorized', 'Could not resolve tenant from session', 401)
|
tenant = _resolve_tenant_by_email(updates.pop('email', None))
|
||||||
updates = request.json or {}
|
tenant_id = tenant['id'] if tenant else None
|
||||||
|
else:
|
||||||
|
updates.pop('email', None)
|
||||||
|
|
||||||
|
if not tenant_id:
|
||||||
|
return error_response('unauthorized', 'Could not resolve tenant from session or email', 401)
|
||||||
|
|
||||||
checklist = onboarding_service.update_onboarding(tenant_id, updates)
|
checklist = onboarding_service.update_onboarding(tenant_id, updates)
|
||||||
return jsonify({'onboarding_checklist': checklist})
|
return jsonify({'onboarding_checklist': checklist})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,14 @@ class ScrapeRepository:
|
||||||
COLLECTION, filter_str=f'tenant_id = "{tenant_id}"', sort='-started_at', per_page=limit
|
COLLECTION, filter_str=f'tenant_id = "{tenant_id}"', sort='-started_at', per_page=limit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_latest_complete_for_tenant(self, tenant_id):
|
||||||
|
"""Returns the tenant's most recent completed run, or None — backs GET /research/history/latest."""
|
||||||
|
items = pb_client.list(
|
||||||
|
COLLECTION, filter_str=f'tenant_id = "{tenant_id}" && status = "complete"',
|
||||||
|
sort='-started_at', per_page=1
|
||||||
|
)
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
|
||||||
class MockScrapeRepository:
|
class MockScrapeRepository:
|
||||||
"""In-memory stand-in for ScrapeRepository."""
|
"""In-memory stand-in for ScrapeRepository."""
|
||||||
|
|
@ -50,5 +58,13 @@ class MockScrapeRepository:
|
||||||
items.sort(key=lambda r: r.get('started_at') or '', reverse=True)
|
items.sort(key=lambda r: r.get('started_at') or '', reverse=True)
|
||||||
return items[:limit]
|
return items[:limit]
|
||||||
|
|
||||||
|
def get_latest_complete_for_tenant(self, tenant_id):
|
||||||
|
items = [
|
||||||
|
r for r in self._records.values()
|
||||||
|
if r.get('tenant_id') == tenant_id and r.get('status') == 'complete'
|
||||||
|
]
|
||||||
|
items.sort(key=lambda r: r.get('started_at') or '', reverse=True)
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
|
||||||
scrape_repository = MockScrapeRepository() if USE_MOCK_REPOSITORIES else ScrapeRepository()
|
scrape_repository = MockScrapeRepository() if USE_MOCK_REPOSITORIES else ScrapeRepository()
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ from repositories.tenant_repository import tenant_repository
|
||||||
DEFAULT_CHECKLIST = {
|
DEFAULT_CHECKLIST = {
|
||||||
'add_competitor': False,
|
'add_competitor': False,
|
||||||
'run_analysis': False,
|
'run_analysis': False,
|
||||||
'push_to_sheet': False,
|
'template_imported': False,
|
||||||
'download_template': False,
|
'sidebar_installed': False,
|
||||||
|
'sync_from_sheet': False,
|
||||||
'tour_completed': False,
|
'tour_completed': False,
|
||||||
'tour_skipped': False,
|
'tour_skipped': False,
|
||||||
'tour_step': 0,
|
'tour_step': 0,
|
||||||
|
|
@ -16,7 +17,10 @@ def update_onboarding(tenant_id, updates):
|
||||||
Merges partial onboarding_checklist updates into a tenant's record.
|
Merges partial onboarding_checklist updates into a tenant's record.
|
||||||
Used both by the guided-tour composable (tour_step, tour_completed,
|
Used both by the guided-tour composable (tour_step, tour_completed,
|
||||||
tour_skipped) and by checklist auto-completion logic elsewhere
|
tour_skipped) and by checklist auto-completion logic elsewhere
|
||||||
(add_competitor, run_analysis, push_to_sheet, download_template).
|
(add_competitor, run_analysis, template_imported — set from
|
||||||
|
AccountPage's verification helper, sidebar_installed — set on first
|
||||||
|
successful validateLicence(), sync_from_sheet — set on first
|
||||||
|
successful pullLatestResults()).
|
||||||
|
|
||||||
Data flow:
|
Data flow:
|
||||||
tenant_id + partial onboarding dict →
|
tenant_id + partial onboarding dict →
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,16 @@ def test_scrape_repository_delegates_correctly(monkeypatch):
|
||||||
assert repo.update('r1', {'status': 'complete'}) == fake.update_return
|
assert repo.update('r1', {'status': 'complete'}) == fake.update_return
|
||||||
assert repo.get_by_id('r1') == fake.get_one_return
|
assert repo.get_by_id('r1') == fake.get_one_return
|
||||||
assert repo.list_recent_for_tenant('t1') == fake.list_return
|
assert repo.list_recent_for_tenant('t1') == fake.list_return
|
||||||
|
assert repo.get_latest_complete_for_tenant('t1') == fake.list_return[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrape_repository_get_latest_complete_returns_none_when_no_runs(monkeypatch):
|
||||||
|
fake = _FakePbClient()
|
||||||
|
fake.list_return = []
|
||||||
|
monkeypatch.setattr('repositories.scrape_repository.pb_client', fake)
|
||||||
|
repo = ScrapeRepository()
|
||||||
|
|
||||||
|
assert repo.get_latest_complete_for_tenant('t1') is None
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_repository_delegates_correctly(monkeypatch):
|
def test_proxy_repository_delegates_correctly(monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -129,10 +129,11 @@ def test_delete_unknown_competitor_404(client, api_headers):
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_account_template_returns_static_link(client, api_headers):
|
def test_account_template_returns_configured_url(client, api_headers, monkeypatch):
|
||||||
|
monkeypatch.setattr('api.TEMPLATE_SHEET_URL', 'https://docs.google.com/spreadsheets/d/abc123/copy')
|
||||||
response = client.get('/account/template', headers=api_headers)
|
response = client.get('/account/template', headers=api_headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert 'template_url' in response.json
|
assert response.json['template_url'] == 'https://docs.google.com/spreadsheets/d/abc123/copy'
|
||||||
|
|
||||||
|
|
||||||
def test_onboarding_patch_requires_resolvable_session(client, api_headers):
|
def test_onboarding_patch_requires_resolvable_session(client, api_headers):
|
||||||
|
|
@ -153,6 +154,28 @@ def test_onboarding_patch_updates_checklist(client, api_headers, monkeypatch):
|
||||||
assert response.json['onboarding_checklist']['tour_step'] == 2
|
assert response.json['onboarding_checklist']['tour_step'] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboarding_patch_falls_back_to_email_when_no_jwt_session(client, api_headers):
|
||||||
|
# This is the Apps Script's only path — it has no PocketBase JWT,
|
||||||
|
# just the PM's Google email via Session.getActiveUser().
|
||||||
|
tenant = _make_tenant()
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
'/account/onboarding', json={'email': 'pm@gadventures.com', 'sync_from_sheet': True},
|
||||||
|
headers=api_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json['onboarding_checklist']['sync_from_sheet'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboarding_patch_401_when_neither_jwt_nor_email_resolve(client, api_headers):
|
||||||
|
response = client.patch(
|
||||||
|
'/account/onboarding', json={'email': 'nobody@nowhere.com', 'sync_from_sheet': True},
|
||||||
|
headers=api_headers
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_delete_account_requires_tenant_id(client, api_headers):
|
def test_delete_account_requires_tenant_id(client, api_headers):
|
||||||
response = client.post('/account/delete', json={}, headers=api_headers)
|
response = client.post('/account/delete', json={}, headers=api_headers)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
@ -431,25 +454,41 @@ def test_internal_match_comparable(client, api_headers, monkeypatch):
|
||||||
assert len(comparable_repository.list_for_tenant(tenant['id'])) == 1
|
assert len(comparable_repository.list_for_tenant(tenant['id'])) == 1
|
||||||
|
|
||||||
|
|
||||||
# ── Sheet (Phase 4 dependency — expect structured 501) ─────────────
|
# ── Research history — latest (Apps Script sidebar pull) ────────────
|
||||||
|
|
||||||
def test_sheet_push_returns_not_yet_available(client, api_headers):
|
def test_research_history_latest_requires_known_email(client, api_headers):
|
||||||
tenant = _make_tenant()
|
response = client.get('/research/history/latest', query_string={'email': 'nobody@nowhere.com'}, headers=api_headers)
|
||||||
run = scrape_repository.create({
|
assert response.status_code == 404
|
||||||
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'complete',
|
|
||||||
'competitors_total': 0, 'competitors_done': 0, 'results': {},
|
|
||||||
})
|
|
||||||
response = client.post(
|
|
||||||
'/sheet/push', json={'email': 'pm@gadventures.com', 'job_id': run['id'], 'tab_name': 'x'},
|
|
||||||
headers=api_headers
|
|
||||||
)
|
|
||||||
assert response.status_code == 501
|
|
||||||
|
|
||||||
|
|
||||||
def test_sheet_tabs_returns_not_yet_available(client, api_headers):
|
def test_research_history_latest_no_completed_runs_yet(client, api_headers):
|
||||||
_make_tenant()
|
_make_tenant()
|
||||||
response = client.get('/sheet/tabs', headers={**api_headers, 'X-User-Email': 'pm@gadventures.com'})
|
response = client.get(
|
||||||
assert response.status_code == 501
|
'/research/history/latest', query_string={'email': 'pm@gadventures.com'}, headers=api_headers
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json['code'] == 'not_found'
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_history_latest_returns_most_recent_completed_run(client, api_headers):
|
||||||
|
tenant = _make_tenant()
|
||||||
|
scrape_repository.create({
|
||||||
|
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'failed',
|
||||||
|
'competitors_total': 1, 'competitors_done': 1, 'results': {},
|
||||||
|
'started_at': '2026-06-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
latest = scrape_repository.create({
|
||||||
|
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'complete',
|
||||||
|
'competitors_total': 2, 'competitors_done': 2, 'results': {'success': [{'name': 'Intrepid'}]},
|
||||||
|
'started_at': '2026-06-15T00:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
'/research/history/latest', query_string={'email': 'pm@gadventures.com'}, headers=api_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json['id'] == latest['id']
|
||||||
|
|
||||||
|
|
||||||
# ── Research history / status / battlecards / comparable-trips ────
|
# ── Research history / status / battlecards / comparable-trips ────
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,8 @@ VITE_API_BASE_URL=http://localhost:5000
|
||||||
VITE_API_KEY=
|
VITE_API_KEY=
|
||||||
VITE_SENTRY_DSN=
|
VITE_SENTRY_DSN=
|
||||||
VITE_ENV=development
|
VITE_ENV=development
|
||||||
|
|
||||||
|
# Apps Script add-on install link (CLAUDE.md §15) — shown on AccountPage's
|
||||||
|
# template import section. Populate once the add-on is published.
|
||||||
|
VITE_APPS_SCRIPT_INSTALL_URL=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useAuthStore } from '../../stores/auth_store'
|
|
||||||
import * as analysisRepository from '../../repositories/analysis_repository'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
jobId: { type: String, required: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const tabs = ref([])
|
|
||||||
const selectedTab = ref(null)
|
|
||||||
const pushing = ref(false)
|
|
||||||
const notice = ref(null)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const data = await analysisRepository.getSheetTabs(authStore.email)
|
|
||||||
tabs.value = data.tabs || []
|
|
||||||
selectedTab.value = tabs.value[0] || null
|
|
||||||
} catch {
|
|
||||||
// Apps Script plugin (Phase 4) isn't installed/deployed yet — the
|
|
||||||
// backend returns a structured 501 for this, which we surface as
|
|
||||||
// a plain, non-alarming notice rather than an error toast.
|
|
||||||
notice.value = 'Push to Sheet needs the Bettersight Sheets plugin — install it from your Account page.'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function push() {
|
|
||||||
pushing.value = true
|
|
||||||
try {
|
|
||||||
await analysisRepository.pushToSheet(authStore.email, props.jobId, selectedTab.value)
|
|
||||||
await authStore.updateOnboarding({ push_to_sheet: true })
|
|
||||||
notice.value = 'Pushed to Sheet ✓'
|
|
||||||
} catch {
|
|
||||||
notice.value = 'Push to Sheet needs the Bettersight Sheets plugin — install it from your Account page.'
|
|
||||||
} finally {
|
|
||||||
pushing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="card" style="padding:16px 20px;display:flex;align-items:center;gap:10px;" data-tour="push-to-sheet">
|
|
||||||
<USelect
|
|
||||||
v-if="tabs.length"
|
|
||||||
v-model="selectedTab"
|
|
||||||
:items="tabs"
|
|
||||||
style="width:200px;"
|
|
||||||
/>
|
|
||||||
<UButton color="primary" :disabled="!tabs.length" :loading="pushing" @click="push">
|
|
||||||
Push to Sheet
|
|
||||||
</UButton>
|
|
||||||
<span v-if="notice" style="font-size:12px;color:var(--t3);">{{ notice }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// Replaces PushToSheet.vue per the Sheet-initiated pull architecture
|
||||||
|
// (CLAUDE.md §15): a stateless Flask backend has no way to reach into
|
||||||
|
// whichever Sheet a PM currently has open, so there is no "push"
|
||||||
|
// action here at all — only a clipboard copy plus a reminder that the
|
||||||
|
// actual write happens from inside the Sheet's own Bettersight sidebar
|
||||||
|
// via [Pull latest results].
|
||||||
|
const props = defineProps({
|
||||||
|
results: { type: Array, required: true } // [{ name, result, primary_market }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
async function copyRows() {
|
||||||
|
const rows = props.results.map((item) => [
|
||||||
|
item.name,
|
||||||
|
item.result?.tripName || '',
|
||||||
|
item.result?.majorityPrice ?? '',
|
||||||
|
item.result?.duration ?? '',
|
||||||
|
(item.result?.comments || '').replace(/\t|\n/g, ' '),
|
||||||
|
].join('\t'))
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(rows.join('\n'))
|
||||||
|
copied.value = true
|
||||||
|
// Not an onboarding checklist event — sync_from_sheet only flips on a
|
||||||
|
// real pullLatestResults() success from inside the Sheet itself.
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card" style="padding:18px 20px;display:flex;flex-direction:column;gap:10px;" data-tour="sync-to-sheet">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<UButton color="primary" variant="outline" icon="i-lucide-clipboard-copy" @click="copyRows">
|
||||||
|
{{ copied ? 'Copied ✓' : 'Copy rows' }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--t3);margin:0;line-height:1.5;">
|
||||||
|
Your results are saved. To pull them directly into your Sheet: open your
|
||||||
|
workbook → <strong>Extensions → Bettersight → Pull latest results</strong>,
|
||||||
|
then pick the tab to write to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -85,12 +85,12 @@ export function useOnboardingTour() {
|
||||||
})
|
})
|
||||||
|
|
||||||
tour.addStep({
|
tour.addStep({
|
||||||
id: 'push-to-sheet',
|
id: 'sync-to-sheet',
|
||||||
attachTo: { element: '[data-tour="push-to-sheet"]', on: 'top' },
|
attachTo: { element: '[data-tour="sync-to-sheet"]', on: 'top' },
|
||||||
text: `
|
text: `
|
||||||
<strong>Push results to your Sheet 📊</strong><br><br>
|
<strong>Get results into your Sheet 📊</strong><br><br>
|
||||||
Once your analysis completes, push results directly into your Google
|
Copy rows to the clipboard from here, or open your workbook and click
|
||||||
Sheet with one click. Select which tab to write to.
|
Bettersight → Pull latest results in the sidebar.
|
||||||
`,
|
`,
|
||||||
buttons: [
|
buttons: [
|
||||||
{ text: '← Back', action: tour.back, classes: 'bs-btn-ghost' },
|
{ text: '← Back', action: tour.back, classes: 'bs-btn-ghost' },
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const TIER_PITCH = {
|
||||||
|
|
||||||
const nextTier = computed(() => NEXT_TIER[authStore.tier])
|
const nextTier = computed(() => NEXT_TIER[authStore.tier])
|
||||||
const upgradePitch = computed(() => nextTier.value ? TIER_PITCH[nextTier.value] : null)
|
const upgradePitch = computed(() => nextTier.value ? TIER_PITCH[nextTier.value] : null)
|
||||||
|
const appsScriptInstallUrl = import.meta.env.VITE_APPS_SCRIPT_INSTALL_URL
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await authStore.fetchTenant()
|
await authStore.fetchTenant()
|
||||||
|
|
@ -74,12 +75,23 @@ async function upgrade() {
|
||||||
window.location.href = checkoutUrl
|
window.location.href = checkoutUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadTemplate() {
|
async function openTemplate() {
|
||||||
const url = await tenantRepository.getTemplateUrl()
|
const url = await tenantRepository.getTemplateUrl()
|
||||||
await authStore.updateOnboarding({ download_template: true })
|
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Genuinely no-op on the pasted value per design: we never validate it
|
||||||
|
// against Google, never fetch it, never store it beyond this local ref.
|
||||||
|
// The confirmation is psychological — the PM telling themselves they've
|
||||||
|
// done the step — not a real integration point (CLAUDE.md §15).
|
||||||
|
const workbookUrlInput = ref('')
|
||||||
|
const templateConfirmed = ref(false)
|
||||||
|
|
||||||
|
async function confirmTemplateImported() {
|
||||||
|
await authStore.updateOnboarding({ template_imported: true })
|
||||||
|
templateConfirmed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function replayTour() {
|
function replayTour() {
|
||||||
authStore.updateOnboarding({ tour_completed: false, tour_skipped: false, tour_step: 0 })
|
authStore.updateOnboarding({ tour_completed: false, tour_skipped: false, tour_step: 0 })
|
||||||
tour.start()
|
tour.start()
|
||||||
|
|
@ -107,12 +119,44 @@ function replayTour() {
|
||||||
<UButton color="primary" block @click="upgrade">Upgrade now →</UButton>
|
<UButton color="primary" block @click="upgrade">Upgrade now →</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="padding:20px;">
|
<div
|
||||||
<div class="ct" style="margin-bottom:10px;">Google Sheet template</div>
|
v-if="!authStore.onboarding.template_imported && !templateConfirmed"
|
||||||
<p style="font-size:12.5px;color:var(--t3);margin-bottom:12px;">
|
class="card"
|
||||||
Download the Bettersight tab to import into your existing workbook.
|
style="padding:20px;display:flex;flex-direction:column;gap:14px;"
|
||||||
</p>
|
>
|
||||||
<UButton color="neutral" variant="outline" @click="downloadTemplate">Download template</UButton>
|
<div class="ct">Connect your Google Sheet</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p style="font-size:12.5px;color:var(--t3);margin-bottom:8px;">
|
||||||
|
1. Open the Bettersight template and copy its one tab into your
|
||||||
|
existing workbook — nothing else in your workbook changes.
|
||||||
|
</p>
|
||||||
|
<UButton color="neutral" variant="outline" @click="openTemplate">Open template ↗</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--surface-2);border-radius:10px;padding:14px;font-size:12px;color:var(--t2);line-height:1.7;">
|
||||||
|
Import the Bettersight tab into your workbook:
|
||||||
|
<ol style="margin:8px 0 0;padding-left:18px;">
|
||||||
|
<li>Right-click any tab in the template → Copy to → Existing spreadsheet → select your workbook</li>
|
||||||
|
<li>Rename the tab to anything you want (optional)</li>
|
||||||
|
<li>Install the Bettersight sidebar (one click): <a :href="appsScriptInstallUrl" target="_blank" rel="noopener" style="color:var(--blue-vivid);">Install sidebar ↗</a></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p style="font-size:12px;color:var(--t3);margin-bottom:8px;">
|
||||||
|
Once you've imported the tab, paste your workbook's URL below just to confirm to yourself it's done:
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<UInput v-model="workbookUrlInput" placeholder="https://docs.google.com/spreadsheets/..." class="flex-1" />
|
||||||
|
<UButton color="primary" @click="confirmTemplateImported">It worked</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card" style="padding:16px 20px;display:flex;align-items:center;gap:10px;">
|
||||||
|
<span class="badge bg-green">✓ Connected</span>
|
||||||
|
<span style="font-size:12.5px;color:var(--t3);">Your Sheet template is set up.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import UrlConfirmation from '../components/analyse/UrlConfirmation.vue'
|
||||||
import JobProgress from '../components/analyse/JobProgress.vue'
|
import JobProgress from '../components/analyse/JobProgress.vue'
|
||||||
import ResultsTable from '../components/analyse/ResultsTable.vue'
|
import ResultsTable from '../components/analyse/ResultsTable.vue'
|
||||||
import ConfidenceBar from '../components/analyse/ConfidenceBar.vue'
|
import ConfidenceBar from '../components/analyse/ConfidenceBar.vue'
|
||||||
import PushToSheet from '../components/analyse/PushToSheet.vue'
|
import SyncToSheet from '../components/analyse/SyncToSheet.vue'
|
||||||
import RunHistory from '../components/analyse/RunHistory.vue'
|
import RunHistory from '../components/analyse/RunHistory.vue'
|
||||||
import { useAuthStore } from '../stores/auth_store'
|
import { useAuthStore } from '../stores/auth_store'
|
||||||
import { useAnalysisStore } from '../stores/analysis_store'
|
import { useAnalysisStore } from '../stores/analysis_store'
|
||||||
|
|
@ -194,7 +194,7 @@ async function restoreFromHistory(jobId) {
|
||||||
:relevancy="item.result?.relevancy"
|
:relevancy="item.result?.relevancy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PushToSheet v-if="analysisStore.activeJobId" :job-id="analysisStore.activeJobId" />
|
<SyncToSheet v-if="displayResults.length" :results="displayResults" />
|
||||||
<UButton color="neutral" variant="ghost" @click="startOver">← Run another analysis</UButton>
|
<UButton color="neutral" variant="ghost" @click="startOver">← Run another analysis</UButton>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -32,16 +32,6 @@ export async function previewPrompt(payload) {
|
||||||
return data.prompt
|
return data.prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pushToSheet(email, jobId, tabName) {
|
|
||||||
const { data } = await apiService.post('/sheet/push', { email, job_id: jobId, tab_name: tabName })
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSheetTabs(email) {
|
|
||||||
const { data } = await apiService.get('/sheet/tabs', { headers: { 'X-User-Email': email } })
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBattlecards(email) {
|
export async function getBattlecards(email) {
|
||||||
const { data } = await apiService.post('/battlecards', { email })
|
const { data } = await apiService.post('/battlecards', { email })
|
||||||
return data
|
return data
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue