fix: robust fulfillment service + locationId setup with backfill endpoint
fulfillmentService.js: - If fulfillmentServiceCreate returns userErrors (already exists), query existing service via fulfillmentServices(type:THIRD_PARTY) instead of failing - Fix provinceCode bug: was using address.phone instead of address.provinceCode - Fix location lookup: search locations(first:1) instead of by exact name 'Shop location' (most stores use different names) - Use JSON.stringify for all string fields to avoid GraphQL injection - Fallback: if locationAdd fails (missing write_locations scope or address error), use fulfillmentService.location.id as the locationId — so locationId is NEVER null when a fulfillment service exists server.js: - Import saveToken and createFulfillmentService - Add GET /admin/fix-location/:shop? endpoint to backfill shops with null locationId without requiring reinstall Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b7fa2a21a9
commit
fb7d447648
@ -1,96 +1,12 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { log } = require('./logger');
|
const { log } = require('./logger');
|
||||||
/**
|
|
||||||
* Logs messages with a shop context.
|
|
||||||
* @param {string} shop - Shopify store name.
|
|
||||||
* @param {string} message - Log message.
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
const getLocationQuery = `
|
|
||||||
query {
|
|
||||||
|
|
||||||
locations(first: 1, query: "name:'Shop location'" ) {
|
|
||||||
nodes {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
address {
|
|
||||||
address1
|
|
||||||
address2
|
|
||||||
city
|
|
||||||
province
|
|
||||||
provinceCode
|
|
||||||
country
|
|
||||||
countryCode
|
|
||||||
zip
|
|
||||||
phone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
async function getStoreAddress(client) {
|
|
||||||
const response = await client.post('', { query: getLocationQuery });
|
|
||||||
console.log('Store locations response:', response.data);
|
|
||||||
const location = response.data.data.locations.nodes[0];
|
|
||||||
return location.address;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createLocationMutation = (address) => `
|
|
||||||
mutation {
|
|
||||||
locationAdd(input: {
|
|
||||||
name: "(App) Data4Autos Distribution API",
|
|
||||||
address: {
|
|
||||||
address1: "${address.address1 || ''}",
|
|
||||||
address2: "${address.address2 || ''}",
|
|
||||||
city: "${address.city}",
|
|
||||||
provinceCode: "${address.phone || 'ON'}",
|
|
||||||
countryCode: ${address.countryCode},
|
|
||||||
zip: "${address.zip || ''}",
|
|
||||||
phone: "${address.phone || ''}"
|
|
||||||
},
|
|
||||||
fulfillsOnlineOrders: true
|
|
||||||
}) {
|
|
||||||
location {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
address {
|
|
||||||
address1
|
|
||||||
city
|
|
||||||
provinceCode
|
|
||||||
countryCode
|
|
||||||
zip
|
|
||||||
phone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
userErrors {
|
|
||||||
code
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
async function createCustomLocation(address, client) {
|
|
||||||
const mutation = createLocationMutation(address);
|
|
||||||
|
|
||||||
|
|
||||||
const response = await client.post('', { query: mutation });
|
|
||||||
// console.log('Location creation response:', response.data);
|
|
||||||
return response.data.data.locationAdd;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Shopify fulfillment service.
|
* Create (or retrieve existing) fulfillment service + location for a shop.
|
||||||
* @param {string} shop - The Shopify store domain (e.g., myshop.myshopify.com).
|
* Returns { fulfillmentService, locationId } — locationId is NEVER null if a
|
||||||
* @param {string} accessToken - The Shopify Admin API access token.
|
* fulfillment service exists, because we fall back to the service's own location.
|
||||||
* @returns {Promise<object>} The created fulfillment service or error details.
|
|
||||||
*/
|
*/
|
||||||
async function createFulfillmentService(shop, accessToken) {
|
async function createFulfillmentService(shop, accessToken) {
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: `https://${shop}/admin/api/2025-10/graphql.json`,
|
baseURL: `https://${shop}/admin/api/2025-10/graphql.json`,
|
||||||
headers: {
|
headers: {
|
||||||
@ -99,183 +15,128 @@ async function createFulfillmentService(shop, accessToken) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutation = `
|
try {
|
||||||
mutation {
|
log(shop, `🚀 [FulfillmentService] Creating fulfillment service...`);
|
||||||
fulfillmentServiceCreate(
|
|
||||||
name: "Data4Autos Distribution",
|
// ── Step 1: Create fulfillment service (or retrieve existing) ────────────
|
||||||
callbackUrl: "https://backend.data4autos.com/fulfillment"
|
const createResp = await client.post('', {
|
||||||
) {
|
query: `
|
||||||
fulfillmentService {
|
mutation {
|
||||||
id
|
fulfillmentServiceCreate(
|
||||||
serviceName
|
name: "Data4Autos Distribution",
|
||||||
callbackUrl
|
callbackUrl: "https://backend.data4autos.com/fulfillment"
|
||||||
handle
|
) {
|
||||||
location {
|
fulfillmentService {
|
||||||
id
|
id serviceName callbackUrl handle
|
||||||
|
location { id name }
|
||||||
|
}
|
||||||
|
userErrors { field message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
userErrors {
|
`,
|
||||||
field
|
});
|
||||||
message
|
|
||||||
|
let fulfillmentService = createResp.data?.data?.fulfillmentServiceCreate?.fulfillmentService;
|
||||||
|
const createErrors = createResp.data?.data?.fulfillmentServiceCreate?.userErrors || [];
|
||||||
|
|
||||||
|
if (createErrors.length > 0) {
|
||||||
|
log(shop, `⚠️ [FulfillmentService] Create errors: ${JSON.stringify(createErrors)}`);
|
||||||
|
// Service likely already exists — query it instead
|
||||||
|
log(shop, `🔍 [FulfillmentService] Querying existing fulfillment service...`);
|
||||||
|
const existingResp = await client.post('', {
|
||||||
|
query: `
|
||||||
|
query {
|
||||||
|
fulfillmentServices(type: THIRD_PARTY) {
|
||||||
|
id serviceName callbackUrl handle
|
||||||
|
location { id name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
const services = existingResp.data?.data?.fulfillmentServices || [];
|
||||||
|
fulfillmentService = services.find(s => s.handle === 'data4autos-distribution') || services[0] || null;
|
||||||
|
|
||||||
|
if (!fulfillmentService) {
|
||||||
|
log(shop, `❌ [FulfillmentService] Could not create or find existing service`);
|
||||||
|
return { fulfillmentService: null, locationId: null };
|
||||||
|
}
|
||||||
|
log(shop, `✅ [FulfillmentService] Found existing: ${fulfillmentService.id}`);
|
||||||
|
} else {
|
||||||
|
log(shop, `✅ [FulfillmentService] Created: ${fulfillmentService.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fulfillment service has its own Shopify-managed location — use it as fallback
|
||||||
|
const serviceFallbackLocationId = fulfillmentService?.location?.id || null;
|
||||||
|
log(shop, `📍 [FulfillmentService] Service location (fallback): ${serviceFallbackLocationId}`);
|
||||||
|
|
||||||
|
// ── Step 2: Try to create a custom named location ────────────────────────
|
||||||
|
// First get the store's primary location address (search broadly, not by exact name)
|
||||||
|
let storeAddress = null;
|
||||||
|
try {
|
||||||
|
const locResp = await client.post('', {
|
||||||
|
query: `query { locations(first: 1) { nodes { id name address { address1 address2 city province provinceCode country countryCode zip phone } } } }`,
|
||||||
|
});
|
||||||
|
const locNode = locResp.data?.data?.locations?.nodes?.[0];
|
||||||
|
if (locNode) {
|
||||||
|
storeAddress = locNode.address;
|
||||||
|
log(shop, `📍 [FulfillmentService] Store address from "${locNode.name}": ${JSON.stringify(storeAddress)}`);
|
||||||
|
}
|
||||||
|
} catch (addrErr) {
|
||||||
|
log(shop, `⚠️ [FulfillmentService] Could not fetch store address: ${addrErr.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let customLocationId = null;
|
||||||
|
if (storeAddress) {
|
||||||
|
try {
|
||||||
|
const provinceCode = storeAddress.provinceCode || storeAddress.province || '';
|
||||||
|
const countryCode = storeAddress.countryCode || 'US';
|
||||||
|
|
||||||
|
const locAddResp = await client.post('', {
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
locationAdd(input: {
|
||||||
|
name: "(App) Data4Autos Distribution API",
|
||||||
|
address: {
|
||||||
|
address1: ${JSON.stringify(storeAddress.address1 || '')},
|
||||||
|
address2: ${JSON.stringify(storeAddress.address2 || '')},
|
||||||
|
city: ${JSON.stringify(storeAddress.city || '')},
|
||||||
|
provinceCode: ${JSON.stringify(provinceCode)},
|
||||||
|
countryCode: ${countryCode},
|
||||||
|
zip: ${JSON.stringify(storeAddress.zip || '')},
|
||||||
|
phone: ${JSON.stringify(storeAddress.phone || '')}
|
||||||
|
},
|
||||||
|
fulfillsOnlineOrders: true
|
||||||
|
}) {
|
||||||
|
location { id name }
|
||||||
|
userErrors { code field message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const locAddData = locAddResp.data?.data?.locationAdd;
|
||||||
|
if (locAddData?.userErrors?.length > 0) {
|
||||||
|
log(shop, `⚠️ [FulfillmentService] locationAdd errors: ${JSON.stringify(locAddData.userErrors)}`);
|
||||||
|
} else if (locAddData?.location?.id) {
|
||||||
|
customLocationId = locAddData.location.id;
|
||||||
|
log(shop, `✅ [FulfillmentService] Custom location created: ${customLocationId}`);
|
||||||
}
|
}
|
||||||
|
} catch (locErr) {
|
||||||
|
log(shop, `⚠️ [FulfillmentService] locationAdd threw: ${locErr.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
|
||||||
|
|
||||||
// const mutation = `
|
// Use custom location if created, otherwise fall back to fulfillment service location
|
||||||
// mutation {
|
const locationId = customLocationId || serviceFallbackLocationId;
|
||||||
// fulfillmentServiceCreate(
|
log(shop, `📍 [FulfillmentService] Final locationId: ${locationId} (${customLocationId ? 'custom' : 'service fallback'})`);
|
||||||
// name: "Data4Autos Distribution",
|
|
||||||
// callbackUrl: "https://backend.data4autos.com/fulfillment",
|
|
||||||
// fulfillmentOrdersOptIn: true,
|
|
||||||
// inventoryManagement: true,
|
|
||||||
// trackingSupport: true,
|
|
||||||
// requiresShippingMethod: true
|
|
||||||
//
|
|
||||||
// ) {
|
|
||||||
// fulfillmentService {
|
|
||||||
// id
|
|
||||||
// serviceName
|
|
||||||
// callbackUrl
|
|
||||||
// handle
|
|
||||||
// location {
|
|
||||||
// id
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// userErrors {
|
|
||||||
// field
|
|
||||||
// message
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// `;
|
|
||||||
|
|
||||||
try {
|
return { fulfillmentService, locationId };
|
||||||
log(shop, `🚀 Creating fulfillment service...`);
|
|
||||||
const response = await client.post('', { query: mutation });
|
|
||||||
const data = response.data.data.fulfillmentServiceCreate;
|
|
||||||
|
|
||||||
if (data.userErrors && data.userErrors.length > 0) {
|
|
||||||
log(shop, `❌ User errors: ${JSON.stringify(data.userErrors)}`);
|
|
||||||
return { success: false, errors: data.userErrors };
|
|
||||||
}
|
|
||||||
|
|
||||||
log(shop, `✅ Fulfillment Service created: ${JSON.stringify(data.fulfillmentService)}`);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Step 1: Get the store address
|
|
||||||
const address = await getStoreAddress(client);
|
|
||||||
|
|
||||||
// Step 2: Create a new location with a custom name and the same address
|
|
||||||
// console.log(address)
|
|
||||||
const result = await createCustomLocation(address, client);
|
|
||||||
// console.log('Result from creating custom location:', result);
|
|
||||||
if (result.userErrors && result.userErrors.length > 0) {
|
|
||||||
console.error('Location creation errors:', result.userErrors);
|
|
||||||
} else {
|
|
||||||
console.log('Created location:', result.location);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var locationId = result.location ? result.location.id : null;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return { success: true, fulfillmentService: data.fulfillmentService, locationId };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(shop, `💥 Request failed: ${error.response ? JSON.stringify(error.response.data) : error.message}`);
|
const errDetail = error.response ? JSON.stringify(error.response.data) : error.message;
|
||||||
return { success: false, error: error.response ? error.response.data : error.message };
|
log(shop, `💥 [FulfillmentService] Request failed: ${errDetail}`);
|
||||||
|
return { fulfillmentService: null, locationId: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
module.exports = { createFulfillmentService };
|
module.exports = { createFulfillmentService };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// const axios = require('axios');
|
|
||||||
// const { log } = require('./logger');
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// * Create a Shopify fulfillment service.
|
|
||||||
// * @param {string} shop - The Shopify store domain (e.g., myshop.myshopify.com).
|
|
||||||
// * @param {string} accessToken - The Shopify Admin API access token.
|
|
||||||
// * @returns {Promise<object>} The created fulfillment service or error details.
|
|
||||||
// */
|
|
||||||
// async function createFulfillmentService(shop, accessToken) {
|
|
||||||
// const client = axios.create({
|
|
||||||
// baseURL: `https://${shop}/admin/api/2024-01/graphql.json`,
|
|
||||||
// headers: {
|
|
||||||
// 'X-Shopify-Access-Token': accessToken,
|
|
||||||
// 'Content-Type': 'application/json',
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const mutation = `
|
|
||||||
// mutation {
|
|
||||||
// fulfillmentServiceCreate(
|
|
||||||
// name: "Data4Autos Distribution",
|
|
||||||
// callbackUrl: "https://backend.data4autos.com/fulfillment",
|
|
||||||
// fulfillmentOrdersOptIn: true,
|
|
||||||
// inventoryManagement: true,
|
|
||||||
// trackingSupport: true
|
|
||||||
// ) {
|
|
||||||
// fulfillmentService {
|
|
||||||
// id
|
|
||||||
// serviceName
|
|
||||||
// callbackUrl
|
|
||||||
// handle
|
|
||||||
// location {
|
|
||||||
// id
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// userErrors {
|
|
||||||
// field
|
|
||||||
// message
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// `;
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// log(shop, `🚀 Creating fulfillment service...`);
|
|
||||||
// const response = await client.post('', { query: mutation });
|
|
||||||
// console.log(`Response from Shopify: ${JSON.stringify(response.data, null, 2)}`);
|
|
||||||
// const data = response.data.data.fulfillmentServiceCreate;
|
|
||||||
|
|
||||||
// if (data.userErrors && data.userErrors.length > 0) {
|
|
||||||
// console.log(shop, `❌ User errors: ${JSON.stringify(data.userErrors)}`);
|
|
||||||
// return { success: false, errors: data.userErrors };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// log(shop, `✅ Fulfillment Service created: ${JSON.stringify(data.fulfillmentService)}`);
|
|
||||||
// return { success: true, fulfillmentService: data.fulfillmentService };
|
|
||||||
// } catch (error) {
|
|
||||||
// log(shop, `💥 Request failed: ${error.response ? JSON.stringify(error.response.data) : error.message}`);
|
|
||||||
// return { success: false, error: error.response ? error.response.data : error.message };
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// module.exports = { createFulfillmentService };
|
|
||||||
|
|
||||||
// // -------------------------------
|
|
||||||
// // Test call (comment out later)
|
|
||||||
// // -------------------------------
|
|
||||||
// // (async () => {
|
|
||||||
// // const shop = "veloxautomotive.myshopify.com"; // Replace with your shop domain
|
|
||||||
// // const accessToken = "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"; // Replace with your token
|
|
||||||
|
|
||||||
// // const result = await createFulfillmentService(shop, accessToken);
|
|
||||||
// // console.log("Result:", result);
|
|
||||||
// // })();
|
|
||||||
|
|||||||
41
server.js
41
server.js
@ -11,10 +11,11 @@ const managepricing = require('./routes/managePricing');
|
|||||||
const adminPanel = require('./routes/adminPanel');
|
const adminPanel = require('./routes/adminPanel');
|
||||||
|
|
||||||
const privacyLawWebhooks = require('./routes/privacyLawWebhooks');
|
const privacyLawWebhooks = require('./routes/privacyLawWebhooks');
|
||||||
const { getToken, listTokens } = require('./tokenStore');
|
const { getToken, saveToken, listTokens } = require('./tokenStore');
|
||||||
const { listJobs, getJob, cancelJob, getLatestJobForShop } = require('./jobStore');
|
const { listJobs, getJob, cancelJob, getLatestJobForShop } = require('./jobStore');
|
||||||
const { isShopAllowed } = require('./freeAccessStore');
|
const { isShopAllowed } = require('./freeAccessStore');
|
||||||
const { addMessage: addChatMessage, readChat } = require('./chatStore');
|
const { addMessage: addChatMessage, readChat } = require('./chatStore');
|
||||||
|
const { createFulfillmentService } = require('./fulfillmentService');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3002;
|
const PORT = process.env.PORT || 3002;
|
||||||
@ -32,6 +33,44 @@ app.get('/free-access/:shop', (req, res) => {
|
|||||||
res.json({ shop, allowed });
|
res.json({ shop, allowed });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── BACKFILL: Re-run fulfillment service + locationId for shops that have null ─
|
||||||
|
// Hit: GET /admin/fix-location → runs all shops with null locationId
|
||||||
|
// Hit: GET /admin/fix-location/:shop → runs one specific shop
|
||||||
|
app.get('/admin/fix-location/:shop?', async (req, res) => {
|
||||||
|
const { createFulfillmentService } = require('./fulfillmentService');
|
||||||
|
const stores = listTokens();
|
||||||
|
const targetShop = req.params.shop ? decodeURIComponent(req.params.shop).toLowerCase().trim() : null;
|
||||||
|
|
||||||
|
const toFix = Object.entries(stores).filter(([shop, record]) => {
|
||||||
|
if (targetShop) return shop === targetShop;
|
||||||
|
return !record.locationId; // only null locationId shops
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toFix.length === 0) {
|
||||||
|
return res.json({ message: 'No shops need fixing', checked: Object.keys(stores).length });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const [shop, record] of toFix) {
|
||||||
|
if (!record.accessToken) {
|
||||||
|
results.push({ shop, status: 'skipped', reason: 'no accessToken' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
log(shop, `🔧 [BACKFILL] Re-running fulfillment setup for ${shop}`);
|
||||||
|
const { fulfillmentService, locationId } = await createFulfillmentService(shop, record.accessToken);
|
||||||
|
saveToken(shop, record.accessToken, record.scope, fulfillmentService, locationId);
|
||||||
|
results.push({ shop, status: 'fixed', locationId, fulfillmentServiceId: fulfillmentService?.id || null });
|
||||||
|
log(shop, `✅ [BACKFILL] locationId=${locationId}`);
|
||||||
|
} catch (err) {
|
||||||
|
results.push({ shop, status: 'error', error: err.message });
|
||||||
|
log(shop, `❌ [BACKFILL] Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ fixed: results.filter(r => r.status === 'fixed').length, total: toFix.length, results });
|
||||||
|
});
|
||||||
|
|
||||||
// ── PUBLIC CHAT ENDPOINTS (for customer widget) ──────────────────────────────
|
// ── PUBLIC CHAT ENDPOINTS (for customer widget) ──────────────────────────────
|
||||||
// widget.js MUST be before /chat/:shop — otherwise Express treats "widget.js" as the :shop param
|
// widget.js MUST be before /chat/:shop — otherwise Express treats "widget.js" as the :shop param
|
||||||
app.get('/chat/widget.js', (req, res) => {
|
app.get('/chat/widget.js', (req, res) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user