diff --git a/.env.example b/.env.example index 5c2ccb5..101cac6 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,10 @@ SHEET_ID= STANDARD_TAB= NGS_TAB= +# Bettersight template Sheet — hosted share URL, served via GET +# /account/template (CLAUDE.md §15 Template Import) +TEMPLATE_SHEET_URL= + # Frontend (Vite) VITE_SENTRY_DSN= VITE_ENV=development diff --git a/CLAUDE.md b/CLAUDE.md index fcd9c5d..3e89e3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,13 +190,23 @@ Bettersight Dashboard — PRIMARY INTERFACE (app.bettersight.io) Path 2 REFRESH — last_scraped > 24hrs OR change_detected = true → RQ job → app.py Playwright scrape → PocketBase → LLM (~2 mins) → 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) - → getWorkbookTabs() — returns PM's workbook tab list for dashboard dropdown - → pushToSheet(results, tabName) — writes dashboard results to Sheet on demand +Apps Script (standalone — Sheet-initiated pull integration) + → getWorkbookTabs() — returns tab list for the sidebar's own selector + → pullLatestResults(tabName) — Sheet-initiated fetch from Flask + write → 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) → Watches all active competitor URLs @@ -469,7 +479,7 @@ bettersight/ │ │ │ │ ├── JobProgress.vue # Per-competitor real-time status during job │ │ │ │ ├── ResultsTable.vue # Side-by-side competitor comparison │ │ │ │ ├── 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 │ │ │ ├── activity/ │ │ │ │ ├── FeedItem.vue # Single activity feed row with dot grid @@ -506,11 +516,11 @@ bettersight/ │ ├── package.json │ ├── vite.config.js │ └── index.html -├── appscript/ # Export integration only — 3 files +├── appscript/ # Sheet-initiated pull integration — 3 files │ ├── Code.gs # onOpen(), install entry point │ ├── Validation.gs # validateLicence() — email only -│ ├── Export.gs # pushToSheet(), getWorkbookTabs(), detectColumns() -│ └── Sidebar.html # Minimal UI — tab selector + push button only +│ ├── Sync.gs # pullLatestResults(), getWorkbookTabs(), detectColumns() +│ └── Sidebar.html # Tab selector + pull button + last-pull status ├── n8n/ │ └── workflows/ # n8n workflow JSON exports ├── docker-compose.yml @@ -640,7 +650,22 @@ Services (src/services/) → [Upgrade now] creates a Stripe checkout session for the next tier → On successful upgrade: PocketBase tier updated via Stripe webhook → 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) - Stripe customer portal link (manage billing, cancel) - Onboarding checklist (tracked in PocketBase) @@ -671,7 +696,13 @@ Services (src/services/) → If primary market currency not extracted, fall back to USD only - ConfidenceBar — per-competitor AI confidence with low-confidence warning - 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** - Stat cards (price changes, drops, new products, competitors tracked) @@ -1229,6 +1260,11 @@ GET /research/history Query: email, limit (default 5) 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/ Returns: full stored results for a specific run @@ -1253,17 +1289,27 @@ POST /comparable-trips similarity_score, price_difference, duration_difference } ] ``` -### Apps Script Export Integration -``` -POST /sheet/push - Body: { email, job_id, tab_name } - Triggers Apps Script pushToSheet() with stored results for job_id - Returns: { success, rows_written } +### Apps Script Sheet Sync + +No dedicated routes. The Sheet-initiated pull uses the existing +research history endpoints: -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/ + 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 ``` @@ -1869,18 +1915,30 @@ def send_weekly_brief(tenant_id: str): ## 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 script's only job is to push analysis results from the dashboard -into the PM's Google Sheet workbook on demand. +The Apps Script is not the primary interface. The dashboard is. +The script's only job is to **pull** completed analysis results from +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:** ``` -pushToSheet(results, tabName) — writes dashboard results to selected tab -getWorkbookTabs() — returns tab list for dashboard push dropdown -validateLicence() — confirms PM is authorised (email check only) +pullLatestResults(tabName) — Sheet-initiated fetch + write for the most recent job +getWorkbookTabs() — returns tab list for the sidebar's own tab selector +validateLicence() — confirms PM is authorised (email check only) ``` ### 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. 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 ``` appscript/ Code.gs — onOpen(), install entry point Validation.gs — validateLicence() email-only - Export.gs — pushToSheet(), getWorkbookTabs() - Sidebar.html — minimal UI: tab selector + push button only + Sync.gs — pullLatestResults(), getWorkbookTabs(), detectColumns() + Sidebar.html — tab selector + pull button + last-pull status ``` -### Export Flow +### Pull Flow ```javascript /** - * Pushes analysis results from the dashboard to the PM's selected - * workbook tab. Called via the dashboard "Push to Sheet" button - * which hits Flask POST /sheet/push, which calls this function. + * Fetches the most recent completed analysis for the PM from Flask + * and writes it to the selected tab of the currently open workbook. + * Sheet-initiated only — cannot be triggered by any external server. * * Writes results using header-based column detection — never * hardcoded column positions. Resilient to any sheet restructuring. * * Flow: - * job_id → Flask /research/history/{job_id} → stored results → - * getWorkbookTabs() shows tab selector in dashboard dropdown → - * PM selects tab → POST /sheet/push → - * detectColumns(sheet) → writeResults(results, columnMap, sheet) + * PM clicks [Pull latest results] in sidebar → + * validateLicence() confirms authorisation → + * GET Flask /research/history/latest?email={sessionEmail} → + * 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. - * Used by the dashboard to populate the "Push to Sheet" tab selector. + * Returns list of all tab names in the PM's currently open workbook. + * Used by the sidebar (not the dashboard) to populate its own tab selector. */ function getWorkbookTabs() { return SpreadsheetApp.getActiveSpreadsheet() @@ -1936,26 +2068,26 @@ function getWorkbookTabs() { function detectColumns(sheet) { ... } ``` -### Sidebar UI (minimal) +### Sidebar UI ``` ┌─────────────────────────────┐ │ 🔵 Bettersight │ -│ Export results to Sheet │ +│ Pull results into Sheet │ │ ───────────────────────── │ -│ Push to tab: │ +│ Write to tab: │ │ [Bettersight Analysis ▾] │ │ │ -│ [ Push last analysis ] │ +│ [ Pull latest results ] │ │ ───────────────────────── │ -│ Last push: Today 9:14am │ +│ Pulled: Today 9:14am │ │ 5 competitors · Peru │ └─────────────────────────────┘ ``` The full analysis workflow — competitor selection, trip-intent matching, 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 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 36. Tour replay link on AccountPage.vue -Phase 4 — Apps Script (export integration — simple) - 31. Deploy as standalone Apps Script +Phase 4 — Apps Script (Sheet-initiated pull — simple) + 31. Deploy as standalone Apps Script — not container-bound to any workbook 32. Validation.gs — validateLicence() email-only - 33. Export.gs — pushToSheet(), getWorkbookTabs(), detectColumns() - 34. Sidebar.html — minimal: tab selector + push button only + 33. Sync.gs — pullLatestResults(), getWorkbookTabs(), detectColumns() + 34. Sidebar.html — tab selector + [Pull latest results] + last-pull status 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 37. n8n daily scrape cron — changed/stale competitors only @@ -2976,14 +3108,15 @@ Updated automatically as each action is completed. Collapses when all steps done ``` onboarding_checklist (JSON field on tenants) - { - "add_competitor": false, // set true when first competitor saved - "run_analysis": false, // set true when first job completes - "push_to_sheet": false, // set true when first push to Sheet runs - "download_template": false, // set true when template downloaded - "tour_completed": false, // set true when tour reaches final step - "tour_skipped": false, // set true when PM clicks Skip - "tour_step": 0 // last step reached — for resume on return + { + "add_competitor": false, // set true when first competitor is created + "run_analysis": false, // set true when first job completes + "template_imported": false, // set true from AccountPage confirmation + "sidebar_installed": false, // set true first time validateLicence() succeeds + "sync_from_sheet": false, // set true first time pullLatestResults() succeeds + "tour_completed": false, // set true when guided tour reaches final step + "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') -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 │ - │ results directly into your Google Sheet │ - │ with one click. Select which tab to write to. │ + │ Once your analysis is complete, copy rows or │ + │ open your workbook and click Bettersight → │ + │ Pull latest results in the sidebar. │ │ │ │ [← Back] [Done — let's go ✓] │ └─────────────────────────────────────────────────┘ @@ -3195,13 +3328,13 @@ export function useOnboardingTour() { }) tour.addStep({ - id: 'push-to-sheet', - attachTo: { element: '[data-tour="push-to-sheet"]', + id: 'sync-to-sheet', + attachTo: { element: '[data-tour="sync-to-sheet"]', on: 'top' }, text: ` - Push results to your Sheet 📊

- Once your analysis completes, push results directly into - your Google Sheet with one click. Select which tab to write to. + Get results into your Sheet 📊

+ Copy rows to the clipboard from here, or open your workbook + and click Bettersight → Pull latest results in the sidebar. `, buttons: [ { text: '← Back', action: tour.back, @@ -3275,8 +3408,8 @@ onMounted(() => { - -
+ +
``` **Custom tour styles — extend Bettersight design system:** @@ -5188,9 +5321,9 @@ def build_json_schema(is_ngs: bool, custom_fields: list) -> dict: 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 Apps Script for new custom fields. @@ -5734,13 +5867,14 @@ POST /api/collections/tenants/records "stripe_customer_id": "cus_xxx", "stripe_subscription_id": "sub_xxx", "onboarding_checklist": { - "add_competitor": false, - "run_analysis": false, - "push_to_sheet": false, - "download_template": false, - "tour_completed": false, - "tour_skipped": false, - "tour_step": 0 + "add_competitor": false, + "run_analysis": false, + "template_imported": false, + "sidebar_installed": false, + "sync_from_sheet": false, + "tour_completed": false, + "tour_skipped": false, + "tour_step": 0 } } ``` @@ -5824,4 +5958,4 @@ Client name | Tier | Trial start | Trial end | Converted? | MRR | Notes ``` Update after every client action. This is your first customer success dashboard -before you build anything automated. +before you build anything automated. \ No newline at end of file diff --git a/appscript/Code.gs b/appscript/Code.gs new file mode 100644 index 0000000..c8586bb --- /dev/null +++ b/appscript/Code.gs @@ -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); +} diff --git a/appscript/Sidebar.html b/appscript/Sidebar.html new file mode 100644 index 0000000..bb6a05f --- /dev/null +++ b/appscript/Sidebar.html @@ -0,0 +1,105 @@ + + + + + + + + +
Pull results into Sheet
+ + + + + + +
+
Loading…
+ + + + diff --git a/appscript/Sync.gs b/appscript/Sync.gs new file mode 100644 index 0000000..7fd1157 --- /dev/null +++ b/appscript/Sync.gs @@ -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') || ''; +} diff --git a/appscript/Validation.gs b/appscript/Validation.gs new file mode 100644 index 0000000..768a34c --- /dev/null +++ b/appscript/Validation.gs @@ -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; +} diff --git a/appscript/appsscript.json b/appscript/appsscript.json new file mode 100644 index 0000000..9323df4 --- /dev/null +++ b/appscript/appsscript.json @@ -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 + } + } + } +} diff --git a/backend/api.py b/backend/api.py index dc2801c..ca5707e 100644 --- a/backend/api.py +++ b/backend/api.py @@ -34,6 +34,7 @@ api_bp = Blueprint('api', __name__) API_SECRET_KEY = os.getenv('API_SECRET_KEY') 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') 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)) +@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/', methods=['GET']) @require_api_key def research_history_detail(job_id): @@ -419,51 +440,14 @@ def mark_alert_read(alert_id): return jsonify(alert_repository.mark_read(alert_id)) -# ── APPS SCRIPT EXPORT INTEGRATION ──────────────────────────────────── -# NOTE: the Apps Script is a standalone script running in the PM's own -# Google account (CLAUDE.md §15) — it is not deployed or addressable by -# this backend until Phase 4 defines its Web App URL / install model. -# These two routes have a correct, working contract against PocketBase -# today; the actual "push the bytes into the PM's open Sheet" step is a -# Phase 4 dependency and is clearly marked below rather than guessed at. - -@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 - ) +# ── APPS SCRIPT SHEET SYNC ────────────────────────────────────────── +# No dedicated routes here per CLAUDE.md §15 — the Sheet-initiated pull +# uses GET /research/history/latest (above) and GET +# /research/history/ (below). A stateless Flask backend has no +# way to reach into whichever Sheet a PM currently has open, so the +# Sheet pulls from Flask rather than Flask pushing to the Sheet; +# getWorkbookTabs() runs entirely inside the script and needs no Flask +# counterpart. # ── DASHBOARD ACCOUNT ────────────────────────────────────────────────── @@ -624,18 +608,39 @@ def delete_competitor(competitor_id): @api_bp.route('/account/template', methods=['GET']) @require_api_key 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. - return jsonify({'template_url': 'https://docs.google.com/spreadsheets/d/TEMPLATE_SHEET_ID/copy'}) + """ + Returns the hosted Bettersight template Sheet's share URL, per + 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']) @require_api_key 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) if not tenant_id: - return error_response('unauthorized', 'Could not resolve tenant from session', 401) - updates = request.json or {} + tenant = _resolve_tenant_by_email(updates.pop('email', None)) + 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) return jsonify({'onboarding_checklist': checklist}) diff --git a/backend/repositories/scrape_repository.py b/backend/repositories/scrape_repository.py index f049b8f..9ee0916 100644 --- a/backend/repositories/scrape_repository.py +++ b/backend/repositories/scrape_repository.py @@ -23,6 +23,14 @@ class ScrapeRepository: 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: """In-memory stand-in for ScrapeRepository.""" @@ -50,5 +58,13 @@ class MockScrapeRepository: items.sort(key=lambda r: r.get('started_at') or '', reverse=True) 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() diff --git a/backend/services/onboarding_service.py b/backend/services/onboarding_service.py index 8e8b63b..a860149 100644 --- a/backend/services/onboarding_service.py +++ b/backend/services/onboarding_service.py @@ -3,8 +3,9 @@ from repositories.tenant_repository import tenant_repository DEFAULT_CHECKLIST = { 'add_competitor': False, 'run_analysis': False, - 'push_to_sheet': False, - 'download_template': False, + 'template_imported': False, + 'sidebar_installed': False, + 'sync_from_sheet': False, 'tour_completed': False, 'tour_skipped': False, 'tour_step': 0, @@ -16,7 +17,10 @@ def update_onboarding(tenant_id, updates): Merges partial onboarding_checklist updates into a tenant's record. Used both by the guided-tour composable (tour_step, tour_completed, 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: tenant_id + partial onboarding dict → diff --git a/backend/tests/repositories/test_real_repositories.py b/backend/tests/repositories/test_real_repositories.py index ae8c56e..3354178 100644 --- a/backend/tests/repositories/test_real_repositories.py +++ b/backend/tests/repositories/test_real_repositories.py @@ -118,6 +118,16 @@ def test_scrape_repository_delegates_correctly(monkeypatch): assert repo.update('r1', {'status': 'complete'}) == fake.update_return assert repo.get_by_id('r1') == fake.get_one_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): diff --git a/backend/tests/test_api_extended.py b/backend/tests/test_api_extended.py index 8559556..7e572c6 100644 --- a/backend/tests/test_api_extended.py +++ b/backend/tests/test_api_extended.py @@ -129,10 +129,11 @@ def test_delete_unknown_competitor_404(client, api_headers): 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) 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): @@ -153,6 +154,28 @@ def test_onboarding_patch_updates_checklist(client, api_headers, monkeypatch): 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): response = client.post('/account/delete', json={}, headers=api_headers) 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 -# ── 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): - tenant = _make_tenant() - run = scrape_repository.create({ - '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_research_history_latest_requires_known_email(client, api_headers): + response = client.get('/research/history/latest', query_string={'email': 'nobody@nowhere.com'}, headers=api_headers) + assert response.status_code == 404 -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() - response = client.get('/sheet/tabs', headers={**api_headers, 'X-User-Email': 'pm@gadventures.com'}) - assert response.status_code == 501 + response = client.get( + '/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 ──── diff --git a/frontend/.env.example b/frontend/.env.example index a27cb5c..96e6a4c 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,8 @@ VITE_API_BASE_URL=http://localhost:5000 VITE_API_KEY= VITE_SENTRY_DSN= 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= + diff --git a/frontend/src/components/analyse/PushToSheet.vue b/frontend/src/components/analyse/PushToSheet.vue deleted file mode 100644 index b416670..0000000 --- a/frontend/src/components/analyse/PushToSheet.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - diff --git a/frontend/src/components/analyse/SyncToSheet.vue b/frontend/src/components/analyse/SyncToSheet.vue new file mode 100644 index 0000000..8e1b278 --- /dev/null +++ b/frontend/src/components/analyse/SyncToSheet.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/composables/useOnboardingTour.js b/frontend/src/composables/useOnboardingTour.js index 4e88e33..1d817a9 100644 --- a/frontend/src/composables/useOnboardingTour.js +++ b/frontend/src/composables/useOnboardingTour.js @@ -85,12 +85,12 @@ export function useOnboardingTour() { }) tour.addStep({ - id: 'push-to-sheet', - attachTo: { element: '[data-tour="push-to-sheet"]', on: 'top' }, + id: 'sync-to-sheet', + attachTo: { element: '[data-tour="sync-to-sheet"]', on: 'top' }, text: ` - Push results to your Sheet 📊

- Once your analysis completes, push results directly into your Google - Sheet with one click. Select which tab to write to. + Get results into your Sheet 📊

+ Copy rows to the clipboard from here, or open your workbook and click + Bettersight → Pull latest results in the sidebar. `, buttons: [ { text: '← Back', action: tour.back, classes: 'bs-btn-ghost' }, diff --git a/frontend/src/pages/AccountPage.vue b/frontend/src/pages/AccountPage.vue index f33feb2..a6859a6 100644 --- a/frontend/src/pages/AccountPage.vue +++ b/frontend/src/pages/AccountPage.vue @@ -38,6 +38,7 @@ const TIER_PITCH = { const nextTier = computed(() => NEXT_TIER[authStore.tier]) const upgradePitch = computed(() => nextTier.value ? TIER_PITCH[nextTier.value] : null) +const appsScriptInstallUrl = import.meta.env.VITE_APPS_SCRIPT_INSTALL_URL onMounted(async () => { await authStore.fetchTenant() @@ -74,12 +75,23 @@ async function upgrade() { window.location.href = checkoutUrl } -async function downloadTemplate() { +async function openTemplate() { const url = await tenantRepository.getTemplateUrl() - await authStore.updateOnboarding({ download_template: true }) 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() { authStore.updateOnboarding({ tour_completed: false, tour_skipped: false, tour_step: 0 }) tour.start() @@ -107,12 +119,44 @@ function replayTour() { Upgrade now →
-
-
Google Sheet template
-

- Download the Bettersight tab to import into your existing workbook. -

- Download template +
+
Connect your Google Sheet
+ +
+

+ 1. Open the Bettersight template and copy its one tab into your + existing workbook — nothing else in your workbook changes. +

+ Open template ↗ +
+ +
+ Import the Bettersight tab into your workbook: +
    +
  1. Right-click any tab in the template → Copy to → Existing spreadsheet → select your workbook
  2. +
  3. Rename the tab to anything you want (optional)
  4. +
  5. Install the Bettersight sidebar (one click): Install sidebar ↗
  6. +
+
+ +
+

+ Once you've imported the tab, paste your workbook's URL below just to confirm to yourself it's done: +

+
+ + It worked +
+
+
+ +
+ ✓ Connected + Your Sheet template is set up.
diff --git a/frontend/src/pages/AnalysePage.vue b/frontend/src/pages/AnalysePage.vue index 636b6c4..dea898a 100644 --- a/frontend/src/pages/AnalysePage.vue +++ b/frontend/src/pages/AnalysePage.vue @@ -7,7 +7,7 @@ import UrlConfirmation from '../components/analyse/UrlConfirmation.vue' import JobProgress from '../components/analyse/JobProgress.vue' import ResultsTable from '../components/analyse/ResultsTable.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 { useAuthStore } from '../stores/auth_store' import { useAnalysisStore } from '../stores/analysis_store' @@ -194,7 +194,7 @@ async function restoreFromHistory(jobId) { :relevancy="item.result?.relevancy" />
- + ← Run another analysis diff --git a/frontend/src/repositories/analysis_repository.js b/frontend/src/repositories/analysis_repository.js index 6430b2d..4794d9b 100644 --- a/frontend/src/repositories/analysis_repository.js +++ b/frontend/src/repositories/analysis_repository.js @@ -32,16 +32,6 @@ export async function previewPrompt(payload) { 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) { const { data } = await apiService.post('/battlecards', { email }) return data