-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathappscript.js
More file actions
148 lines (128 loc) · 5.83 KB
/
appscript.js
File metadata and controls
148 lines (128 loc) · 5.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
* Web-app endpoint called by a Shopify **Custom Pixel** on the
* “checkout_completed” event.
*
* ▶ Why POST instead of GET?
* • Keeps the API key and customerId out of the URL, so they don’t end up in
* browser history, server logs or intermediary caches.
* • Avoids accidental pre-fetching and caching of customer data.
*
* ▶ Security layers applied:
* • Shared-secret API key validated on every call.
* • The Shopify app that owns the access token is configured with the
* **minimum scope**: `read_customers` and `read_orders` only.
* • CORS headers are returned to let the Pixel (running in a sandboxed
* iframe with `Origin: null`) read the response while still blocking
* other browsers unless explicitly allowed.
*
* ▶ What the endpoint does:
* 1. Validates and parses the JSON payload sent by the Pixel.
* 2. Pulls all orders for the given customer via Shopify REST Admin API
* (2025-04).
* 3. Calculates order count, customer lifetime value and status
* (“New/Returning”).
* 4. Returns a compact JSON object that the Pixel pushes into the
* `dataLayer` for downstream analytics.
*/
function doPost(e) {
Logger.log('--- doPost Started ---');
try {
const props = PropertiesService.getScriptProperties();
const SECRET_API_KEY = props.getProperty('SECRET_API_KEY');
/* ---------------------------------------------------------------------
1. Parse and validate the incoming JSON payload
--------------------------------------------------------------------- */
Logger.log('Raw POST data received: ' + e.postData.contents);
let req;
try {
req = JSON.parse(e.postData.contents);
Logger.log('JSON parsing successful: ' + JSON.stringify(req));
} catch (err) {
Logger.log('!!! ERROR: Invalid JSON – ' + err.message);
return createJsonResponse({ error: 'Invalid JSON payload.' });
}
const { apiKey, customerId } = req;
/* Shared-secret check */
if (!apiKey || apiKey !== SECRET_API_KEY) {
Logger.log('!!! SECURITY: Unauthorized API key – ' + apiKey);
return createJsonResponse({ error: 'Unauthorized access.' });
}
if (!customerId) {
Logger.log('!!! ERROR: Missing customer ID');
return createJsonResponse({ error: 'Missing customer ID.' });
}
/* ---------------------------------------------------------------------
2. Prepare Shopify call (token has read-only scopes)
--------------------------------------------------------------------- */
const SHOP_TOKEN = props.getProperty('SHOPIFY_ACCESS_TOKEN');
const SHOP_NAME = props.getProperty('SHOP_NAME');
if (!SHOP_TOKEN || !SHOP_NAME) {
Logger.log('!!! CONFIG ERROR: Missing Shopify credentials');
return createJsonResponse({ error: 'Server configuration error.' });
}
const apiUrl =
`https://${SHOP_NAME}/admin/api/2025-04/customers/${customerId}` +
`/orders.json?status=any`;
Logger.log('Calling Shopify API URL: ' + apiUrl);
const resp = UrlFetchApp.fetch(apiUrl, {
method : 'get',
headers: { 'X-Shopify-Access-Token': SHOP_TOKEN },
muteHttpExceptions: true
});
if (resp.getResponseCode() !== 200) {
Logger.log('!!! Shopify error. Body: ' + resp.getContentText());
return createJsonResponse({ error: 'Unable to retrieve customer data.' });
}
/* ---------------------------------------------------------------------
3. Aggregate order data
--------------------------------------------------------------------- */
const orders = JSON.parse(resp.getContentText()).orders;
const orderCount = orders.length;
const lifetimeValue = orders
.reduce((sum, o) => sum + parseFloat(o.total_price), 0);
const customerStatus =
orderCount === 0 ? 'New - No Orders' :
orderCount === 1 ? 'New - First Order' :
'Returning Customer';
const result = {
success: true,
customerStatus,
orderCount,
lifetimeValue: lifetimeValue.toFixed(2)
};
Logger.log('--- doPost Success --- ' + JSON.stringify(result));
return createJsonResponse(result);
} catch (err) {
Logger.log('!!! CRITICAL ERROR: ' + err.message);
Logger.log(err.stack);
return createJsonResponse({ error: 'Unexpected internal server error.' });
}
}
/* ------------------------------------------------------------------------
Handles the CORS pre-flight (OPTIONS) automatically sent by the browser.
The Custom Pixel runs in an iframe whose Origin is forced to "null", so
we allow that plus any additional domains you might whitelist.
------------------------------------------------------------------------- */
function doOptions(e) {
Logger.log('--- doOptions Triggered (CORS Preflight) ---');
return ContentService.createTextOutput('')
.setMimeType(ContentService.MimeType.TEXT)
.setHeader('Access-Control-Allow-Origin', '*') // allow "null"
.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
/* Utility: builds a JSON response and appends CORS headers */
function createJsonResponse(data) {
const json = JSON.stringify(data);
Logger.log('Sending JSON response: ' + json);
return ContentService.createTextOutput(json)
.setMimeType(ContentService.MimeType.JSON)
.setHeader('Access-Control-Allow-Origin', '*')
.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
/* ------------------------------------------------------------------
DEBUGGING NOTES:
• Deploy as Web App: Execute as “Me”, access “Anyone”.
• Check Apps Script → Executions for doOptions / doPost logs.
------------------------------------------------------------------ */