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:
JasonFraser 2026-07-01 17:29:20 -04:00
parent c0a77a1a65
commit d4f2c7d96b
19 changed files with 1009 additions and 232 deletions

View File

@ -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
View File

@ -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
} }
} }
``` ```

71
appscript/Code.gs Normal file
View File

@ -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);
}

105
appscript/Sidebar.html Normal file
View File

@ -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>

224
appscript/Sync.gs Normal file
View File

@ -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') || '';
}

106
appscript/Validation.gs Normal file
View File

@ -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;
}

30
appscript/appsscript.json Normal file
View File

@ -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
}
}
}
}

View File

@ -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})

View File

@ -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()

View File

@ -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

View File

@ -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):

View File

@ -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 ────

View File

@ -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=

View File

@ -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>

View File

@ -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>

View File

@ -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' },

View File

@ -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>

View File

@ -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>

View File

@ -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