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 { 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.
|
||||
* @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.
|
||||
* Create (or retrieve existing) fulfillment service + location for a shop.
|
||||
* Returns { fulfillmentService, locationId } — locationId is NEVER null if a
|
||||
* fulfillment service exists, because we fall back to the service's own location.
|
||||
*/
|
||||
async function createFulfillmentService(shop, accessToken) {
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: `https://${shop}/admin/api/2025-10/graphql.json`,
|
||||
headers: {
|
||||
@ -99,183 +15,128 @@ async function createFulfillmentService(shop, accessToken) {
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = `
|
||||
try {
|
||||
log(shop, `🚀 [FulfillmentService] Creating fulfillment service...`);
|
||||
|
||||
// ── Step 1: Create fulfillment service (or retrieve existing) ────────────
|
||||
const createResp = await client.post('', {
|
||||
query: `
|
||||
mutation {
|
||||
fulfillmentServiceCreate(
|
||||
name: "Data4Autos Distribution",
|
||||
callbackUrl: "https://backend.data4autos.com/fulfillment"
|
||||
) {
|
||||
fulfillmentService {
|
||||
id
|
||||
serviceName
|
||||
callbackUrl
|
||||
handle
|
||||
location {
|
||||
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 };
|
||||
}
|
||||
`;
|
||||
|
||||
// const mutation = `
|
||||
// mutation {
|
||||
// fulfillmentServiceCreate(
|
||||
// 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 {
|
||||
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);
|
||||
log(shop, `✅ [FulfillmentService] Found existing: ${fulfillmentService.id}`);
|
||||
} else {
|
||||
console.log('Created location:', result.location);
|
||||
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}`);
|
||||
|
||||
var locationId = result.location ? result.location.id : null;
|
||||
// ── 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom location if created, otherwise fall back to fulfillment service location
|
||||
const locationId = customLocationId || serviceFallbackLocationId;
|
||||
log(shop, `📍 [FulfillmentService] Final locationId: ${locationId} (${customLocationId ? 'custom' : 'service fallback'})`);
|
||||
|
||||
return { fulfillmentService, locationId };
|
||||
|
||||
return { success: true, fulfillmentService: data.fulfillmentService, locationId };
|
||||
} 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 };
|
||||
const errDetail = error.response ? JSON.stringify(error.response.data) : error.message;
|
||||
log(shop, `💥 [FulfillmentService] Request failed: ${errDetail}`);
|
||||
return { fulfillmentService: null, locationId: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 privacyLawWebhooks = require('./routes/privacyLawWebhooks');
|
||||
const { getToken, listTokens } = require('./tokenStore');
|
||||
const { getToken, saveToken, listTokens } = require('./tokenStore');
|
||||
const { listJobs, getJob, cancelJob, getLatestJobForShop } = require('./jobStore');
|
||||
const { isShopAllowed } = require('./freeAccessStore');
|
||||
const { addMessage: addChatMessage, readChat } = require('./chatStore');
|
||||
const { createFulfillmentService } = require('./fulfillmentService');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3002;
|
||||
@ -32,6 +33,44 @@ app.get('/free-access/:shop', (req, res) => {
|
||||
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) ──────────────────────────────
|
||||
// widget.js MUST be before /chat/:shop — otherwise Express treats "widget.js" as the :shop param
|
||||
app.get('/chat/widget.js', (req, res) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user