From 8ba675e45a700273941dc1e3f0eed050d4beff3e Mon Sep 17 00:00:00 2001 From: MOHAN Date: Sun, 29 Mar 2026 18:51:57 +0530 Subject: [PATCH] Add menu troubleshooting preflight validation for common API errors --- .../05-menu-integration-audit.md | 4 + docs/developer-portal/05-menus.md | 2 + .../19-menu-troubleshooting-audit.md | 34 ++++ postman/Uber_Wrapper.postman_collection.json | 33 ++++ src/modules/proxy/menuValidation.js | 166 ++++++++++++++++++ src/modules/proxy/proxy.controller.js | 36 +++- 6 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 docs/developer-portal/19-menu-troubleshooting-audit.md create mode 100644 src/modules/proxy/menuValidation.js diff --git a/docs/developer-portal/05-menu-integration-audit.md b/docs/developer-portal/05-menu-integration-audit.md index 16a7a86..19150e4 100644 --- a/docs/developer-portal/05-menu-integration-audit.md +++ b/docs/developer-portal/05-menu-integration-audit.md @@ -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 diff --git a/docs/developer-portal/05-menus.md b/docs/developer-portal/05-menus.md index 6e3d690..4d01253 100644 --- a/docs/developer-portal/05-menus.md +++ b/docs/developer-portal/05-menus.md @@ -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: diff --git a/docs/developer-portal/19-menu-troubleshooting-audit.md b/docs/developer-portal/19-menu-troubleshooting-audit.md new file mode 100644 index 0000000..1c5528a --- /dev/null +++ b/docs/developer-portal/19-menu-troubleshooting-audit.md @@ -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. diff --git a/postman/Uber_Wrapper.postman_collection.json b/postman/Uber_Wrapper.postman_collection.json index 6b17a8b..040c9a0 100644 --- a/postman/Uber_Wrapper.postman_collection.json +++ b/postman/Uber_Wrapper.postman_collection.json @@ -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": { diff --git a/src/modules/proxy/menuValidation.js b/src/modules/proxy/menuValidation.js new file mode 100644 index 0000000..4657d30 --- /dev/null +++ b/src/modules/proxy/menuValidation.js @@ -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 || ""} 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 +}; diff --git a/src/modules/proxy/proxy.controller.js b/src/modules/proxy/proxy.controller.js index ff779ab..833958d 100644 --- a/src/modules/proxy/proxy.controller.js +++ b/src/modules/proxy/proxy.controller.js @@ -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,