Compare commits

..

10 Commits

39 changed files with 3171 additions and 105 deletions

View File

@ -14,7 +14,7 @@ This file intentionally separates high-priority vs extended APIs for easier team
## Group B (Extended / Optional / Can Be Added Incrementally)
- Promotions
- Promotions (Promotions API v1.0.0 route set available)
- Ads / sponsored listings
- Payout and financial reconciliation (Reporting API route now available)
- Store holiday/special schedules

View File

@ -0,0 +1,29 @@
# 05 Menu Example Payloads (v2)
These files are curated from the Uber v2 menu examples you shared, cleaned into valid JSON and ready for direct use in this wrapper.
Use with:
- Upload menu: `PUT /api/v1/uber/menu/replace`
- Update one item (sparse): `POST /api/v1/uber/menu/items`
## Files
- `docs/examples/menus/v2/empty-menu.json`
- Empty menu payload (can clear existing menu)
- `docs/examples/menus/v2/simple-menu.json`
- Simple menu with categories, items, and modifier groups
- `docs/examples/menus/v2/fulfillment-delivery-menu.json`
- Delivery-specific menu (`menu_type = MENU_TYPE_FULFILLMENT_DELIVERY`)
- `docs/examples/menus/v2/fulfillment-pickup-menu.json`
- Pickup-specific menu (`menu_type = MENU_TYPE_FULFILLMENT_PICK_UP`)
- `docs/examples/menus/v2/combo-bundled-items.json`
- Combo example with `bundled_items` + `core_price`
- `docs/examples/menus/v2/update-item-sparse.json`
- Sparse item update for `POST /v2/eats/stores/{store_id}/menus/items/{item_id}`
## Practical Notes
- For split fulfillment menus, upload `DELIVERY` first; after split, manage each menu type separately.
- Keep item IDs stable and avoid problematic characters like `/` and `;`.
- `alcoholic_items > 0` behaves as sticky in Uber and cannot be reverted by normal API update.

View File

@ -6,11 +6,27 @@ Source checked: Uber Eats "Menu Integration" section shared by you.
- Retrieve menu:
- `GET /api/v1/uber/menu`
- aligned to upstream `GET /v2/eats/stores/{store_id}/menus`
- supports `menu_type` query values for delivery/pick-up/dine-in
- applies `Accept-Encoding: gzip` for large payload responses
- Full menu upload/replace:
- `PUT /api/v1/uber/menu/replace` (primary)
- aligned to upstream `PUT /v2/eats/stores/{store_id}/menus`
- wrapper uploads gzip-compressed JSON (`Content-Encoding: gzip`)
- supports optional `menu.menu_type` (delivery/pick-up/dine-in)
- validates known menu_type enum values
- Individual item updates:
- `POST /api/v1/uber/menu/items`
- supports stock/price style item-level updates via Menu Items endpoint
- aligned to upstream `POST /v2/eats/stores/{store_id}/menus/items/{item_id}`
- request body is sparse update (only provided fields are changed)
- supports `menu_type` enum for split delivery/pickup/dine-in menus
- 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
@ -22,4 +38,3 @@ Source checked: Uber Eats "Menu Integration" section shared by you.
- Strict typed schemas for full menu payload entities (item, modifier group, category, menu)
- Validation rules for image metadata limits and alcoholic item classifications
- Dedicated mapper helpers for `core_price` and `bundled_items` enrichment

View File

@ -11,11 +11,45 @@ Menu sync between POS and Uber Eats:
Current wrapper route for full replacement:
- `PUT /api/v1/uber/menu/replace`
- upstream mapped to `PUT /v2/eats/stores/{store_id}/menus`
- request body is gzip-compressed for upload (`Content-Encoding: gzip`, `Content-Type: application/json`)
- optional `menu.menu_type` supported:
- `MENU_TYPE_FULFILLMENT_DELIVERY` (default)
- `MENU_TYPE_FULFILLMENT_PICK_UP`
- `MENU_TYPE_FULFILLMENT_DINE_IN`
Item update route:
- `POST /api/v1/uber/menu/items`
- upstream mapped to `POST /v2/eats/stores/{store_id}/menus/items/{item_id}`
- sparse update only (only provided fields are changed)
- wrapper payload shape:
- `merchantId`
- `storeId`
- `itemId`
- `update` object with fields such as `price_info`, `suspension_info`, `menu_type`, `product_info`, `classifications`, `beverage_info`, `physical_properties_info`, `medication_info`, `nutritional_info`, `selling_info`
- reference catalogs for `product_info`:
- `GET /api/v1/uber/catalog/product-types`
- includes `product_types` and `mixin_types`
Menu fetch route:
- `GET /api/v1/uber/menu`
- upstream mapped to `GET /v2/eats/stores/{store_id}/menus`
- supports query `menu_type`:
- `MENU_TYPE_FULFILLMENT_DELIVERY` (default)
- `MENU_TYPE_FULFILLMENT_PICK_UP`
- `MENU_TYPE_FULFILLMENT_DINE_IN`
- sends `Accept-Encoding: gzip` upstream for large menu payloads
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:
- See [05-menu-example-payloads.md](./05-menu-example-payloads.md)
- JSON payloads are in `docs/examples/menus/v2/`

View File

@ -16,6 +16,14 @@ Source checked: Uber Eats "Webhook" section shared by you.
- `resource_id`
- `resource_href`
- signature and dedupe key
- Added explicit event handling for `store.menu_refresh_request`:
- mapped via `store_id`
- records latest menu refresh request metadata on `uber_connections`
- stores webhook UUID and `X-Environment` marker
- Added explicit event handling for `eats.report.success`:
- maps by `job_id` / `workflow_id`
- marks report jobs completed
- persists report sections metadata for download orchestration
## Existing Before
@ -27,4 +35,3 @@ Source checked: Uber Eats "Webhook" section shared by you.
- Per-event downstream job workers (accept/deny SLA orchestration).
- Alerting if order accept/deny not sent before timeout window.
- Full typed schema validation per webhook `event_type`.

View File

@ -25,7 +25,17 @@ Common event types handled:
- `delivery.state_changed`
- `store.provisioned`
- `store.deprovisioned`
- `store.menu_refresh_request`
- `store.status.changed`
- `eats.report.success`
Menu refresh handling:
- On `store.menu_refresh_request`, wrapper records the request on the mapped Uber store connection.
- Persisted fields include:
- `last_menu_refresh_requested_at`
- `last_menu_refresh_webhook_uuid`
- `last_webhook_environment` (from `X-Environment`)
Retail fulfillment follow-up:

View File

@ -6,6 +6,7 @@ Source checked: Uber Eats "Reporting Guide" section shared by you.
- Dedicated Reporting route:
- `POST /api/v1/uber/reporting/fetch`
- `POST /api/v1/uber/reporting/create`
- Uses `eats.report` client-credentials scope.
- Retry policy implemented for safe transient failures only:
- `429`, `408`, `500`, `502`, `503`, `504`, and network errors
@ -16,6 +17,11 @@ Source checked: Uber Eats "Reporting Guide" section shared by you.
- ignore unknown extra columns
- tolerate missing columns with null/default behavior
- report missing required headers in response metadata
- Marketplace Reporting API async flow aligned:
- `POST /v1/eats/report` wrapper support with report-type/date constraints
- workflow tracking via `workflow_id`
- webhook completion handling for `eats.report.success`
- report sections metadata persisted on completion
## Existing Before
@ -24,7 +30,5 @@ Source checked: Uber Eats "Reporting Guide" section shared by you.
## Pending
- Final typed endpoint wrappers for specific reporting reference endpoints once exact paths are shared
- Overnight polling scheduler/job orchestration
- Overnight polling scheduler/job orchestration for report section downloads
- Reconciliation materialization tables for settled vs provisional values

View File

@ -9,6 +9,7 @@ Reporting focus:
Typed route:
- `POST /api/v1/uber/reporting/fetch`
- `POST /api/v1/uber/reporting/create`
Key behavior:
@ -16,4 +17,6 @@ Key behavior:
- Retries transient errors only (`408`, `429`, `500`, `502`, `503`, `504`, network timeouts)
- Parses CSV by header names (not fixed column positions)
- Tolerates unknown columns and missing optional fields
- Reporting job flow:
- create job returns `workflow_id`
- completion arrives via webhook `eats.report.success`

View File

@ -0,0 +1,32 @@
# 17 Promotions API 1.0.0 Audit
Source checked: "Uber Eats Marketplace Promotions API (1.0.0)" shared by you.
## Implemented Now (Dedicated Wrapper Namespace)
- Create Promotion:
- `POST /api/v1/uber/delivery-promotions/stores/{storeId}`
- upstream: `/v1/delivery/stores/{store_id}/promotion`
- Revoke Promotion:
- `POST /api/v1/uber/delivery-promotions/{promotionId}/revoke`
- upstream: `/v1/delivery/promotions/{promotion_id}/revoke`
- Get Promotion:
- `GET /api/v1/uber/delivery-promotions/{promotionId}`
- upstream: `/v1/delivery/promotions/{promotion_id}`
- Get Promotions:
- `GET /api/v1/uber/delivery-promotions/stores/{storeId}`
- upstream: `/v1/delivery/stores/{store_id}/promotions`
## Validation Added
- `user_group` enum: `ALL_CUSTOMERS | FIRST_TIME_CUSTOMERS`
- promotions list `state` enum:
- `active`, `pending`, `completed`, `revoked`, `expired`, `deleted`
- required top-level create payload fields enforced
## Pending
- Strict schema unions for each promotion type object variant:
- flat_off, free_item, bogo, percent_off, menu_item, free_delivery
- typed handling for `time_range` object query form when full exact query contract is shared

View File

@ -0,0 +1,14 @@
# 17 Promotions
Promotions API 1.0.0 wrapper routes:
- `POST /api/v1/uber/delivery-promotions/stores/{storeId}` (Create promotion)
- `GET /api/v1/uber/delivery-promotions/stores/{storeId}` (List promotions)
- `GET /api/v1/uber/delivery-promotions/{promotionId}` (Get single promotion)
- `POST /api/v1/uber/delivery-promotions/{promotionId}/revoke` (Revoke promotion)
Notes:
- Only promotions created via API are expected to be returned by get/list endpoints.
- User group and state filters are validated.

View File

@ -0,0 +1,19 @@
# 18 Product Types Audit
Source checked: Uber Eats "Uber Product Types / Uber Mixin Types" section shared by you.
## Implemented Now
- Added a centralized in-code product catalog:
- [uberProductCatalog.js](../../src/config/uberProductCatalog.js)
- `PRODUCT_TYPES`
- `MIXIN_TYPES`
- Added a wrapper endpoint for developers/POS UIs:
- `GET /api/v1/uber/catalog/product-types`
- returns both product and mixin type lists as JSON
- Added Swagger route annotation for the catalog endpoint.
## Notes
- The catalog endpoint is reference data for building menu payloads (`product_info.product_type`, `product_info.product_traits`).
- Wrapper still keeps menu payload bodies flexible (`z.any`) to avoid blocking future Uber enum additions.

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

@ -0,0 +1,28 @@
# 20 Marketplace Reporting API 1.0.0 Audit
Source checked: Uber Eats "Marketplace Reporting API (1.0.0)" section shared by you.
## Implemented Now
- Added official create-report wrapper route:
- `POST /api/v1/uber/reporting/create`
- upstream `POST /v1/eats/report`
- returns `workflow_id`
- Added request validation:
- `report_type` enum validation (10 report types)
- requires at least one `store_uuids` or `group_uuids`
- validates date format by report type
- validates `start_date <= end_date`
- validates range/lookback constraints from the docs
- Added report job persistence (`report_jobs`):
- workflow tracking + report type + request windows + status
- Added webhook completion handling:
- `eats.report.success` marks job completed by `job_id`
- persists report sections metadata for downstream download workers
- Existing CSV fetch route retained:
- `POST /api/v1/uber/reporting/fetch` for direct CSV pulls where applicable
## Pending
- Worker queue for downloading and processing each `report_metadata.sections[]` URL.
- Retry orchestration for section URL fetches and long-running reconciliation pipelines.

View File

@ -0,0 +1,52 @@
{
"menus": [
{
"id": "Menu",
"title": { "translations": { "en_us": "Menu" } },
"service_availability": [
{ "day_of_week": "monday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] }
],
"category_ids": ["Mains"]
}
],
"categories": [
{
"id": "Mains",
"title": { "translations": { "en_us": "Mains" } },
"entities": [
{ "type": "ITEM", "id": "Burger_Combo" },
{ "type": "ITEM", "id": "Best_Fries" }
]
}
],
"items": [
{
"id": "Burger_Combo",
"title": { "translations": { "en_us": "Best Burger Combo" } },
"price_info": { "price": 900, "overrides": [] },
"modifier_group_ids": { "ids": ["Select-Drink"] },
"bundled_items": [
{ "item_id": "Best_Fries", "core_price": 300, "included_quantity": 1 }
]
},
{
"id": "Best_Fries",
"title": { "translations": { "en_us": "Best Fries" } },
"price_info": { "price": 300, "overrides": [] }
},
{
"id": "Root_Beer",
"title": { "translations": { "en_us": "Root Beer" } },
"price_info": { "price": 0, "core_price": 200, "overrides": [] }
}
],
"modifier_groups": [
{
"id": "Select-Drink",
"title": { "translations": { "en_us": "Select Drink" } },
"quantity_info": { "quantity": { "max_permitted": 1 } },
"modifier_options": [{ "type": "ITEM", "id": "Root_Beer" }]
}
],
"display_options": {}
}

View File

@ -0,0 +1,22 @@
{
"items": [],
"modifier_groups": [],
"categories": [],
"menus": [
{
"id": "empty_menu_id",
"title": { "translations": { "en_us": "Empty Menu" } },
"service_availability": [
{ "day_of_week": "monday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] },
{ "day_of_week": "tuesday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] },
{ "day_of_week": "wednesday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] },
{ "day_of_week": "thursday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] },
{ "day_of_week": "friday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] },
{ "day_of_week": "saturday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] },
{ "day_of_week": "sunday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] }
],
"category_ids": []
}
],
"display_options": {}
}

View File

@ -0,0 +1,39 @@
{
"menu_type": "MENU_TYPE_FULFILLMENT_DELIVERY",
"menus": [
{
"id": "Special",
"title": { "translations": { "en_us": "Specials" } },
"service_availability": [
{ "day_of_week": "monday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] }
],
"category_ids": ["Specials"]
}
],
"categories": [
{
"id": "Specials",
"title": { "translations": { "en_us": "Specials" } },
"entities": [
{ "type": "ITEM", "id": "Best_Burger_Delivery" },
{ "type": "ITEM", "id": "Best_Fries" }
]
}
],
"items": [
{
"id": "Best_Burger_Delivery",
"title": { "translations": { "en_us": "Best Burger" } },
"price_info": { "price": 900 },
"tax_info": {}
},
{
"id": "Best_Fries",
"title": { "translations": { "en_us": "Best Fries" } },
"price_info": { "price": 300 },
"tax_info": {}
}
],
"modifier_groups": [],
"display_options": {}
}

View File

@ -0,0 +1,39 @@
{
"menu_type": "MENU_TYPE_FULFILLMENT_PICK_UP",
"menus": [
{
"id": "Special",
"title": { "translations": { "en_us": "Specials" } },
"service_availability": [
{ "day_of_week": "monday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] }
],
"category_ids": ["Specials"]
}
],
"categories": [
{
"id": "Specials",
"title": { "translations": { "en_us": "Specials" } },
"entities": [
{ "type": "ITEM", "id": "Best_Burger_Pickup" },
{ "type": "ITEM", "id": "Best_Fries" }
]
}
],
"items": [
{
"id": "Best_Burger_Pickup",
"title": { "translations": { "en_us": "Best Burger + Pop" } },
"price_info": { "price": 1100 },
"tax_info": {}
},
{
"id": "Best_Fries",
"title": { "translations": { "en_us": "Best Fries" } },
"price_info": { "price": 500 },
"tax_info": {}
}
],
"modifier_groups": [],
"display_options": {}
}

View File

@ -0,0 +1,92 @@
{
"items": [
{
"id": "Coffee",
"title": { "translations": { "en_us": "Coffee" } },
"description": { "translations": { "en_us": "Deliciously roasted beans" } },
"modifier_group_ids": { "ids": ["Add-milk", "Add-sugar"] },
"price_info": { "price": 300 },
"tax_info": { "tax_rate": 8 }
},
{
"id": "Milk",
"title": { "translations": { "en_us": "Milk" } },
"quantity_info": {
"overrides": [
{
"context_type": "MODIFIER_GROUP",
"context_value": "Add-milk",
"quantity": { "max_permitted": 1 }
}
]
},
"price_info": {
"price": 0,
"overrides": [
{
"context_type": "MODIFIER_GROUP",
"context_value": "Add-milk",
"price": 0
}
]
},
"tax_info": { "tax_rate": 8 }
},
{
"id": "Sugar",
"title": { "translations": { "en_us": "Sugar" } },
"quantity_info": {
"overrides": [
{
"context_type": "MODIFIER_GROUP",
"context_value": "Add-sugar",
"quantity": { "max_permitted": 2 }
}
]
},
"price_info": {
"price": 2,
"overrides": [
{
"context_type": "MODIFIER_GROUP",
"context_value": "Add-sugar",
"price": 0
}
]
},
"tax_info": { "tax_rate": 8 }
}
],
"modifier_groups": [
{
"id": "Add-milk",
"title": { "translations": { "en_us": "Add milk" } },
"quantity_info": { "quantity": { "max_permitted": 1 } },
"modifier_options": [{ "type": "ITEM", "id": "Milk" }]
},
{
"id": "Add-sugar",
"title": { "translations": { "en_us": "Add sugar" } },
"quantity_info": { "quantity": { "max_permitted": 2 } },
"modifier_options": [{ "type": "ITEM", "id": "Sugar" }]
}
],
"categories": [
{
"id": "Drinks",
"title": { "translations": { "en_us": "Drinks" } },
"entities": [{ "type": "ITEM", "id": "Coffee" }]
}
],
"menus": [
{
"id": "All-day",
"title": { "translations": { "en_us": "All day" } },
"service_availability": [
{ "day_of_week": "monday", "time_periods": [{ "start_time": "00:00", "end_time": "23:59" }] }
],
"category_ids": ["Drinks"]
}
],
"display_options": { "disable_item_instructions": true }
}

View File

@ -0,0 +1,25 @@
{
"menu_type": "MENU_TYPE_FULFILLMENT_DELIVERY",
"price_info": {
"price": 1300,
"container_deposit": 100,
"overrides": []
},
"suspension_info": {
"suspension": null,
"overrides": [
{
"context_type": "MODIFIER_GROUP",
"context_value": "size",
"suspension": {
"suspend_until": 8640000000,
"reason": null
}
}
]
},
"product_info": {
"target_market": "EU",
"gtin": "1354435445"
}
}

View File

@ -290,6 +290,19 @@
}
}
},
"/api/v1/uber/catalog/product-types": {
"get": {
"summary": "Return Uber Product Types and Uber Mixin Types catalog",
"tags": [
"Uber Menu"
],
"responses": {
"200": {
"description": "Catalog returned"
}
}
}
},
"/api/v1/uber/menu/upsert": {
"post": {
"summary": "Legacy upsert helper for store menu",
@ -305,26 +318,26 @@
},
"/api/v1/uber/menu/replace": {
"put": {
"summary": "Replace store menu (full upload)",
"summary": "Upload/replace store menu (PUT /v2/eats/stores/{store_id}/menus)",
"tags": [
"Uber Menu"
],
"responses": {
"200": {
"description": "Menu replaced"
"description": "Menu replaced (Uber returns 204 No Content)"
}
}
}
},
"/api/v1/uber/menu/items": {
"post": {
"summary": "Update individual menu items (stock/price updates)",
"summary": "Update single menu item (POST /v2/eats/stores/{store_id}/menus/items/{item_id})",
"tags": [
"Uber Menu"
],
"responses": {
"200": {
"description": "Menu items updated"
"description": "Menu item sparsely updated (Uber returns 204 No Content)"
}
}
}
@ -335,6 +348,38 @@
"tags": [
"Uber Menu"
],
"parameters": [
{
"in": "query",
"name": "merchantId",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "storeId",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "menu_type",
"required": false,
"schema": {
"type": "string",
"enum": [
"MENU_TYPE_FULFILLMENT_DELIVERY",
"MENU_TYPE_FULFILLMENT_PICK_UP",
"MENU_TYPE_FULFILLMENT_DINE_IN"
]
},
"description": "Defaults to MENU_TYPE_FULFILLMENT_DELIVERY."
}
],
"responses": {
"200": {
"description": "Menu fetched"
@ -1026,6 +1071,96 @@
}
}
},
"/api/v1/uber/delivery-promotions/stores/{storeId}": {
"post": {
"summary": "Promotions API 1.0.0 - Create promotion",
"tags": [
"Uber Delivery Promotions v1"
],
"parameters": [
{
"in": "path",
"name": "storeId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Promotion created"
}
}
},
"get": {
"summary": "Promotions API 1.0.0 - Get promotions by store",
"tags": [
"Uber Delivery Promotions v1"
],
"parameters": [
{
"in": "path",
"name": "storeId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Promotions listed"
}
}
}
},
"/api/v1/uber/delivery-promotions/{promotionId}": {
"get": {
"summary": "Promotions API 1.0.0 - Get promotion by ID",
"tags": [
"Uber Delivery Promotions v1"
],
"parameters": [
{
"in": "path",
"name": "promotionId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Promotion retrieved"
}
}
}
},
"/api/v1/uber/delivery-promotions/{promotionId}/revoke": {
"post": {
"summary": "Promotions API 1.0.0 - Revoke promotion",
"tags": [
"Uber Delivery Promotions v1"
],
"parameters": [
{
"in": "path",
"name": "promotionId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Promotion revoked"
}
}
}
},
"/api/v1/uber/reporting/fetch": {
"post": {
"summary": "Fetch Uber reporting CSV with retries and header-based parsing",
@ -1039,6 +1174,19 @@
}
}
},
"/api/v1/uber/reporting/create": {
"post": {
"summary": "Create Marketplace Reporting API job (POST /v1/eats/report)",
"tags": [
"Uber Reporting"
],
"responses": {
"200": {
"description": "Report creation requested successfully (workflow_id returned)"
}
}
}
},
"/api/v1/webhooks/uber": {
"post": {
"summary": "Ingest Uber webhook events",

View File

@ -167,7 +167,32 @@
}
},
{
"name": "Replace Menu (PUT)",
"name": "Get Uber Product Types Catalog",
"request": {
"method": "GET",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/uber/catalog/product-types",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"catalog",
"product-types"
]
}
}
},
{
"name": "Upload Menu (PUT v2)",
"request": {
"method": "PUT",
"header": [
@ -182,7 +207,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"storeId\": \"{{storeId}}\",\n \"menu\": {\n \"categories\": []\n }\n}"
"raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"storeId\": \"{{storeId}}\",\n \"menu\": {\n \"menu_type\": \"MENU_TYPE_FULFILLMENT_DELIVERY\",\n \"menus\": [],\n \"categories\": [],\n \"items\": [],\n \"modifier_groups\": []\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/uber/menu/replace",
@ -200,7 +225,106 @@
}
},
{
"name": "Update Menu Items",
"name": "Upload Simple Menu Example",
"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 {\n \"id\": \"Coffee\",\n \"title\": { \"translations\": { \"en_us\": \"Coffee\" } },\n \"price_info\": { \"price\": 300 },\n \"tax_info\": { \"tax_rate\": 8 }\n }\n ],\n \"modifier_groups\": [],\n \"categories\": [\n {\n \"id\": \"Drinks\",\n \"title\": { \"translations\": { \"en_us\": \"Drinks\" } },\n \"entities\": [{ \"type\": \"ITEM\", \"id\": \"Coffee\" }]\n }\n ],\n \"menus\": [\n {\n \"id\": \"All-day\",\n \"title\": { \"translations\": { \"en_us\": \"All day\" } },\n \"service_availability\": [\n { \"day_of_week\": \"monday\", \"time_periods\": [{ \"start_time\": \"00:00\", \"end_time\": \"23:59\" }] }\n ],\n \"category_ids\": [\"Drinks\"]\n }\n ],\n \"display_options\": {}\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/uber/menu/replace",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"menu",
"replace"
]
}
}
},
{
"name": "Upload Empty Menu Example",
"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\": \"empty_menu_id\",\n \"title\": { \"translations\": { \"en_us\": \"Empty Menu\" } },\n \"service_availability\": [\n { \"day_of_week\": \"monday\", \"time_periods\": [{ \"start_time\": \"00:00\", \"end_time\": \"23:59\" }] }\n ],\n \"category_ids\": []\n }\n ],\n \"display_options\": {}\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/uber/menu/replace",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"menu",
"replace"
]
}
}
},
{
"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": {
"method": "POST",
"header": [
@ -215,7 +339,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"storeId\": \"{{storeId}}\",\n \"items\": [\n {\n \"id\": \"item_1\",\n \"price_info\": {\n \"price\": 799\n },\n \"suspension_info\": {\n \"suspend_until\": null\n }\n }\n ]\n}"
"raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"storeId\": \"{{storeId}}\",\n \"itemId\": \"item_1\",\n \"update\": {\n \"menu_type\": \"MENU_TYPE_FULFILLMENT_DELIVERY\",\n \"price_info\": {\n \"price\": 799\n },\n \"suspension_info\": {\n \"suspension\": null\n }\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/uber/menu/items",
@ -232,6 +356,44 @@
}
}
},
{
"name": "Get Menu (v2)",
"request": {
"method": "GET",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/uber/menu?merchantId={{merchantId}}&storeId={{storeId}}&menu_type=MENU_TYPE_FULFILLMENT_DELIVERY",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"menu"
],
"query": [
{
"key": "merchantId",
"value": "{{merchantId}}"
},
{
"key": "storeId",
"value": "{{storeId}}"
},
{
"key": "menu_type",
"value": "MENU_TYPE_FULFILLMENT_DELIVERY"
}
]
}
}
},
{
"name": "List Provisionable Stores",
"request": {
@ -927,6 +1089,123 @@
}
}
},
{
"name": "Promotions API - Create Promotion",
"request": {
"method": "POST",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"start_time\": \"2026-04-10T00:00:00-07:00\",\n \"end_time\": \"2026-04-20T00:00:00-07:00\",\n \"external_promotion_id\": \"Uber_Superbowl\",\n \"user_group\": \"ALL_CUSTOMERS\",\n \"allow_unlimited_apply\": true,\n \"currency_code\": \"USD\",\n \"budget\": {\n \"unlimited_budget\": true\n },\n \"promo_type\": \"FLATOFF\",\n \"promotion_discount\": {\n \"flat_off_discount\": {}\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/uber/delivery-promotions/stores/{{storeId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"delivery-promotions",
"stores",
"{{storeId}}"
]
}
}
},
{
"name": "Promotions API - List Promotions",
"request": {
"method": "GET",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/uber/delivery-promotions/stores/{{storeId}}?state=active",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"delivery-promotions",
"stores",
"{{storeId}}"
],
"query": [
{
"key": "state",
"value": "active"
}
]
}
}
},
{
"name": "Promotions API - Get Promotion",
"request": {
"method": "GET",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/uber/delivery-promotions/{{promotionId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"delivery-promotions",
"{{promotionId}}"
]
}
}
},
{
"name": "Promotions API - Revoke Promotion",
"request": {
"method": "POST",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/uber/delivery-promotions/{{promotionId}}/revoke",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"delivery-promotions",
"{{promotionId}}",
"revoke"
]
}
}
},
{
"name": "Get Store By ID",
"request": {
@ -1252,6 +1531,39 @@
}
}
},
{
"name": "Create Marketplace Report Job",
"request": {
"method": "POST",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"report_type\": \"ORDERS_AND_ITEMS_REPORT\",\n \"store_uuids\": [\"{{storeId}}\"],\n \"start_date\": \"2026-03-01\",\n \"end_date\": \"2026-03-10\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/uber/reporting/create",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"reporting",
"create"
]
}
}
},
{
"name": "Set Holiday Hours",
"request": {
@ -1438,6 +1750,74 @@
}
}
},
{
"name": "Webhook Ingest - Menu Refresh (Simulation)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-Uber-Signature",
"value": "replace-with-valid-hmac"
},
{
"key": "X-Environment",
"value": "production"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"event_type\": \"store.menu_refresh_request\",\n \"partner_store_id\": \"123456\",\n \"resource_href\": \"https://api.uber.com/v1/eats/stores/{{storeId}}\",\n \"store_id\": \"{{storeId}}\",\n \"webhook_meta\": {\n \"client_id\": \"app_client_id\",\n \"webhook_config_id\": \"merchant-integration.menu-refresh-request\",\n \"webhook_msg_timestamp\": 1622813397,\n \"webhook_msg_uuid\": \"b2340f4c-6dd7-4d65-a9bc-016631a2a13a\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/webhooks/uber",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"webhooks",
"uber"
]
}
}
},
{
"name": "Webhook Ingest - Report Success (Simulation)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-Uber-Signature",
"value": "replace-with-valid-hmac"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"event_type\": \"eats.report.success\",\n \"event_id\": \"cd14f0bb-2d8c-44fb-9622-f6a4be18773e_2f7a1bdd-7993-4485-a11b-eacadce96b67\",\n \"job_id\": \"c7e05234-04ca-4460-8b03-d587df71228e\",\n \"report_type\": \"ORDERS_AND_ITEMS_REPORT\",\n \"start_time_ms\": 1742169600000,\n \"end_time_ms\": 1743119999000,\n \"report_metadata\": {\n \"sections\": []\n },\n \"webhook_meta\": {\n \"client_id\": \"ndkjscgfS5bvdiuyhv84sdhviudn\",\n \"webhook_config_id\": \"restaurant-financial-data.road-report-completion\",\n \"webhook_msg_timestamp\": 1743119999000,\n \"webhook_msg_uuid\": \"cd14f0bb-2d8c-44fb-9622-f6a4be18773e\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/webhooks/uber",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"webhooks",
"uber"
]
}
}
},
{
"name": "Generic Uber Request",
"request": {
@ -1491,6 +1871,10 @@
{
"key": "orderId",
"value": ""
},
{
"key": "promotionId",
"value": ""
}
]
}

View File

@ -15,7 +15,13 @@ const envSchema = z.object({
WEBHOOK_BASIC_AUTH_USERNAME: z.string().optional(),
WEBHOOK_BASIC_AUTH_PASSWORD: z.string().optional(),
SQLITE_PATH: z.string().default("./data/uber_wrapper.db"),
WRAPPER_API_KEY: z.string().optional()
WRAPPER_API_KEY: z.string().optional(),
ASYNC_WORKERS_ENABLED: z
.preprocess((value) => String(value ?? "true").toLowerCase(), z.enum(["true", "false"]))
.transform((value) => value === "true"),
ASYNC_WORKER_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(1000),
ASYNC_WORKER_BATCH_SIZE: z.coerce.number().int().positive().default(5),
REPORT_DOWNLOAD_DIR: z.string().default("./data/reports")
});
const parsed = envSchema.safeParse(process.env);
@ -27,5 +33,6 @@ if (!parsed.success) {
const env = parsed.data;
env.SQLITE_PATH = path.resolve(process.cwd(), env.SQLITE_PATH);
env.REPORT_DOWNLOAD_DIR = path.resolve(process.cwd(), env.REPORT_DOWNLOAD_DIR);
module.exports = env;

View File

@ -1,8 +1,9 @@
module.exports = {
menu: {
upsert: "/v1/eats/stores/{storeId}/menus",
get: "/v1/eats/stores/{storeId}/menus",
itemsUpdate: "/v1/eats/stores/{storeId}/menus/items"
upload: "/v2/eats/stores/{storeId}/menus",
get: "/v2/eats/stores/{storeId}/menus",
itemUpdate: "/v2/eats/stores/{storeId}/menus/items/{itemId}"
},
orders: {
list: "/v1/eats/stores/{storeId}/orders",
@ -49,6 +50,12 @@ module.exports = {
deliveryByoc: {
ingestCourierLocation: "/v1/eats/byoc/restaurants/orders/event/location"
},
deliveryPromotions: {
createByStore: "/v1/delivery/stores/{storeId}/promotion",
revokeById: "/v1/delivery/promotions/{promotionId}/revoke",
getById: "/v1/delivery/promotions/{promotionId}",
listByStore: "/v1/delivery/stores/{storeId}/promotions"
},
webhooks: {
events: "/v1/eats/stores/{storeId}/event_feed"
}

View File

@ -0,0 +1,346 @@
const RAW_PRODUCT_TYPES = `
ANIMALS_AND_PET_SUPPLIES
LIVE_ANIMALS
BIRD_SUPPLIES
CAT_SUPPLIES
DOG_SUPPLIES
FISH_SUPPLIES
REPTILE_AND_AMPHIBIAN_SUPPLIES
SMALL_ANIMAL_SUPPLIES
OTHER_PET_SUPPLIES
APPAREL_AND_ACCESSORIES
ARTS_AND_ENTERTAINMENT
ARTS_AND_CRAFTS
MUSICAL_INSTRUMENTS_AND_ACCESSORIES
PARTY_AND_OCCASION
BABY_AND_TODDLER
BABY_BATH_AND_BODY_CARE
BABY_DIAPERING_AND_WIPES
BABY_NURSING_AND_FEEDING
BABY_STROLLERS
BABY_CAR_SEATS
BABY_CARRIERS
BABY_TRANSPORT_ACCESSORIES
BABY_BEDDING_AND_DECOR
BABY_FURNITURE_AND_SAFETY_ACCESSORIES
BABY_CLOTHING_AND_ACCESSORIES
BABY_TOYS_AND_ACTIVITY_EQUIPMENT
BEVERAGE
BEVERAGE_ALCOHOLIC
BEVERAGE_ALCOHOLIC_BEER
BEVERAGE_ALCOHOLIC_WINE
LIQUORS_AND_SPIRITS
BEVERAGE_ALCOHOLIC_HARD_SELTZERS_AND_ALTERNATIVES
HARD_CIDER
SAKE
SHOCHU
BEVERAGE_NON-ALCOHOLIC
BEVERAGE_NON-ALCOHOLIC_SOFT_DRINKS
BEVERAGE_NON-ALCOHOLIC_COFFEE
BEVERAGE_NON-ALCOHOLIC_TEA
BEVERAGE_NON-ALCOHOLIC_WATER_AND_SELTZER
JUICE
POWDERED_DRINK_MIXES
SPORTS_ENERGY_AND_ELECTROLYTE_DRINKS
MILK_EGGNOG_AND_BUTTERMILK
MEAL_REPLACEMENTS_AND_PROTEIN_DRINKS
BEVERAGE_NON-ALCOHOLIC_WINE
BEVERAGE_NON-ALCOHOLIC_BEER
BEVERAGE_NON-ALCOHOLIC_SPIRITS
NON-ALCOHOLIC_COCKTAIL_MIXERS
BUSINESS_AND_INDUSTRIAL
CAMERAS_AND_OPTICS
CANNABIS
CANNABIS_VAPE
CANNABIS_EDIBLE
CANNABIS_BEVERAGE
CANNABIS_TOPICAL
CANNABIS_EXTRACT
CANNABIS_FLOWER
CANNABIS_PRE-ROLL
CANNABIS_OIL
CANNABIS_CAPSULE
CANNABIS_SPRAY
CANNABIS_OTHER
ELECTRONICS
FOOD
FOOD_RAW_MEATS_AND_FISH
PORK
BEEF
CHICKEN
TURKEY
OTHER_POULTRY
FISH_AND_SEAFOOD
LAMB
HOT_DOGS_SAUSAGES_AND_BACON
LUNCH_AND_DELI_MEAT
MEAT_AND_SEAFOOD_GIFTS
TOFU_SOY_AND_MEAT_ALTERNATIVES
MEAT_ALTERNATIVES
SEITAN
TEMPEH
TOFU
FOOD_PRODUCE
FRESH_FRUIT
FRESH_VEGETABLES
FRESH_HERBS
PRE-PACKAGED_FRUIT_AND_VEGETABLES
FOOD_BAKERY
BAGELS
BAKERY_ASSORTMENTS
BREADS_AND_BUNS
CAKES_AND_DESSERT_BARS
COFFEE_CAKES
CUPCAKES
DONUTS
ICE_CREAM_CONES
MUFFINS
PASTRIES_AND_SCONES
PIES_AND_TARTS
TACO_SHELLS_AND_TOSTADAS
TORTILLAS_AND_WRAPS
CANDIED_AND_CHOCOLATE_COVERED_FRUIT
CANDY_COOKIES_AND_CHOCOLATE
CANDY_AND_CHOCOLATE
COOKIES_AND_SWEET_BISCUITS
FOOD_SNACKS_AND_CANDY
APPLESAUCE_AND_FRUIT_CUPS
BREADSTICKS
BARS
CHEESE_PUFFS
CHIPS_AND_CRISPS
CRACKERS
CROUTONS
DRIED_FRUIT_AND_RAISINS
FRUIT_LEATHER
FRUIT_SNACKS
GUMN_AND_MINTS
ICE_CREAM_CONES_AND_TOPPINGS
JERKY_AND_PORK_RINDS
NUTS_AND_SEEDS
POPCORN
PRETZELS
PUDDING_AND_GELATIN_SNACKS
PUFFED_RICE_CAKES
SALAD_TOPPINGS
SESAME_STICKS
SNACK_CAKES
STICKY_RICE_CAKES
TRAIL_AND_SNACK_MIXES
FOOD_FROZEN_FOODS
FROZEN_APPETIZERS_AND_SNACKS
FROZEN_BREAD_AND_DOUGH
FROZEN_BREAKFAST_FOODS
FROZEN_DESSERTS_AND_TOPPINGS
FROZEN_FRUIT
ICE
ICE_CREAM_AND_NOVELTIES
FROZEN_JUICE
FROZEN_MEALS_AND_ENTREES
FROZEN_MEATS
FROZEN_PASTA_AND_SAUCES
FROZEN_PIZZA
FROZEN_POTATOES_AND_ONION_RINGS
FROZEN_FISH_AND_SEAFOOD
FROZEN_VEGETABLES
FOOD_DAIRY
BUTTER_AND_MARGARINE
CHEESE
COFFEE_CREAMER
COTTAGE_CHEESE
CREAM
EGGS
SOUR_CREAM
WHIPPED_CREAM
YOGURT
FOOD_CONDIMENTS
COCKTAIL_SAUCE
CURRY_SAUCE
DESSERT_TOPPINGS
FISH_SAUCE
GRAVY
HONEY
HORSERADISH_SAUCE
HOT_SAUCE
KETCHUP
MARINADES_AND_GRILLING_SAUCES
MAYONNAISE
MUSTARD
OLIVES_AND_CAPERS
PASTA_SAUCE
PICKLED_FRUITS_AND_VEGETABLES
PIZZA_SAUCE
RELISH_AND_CHUTNEY
SALAD_DRESSING
SATAY_SAUCE
SOY_SAUCE
SWEET_AND_SOUR_SAUCES
SYRUP
TAHINI
TARTAR_SAUCE
WHITE_AND_CREAM_SAUCES
WORCESTERSHIRE_SAUCE
COOKING_AND_BAKING_INGREDIENTS
BAKING_CHIPS
BAKING_CHOCOLATE
BAKING_FLAVORS_AND_EXTRACTS
BAKING_MIXERS
BAKING_POWDER
BAKING_SODA
BATTER_AND_COATING_MIXES
BEAN_PASTE
BREAD_CRUMBS
CANNED_AND_DRY_MILK
COOKIE_DECORATING_KITS
COOKING_OILS
COOKING_STARCH
COOKING_WINE
CORN_SYRUP
DOUGH
EDIBLE_BAKING_DECORATIONS
EGG_REPLACERS
FLOSS_SUGAR
FLOUR
FOOD_COLORING
FROSTING_AND_ICING
LEMON_AND_LIME_JUICE
MARSHMALLOWS
MEAL
MOLASSES
PIE_AND_PASTRY_FILLINGS
SHORTENING_AND_LARD
STARTER_CULTURES
SUGAR_AND_SWEETENERS
TAPIOCA_PEARLS
TOMATO_PASTE
UNFLAVORED_GELATIN
VINEGAR
WAFFLE_AND_PANCAKE_MIXES
YEAST
DIPS_AND_SPREADS
APPLE_BUTTER
CHEESE_DIPS_AND_SPREADS
CREAM_CHEESE
GUACAMOLE
HUMMUS
JAMS_AND_JELLIES
NUT_BUTTERS
SALSA
TAPENADE
VEGETABLE_DIP
GRAINS_RICE_AND_LEGUMES
AMARANTH
BARLEY
BUCKWHEAT
MILLET
QUINOA
RICE
RYE
WHEAT
DRIED_BEANS
DRIED_LENTILS_AND_PEAS
CEREAL_AND_GRANOLA
OATS_GRITS_AND_HOT_CEREAL
CEREAL_OATMEAL_AND_GRANOLA
FOOD_PREPARED_FOOD
INSTANT_MEALS
PASTA_AND_NOODLES
SEASONINGS_AND_SPICES
CANNED_PRODUCE_AND_SOUPS
CANNED_AND_PREPARED_BEANS_LENTILS_AND_PEAS
CANNED_AND_JARRED_FRUITS
CANNED_MEAT_AND_SEAFOOD
CANNED_AND_BOXED_SOUP_STOCKS_AND_BROTHS
FOOD_AND_NON-ALCOHOLIC_BEVERAGE_COMBO
FOOD_AND_ALCOHOLIC_BEVERAGE_COMBO
FURNITURE
HARDWARE
HEALTH_AND_BEAUTY
HEALTH_AND_BEAUTY_FAMILY_PLANNING
MATURE_CONDOMS
MATURE_LUBRICANTS
HEALTH_AND_BEAUTY_FIRST_AID
VITAMINS_AND_SUPPLEMENTS
MEDICAL_SUPPLIES_AND_MONITORS
HEALTH_AND_BEAUTY_MEDICINE_AND_DRUGS
HEALTH_AND_BEAUTY_MEDICINE_AND_DRUGS_ANTACIDS_AND_ANTIFLATULENTS
HEALTH_AND_BEAUTY_MEDICINE_AND_DRUGS_COUGH_SUPRESSANTS
HEALTH_AND_BEAUTY_MEDICINE_AND_DRUGS_COLD_AND_FLU_MEDICINES
HEALTH_AND_BEAUTY_MEDICINE_AND_DRUGS_PRESCRIPTION_DRUGS
BATH_AND_BODY
COTTON_SWABS_AND_BALLS
DEODORANT_AND_ANTIPERSPIRANT
EYE_AND_EAR_CARE
FEMININE_CARE
HAIR_CARE
HAND_AND_FOOT_CARE
JEWELRY_CLEANING_AND_CARE
MAKEUP_NAILS_AND_COSMETIC_TOOLS
ORAL_CARE
PERFUME_AND_COLOGNE
SHAVING_AND_GROOMING
SKINCARE
SLEEP_AND_MASSAGE
HOUSEHOLD_ESSENTIALS
HOUSEHOLD_CLEANING_SUPPLIES
HOUSEHOLD_PAPER_PRODUCTS
LAUNDRY_SUPPLIES
TRASH_BINS_AND_BAGS
FOOD_STORAGE_AND_ACCESSORIES
AIR_FRESHENERS_CANDLES_AND_FRAGRANCE
INSECT_AND_PEST_CONTROL
SHOE_CARE_AND_TOOLS
LIGHTERS_AND_MATCHES
HOME_AND_GARDEN
HOME_AND_GARDEN_BATHROOM_ACCESSORIES
HOME_AND_GARDEN_DECOR
ORGANIZERS
HOME_AND_GARDEN_KITCHEN_AND_DINING
HOME_AND_GARDEN_LAWN_AND_GARDEN
HOME_AND_GARDEN_LIGHTING_AND_LIGHTING_ACCESSORIES
HOME_AND_GARDEN_LINENS_AND_BEDDING
HOME_AND_GARDEN_POOL_AND_SPA
HOME_AND_GARDEN_SMOKING_ACCESSORIES
HOME_AND_GARDEN_GARDEN_CHEMICALS
HOME_AND_GARDEN_FUEL
FLOWERS_AND_PLANTS
FLOWERS_AND_BOUQUETS
HOME_AND_GARDEN_PLANTS
LUGGAGE_AND_BAGS
MATURE
MATURE_ADULT_GAMES
MATURE_VIBRATORS
LINGERIE
LOTTERY
MEDIA
MEDIA_BOOKS
MEDIA_DVDS_AND_VIDEOS
MEDIA_MAGAZINES_AND_NEWSPAPERS
MEDIA_MUSIC_AND_SOUND_RECORDINGS
OFFICE_SUPPLIES
SOFTWARE
SPORTING_GOODS
TOBACCO
TOBACCO_CIGARETTES
TOBACCO_CIGARS
TOBACCO_E-CIGARETTE
TOBACCO_VAPES
TOBACCO_SMOKELESS_TOBACCO
TOYS_AND_GAMES
VEHICLES_AND_PARTS
`;
const PRODUCT_TYPES = Array.from(
new Set(
RAW_PRODUCT_TYPES.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
)
);
const MIXIN_TYPES = ["CONTAINS_ALCOHOL", "CONTAINS_CANNABIS"];
module.exports = {
PRODUCT_TYPES,
MIXIN_TYPES
};

View File

@ -11,5 +11,8 @@ module.exports = {
webhookRepository: repositories.webhookRepository,
apiLogRepository: repositories.apiLogRepository,
appTokenRepository: repositories.appTokenRepository,
tokenRequestLogRepository: repositories.tokenRequestLogRepository
tokenRequestLogRepository: repositories.tokenRequestLogRepository,
reportJobRepository: repositories.reportJobRepository,
asyncJobRepository: repositories.asyncJobRepository,
reportSectionRepository: repositories.reportSectionRepository
};

View File

@ -107,6 +107,31 @@ const uberConnectionRepository = {
return this.findByMerchantId(merchantId);
},
markMenuRefreshRequestedByStoreId(uberStoreId, payload) {
const existing = this.findByUberStoreId(uberStoreId);
if (!existing) {
return null;
}
const timestamp = nowIso();
db.prepare(
`
UPDATE uber_connections
SET last_menu_refresh_requested_at = ?,
last_menu_refresh_webhook_uuid = ?,
last_webhook_environment = ?,
updated_at = ?
WHERE uber_store_id = ?
`
).run(
payload?.requestedAt || timestamp,
payload?.webhookMsgUuid || null,
payload?.environment || null,
timestamp,
uberStoreId
);
return this.findByUberStoreId(uberStoreId);
},
list() {
return db
.prepare(`
@ -120,6 +145,10 @@ const uberConnectionRepository = {
};
const webhookRepository = {
findById(id) {
return db.prepare("SELECT * FROM webhook_events WHERE id = ? LIMIT 1").get(id);
},
findByDedupeKey(dedupeKey) {
if (!dedupeKey) {
return null;
@ -166,6 +195,31 @@ const webhookRepository = {
`);
stmt.run(row);
return row;
},
markProcessed(id) {
const timestamp = nowIso();
db.prepare(
`
UPDATE webhook_events
SET processing_status = 'processed',
processed_at = ?,
last_error = NULL
WHERE id = ?
`
).run(timestamp, id);
},
markFailed(id, errorMessage) {
db.prepare(
`
UPDATE webhook_events
SET processing_status = 'failed',
processed_at = ?,
last_error = ?
WHERE id = ?
`
).run(nowIso(), String(errorMessage || "Unknown webhook processing error"), id);
}
};
@ -402,6 +456,15 @@ const appTokenRepository = {
`
)
.get(provider, grantType, scope);
},
deleteByProviderGrantScope({ provider, grantType, scope }) {
db.prepare(
`
DELETE FROM app_tokens
WHERE provider = ? AND grant_type = ? AND scope = ?
`
).run(provider, grantType, scope);
}
};
@ -434,11 +497,355 @@ const tokenRequestLogRepository = {
}
};
const reportJobRepository = {
createRequested({
merchantId,
workflowId,
reportType,
storeUuids,
groupUuids,
startDate,
endDate
}) {
const existing = this.findByWorkflowId(workflowId);
const timestamp = nowIso();
const row = {
id: existing?.id || uuidv4(),
merchant_id: merchantId || existing?.merchant_id || null,
workflow_id: workflowId,
report_type: reportType,
store_uuids_json: JSON.stringify(storeUuids || []),
group_uuids_json: JSON.stringify(groupUuids || []),
start_date: startDate,
end_date: endDate,
status: existing?.status === "completed" ? "completed" : "requested",
webhook_event_id: existing?.webhook_event_id || null,
webhook_msg_uuid: existing?.webhook_msg_uuid || null,
report_sections_json: existing?.report_sections_json || null,
webhook_payload_json: existing?.webhook_payload_json || null,
created_at: existing?.created_at || timestamp,
completed_at: existing?.completed_at || null,
updated_at: timestamp
};
db.prepare(
`
INSERT INTO report_jobs (
id, merchant_id, workflow_id, report_type, store_uuids_json, group_uuids_json,
start_date, end_date, status, webhook_event_id, webhook_msg_uuid, report_sections_json,
webhook_payload_json, created_at, completed_at, updated_at
)
VALUES (
@id, @merchant_id, @workflow_id, @report_type, @store_uuids_json, @group_uuids_json,
@start_date, @end_date, @status, @webhook_event_id, @webhook_msg_uuid, @report_sections_json,
@webhook_payload_json, @created_at, @completed_at, @updated_at
)
ON CONFLICT(workflow_id) DO UPDATE SET
merchant_id = excluded.merchant_id,
report_type = excluded.report_type,
store_uuids_json = excluded.store_uuids_json,
group_uuids_json = excluded.group_uuids_json,
start_date = excluded.start_date,
end_date = excluded.end_date,
updated_at = excluded.updated_at
`
).run(row);
return this.findByWorkflowId(workflowId);
},
markSuccessFromWebhook({
workflowId,
eventId,
webhookMsgUuid,
reportType,
sections,
payload
}) {
const existing = this.findByWorkflowId(workflowId);
const timestamp = nowIso();
if (!existing) {
const row = {
id: uuidv4(),
merchant_id: null,
workflow_id: workflowId,
report_type: reportType || "UNKNOWN",
store_uuids_json: JSON.stringify([]),
group_uuids_json: JSON.stringify([]),
start_date: "",
end_date: "",
status: "completed",
webhook_event_id: eventId || null,
webhook_msg_uuid: webhookMsgUuid || null,
report_sections_json: JSON.stringify(sections || []),
webhook_payload_json: JSON.stringify(payload || {}),
created_at: timestamp,
completed_at: timestamp,
updated_at: timestamp
};
db.prepare(
`
INSERT INTO report_jobs (
id, merchant_id, workflow_id, report_type, store_uuids_json, group_uuids_json,
start_date, end_date, status, webhook_event_id, webhook_msg_uuid, report_sections_json,
webhook_payload_json, created_at, completed_at, updated_at
)
VALUES (
@id, @merchant_id, @workflow_id, @report_type, @store_uuids_json, @group_uuids_json,
@start_date, @end_date, @status, @webhook_event_id, @webhook_msg_uuid, @report_sections_json,
@webhook_payload_json, @created_at, @completed_at, @updated_at
)
`
).run(row);
return this.findByWorkflowId(workflowId);
}
db.prepare(
`
UPDATE report_jobs
SET status = 'completed',
report_type = COALESCE(?, report_type),
webhook_event_id = ?,
webhook_msg_uuid = ?,
report_sections_json = ?,
webhook_payload_json = ?,
completed_at = ?,
updated_at = ?
WHERE workflow_id = ?
`
).run(
reportType || null,
eventId || null,
webhookMsgUuid || null,
JSON.stringify(sections || []),
JSON.stringify(payload || {}),
timestamp,
timestamp,
workflowId
);
return this.findByWorkflowId(workflowId);
},
findByWorkflowId(workflowId) {
return db.prepare("SELECT * FROM report_jobs WHERE workflow_id = ? LIMIT 1").get(workflowId);
}
};
const asyncJobRepository = {
enqueue({
jobType,
payload,
maxAttempts = 7,
nextRunAt = nowIso()
}) {
const timestamp = nowIso();
const row = {
id: uuidv4(),
job_type: jobType,
payload_json: JSON.stringify(payload || {}),
status: "queued",
attempt_count: 0,
max_attempts: maxAttempts,
next_run_at: nextRunAt,
last_error: null,
lock_token: null,
locked_at: null,
created_at: timestamp,
updated_at: timestamp
};
db.prepare(
`
INSERT INTO async_jobs (
id, job_type, payload_json, status, attempt_count, max_attempts, next_run_at,
last_error, lock_token, locked_at, created_at, updated_at
)
VALUES (
@id, @job_type, @payload_json, @status, @attempt_count, @max_attempts, @next_run_at,
@last_error, @lock_token, @locked_at, @created_at, @updated_at
)
`
).run(row);
return row;
},
claimNext({ workerId, lockTimeoutSeconds = 120 }) {
const timestamp = nowIso();
const lockCutoffIso = new Date(Date.now() - lockTimeoutSeconds * 1000).toISOString();
const selectStmt = db.prepare(
`
SELECT *
FROM async_jobs
WHERE (status = 'queued' OR status = 'retry')
AND next_run_at <= ?
AND (locked_at IS NULL OR locked_at < ?)
ORDER BY created_at ASC
LIMIT 1
`
);
const updateStmt = db.prepare(
`
UPDATE async_jobs
SET status = 'processing',
lock_token = ?,
locked_at = ?,
attempt_count = attempt_count + 1,
updated_at = ?
WHERE id = ?
`
);
const tx = db.transaction(() => {
const row = selectStmt.get(timestamp, lockCutoffIso);
if (!row) {
return null;
}
updateStmt.run(workerId, timestamp, timestamp, row.id);
return db.prepare("SELECT * FROM async_jobs WHERE id = ? LIMIT 1").get(row.id);
});
return tx();
},
markCompleted(id) {
const timestamp = nowIso();
db.prepare(
`
UPDATE async_jobs
SET status = 'completed',
lock_token = NULL,
locked_at = NULL,
updated_at = ?,
last_error = NULL
WHERE id = ?
`
).run(timestamp, id);
},
markFailedOrRetry({ id, attemptCount, maxAttempts, errorMessage }) {
const timestamp = nowIso();
if (attemptCount >= maxAttempts) {
db.prepare(
`
UPDATE async_jobs
SET status = 'failed',
lock_token = NULL,
locked_at = NULL,
updated_at = ?,
last_error = ?
WHERE id = ?
`
).run(timestamp, String(errorMessage || "Unknown async worker error"), id);
return "failed";
}
const backoffSeconds = Math.min(300, Math.max(1, 2 ** Math.max(0, attemptCount - 1)));
const nextRunAt = new Date(Date.now() + backoffSeconds * 1000).toISOString();
db.prepare(
`
UPDATE async_jobs
SET status = 'retry',
next_run_at = ?,
lock_token = NULL,
locked_at = NULL,
updated_at = ?,
last_error = ?
WHERE id = ?
`
).run(nextRunAt, timestamp, String(errorMessage || "Unknown async worker error"), id);
return "retry";
}
};
const reportSectionRepository = {
upsert({
workflowId,
sectionIndex,
sectionName,
downloadUrl,
filePath,
contentType,
rowCount,
headers,
sha256,
status,
errorMessage
}) {
const existing = db
.prepare(
`
SELECT * FROM report_sections
WHERE workflow_id = ? AND section_index = ?
LIMIT 1
`
)
.get(workflowId, sectionIndex);
const timestamp = nowIso();
const row = {
id: existing?.id || uuidv4(),
workflow_id: workflowId,
section_index: sectionIndex,
section_name: sectionName || null,
download_url: downloadUrl || null,
file_path: filePath || existing?.file_path || null,
content_type: contentType || null,
row_count: Number.isFinite(rowCount) ? rowCount : null,
headers_json: headers ? JSON.stringify(headers) : existing?.headers_json || null,
sha256: sha256 || null,
status: status || existing?.status || "queued",
error_message: errorMessage || null,
created_at: existing?.created_at || timestamp,
updated_at: timestamp
};
db.prepare(
`
INSERT INTO report_sections (
id, workflow_id, section_index, section_name, download_url, file_path, content_type,
row_count, headers_json, sha256, status, error_message, created_at, updated_at
)
VALUES (
@id, @workflow_id, @section_index, @section_name, @download_url, @file_path, @content_type,
@row_count, @headers_json, @sha256, @status, @error_message, @created_at, @updated_at
)
ON CONFLICT(workflow_id, section_index) DO UPDATE SET
section_name = excluded.section_name,
download_url = excluded.download_url,
file_path = excluded.file_path,
content_type = excluded.content_type,
row_count = excluded.row_count,
headers_json = excluded.headers_json,
sha256 = excluded.sha256,
status = excluded.status,
error_message = excluded.error_message,
updated_at = excluded.updated_at
`
).run(row);
return db
.prepare(
`
SELECT *
FROM report_sections
WHERE workflow_id = ? AND section_index = ?
LIMIT 1
`
)
.get(workflowId, sectionIndex);
}
};
module.exports = {
merchantRepository,
uberConnectionRepository,
webhookRepository,
apiLogRepository,
appTokenRepository,
tokenRequestLogRepository
tokenRequestLogRepository,
reportJobRepository,
asyncJobRepository,
reportSectionRepository
};

View File

@ -58,6 +58,7 @@ function initSchema() {
headers_json TEXT,
received_at TEXT NOT NULL,
processed_at TEXT,
last_error TEXT,
processing_status TEXT NOT NULL DEFAULT 'received',
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
);
@ -97,6 +98,64 @@ function initSchema() {
grant_type TEXT NOT NULL,
requested_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS report_jobs (
id TEXT PRIMARY KEY,
merchant_id TEXT,
workflow_id TEXT NOT NULL UNIQUE,
report_type TEXT NOT NULL,
store_uuids_json TEXT,
group_uuids_json TEXT,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
status TEXT NOT NULL,
webhook_event_id TEXT,
webhook_msg_uuid TEXT,
report_sections_json TEXT,
webhook_payload_json TEXT,
created_at TEXT NOT NULL,
completed_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
);
CREATE TABLE IF NOT EXISTS async_jobs (
id TEXT PRIMARY KEY,
job_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 7,
next_run_at TEXT NOT NULL,
last_error TEXT,
lock_token TEXT,
locked_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_async_jobs_run_window
ON async_jobs(status, next_run_at, created_at);
CREATE TABLE IF NOT EXISTS report_sections (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL,
section_index INTEGER NOT NULL,
section_name TEXT,
download_url TEXT,
file_path TEXT,
content_type TEXT,
row_count INTEGER,
headers_json TEXT,
sha256 TEXT,
status TEXT NOT NULL,
error_message TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_report_sections_workflow_section
ON report_sections(workflow_id, section_index);
`);
if (!tableHasColumn("webhook_events", "resource_id")) {
@ -120,6 +179,40 @@ function initSchema() {
if (!tableHasColumn("api_logs", "order_id")) {
db.exec("ALTER TABLE api_logs ADD COLUMN order_id TEXT");
}
if (!tableHasColumn("uber_connections", "last_menu_refresh_requested_at")) {
db.exec("ALTER TABLE uber_connections ADD COLUMN last_menu_refresh_requested_at TEXT");
}
if (!tableHasColumn("uber_connections", "last_menu_refresh_webhook_uuid")) {
db.exec("ALTER TABLE uber_connections ADD COLUMN last_menu_refresh_webhook_uuid TEXT");
}
if (!tableHasColumn("uber_connections", "last_webhook_environment")) {
db.exec("ALTER TABLE uber_connections ADD COLUMN last_webhook_environment TEXT");
}
if (!tableHasColumn("report_jobs", "webhook_event_id")) {
db.exec("ALTER TABLE report_jobs ADD COLUMN webhook_event_id TEXT");
}
if (!tableHasColumn("report_jobs", "webhook_msg_uuid")) {
db.exec("ALTER TABLE report_jobs ADD COLUMN webhook_msg_uuid TEXT");
}
if (!tableHasColumn("report_jobs", "report_sections_json")) {
db.exec("ALTER TABLE report_jobs ADD COLUMN report_sections_json TEXT");
}
if (!tableHasColumn("report_jobs", "webhook_payload_json")) {
db.exec("ALTER TABLE report_jobs ADD COLUMN webhook_payload_json TEXT");
}
if (!tableHasColumn("webhook_events", "processed_at")) {
db.exec("ALTER TABLE webhook_events ADD COLUMN processed_at TEXT");
}
if (!tableHasColumn("webhook_events", "processing_status")) {
db.exec("ALTER TABLE webhook_events ADD COLUMN processing_status TEXT");
db.exec("UPDATE webhook_events SET processing_status = 'received' WHERE processing_status IS NULL");
}
if (!tableHasColumn("webhook_events", "last_error")) {
db.exec("ALTER TABLE webhook_events ADD COLUMN last_error TEXT");
}
}
module.exports = {

View File

@ -121,21 +121,23 @@ function parseExpiresAt(expiresInSeconds) {
return new Date(Date.now() + seconds * 1000).toISOString();
}
async function getCachedClientCredentialsToken({ scope }) {
async function getCachedClientCredentialsToken({ scope, forceRefresh = false } = {}) {
const normalizedScope = (scope || AUTH_SCOPES.ORDER).trim().split(/\s+/).sort().join(" ");
const cached = appTokenRepository.findValid({
provider: "uber",
grantType: AUTH_GRANT_TYPES.CLIENT_CREDENTIALS,
scope: normalizedScope
});
if (cached) {
return {
access_token: cached.access_token,
token_type: cached.token_type,
scope: cached.scope,
expires_in: Math.max(0, Math.floor((new Date(cached.expires_at).getTime() - Date.now()) / 1000)),
source: "cache"
};
if (!forceRefresh) {
const cached = appTokenRepository.findValid({
provider: "uber",
grantType: AUTH_GRANT_TYPES.CLIENT_CREDENTIALS,
scope: normalizedScope
});
if (cached) {
return {
access_token: cached.access_token,
token_type: cached.token_type,
scope: cached.scope,
expires_in: Math.max(0, Math.floor((new Date(cached.expires_at).getTime() - Date.now()) / 1000)),
source: "cache"
};
}
}
const requestsLastHour = tokenRequestLogRepository.countInLastHour({

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,5 +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(),
@ -20,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);
@ -35,20 +37,58 @@ 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",
"MENU_TYPE_FULFILLMENT_PICK_UP",
"MENU_TYPE_FULFILLMENT_DINE_IN"
])
.optional(),
menuType: z
.enum([
"MENU_TYPE_FULFILLMENT_DELIVERY",
"MENU_TYPE_FULFILLMENT_PICK_UP",
"MENU_TYPE_FULFILLMENT_DINE_IN"
])
.optional()
});
const payload = schema.parse(req.query);
const data = await proxyService.menuGet(payload);
const data = await proxyService.menuGet({
merchantId: payload.merchantId,
storeId: payload.storeId,
menuType: payload.menu_type || payload.menuType
});
return res.json({ success: true, data });
}
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);
const allowedMenuTypes = new Set([
"MENU_TYPE_FULFILLMENT_DELIVERY",
"MENU_TYPE_FULFILLMENT_PICK_UP",
"MENU_TYPE_FULFILLMENT_DINE_IN"
]);
const menuType = payload.menu && payload.menu.menu_type;
if (menuType && !allowedMenuTypes.has(menuType)) {
const error = new Error(
"menu.menu_type must be one of MENU_TYPE_FULFILLMENT_DELIVERY, MENU_TYPE_FULFILLMENT_PICK_UP, MENU_TYPE_FULFILLMENT_DINE_IN"
);
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,
storeId: payload.storeId,
@ -58,16 +98,60 @@ async function replaceMenu(req, res) {
}
async function updateMenuItems(req, res) {
const menuTypeEnum = z.enum([
"MENU_TYPE_FULFILLMENT_DELIVERY",
"MENU_TYPE_FULFILLMENT_PICK_UP",
"MENU_TYPE_FULFILLMENT_DINE_IN"
]);
const updateSchema = z
.object({
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(),
classifications: z.any().optional(),
beverage_info: z.any().optional(),
physical_properties_info: z.any().optional(),
medication_info: z.any().optional(),
nutritional_info: z.any().optional(),
selling_info: z.any().optional()
})
.refine((value) => Object.keys(value).length > 0, {
message: "update must include at least one sparse-update field"
});
const schema = z.object({
merchantId: z.string().min(1),
storeId: z.string().min(1),
items: z.array(z.any()).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,
payload: { items: payload.items }
itemId: payload.itemId,
payload: payload.update
});
return res.json({ success: true, data });
}
@ -635,6 +719,73 @@ async function deliveryByocIngestCourierLocation(req, res) {
return res.json({ success: true, data });
}
async function deliveryCreatePromotion(req, res) {
const budgetSchema = z
.object({
unlimited_budget: z.boolean().optional(),
amount_e5: z.coerce.number().optional()
})
.optional();
const promotionSchema = z.object({
start_time: z.string(),
end_time: z.string(),
external_promotion_id: z.string().optional(),
user_group: z.enum(["ALL_CUSTOMERS", "FIRST_TIME_CUSTOMERS"]).optional(),
allow_unlimited_apply: z.boolean().optional(),
currency_code: z.string().optional(),
budget: budgetSchema,
promo_type: z.string(),
promotion_discount: z.record(z.string(), z.any()).optional(),
promotion_customization: z.record(z.string(), z.any()).optional()
});
const payload = promotionSchema.parse(req.body || {});
const data = await proxyService.deliveryCreatePromotion({
storeId: req.params.storeId,
payload
});
return res.json({ success: true, data });
}
async function deliveryRevokePromotion(req, res) {
const data = await proxyService.deliveryRevokePromotion({
promotionId: req.params.promotionId
});
return res.json({ success: true, data });
}
async function deliveryGetPromotion(req, res) {
const data = await proxyService.deliveryGetPromotion({
promotionId: req.params.promotionId
});
return res.json({ success: true, data });
}
async function deliveryListPromotions(req, res) {
const schema = z.object({
state: z.enum(["active", "pending", "completed", "revoked", "expired", "deleted"]).optional(),
start_time: z.string().optional(),
end_time: z.string().optional()
});
const query = schema.parse(req.query || {});
const data = await proxyService.deliveryListPromotions({
storeId: req.params.storeId,
query
});
return res.json({ success: true, data });
}
async function getUberProductTypes(req, res) {
return res.json({
success: true,
data: {
product_types: PRODUCT_TYPES,
mixin_types: MIXIN_TYPES
}
});
}
module.exports = {
genericProxy,
upsertMenu,
@ -676,5 +827,10 @@ module.exports = {
deliveryResolveFulfillmentIssues,
deliveryGetReplacementRecommendations,
deliveryUpdatePartnerCount,
deliveryByocIngestCourierLocation
deliveryByocIngestCourierLocation,
deliveryCreatePromotion,
deliveryRevokePromotion,
deliveryGetPromotion,
deliveryListPromotions,
getUberProductTypes
};

View File

@ -1,8 +1,15 @@
const axios = require("axios");
const zlib = require("zlib");
const { promisify } = require("util");
const env = require("../../config/env");
const uberEndpoints = require("../../config/uberEndpoints");
const { uberConnectionRepository, apiLogRepository } = require("../../db/adapter");
const { getCachedClientCredentialsToken, AUTH_SCOPES } = require("../auth/auth.service");
const { uberConnectionRepository, apiLogRepository, appTokenRepository } = require("../../db/adapter");
const {
getCachedClientCredentialsToken,
refreshToken,
AUTH_SCOPES,
AUTH_GRANT_TYPES
} = require("../auth/auth.service");
const { normalizeUberError, isRetryableUberError } = require("../common/errors/uberError");
const { withExponentialBackoffRetry } = require("../common/http/retry");
@ -11,6 +18,8 @@ const uberApiClient = axios.create({
timeout: 30000
});
const gzipAsync = promisify(zlib.gzip);
function interpolatePath(pathTemplate, params = {}) {
let output = pathTemplate;
Object.entries(params).forEach(([key, value]) => {
@ -55,11 +64,60 @@ async function resolveAuthToken({ authMode = "app", merchantId, scopes }) {
};
}
async function callUberApi({ merchantId, method, uberPath, query, body, wrapperRoute, authMode, scopes }) {
const resolvedAuth = await resolveAuthToken({ authMode, merchantId, scopes });
function isUnauthorizedError(error) {
return Number(error?.response?.status || 0) === 401;
}
try {
const response = await withExponentialBackoffRetry({
async function refreshMerchantConnectionToken(merchantId) {
const connection = uberConnectionRepository.findByMerchantId(merchantId);
if (!connection) {
const error = new Error("Uber merchant connection not found for token refresh.");
error.status = 404;
throw error;
}
if (!connection.refresh_token) {
const error = new Error("Refresh token is not available for merchant OAuth connection.");
error.status = 401;
throw error;
}
const tokenData = await refreshToken(connection.refresh_token);
const expiresAt = tokenData.expires_in
? new Date(Date.now() + Number(tokenData.expires_in) * 1000).toISOString()
: connection.expires_at;
const updated = uberConnectionRepository.upsertByMerchantId(merchantId, {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || connection.refresh_token,
tokenType: tokenData.token_type || connection.token_type || "Bearer",
scope: tokenData.scope || connection.scope || null,
expiresAt,
status: "active"
});
return {
tokenType: updated.token_type || "Bearer",
accessToken: updated.access_token
};
}
async function callUberApi({
merchantId,
method,
uberPath,
query,
body,
logRequestBody,
wrapperRoute,
authMode,
scopes,
headers
}) {
const scopeKey = scopes || AUTH_SCOPES.ORDER;
const normalizedScope = scopeKey.trim().split(/\s+/).sort().join(" ");
let resolvedAuth = await resolveAuthToken({ authMode, merchantId, scopes: normalizedScope });
const buildRequest = () =>
withExponentialBackoffRetry({
fn: async () =>
uberApiClient.request({
method,
@ -68,13 +126,43 @@ async function callUberApi({ merchantId, method, uberPath, query, body, wrapperR
data: body,
headers: {
Authorization: buildAuthHeader(resolvedAuth.tokenType, resolvedAuth.accessToken),
"Content-Type": "application/json"
"Content-Type": "application/json",
...(headers || {})
}
}),
maxAttempts: 4,
baseDelayMs: 300,
shouldRetry: (error) => isRetryableUberError(error)
});
try {
let response;
try {
response = await buildRequest();
} catch (error) {
if (!isUnauthorizedError(error)) {
throw error;
}
if (authMode === "merchant" && merchantId) {
resolvedAuth = await refreshMerchantConnectionToken(merchantId);
} else {
appTokenRepository.deleteByProviderGrantScope({
provider: "uber",
grantType: AUTH_GRANT_TYPES.CLIENT_CREDENTIALS,
scope: normalizedScope
});
const freshToken = await getCachedClientCredentialsToken({
scope: normalizedScope,
forceRefresh: true
});
resolvedAuth = {
tokenType: freshToken.token_type || "Bearer",
accessToken: freshToken.access_token
};
}
response = await buildRequest();
}
apiLogRepository.insert({
merchantId,
@ -82,7 +170,7 @@ async function callUberApi({ merchantId, method, uberPath, query, body, wrapperR
wrapperRoute,
uberPath,
responseStatus: response.status,
requestBody: body,
requestBody: logRequestBody !== undefined ? logRequestBody : body,
responseBody: response.data
});
@ -96,7 +184,7 @@ async function callUberApi({ merchantId, method, uberPath, query, body, wrapperR
wrapperRoute,
uberPath,
responseStatus: normalized.status,
requestBody: body,
requestBody: logRequestBody !== undefined ? logRequestBody : body,
responseBody: {
code: normalized.code,
message: normalized.message,
@ -136,32 +224,44 @@ async function menuUpsert({ merchantId, storeId, payload }) {
}
async function menuReplace({ merchantId, storeId, payload }) {
const uberPath = interpolatePath(uberEndpoints.menu.upsert, { storeId });
const uberPath = interpolatePath(uberEndpoints.menu.upload, { storeId });
const compressedPayload = await gzipAsync(Buffer.from(JSON.stringify(payload), "utf8"));
return callUberApi({
merchantId,
method: "PUT",
uberPath,
body: payload,
body: compressedPayload,
logRequestBody: payload,
headers: {
"Content-Encoding": "gzip",
"Content-Type": "application/json"
},
wrapperRoute: "/api/v1/uber/menu/replace",
authMode: "app",
scopes: AUTH_SCOPES.STORE
});
}
async function menuGet({ merchantId, storeId }) {
async function menuGet({ merchantId, storeId, menuType }) {
const uberPath = interpolatePath(uberEndpoints.menu.get, { storeId });
return callUberApi({
merchantId,
method: "GET",
uberPath,
query: {
menu_type: menuType || "MENU_TYPE_FULFILLMENT_DELIVERY"
},
headers: {
"Accept-Encoding": "gzip"
},
wrapperRoute: "/api/v1/uber/menu",
authMode: "app",
scopes: AUTH_SCOPES.STORE
});
}
async function updateMenuItems({ merchantId, storeId, payload }) {
const uberPath = interpolatePath(uberEndpoints.menu.itemsUpdate, { storeId });
async function updateMenuItems({ merchantId, storeId, itemId, payload }) {
const uberPath = interpolatePath(uberEndpoints.menu.itemUpdate, { storeId, itemId });
return callUberApi({
merchantId,
method: "POST",
@ -612,6 +712,53 @@ async function deliveryByocIngestCourierLocation({ payload }) {
});
}
async function deliveryCreatePromotion({ storeId, payload }) {
const uberPath = interpolatePath(uberEndpoints.deliveryPromotions.createByStore, { storeId });
return callUberApi({
method: "POST",
uberPath,
body: payload,
wrapperRoute: "/api/v1/uber/delivery-promotions/stores/:storeId",
authMode: "app",
scopes: AUTH_SCOPES.STORE
});
}
async function deliveryRevokePromotion({ promotionId }) {
const uberPath = interpolatePath(uberEndpoints.deliveryPromotions.revokeById, { promotionId });
return callUberApi({
method: "POST",
uberPath,
body: {},
wrapperRoute: "/api/v1/uber/delivery-promotions/:promotionId/revoke",
authMode: "app",
scopes: AUTH_SCOPES.STORE
});
}
async function deliveryGetPromotion({ promotionId }) {
const uberPath = interpolatePath(uberEndpoints.deliveryPromotions.getById, { promotionId });
return callUberApi({
method: "GET",
uberPath,
wrapperRoute: "/api/v1/uber/delivery-promotions/:promotionId",
authMode: "app",
scopes: AUTH_SCOPES.STORE
});
}
async function deliveryListPromotions({ storeId, query }) {
const uberPath = interpolatePath(uberEndpoints.deliveryPromotions.listByStore, { storeId });
return callUberApi({
method: "GET",
uberPath,
query,
wrapperRoute: "/api/v1/uber/delivery-promotions/stores/:storeId",
authMode: "app",
scopes: AUTH_SCOPES.STORE
});
}
module.exports = {
genericProxy,
menuUpsert,
@ -653,5 +800,9 @@ module.exports = {
deliveryResolveFulfillmentIssues,
deliveryGetReplacementRecommendations,
deliveryUpdatePartnerCount,
deliveryByocIngestCourierLocation
deliveryByocIngestCourierLocation,
deliveryCreatePromotion,
deliveryRevokePromotion,
deliveryGetPromotion,
deliveryListPromotions
};

View File

@ -1,5 +1,34 @@
const { z } = require("zod");
const { fetchReport } = require("./reporting.service");
const { fetchReport, createReportJob } = require("./reporting.service");
const REPORT_TYPES = [
"PAYMENT_DETAILS_REPORT",
"ORDER_ERRORS_MENU_ITEM_REPORT",
"ORDER_ERRORS_TRANSACTION_REPORT",
"ORDER_HISTORY_REPORT",
"DOWNTIME_REPORT",
"CUSTOMER_AND_DELIVERY_FEEDBACK_REPORT",
"MENU_ITEM_FEEDBACK_REPORT",
"BILLING_DETAILS_REPORT",
"ORDERS_AND_ITEMS_REPORT",
"FINANCE_SUMMARY_REPORT"
];
const RANGE_LIMIT_DAYS = {
PAYMENT_DETAILS_REPORT: 30,
ORDERS_AND_ITEMS_REPORT: 15,
FINANCE_SUMMARY_REPORT: 30
};
const LOOKBACK_RULES = {
ORDER_ERRORS_MENU_ITEM_REPORT: { minDaysAgo: 188, maxDaysAgo: 2 },
ORDER_ERRORS_TRANSACTION_REPORT: { minDaysAgo: 190, maxDaysAgo: 4 },
ORDER_HISTORY_REPORT: { minDaysAgo: 188, maxDaysAgo: 2 },
DOWNTIME_REPORT: { minDaysAgo: 188, maxDaysAgo: 2 },
CUSTOMER_AND_DELIVERY_FEEDBACK_REPORT: { minDaysAgo: 188, maxDaysAgo: 2 },
MENU_ITEM_FEEDBACK_REPORT: { minDaysAgo: 188, maxDaysAgo: 2 },
BILLING_DETAILS_REPORT: { minDaysAgo: 1825, maxDaysAgo: 2 }
};
const fetchSchema = z.object({
method: z.enum(["GET", "POST"]).default("GET"),
@ -28,7 +57,109 @@ async function fetchReportingCsv(req, res) {
});
}
module.exports = {
fetchReportingCsv
};
function parseDateLike(input, allowDateTime) {
const raw = String(input || "");
if (allowDateTime) {
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
return new Date(`${raw}T00:00:00.000Z`);
}
return new Date(raw);
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
return null;
}
return new Date(`${raw}T00:00:00.000Z`);
}
function daysBetweenInclusive(start, end) {
const msPerDay = 24 * 60 * 60 * 1000;
return Math.floor((end.getTime() - start.getTime()) / msPerDay) + 1;
}
function daysAgo(date) {
const now = new Date();
const utcNow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const utcDate = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const msPerDay = 24 * 60 * 60 * 1000;
return Math.floor((utcNow.getTime() - utcDate.getTime()) / msPerDay);
}
const createReportSchema = z
.object({
merchantId: z.string().min(1).optional(),
report_type: z.enum(REPORT_TYPES),
store_uuids: z.array(z.string().uuid()).optional(),
group_uuids: z.array(z.string().uuid()).optional(),
start_date: z.string().min(1),
end_date: z.string().min(1)
})
.refine((value) => (value.store_uuids?.length || 0) + (value.group_uuids?.length || 0) > 0, {
message: "At least one store_uuids or group_uuids value is required."
});
function validateCreateReportConstraints(payload) {
const allowDateTime = payload.report_type === "PAYMENT_DETAILS_REPORT";
const start = parseDateLike(payload.start_date, allowDateTime);
const end = parseDateLike(payload.end_date, allowDateTime);
if (!start || Number.isNaN(start.getTime()) || !end || Number.isNaN(end.getTime())) {
const error = new Error("Invalid report date format for selected report_type.");
error.status = 400;
throw error;
}
if (start.getTime() > end.getTime()) {
const error = new Error("start_date must be on or before end_date.");
error.status = 414;
throw error;
}
const rangeLimit = RANGE_LIMIT_DAYS[payload.report_type];
if (rangeLimit) {
const rangeDays = daysBetweenInclusive(start, end);
if (rangeDays > rangeLimit) {
const error = new Error(
`${payload.report_type} supports a maximum range period of ${rangeLimit} days.`
);
error.status = 416;
throw error;
}
}
const lookback = LOOKBACK_RULES[payload.report_type];
if (lookback) {
const startAgo = daysAgo(start);
const endAgo = daysAgo(end);
const startOutOfWindow = startAgo < lookback.maxDaysAgo || startAgo > lookback.minDaysAgo;
const endOutOfWindow = endAgo < lookback.maxDaysAgo || endAgo > lookback.minDaysAgo;
if (startOutOfWindow || endOutOfWindow) {
const error = new Error(
`${payload.report_type} must be within [T-${lookback.minDaysAgo}, T-${lookback.maxDaysAgo}] days.`
);
error.status = 416;
throw error;
}
}
}
async function createMarketplaceReport(req, res) {
const payload = createReportSchema.parse(req.body || {});
validateCreateReportConstraints(payload);
const data = await createReportJob({
merchantId: payload.merchantId || null,
reportType: payload.report_type,
storeUuids: payload.store_uuids || [],
groupUuids: payload.group_uuids || [],
startDate: payload.start_date,
endDate: payload.end_date
});
return res.json({
success: true,
data
});
}
module.exports = {
fetchReportingCsv,
createMarketplaceReport
};

View File

@ -1,7 +1,11 @@
const axios = require("axios");
const env = require("../../config/env");
const { apiLogRepository } = require("../../db/adapter");
const { getCachedClientCredentialsToken, AUTH_SCOPES } = require("../auth/auth.service");
const { apiLogRepository, reportJobRepository, appTokenRepository } = require("../../db/adapter");
const {
getCachedClientCredentialsToken,
AUTH_SCOPES,
AUTH_GRANT_TYPES
} = require("../auth/auth.service");
const { normalizeUberError, isRetryableUberError } = require("../common/errors/uberError");
const { withExponentialBackoffRetry } = require("../common/http/retry");
@ -14,6 +18,43 @@ function buildAuthorizationHeader(tokenType, accessToken) {
return `${tokenType || "Bearer"} ${accessToken}`;
}
function isUnauthorizedError(error) {
return Number(error?.response?.status || 0) === 401;
}
async function executeWithReportTokenRetry({ requestFactory }) {
const scope = AUTH_SCOPES.REPORT;
let token = await getCachedClientCredentialsToken({ scope });
const run = async () =>
withExponentialBackoffRetry({
fn: async () => requestFactory(token),
maxAttempts: 4,
baseDelayMs: 400,
shouldRetry: (error) => isRetryableUberError(error)
});
try {
return await run();
} catch (error) {
if (!isUnauthorizedError(error)) {
throw error;
}
appTokenRepository.deleteByProviderGrantScope({
provider: "uber",
grantType: AUTH_GRANT_TYPES.CLIENT_CREDENTIALS,
scope
});
token = await getCachedClientCredentialsToken({
scope,
forceRefresh: true
});
return run();
}
}
function parseCsvLine(line) {
const output = [];
let current = "";
@ -89,14 +130,11 @@ async function fetchReport({
requiredHeaders = [],
wrapperRoute = "/api/v1/uber/reporting/fetch"
}) {
const token = await getCachedClientCredentialsToken({
scope: AUTH_SCOPES.REPORT
});
const requestMethod = String(method || "GET").toUpperCase();
try {
const response = await withExponentialBackoffRetry({
fn: async () =>
const response = await executeWithReportTokenRetry({
requestFactory: async (token) =>
reportingClient.request({
method: requestMethod,
url: upstreamPath,
@ -107,10 +145,7 @@ async function fetchReport({
Authorization: buildAuthorizationHeader(token.token_type, token.access_token),
"Content-Type": "application/json"
}
}),
maxAttempts: 4,
baseDelayMs: 400,
shouldRetry: (error) => isRetryableUberError(error)
})
});
const textData =
@ -155,6 +190,86 @@ async function fetchReport({
}
module.exports = {
async createReportJob({
merchantId,
reportType,
storeUuids,
groupUuids,
startDate,
endDate
}) {
const body = {
report_type: reportType,
store_uuids: storeUuids || [],
group_uuids: groupUuids || [],
start_date: startDate,
end_date: endDate
};
try {
const response = await executeWithReportTokenRetry({
requestFactory: async (token) =>
reportingClient.request({
method: "POST",
url: "/v1/eats/report",
data: body,
headers: {
Authorization: buildAuthorizationHeader(token.token_type, token.access_token),
"Content-Type": "application/json"
}
})
});
const workflowId = response?.data?.workflow_id;
if (!workflowId) {
const error = new Error("Missing workflow_id in report creation response.");
error.status = 502;
throw error;
}
reportJobRepository.createRequested({
merchantId,
workflowId,
reportType,
storeUuids,
groupUuids,
startDate,
endDate
});
apiLogRepository.insert({
merchantId,
method: "POST",
wrapperRoute: "/api/v1/uber/reporting/create",
uberPath: "/v1/eats/report",
responseStatus: response.status,
requestBody: body,
responseBody: response.data
});
return {
workflow_id: workflowId
};
} catch (error) {
const normalized = normalizeUberError(error);
apiLogRepository.insert({
merchantId,
method: "POST",
wrapperRoute: "/api/v1/uber/reporting/create",
uberPath: "/v1/eats/report",
responseStatus: normalized.status,
requestBody: body,
responseBody: {
code: normalized.code,
message: normalized.message,
transient: normalized.transient,
details:
typeof normalized.details === "string" ? { raw: normalized.details } : normalized.details
}
});
throw normalized;
}
},
fetchReport,
parseCsvByHeader
};

View File

@ -0,0 +1,134 @@
const {
webhookRepository,
uberConnectionRepository,
reportJobRepository,
asyncJobRepository
} = require("../../db/adapter");
function parseJson(rawValue) {
if (!rawValue) {
return {};
}
if (typeof rawValue === "object") {
return rawValue;
}
try {
return JSON.parse(rawValue);
} catch (error) {
return {};
}
}
function extractStoreId(payload) {
return payload?.user_id || payload?.store_id || payload?.resource_id || payload?.store?.id || null;
}
function pickSectionUrl(section = {}) {
return (
section.url ||
section.download_url ||
section.signed_url ||
section.resource_url ||
section.href ||
null
);
}
function enqueueReportSectionJobs(workflowId, sections) {
(sections || []).forEach((section, index) => {
const url = pickSectionUrl(section);
if (!url) {
return;
}
asyncJobRepository.enqueue({
jobType: "report_section_download",
payload: {
workflowId,
sectionIndex: index,
section
},
maxAttempts: 7
});
});
}
function applyProvisioningStateFromWebhook(eventType, payload) {
if (eventType !== "store.provisioned" && eventType !== "store.deprovisioned") {
return;
}
const storeId = extractStoreId(payload);
if (!storeId) {
return;
}
const connection = uberConnectionRepository.findByUberStoreId(String(storeId));
if (!connection) {
return;
}
const nextStatus = eventType === "store.provisioned" ? "active" : "deprovisioned";
uberConnectionRepository.setStatusByMerchantId(connection.merchant_id, nextStatus);
}
function applyMenuRefreshRequestFromWebhook(eventType, payload, headers) {
if (eventType !== "store.menu_refresh_request") {
return;
}
const storeId = extractStoreId(payload);
if (!storeId) {
return;
}
uberConnectionRepository.markMenuRefreshRequestedByStoreId(String(storeId), {
requestedAt: new Date().toISOString(),
webhookMsgUuid: payload?.webhook_meta?.webhook_msg_uuid || null,
environment: headers?.["x-environment"] || null
});
}
function applyReportSuccessFromWebhook(eventType, payload) {
if (eventType !== "eats.report.success") {
return;
}
const workflowId = payload?.job_id;
if (!workflowId) {
return;
}
const sections = payload?.report_metadata?.sections || [];
reportJobRepository.markSuccessFromWebhook({
workflowId: String(workflowId),
eventId: payload?.event_id || null,
webhookMsgUuid: payload?.webhook_meta?.webhook_msg_uuid || null,
reportType: payload?.report_type || null,
sections,
payload
});
enqueueReportSectionJobs(String(workflowId), sections);
}
async function processWebhookEventById(eventId) {
const event = webhookRepository.findById(eventId);
if (!event) {
return;
}
if (event.processing_status === "processed") {
return;
}
const payload = parseJson(event.payload_json);
const headers = parseJson(event.headers_json);
const eventType = event.event_type || payload?.event_type || payload?.type || "unknown";
applyProvisioningStateFromWebhook(eventType, payload);
applyMenuRefreshRequestFromWebhook(eventType, payload, headers);
applyReportSuccessFromWebhook(eventType, payload);
webhookRepository.markProcessed(eventId);
}
module.exports = {
processWebhookEventById,
pickSectionUrl
};

View File

@ -1,6 +1,9 @@
const crypto = require("crypto");
const env = require("../../config/env");
const { webhookRepository, uberConnectionRepository } = require("../../db/adapter");
const {
webhookRepository,
asyncJobRepository
} = require("../../db/adapter");
function getSignatureFromHeaders(headers) {
const signature = headers["x-uber-signature"];
@ -65,28 +68,6 @@ function buildDedupeKey(signature, req) {
return crypto.createHash("sha256").update(basis).digest("hex");
}
function extractStoreId(payload) {
return payload?.user_id || payload?.store_id || payload?.resource_id || payload?.store?.id || null;
}
function applyProvisioningStateFromWebhook(eventType, payload) {
if (eventType !== "store.provisioned" && eventType !== "store.deprovisioned") {
return;
}
const storeId = extractStoreId(payload);
if (!storeId) {
return;
}
const connection = uberConnectionRepository.findByUberStoreId(String(storeId));
if (!connection) {
return;
}
const nextStatus = eventType === "store.provisioned" ? "active" : "deprovisioned";
uberConnectionRepository.setStatusByMerchantId(connection.merchant_id, nextStatus);
}
async function handleUberWebhook(req, res) {
if (!verifyBasicAuthIfConfigured(req)) {
return res.status(401).json({
@ -120,7 +101,7 @@ async function handleUberWebhook(req, res) {
return res.status(200).end();
}
webhookRepository.insert({
const inserted = webhookRepository.insert({
provider: "uber",
merchantId,
eventType,
@ -132,7 +113,13 @@ async function handleUberWebhook(req, res) {
headersJson: req.headers
});
applyProvisioningStateFromWebhook(eventType, req.body || {});
asyncJobRepository.enqueue({
jobType: "uber_webhook_event",
payload: {
eventId: inserted.id
},
maxAttempts: 7
});
return res.status(200).end();
}

View File

@ -17,6 +17,19 @@ const router = express.Router();
*/
router.post("/uber/request", asyncHandler(controller.genericProxy));
/**
* @openapi
* /api/v1/uber/catalog/product-types:
* get:
* summary: Return Uber Product Types and Uber Mixin Types catalog
* tags:
* - Uber Menu
* responses:
* 200:
* description: Catalog returned
*/
router.get("/uber/catalog/product-types", asyncHandler(controller.getUberProductTypes));
/**
* @openapi
* /api/v1/uber/menu/upsert:
@ -34,12 +47,12 @@ router.post("/uber/menu/upsert", asyncHandler(controller.upsertMenu));
* @openapi
* /api/v1/uber/menu/replace:
* put:
* summary: Replace store menu (full upload)
* summary: Upload/replace store menu (PUT /v2/eats/stores/{store_id}/menus)
* tags:
* - Uber Menu
* responses:
* 200:
* description: Menu replaced
* description: Menu replaced (Uber returns 204 No Content)
*/
router.put("/uber/menu/replace", asyncHandler(controller.replaceMenu));
@ -47,12 +60,12 @@ router.put("/uber/menu/replace", asyncHandler(controller.replaceMenu));
* @openapi
* /api/v1/uber/menu/items:
* post:
* summary: Update individual menu items (stock/price updates)
* summary: Update single menu item (POST /v2/eats/stores/{store_id}/menus/items/{item_id})
* tags:
* - Uber Menu
* responses:
* 200:
* description: Menu items updated
* description: Menu item sparsely updated (Uber returns 204 No Content)
*/
router.post("/uber/menu/items", asyncHandler(controller.updateMenuItems));
@ -63,6 +76,27 @@ router.post("/uber/menu/items", asyncHandler(controller.updateMenuItems));
* summary: Fetch store menu
* tags:
* - Uber Menu
* parameters:
* - in: query
* name: merchantId
* required: true
* schema:
* type: string
* - in: query
* name: storeId
* required: true
* schema:
* type: string
* - in: query
* name: menu_type
* required: false
* schema:
* type: string
* enum:
* - MENU_TYPE_FULFILLMENT_DELIVERY
* - MENU_TYPE_FULFILLMENT_PICK_UP
* - MENU_TYPE_FULFILLMENT_DINE_IN
* description: Defaults to MENU_TYPE_FULFILLMENT_DELIVERY.
* responses:
* 200:
* description: Menu fetched
@ -688,4 +722,87 @@ router.post(
asyncHandler(controller.deliveryByocIngestCourierLocation)
);
/**
* @openapi
* /api/v1/uber/delivery-promotions/stores/{storeId}:
* post:
* summary: Promotions API 1.0.0 - Create promotion
* tags:
* - Uber Delivery Promotions v1
* parameters:
* - in: path
* name: storeId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Promotion created
* get:
* summary: Promotions API 1.0.0 - Get promotions by store
* tags:
* - Uber Delivery Promotions v1
* parameters:
* - in: path
* name: storeId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Promotions listed
*/
router.post(
"/uber/delivery-promotions/stores/:storeId",
asyncHandler(controller.deliveryCreatePromotion)
);
router.get(
"/uber/delivery-promotions/stores/:storeId",
asyncHandler(controller.deliveryListPromotions)
);
/**
* @openapi
* /api/v1/uber/delivery-promotions/{promotionId}:
* get:
* summary: Promotions API 1.0.0 - Get promotion by ID
* tags:
* - Uber Delivery Promotions v1
* parameters:
* - in: path
* name: promotionId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Promotion retrieved
*/
router.get(
"/uber/delivery-promotions/:promotionId",
asyncHandler(controller.deliveryGetPromotion)
);
/**
* @openapi
* /api/v1/uber/delivery-promotions/{promotionId}/revoke:
* post:
* summary: Promotions API 1.0.0 - Revoke promotion
* tags:
* - Uber Delivery Promotions v1
* parameters:
* - in: path
* name: promotionId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Promotion revoked
*/
router.post(
"/uber/delivery-promotions/:promotionId/revoke",
asyncHandler(controller.deliveryRevokePromotion)
);
module.exports = router;

View File

@ -1,6 +1,9 @@
const express = require("express");
const asyncHandler = require("../middleware/asyncHandler");
const { fetchReportingCsv } = require("../modules/reporting/reporting.controller");
const {
fetchReportingCsv,
createMarketplaceReport
} = require("../modules/reporting/reporting.controller");
const router = express.Router();
@ -17,5 +20,17 @@ const router = express.Router();
*/
router.post("/uber/reporting/fetch", asyncHandler(fetchReportingCsv));
module.exports = router;
/**
* @openapi
* /api/v1/uber/reporting/create:
* post:
* summary: Create Marketplace Reporting API job (POST /v1/eats/report)
* tags:
* - Uber Reporting
* responses:
* 200:
* description: Report creation requested successfully (workflow_id returned)
*/
router.post("/uber/reporting/create", asyncHandler(createMarketplaceReport));
module.exports = router;

View File

@ -1,11 +1,12 @@
const app = require("./app");
const env = require("./config/env");
const { initSchema } = require("./db/sqlite");
const { startAsyncWorkers } = require("./workers/asyncWorkers");
initSchema();
startAsyncWorkers();
app.listen(env.PORT, () => {
// eslint-disable-next-line no-console
console.log(`Uber Wrapper listening on http://localhost:${env.PORT}`);
});

198
src/workers/asyncWorkers.js Normal file
View File

@ -0,0 +1,198 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const axios = require("axios");
const { v4: uuidv4 } = require("uuid");
const env = require("../config/env");
const { asyncJobRepository, reportSectionRepository, webhookRepository } = require("../db/adapter");
const { processWebhookEventById, pickSectionUrl } = require("../modules/webhooks/webhookProcessor");
const { parseCsvByHeader } = require("../modules/reporting/reporting.service");
let workerTimer = null;
let isProcessing = false;
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function parseJson(raw) {
if (!raw) {
return {};
}
if (typeof raw === "object") {
return raw;
}
try {
return JSON.parse(raw);
} catch (error) {
return {};
}
}
function safeSectionName(input, index) {
const fallback = `section-${index + 1}`;
const name = String(input || fallback)
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "_")
.slice(0, 80);
return name || fallback;
}
function computeSha256(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
async function handleWebhookJob(payload) {
const eventId = payload?.eventId;
if (!eventId) {
throw new Error("Missing eventId in webhook async job payload.");
}
await processWebhookEventById(String(eventId));
}
async function downloadSectionFile({ workflowId, sectionIndex, section }) {
const sectionUrl = pickSectionUrl(section);
if (!sectionUrl) {
throw new Error(`Missing section URL for workflow ${workflowId} section ${sectionIndex}.`);
}
const baseDir = env.REPORT_DOWNLOAD_DIR;
const workflowDir = path.join(baseDir, String(workflowId));
ensureDir(baseDir);
ensureDir(workflowDir);
const sectionName = safeSectionName(section?.name || section?.section_name, sectionIndex);
const filePath = path.join(workflowDir, `${String(sectionIndex + 1).padStart(2, "0")}-${sectionName}.csv`);
const response = await axios.get(sectionUrl, {
responseType: "arraybuffer",
timeout: 60000
});
const bodyBuffer = Buffer.from(response.data || []);
fs.writeFileSync(filePath, bodyBuffer);
const csvText = bodyBuffer.toString("utf8");
const parsed = parseCsvByHeader(csvText);
const sha256 = computeSha256(bodyBuffer);
const contentType = response.headers?.["content-type"] || "text/csv";
reportSectionRepository.upsert({
workflowId: String(workflowId),
sectionIndex: Number(sectionIndex),
sectionName,
downloadUrl: sectionUrl,
filePath,
contentType,
rowCount: parsed?.rows?.length || 0,
headers: parsed?.headers || [],
sha256,
status: "completed",
errorMessage: null
});
}
async function handleReportSectionJob(payload) {
const workflowId = payload?.workflowId;
const sectionIndex = payload?.sectionIndex;
const section = payload?.section;
if (!workflowId && workflowId !== 0) {
throw new Error("Missing workflowId in report section download job.");
}
if (sectionIndex === undefined || sectionIndex === null) {
throw new Error("Missing sectionIndex in report section download job.");
}
await downloadSectionFile({
workflowId,
sectionIndex,
section
});
}
async function processJob(job) {
const payload = parseJson(job.payload_json);
if (job.job_type === "uber_webhook_event") {
await handleWebhookJob(payload);
return;
}
if (job.job_type === "report_section_download") {
await handleReportSectionJob(payload);
return;
}
throw new Error(`Unsupported async job type: ${job.job_type}`);
}
async function processBatch() {
if (isProcessing) {
return;
}
isProcessing = true;
const workerId = `worker-${uuidv4()}`;
try {
for (let i = 0; i < env.ASYNC_WORKER_BATCH_SIZE; i += 1) {
const job = asyncJobRepository.claimNext({ workerId });
if (!job) {
break;
}
try {
await processJob(job);
asyncJobRepository.markCompleted(job.id);
} catch (error) {
asyncJobRepository.markFailedOrRetry({
id: job.id,
attemptCount: Number(job.attempt_count || 0),
maxAttempts: Number(job.max_attempts || 1),
errorMessage: error?.message || String(error)
});
const payload = parseJson(job.payload_json);
if (job.job_type === "uber_webhook_event" && payload?.eventId) {
webhookRepository.markFailed(payload.eventId, error?.message || String(error));
}
if (job.job_type === "report_section_download") {
reportSectionRepository.upsert({
workflowId: String(payload?.workflowId || ""),
sectionIndex: Number(payload?.sectionIndex || 0),
sectionName: safeSectionName(payload?.section?.name, Number(payload?.sectionIndex || 0)),
downloadUrl: pickSectionUrl(payload?.section || {}),
status: "failed",
errorMessage: error?.message || String(error)
});
}
}
}
} finally {
isProcessing = false;
}
}
function startAsyncWorkers() {
if (!env.ASYNC_WORKERS_ENABLED) {
return;
}
if (workerTimer) {
return;
}
workerTimer = setInterval(() => {
processBatch().catch(() => {
// keep worker alive even when one polling cycle fails
});
}, env.ASYNC_WORKER_POLL_INTERVAL_MS);
workerTimer.unref?.();
}
function stopAsyncWorkers() {
if (workerTimer) {
clearInterval(workerTimer);
workerTimer = null;
}
}
module.exports = {
startAsyncWorkers,
stopAsyncWorkers
};