app.bettersight.io/appscript/Sync.gs

225 lines
9.0 KiB
JavaScript

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