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:
MOHAN 2026-07-02 23:42:00 +05:30
parent b7fa2a21a9
commit fb7d447648
2 changed files with 155 additions and 255 deletions

View File

@ -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);
// // })();

View File

@ -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) => {