The doPost() handler to paste into the Sheet's bound Apps Script project.
It appends a batch of rows in one setValues() call (the client sends
{rows: [...]}), which is what keeps the write cost — and the Apps Script
execution quota — flat regardless of interaction rate.
Details
Column order is taken from cc_log_header(), so the Sheet, the script, and
the client payload cannot drift.
Examples
cat(cc_apps_script())
#> // Code.gs — CalCOFI usage log (bound to the log Sheet).
#> // Generated by calcofi4r::cc_apps_script() — do not hand-edit.
#> // Deploy: Deploy > New deployment > type "Web app",
#> // execute as "Me", who has access "Anyone". Copy the /exec URL into
#> // the CALCOFI_LOG_URL environment variable for the Shiny apps.
#> //
#> // The client (calcofi4r::cc_ga_js) posts {rows:[{...}, ...]} as text/plain so
#> // the request stays CORS-simple (this endpoint does not answer OPTIONS).
#>
#> var COLS = ["timestamp","ip","session","event","params","n_rows","ms","status","error","app_version","app","client_id","session_id","page","referrer","user_agent"];
#>
#> // Health check. Without this, opening the /exec URL in a browser returns
#> // "Script function not found: doGet", which looks like a broken or unauthorized
#> // deployment and sends you hunting through deployment settings. It is not — the
#> // client only ever POSTs. A GET now answers {ok:true,...} so the endpoint can be
#> // verified at a glance. Reports the row count so you can confirm writes land.
#> function doGet(e) {
#> try {
#> var sh = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
#> return ContentService
#> .createTextOutput(JSON.stringify({ ok: true, endpoint: "calcofi-usage-log", rows: sh.getLastRow() - 1 }))
#> .setMimeType(ContentService.MimeType.JSON);
#> } catch (err) {
#> return ContentService
#> .createTextOutput(JSON.stringify({ ok: false, error: String(err) }))
#> .setMimeType(ContentService.MimeType.JSON);
#> }
#> }
#>
#> function doPost(e) {
#> try {
#> var sh = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
#> var body = JSON.parse(e.postData.contents);
#> var rows = body.rows || [body];
#> if (!rows.length) return _ok(0);
#>
#> // one setValues() for the whole batch — far cheaper than appendRow() per event
#> var values = rows.map(function (r) {
#> return COLS.map(function (c) {
#> if (c === "timestamp")
#> // a real Date, matching the rows written before the client-side
#> // rewrite — an ISO string would land as unsortable text.
#> return r.timestamp ? new Date(r.timestamp) : new Date();
#> return r[c] === undefined ? "" : r[c];
#> });
#> });
#> sh.getRange(sh.getLastRow() + 1, 1, values.length, COLS.length).setValues(values);
#> return _ok(values.length);
#> } catch (err) {
#> return ContentService
#> .createTextOutput(JSON.stringify({ ok: false, error: String(err) }))
#> .setMimeType(ContentService.MimeType.JSON);
#> }
#> }
#>
#> function _ok(n) {
#> return ContentService
#> .createTextOutput(JSON.stringify({ ok: true, n: n }))
#> .setMimeType(ContentService.MimeType.JSON);
#> }