272 lines
11 KiB
JavaScript
272 lines
11 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).
|
|
*
|
|
* Layout: TRANSPOSED — one row per field (column A), one column per
|
|
* competitor (starting at column B), row 1 reserved for competitor
|
|
* names. This matches the dashboard's Copy rows / Download Excel
|
|
* export exactly (frontend/src/config/resultFields.js), which was
|
|
* itself verified against the real legacy client reference sheet
|
|
* ("G Compete App V1.xlsx") — NOT the column-per-field/row-per-
|
|
* competitor layout CLAUDE.md §15 originally described, which was
|
|
* never reconciled against that real reference file. ROW_DEFS below
|
|
* is a hand-ported mirror of resultFields.js's STANDARD_ROWS/NGS_ROWS
|
|
* — Apps Script runs in a separate sandbox with no module imports, so
|
|
* this list is duplicated rather than shared. Keep both in sync if
|
|
* either changes.
|
|
*/
|
|
|
|
const DEFAULT_TAB_NAME = 'Bettersight Analysis';
|
|
|
|
function divOrNull_(a, b) {
|
|
if (a == null || b == null || b === 0) return null;
|
|
return a / b;
|
|
}
|
|
|
|
function mulOrNull_(a, b) {
|
|
if (a == null || b == null) return null;
|
|
return a * b;
|
|
}
|
|
|
|
const STANDARD_ROWS = [
|
|
{ key: 'company', label: 'Company', source: 'name' },
|
|
{ key: 'link', label: 'Link' },
|
|
{ key: 'tripName', label: 'Trip Name' },
|
|
{ key: 'tripCode', label: 'Trip Code' },
|
|
{ key: 'serviceLevel', label: 'Service Level' },
|
|
{ key: 'groupSize', label: 'Group size' },
|
|
{ key: 'duration', label: 'Duration (days)' },
|
|
{ key: 'meals', label: 'Meals' },
|
|
{ key: 'startLocation', label: 'Start Location' },
|
|
{ key: 'endLocation', label: 'End Location' },
|
|
{ key: 'departures', label: '# Departures Offered per year' },
|
|
{ key: 'totalInventory', label: 'Total inventory', compute: function (r) { return mulOrNull_(r && r.departures, r && r.groupSize); } },
|
|
{ key: 'seasonality', label: 'Seasonality (what are the price bands in terms of dates?)' },
|
|
{ key: 'startDays', label: 'Start days of week' },
|
|
{ key: 'targetAudience', label: 'Target audience if known' },
|
|
{ key: 'hotels', label: 'Hotels (if applicable)' },
|
|
{ key: 'activities', label: 'Included Activities' },
|
|
{ key: 'relevancy', label: 'Relevancy rating' },
|
|
{ key: 'majorityPrice', label: 'Majority Price (USD)' },
|
|
{ key: 'priceLow', label: 'Price low (USD)' },
|
|
{ key: 'priceHigh', label: 'Price high (USD)' },
|
|
{ key: 'pricePerDay', label: 'Price per day (USD)', compute: function (r) { return divOrNull_(r && r.majorityPrice, r && r.duration); } },
|
|
{ key: 'comments', label: 'Suggested changes for {company}/ Competitive Comments' },
|
|
{ key: 'dateUsed', label: 'Date Used for Majority Price' },
|
|
{ key: 'audPrice', label: 'AUD Majority price' },
|
|
{ key: 'cadPrice', label: 'CAD Majority price' },
|
|
{ key: 'eurPrice', label: 'EUR Majority price' },
|
|
{ key: 'gbpPrice', label: 'GBP Majority price' },
|
|
{ key: 'audPricePerDay', label: 'AUD Price/day', compute: function (r) { return divOrNull_(r && r.audPrice, r && r.duration); } },
|
|
{ key: 'cadPricePerDay', label: 'CAD Price/day', compute: function (r) { return divOrNull_(r && r.cadPrice, r && r.duration); } },
|
|
{ key: 'eurPricePerDay', label: 'EUR Price/day', compute: function (r) { return divOrNull_(r && r.eurPrice, r && r.duration); } },
|
|
{ key: 'gbpPricePerDay', label: 'GBP Price/day', compute: function (r) { return divOrNull_(r && r.gbpPrice, r && r.duration); } }
|
|
];
|
|
|
|
// Spliced in after 'activities' — matches resultFields.js's insertAfter().
|
|
const NGS_EXTRA_ROWS = [
|
|
{ key: 'exclusiveAccess', label: 'Exclusive Access/Special Unique Inclusions' },
|
|
{ key: 'groupLeader', label: 'Group Leader and/or Local Expert Description' },
|
|
{ key: 'sustainability', label: 'Sustainability Inclusions (if applicable)' }
|
|
];
|
|
|
|
function getRowsForTabType_(tabType) {
|
|
if (tabType !== 'NGS') return STANDARD_ROWS;
|
|
const activitiesIndex = STANDARD_ROWS.findIndex(function (r) { return r.key === 'activities'; });
|
|
return STANDARD_ROWS.slice(0, activitiesIndex + 1)
|
|
.concat(NGS_EXTRA_ROWS, STANDARD_ROWS.slice(activitiesIndex + 1));
|
|
}
|
|
|
|
function normaliseHeader_(text) {
|
|
return String(text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
}
|
|
|
|
/**
|
|
* Substitutes the '{company}' token in the comments row's label.
|
|
* Apps Script has no direct route to the tenant's display name (only
|
|
* the dashboard's Pinia store carries that) — always falls back to
|
|
* 'your company'. A no-op for every other row (no token present).
|
|
*/
|
|
function formatLabel_(label, companyName) {
|
|
return label.replace('{company}', companyName || 'your company');
|
|
}
|
|
|
|
function rawValue_(row, item) {
|
|
if (row.compute) return row.compute(item.result || {});
|
|
if (row.source === 'name') return item.name;
|
|
return item.result ? item.result[row.key] : undefined;
|
|
}
|
|
|
|
/**
|
|
* 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 row index for each field by matching normalised labels
|
|
* in column A (rows 2+, row 1 is reserved for competitor names)
|
|
* against `rows` — never hardcoded row positions, so a PM can reorder
|
|
* or delete rows freely. Falls back to `rows`' own order, starting at
|
|
* row 2, only when column A has no recognisable labels at all (e.g. a
|
|
* brand-new blank tab) — writes those labels in on the spot (and sets
|
|
* the 'Field' corner label in A1) so the sheet becomes self-describing
|
|
* from that point on. A PM never has to hand-type the ~30 row labels
|
|
* themselves — leaving the tab blank and pulling once is enough.
|
|
*
|
|
* Data flow:
|
|
* sheet + rows + companyName → read column A (rows 2..lastRow) →
|
|
* normalise each cell → for each row def, match against its
|
|
* (company-substituted) label → { fieldKey: rowNumber } map →
|
|
* if nothing matched at all: bootstrap column A from `rows` in
|
|
* order → same map, now fully populated
|
|
*/
|
|
function detectFieldRows_(sheet, rows, companyName) {
|
|
const lastRow = sheet.getLastRow();
|
|
const rowMap = {};
|
|
|
|
if (lastRow >= 2) {
|
|
const columnA = sheet.getRange(2, 1, lastRow - 1, 1).getValues().map(function (r) { return r[0]; });
|
|
const normalisedColumn = columnA.map(normaliseHeader_);
|
|
|
|
rows.forEach(function (row) {
|
|
const target = normaliseHeader_(formatLabel_(row.label, companyName));
|
|
for (let i = 0; i < normalisedColumn.length; i++) {
|
|
if (normalisedColumn[i] && normalisedColumn[i] === target) {
|
|
rowMap[row.key] = i + 2; // +2: back to 1-indexed, offset past row 1
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const foundAny = Object.keys(rowMap).length > 0;
|
|
if (!foundAny) {
|
|
Logger.log('detectFieldRows_: no recognisable row labels in column A — bootstrapping a fresh template');
|
|
sheet.getRange(1, 1).setValue('Field');
|
|
rows.forEach(function (row, i) {
|
|
const rowNumber = i + 2;
|
|
sheet.getRange(rowNumber, 1).setValue(formatLabel_(row.label, companyName));
|
|
rowMap[row.key] = rowNumber;
|
|
});
|
|
}
|
|
|
|
return rowMap;
|
|
}
|
|
|
|
/**
|
|
* Writes one column per competitor into the sheet, using rowMap from
|
|
* detectFieldRows_(). Only writes fields present in rowMap — a row the
|
|
* PM deleted from the template is silently skipped, same resilience
|
|
* rule the old column-based model used.
|
|
*
|
|
* Every pull REPLACES the previously-written competitor columns
|
|
* (clears columns B onward first) rather than appending indefinitely
|
|
* — this tab always reflects the single most recent run, same as the
|
|
* dashboard's results view. Column A's row labels are left untouched.
|
|
*/
|
|
function writeResultsToSheet_(results, rowMap, rows, sheet) {
|
|
const successItems = (results && results.success) || [];
|
|
|
|
const lastColumn = sheet.getLastColumn();
|
|
if (lastColumn > 1) {
|
|
sheet.getRange(1, 2, sheet.getMaxRows(), lastColumn - 1).clearContent();
|
|
}
|
|
|
|
successItems.forEach(function (item, index) {
|
|
const col = index + 2; // column A is labels, competitors start at column B
|
|
sheet.getRange(1, col).setValue(item.name);
|
|
|
|
rows.forEach(function (row) {
|
|
const rowNumber = rowMap[row.key];
|
|
if (!rowNumber) return;
|
|
const value = rawValue_(row, item);
|
|
sheet.getRange(rowNumber, col).setValue(value == null ? '' : value);
|
|
});
|
|
});
|
|
|
|
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 + tab_type returned → target tab resolved (creates
|
|
* it if missing) → getRowsForTabType_(tab_type) selects the
|
|
* Standard/NGS row list → detectFieldRows_() → 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 rows = getRowsForTabType_(run.tab_type);
|
|
const rowMap = detectFieldRows_(sheet, rows, null);
|
|
const rowsWritten = writeResultsToSheet_(run.results, rowMap, rows, 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') || '';
|
|
}
|