Add menu troubleshooting preflight validation for common API errors

This commit is contained in:
MOHAN 2026-03-29 18:51:57 +05:30
parent 6e3d654df0
commit 8ba675e45a
6 changed files with 270 additions and 5 deletions

View File

@ -23,6 +23,10 @@ Source checked: Uber Eats "Menu Integration" section shared by you.
- Example payload pack:
- Added curated v2 JSON examples under `docs/examples/menus/v2/`
- 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

View File

@ -46,6 +46,8 @@ Best-practice note:
- 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).
- Upload route performs preflight troubleshooting checks:
- no menus, no hours, short-hours (<60 min), overlapping item visibility intervals
Example payload pack:

View 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.

View File

@ -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)",
"request": {

View 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
};

View File

@ -1,6 +1,7 @@
const { z } = require("zod");
const proxyService = require("./proxy.service");
const { PRODUCT_TYPES, MIXIN_TYPES } = require("../../config/uberProductCatalog");
const { validateUploadMenuPayload } = require("./menuValidation");
const genericSchema = z.object({
merchantId: z.string().min(1).optional(),
@ -21,7 +22,7 @@ async function genericProxy(req, res) {
async function upsertMenu(req, res) {
const schema = z.object({
merchantId: z.string().min(1),
storeId: z.string().min(1),
storeId: z.string().uuid(),
menu: z.any()
});
const payload = schema.parse(req.body);
@ -36,7 +37,7 @@ async function upsertMenu(req, res) {
async function getMenu(req, res) {
const schema = z.object({
merchantId: z.string().min(1),
storeId: z.string().min(1),
storeId: z.string().uuid(),
menu_type: z
.enum([
"MENU_TYPE_FULFILLMENT_DELIVERY",
@ -64,7 +65,7 @@ async function getMenu(req, res) {
async function replaceMenu(req, res) {
const schema = z.object({
merchantId: z.string().min(1),
storeId: z.string().min(1),
storeId: z.string().uuid(),
menu: z.any()
});
const payload = schema.parse(req.body);
@ -81,6 +82,12 @@ async function replaceMenu(req, res) {
error.status = 400;
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({
merchantId: payload.merchantId,
@ -99,7 +106,16 @@ async function updateMenuItems(req, res) {
const updateSchema = z
.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(),
menu_type: menuTypeEnum.optional(),
product_info: z.any().optional(),
@ -116,11 +132,21 @@ async function updateMenuItems(req, res) {
const schema = z.object({
merchantId: z.string().min(1),
storeId: z.string().min(1),
storeId: z.string().uuid(),
itemId: z.string().min(1),
update: updateSchema
});
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({
merchantId: payload.merchantId,
storeId: payload.storeId,