Add menu troubleshooting preflight validation for common API errors
This commit is contained in:
parent
6e3d654df0
commit
8ba675e45a
@ -23,6 +23,10 @@ Source checked: Uber Eats "Menu Integration" section shared by you.
|
|||||||
- Example payload pack:
|
- Example payload pack:
|
||||||
- Added curated v2 JSON examples under `docs/examples/menus/v2/`
|
- Added curated v2 JSON examples under `docs/examples/menus/v2/`
|
||||||
- Added index doc `docs/developer-portal/05-menu-example-payloads.md`
|
- Added index doc `docs/developer-portal/05-menu-example-payloads.md`
|
||||||
|
- Added troubleshooting-aligned validation layer:
|
||||||
|
- no menus / no hours / short hours / overlapping visibility checks on upload
|
||||||
|
- UUID guard for `storeId` on menu routes
|
||||||
|
- `core_price >= price` guard on item updates
|
||||||
|
|
||||||
## Existing Before
|
## Existing Before
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,8 @@ Best-practice note:
|
|||||||
|
|
||||||
- Use API-managed menus only for integrated stores (avoid manual Menu Maker edits to prevent drift).
|
- Use API-managed menus only for integrated stores (avoid manual Menu Maker edits to prevent drift).
|
||||||
- Alcoholic item flag is effectively sticky in Uber (`alcoholic_items > 0` cannot be reverted by API update).
|
- Alcoholic item flag is effectively sticky in Uber (`alcoholic_items > 0` cannot be reverted by API update).
|
||||||
|
- Upload route performs preflight troubleshooting checks:
|
||||||
|
- no menus, no hours, short-hours (<60 min), overlapping item visibility intervals
|
||||||
|
|
||||||
Example payload pack:
|
Example payload pack:
|
||||||
|
|
||||||
|
|||||||
34
docs/developer-portal/19-menu-troubleshooting-audit.md
Normal file
34
docs/developer-portal/19-menu-troubleshooting-audit.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# 19 Menu Troubleshooting Audit
|
||||||
|
|
||||||
|
Source checked: Uber Eats "Troubleshooting Errors from the Menu API" section shared by you.
|
||||||
|
|
||||||
|
## Implemented Now
|
||||||
|
|
||||||
|
- Added proactive upload-menu payload validation before calling Uber:
|
||||||
|
- `No Menus Errors` guard:
|
||||||
|
- requires `menu.menus` to have at least one entry
|
||||||
|
- `No Hours Errors` guard:
|
||||||
|
- requires at least one valid `service_availability` interval
|
||||||
|
- `Short Hours Errors` guard:
|
||||||
|
- validates effective contiguous service windows are at least 60 minutes
|
||||||
|
- supports overnight-contiguous windows split across adjacent days
|
||||||
|
- `Invalid Visibility Errors` guard:
|
||||||
|
- detects overlapping `visibility_info.hours[].hours_of_week[].time_periods` for the same day
|
||||||
|
- Added stronger request path validation:
|
||||||
|
- menu routes now validate `storeId` as UUID
|
||||||
|
- helps prevent `invalid uuid` / `orgUUID must be a valid UUID` upstream errors
|
||||||
|
- Added update-item price sanity check:
|
||||||
|
- if both provided, `core_price` must be `>= price`
|
||||||
|
|
||||||
|
## Mapped to Wrapper
|
||||||
|
|
||||||
|
- `PUT /api/v1/uber/menu/replace`
|
||||||
|
- now runs menu troubleshooting validations before upstream call
|
||||||
|
- `POST /api/v1/uber/menu/items`
|
||||||
|
- validates `storeId` UUID and key `price_info` constraints
|
||||||
|
|
||||||
|
## Pending
|
||||||
|
|
||||||
|
- Live item existence precheck against current menu before sparse update (`nil item` prevention).
|
||||||
|
- Configurable per-market max price thresholds from Uber approval config.
|
||||||
|
- User-friendly remediation hints per failing field in structured error payloads.
|
||||||
@ -290,6 +290,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Upload Menu - Short Hours Validation (Expected 400)",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-api-key",
|
||||||
|
"value": "{{apiKey}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"storeId\": \"{{storeId}}\",\n \"menu\": {\n \"items\": [],\n \"modifier_groups\": [],\n \"categories\": [],\n \"menus\": [\n {\n \"id\": \"short-hours-menu\",\n \"title\": { \"translations\": { \"en_us\": \"Short Hours\" } },\n \"service_availability\": [\n {\n \"day_of_week\": \"monday\",\n \"time_periods\": [\n { \"start_time\": \"00:00\", \"end_time\": \"00:00\" }\n ]\n }\n ],\n \"category_ids\": []\n }\n ]\n }\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/uber/menu/replace",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"uber",
|
||||||
|
"menu",
|
||||||
|
"replace"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Update Item (v2 Sparse)",
|
"name": "Update Item (v2 Sparse)",
|
||||||
"request": {
|
"request": {
|
||||||
|
|||||||
166
src/modules/proxy/menuValidation.js
Normal file
166
src/modules/proxy/menuValidation.js
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
function parseTimeToMinutes(time) {
|
||||||
|
if (typeof time !== "string" || !/^\d{2}:\d{2}$/.test(time)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [hourStr, minuteStr] = time.split(":");
|
||||||
|
const hour = Number(hourStr);
|
||||||
|
const minute = Number(minuteStr);
|
||||||
|
if (
|
||||||
|
Number.isNaN(hour) ||
|
||||||
|
Number.isNaN(minute) ||
|
||||||
|
hour < 0 ||
|
||||||
|
hour > 23 ||
|
||||||
|
minute < 0 ||
|
||||||
|
minute > 59
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hour * 60 + minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
|
"sunday"
|
||||||
|
];
|
||||||
|
|
||||||
|
function dayIndex(day) {
|
||||||
|
return DAYS.indexOf(String(day || "").toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWeeklyIntervals(menuPayload) {
|
||||||
|
const menus = Array.isArray(menuPayload?.menus) ? menuPayload.menus : [];
|
||||||
|
const intervals = [];
|
||||||
|
|
||||||
|
menus.forEach((menu, menuIndex) => {
|
||||||
|
const availability = Array.isArray(menu?.service_availability) ? menu.service_availability : [];
|
||||||
|
availability.forEach((dayConfig, dayConfigIndex) => {
|
||||||
|
const dIdx = dayIndex(dayConfig?.day_of_week);
|
||||||
|
if (dIdx < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const periods = Array.isArray(dayConfig?.time_periods) ? dayConfig.time_periods : [];
|
||||||
|
periods.forEach((period, periodIndex) => {
|
||||||
|
const start = parseTimeToMinutes(period?.start_time);
|
||||||
|
const end = parseTimeToMinutes(period?.end_time);
|
||||||
|
if (start === null || end === null || end < start) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base = dIdx * 24 * 60;
|
||||||
|
intervals.push({
|
||||||
|
start: base + start,
|
||||||
|
end: base + end,
|
||||||
|
menuIndex,
|
||||||
|
dayConfigIndex,
|
||||||
|
periodIndex,
|
||||||
|
day: dayConfig?.day_of_week
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return intervals.sort((a, b) => a.start - b.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeContiguousIntervals(intervals) {
|
||||||
|
if (!intervals.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = [Object.assign({}, intervals[0])];
|
||||||
|
for (let i = 1; i < intervals.length; i += 1) {
|
||||||
|
const curr = intervals[i];
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (curr.start <= last.end + 1) {
|
||||||
|
last.end = Math.max(last.end, curr.end);
|
||||||
|
} else {
|
||||||
|
merged.push(Object.assign({}, curr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectVisibilityOverlapErrors(menuPayload) {
|
||||||
|
const items = Array.isArray(menuPayload?.items) ? menuPayload.items : [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const visibilityHours = Array.isArray(item?.visibility_info?.hours)
|
||||||
|
? item.visibility_info.hours
|
||||||
|
: [];
|
||||||
|
visibilityHours.forEach((hourRule) => {
|
||||||
|
const hoursOfWeek = Array.isArray(hourRule?.hours_of_week) ? hourRule.hours_of_week : [];
|
||||||
|
const perDay = new Map();
|
||||||
|
hoursOfWeek.forEach((dayHours) => {
|
||||||
|
const d = String(dayHours?.day_of_week || "").toLowerCase();
|
||||||
|
if (!DAYS.includes(d)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = perDay.get(d) || [];
|
||||||
|
const periods = Array.isArray(dayHours?.time_periods) ? dayHours.time_periods : [];
|
||||||
|
periods.forEach((p) => {
|
||||||
|
const s = parseTimeToMinutes(p?.start_time);
|
||||||
|
const e = parseTimeToMinutes(p?.end_time);
|
||||||
|
if (s === null || e === null || e < s) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.push({ start: s, end: e, raw: p });
|
||||||
|
});
|
||||||
|
perDay.set(d, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
perDay.forEach((periods, d) => {
|
||||||
|
const sorted = periods.sort((a, b) => a.start - b.start);
|
||||||
|
for (let i = 1; i < sorted.length; i += 1) {
|
||||||
|
const prev = sorted[i - 1];
|
||||||
|
const curr = sorted[i];
|
||||||
|
if (curr.start <= prev.end) {
|
||||||
|
errors.push(
|
||||||
|
`Invalid visibility overlap for item ${item?.id || "<unknown>"} on ${d}: ` +
|
||||||
|
`${String(prev.raw?.start_time)}-${String(prev.raw?.end_time)} overlaps ` +
|
||||||
|
`${String(curr.raw?.start_time)}-${String(curr.raw?.end_time)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUploadMenuPayload(menuPayload) {
|
||||||
|
const errors = [];
|
||||||
|
const menus = Array.isArray(menuPayload?.menus) ? menuPayload.menus : [];
|
||||||
|
if (menus.length === 0) {
|
||||||
|
errors.push("No Menus Errors: menu.menus must contain at least one menu.");
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervals = buildWeeklyIntervals(menuPayload);
|
||||||
|
if (intervals.length === 0) {
|
||||||
|
errors.push(
|
||||||
|
"No Hours Errors: at least one service_availability interval is required across the week."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const merged = mergeContiguousIntervals(intervals);
|
||||||
|
const shortBlocks = merged.filter((block) => block.end - block.start + 1 < 60);
|
||||||
|
if (shortBlocks.length > 0) {
|
||||||
|
errors.push(
|
||||||
|
"Short Hours Errors: each effective contiguous service_availability interval must be at least 60 minutes."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errors.push(...collectVisibilityOverlapErrors(menuPayload));
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
validateUploadMenuPayload
|
||||||
|
};
|
||||||
@ -1,6 +1,7 @@
|
|||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
const proxyService = require("./proxy.service");
|
const proxyService = require("./proxy.service");
|
||||||
const { PRODUCT_TYPES, MIXIN_TYPES } = require("../../config/uberProductCatalog");
|
const { PRODUCT_TYPES, MIXIN_TYPES } = require("../../config/uberProductCatalog");
|
||||||
|
const { validateUploadMenuPayload } = require("./menuValidation");
|
||||||
|
|
||||||
const genericSchema = z.object({
|
const genericSchema = z.object({
|
||||||
merchantId: z.string().min(1).optional(),
|
merchantId: z.string().min(1).optional(),
|
||||||
@ -21,7 +22,7 @@ async function genericProxy(req, res) {
|
|||||||
async function upsertMenu(req, res) {
|
async function upsertMenu(req, res) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
merchantId: z.string().min(1),
|
merchantId: z.string().min(1),
|
||||||
storeId: z.string().min(1),
|
storeId: z.string().uuid(),
|
||||||
menu: z.any()
|
menu: z.any()
|
||||||
});
|
});
|
||||||
const payload = schema.parse(req.body);
|
const payload = schema.parse(req.body);
|
||||||
@ -36,7 +37,7 @@ async function upsertMenu(req, res) {
|
|||||||
async function getMenu(req, res) {
|
async function getMenu(req, res) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
merchantId: z.string().min(1),
|
merchantId: z.string().min(1),
|
||||||
storeId: z.string().min(1),
|
storeId: z.string().uuid(),
|
||||||
menu_type: z
|
menu_type: z
|
||||||
.enum([
|
.enum([
|
||||||
"MENU_TYPE_FULFILLMENT_DELIVERY",
|
"MENU_TYPE_FULFILLMENT_DELIVERY",
|
||||||
@ -64,7 +65,7 @@ async function getMenu(req, res) {
|
|||||||
async function replaceMenu(req, res) {
|
async function replaceMenu(req, res) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
merchantId: z.string().min(1),
|
merchantId: z.string().min(1),
|
||||||
storeId: z.string().min(1),
|
storeId: z.string().uuid(),
|
||||||
menu: z.any()
|
menu: z.any()
|
||||||
});
|
});
|
||||||
const payload = schema.parse(req.body);
|
const payload = schema.parse(req.body);
|
||||||
@ -81,6 +82,12 @@ async function replaceMenu(req, res) {
|
|||||||
error.status = 400;
|
error.status = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
const uploadErrors = validateUploadMenuPayload(payload.menu || {});
|
||||||
|
if (uploadErrors.length > 0) {
|
||||||
|
const error = new Error(uploadErrors.join(" "));
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await proxyService.menuReplace({
|
const data = await proxyService.menuReplace({
|
||||||
merchantId: payload.merchantId,
|
merchantId: payload.merchantId,
|
||||||
@ -99,7 +106,16 @@ async function updateMenuItems(req, res) {
|
|||||||
|
|
||||||
const updateSchema = z
|
const updateSchema = z
|
||||||
.object({
|
.object({
|
||||||
price_info: z.any().optional(),
|
price_info: z
|
||||||
|
.object({
|
||||||
|
price: z.coerce.number().int().min(0).optional(),
|
||||||
|
core_price: z.coerce.number().int().min(0).optional(),
|
||||||
|
container_deposit: z.coerce.number().int().min(0).optional(),
|
||||||
|
overrides: z.array(z.any()).optional(),
|
||||||
|
priced_by_unit: z.any().optional()
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.optional(),
|
||||||
suspension_info: z.any().optional(),
|
suspension_info: z.any().optional(),
|
||||||
menu_type: menuTypeEnum.optional(),
|
menu_type: menuTypeEnum.optional(),
|
||||||
product_info: z.any().optional(),
|
product_info: z.any().optional(),
|
||||||
@ -116,11 +132,21 @@ async function updateMenuItems(req, res) {
|
|||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
merchantId: z.string().min(1),
|
merchantId: z.string().min(1),
|
||||||
storeId: z.string().min(1),
|
storeId: z.string().uuid(),
|
||||||
itemId: z.string().min(1),
|
itemId: z.string().min(1),
|
||||||
update: updateSchema
|
update: updateSchema
|
||||||
});
|
});
|
||||||
const payload = schema.parse(req.body);
|
const payload = schema.parse(req.body);
|
||||||
|
if (
|
||||||
|
payload.update.price_info &&
|
||||||
|
payload.update.price_info.core_price !== undefined &&
|
||||||
|
payload.update.price_info.price !== undefined &&
|
||||||
|
payload.update.price_info.core_price < payload.update.price_info.price
|
||||||
|
) {
|
||||||
|
const error = new Error("Invalid Price Info Errors: core_price must be greater than or equal to price.");
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const data = await proxyService.updateMenuItems({
|
const data = await proxyService.updateMenuItems({
|
||||||
merchantId: payload.merchantId,
|
merchantId: payload.merchantId,
|
||||||
storeId: payload.storeId,
|
storeId: payload.storeId,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user