Compare commits
10 Commits
1ce9a38808
...
a89d67ebee
| Author | SHA1 | Date | |
|---|---|---|---|
| a89d67ebee | |||
| 6ff3d800f4 | |||
| 8ba675e45a | |||
| 6e3d654df0 | |||
| bc3d4e6641 | |||
| 64f214f3ae | |||
| 519f2f7169 | |||
| d80f9e9baf | |||
| 5ea2d86a48 | |||
| 0c41ad5858 |
@ -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
|
||||
|
||||
29
docs/developer-portal/05-menu-example-payloads.md
Normal file
29
docs/developer-portal/05-menu-example-payloads.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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/`
|
||||
|
||||
@ -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`.
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
32
docs/developer-portal/17-promotions-api-1-0-0-audit.md
Normal file
32
docs/developer-portal/17-promotions-api-1-0-0-audit.md
Normal 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
|
||||
|
||||
14
docs/developer-portal/17-promotions.md
Normal file
14
docs/developer-portal/17-promotions.md
Normal 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.
|
||||
|
||||
19
docs/developer-portal/18-product-types-audit.md
Normal file
19
docs/developer-portal/18-product-types-audit.md
Normal 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.
|
||||
34
docs/developer-portal/19-menu-troubleshooting-audit.md
Normal file
34
docs/developer-portal/19-menu-troubleshooting-audit.md
Normal file
@ -0,0 +1,34 @@
|
||||
# 19 Menu Troubleshooting Audit
|
||||
|
||||
Source checked: Uber Eats "Troubleshooting Errors from the Menu API" section shared by you.
|
||||
|
||||
## Implemented Now
|
||||
|
||||
- Added proactive upload-menu payload validation before calling Uber:
|
||||
- `No Menus Errors` guard:
|
||||
- requires `menu.menus` to have at least one entry
|
||||
- `No Hours Errors` guard:
|
||||
- requires at least one valid `service_availability` interval
|
||||
- `Short Hours Errors` guard:
|
||||
- validates effective contiguous service windows are at least 60 minutes
|
||||
- supports overnight-contiguous windows split across adjacent days
|
||||
- `Invalid Visibility Errors` guard:
|
||||
- detects overlapping `visibility_info.hours[].hours_of_week[].time_periods` for the same day
|
||||
- Added stronger request path validation:
|
||||
- menu routes now validate `storeId` as UUID
|
||||
- helps prevent `invalid uuid` / `orgUUID must be a valid UUID` upstream errors
|
||||
- Added update-item price sanity check:
|
||||
- if both provided, `core_price` must be `>= price`
|
||||
|
||||
## Mapped to Wrapper
|
||||
|
||||
- `PUT /api/v1/uber/menu/replace`
|
||||
- now runs menu troubleshooting validations before upstream call
|
||||
- `POST /api/v1/uber/menu/items`
|
||||
- validates `storeId` UUID and key `price_info` constraints
|
||||
|
||||
## Pending
|
||||
|
||||
- Live item existence precheck against current menu before sparse update (`nil item` prevention).
|
||||
- Configurable per-market max price thresholds from Uber approval config.
|
||||
- User-friendly remediation hints per failing field in structured error payloads.
|
||||
28
docs/developer-portal/20-marketplace-reporting-api-audit.md
Normal file
28
docs/developer-portal/20-marketplace-reporting-api-audit.md
Normal 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.
|
||||
52
docs/examples/menus/v2/combo-bundled-items.json
Normal file
52
docs/examples/menus/v2/combo-bundled-items.json
Normal 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": {}
|
||||
}
|
||||
22
docs/examples/menus/v2/empty-menu.json
Normal file
22
docs/examples/menus/v2/empty-menu.json
Normal 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": {}
|
||||
}
|
||||
39
docs/examples/menus/v2/fulfillment-delivery-menu.json
Normal file
39
docs/examples/menus/v2/fulfillment-delivery-menu.json
Normal 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": {}
|
||||
}
|
||||
39
docs/examples/menus/v2/fulfillment-pickup-menu.json
Normal file
39
docs/examples/menus/v2/fulfillment-pickup-menu.json
Normal 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": {}
|
||||
}
|
||||
92
docs/examples/menus/v2/simple-menu.json
Normal file
92
docs/examples/menus/v2/simple-menu.json
Normal 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 }
|
||||
}
|
||||
25
docs/examples/menus/v2/update-item-sparse.json
Normal file
25
docs/examples/menus/v2/update-item-sparse.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
346
src/config/uberProductCatalog.js
Normal file
346
src/config/uberProductCatalog.js
Normal 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_DVD’S_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
|
||||
};
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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({
|
||||
|
||||
166
src/modules/proxy/menuValidation.js
Normal file
166
src/modules/proxy/menuValidation.js
Normal file
@ -0,0 +1,166 @@
|
||||
function parseTimeToMinutes(time) {
|
||||
if (typeof time !== "string" || !/^\d{2}:\d{2}$/.test(time)) {
|
||||
return null;
|
||||
}
|
||||
const [hourStr, minuteStr] = time.split(":");
|
||||
const hour = Number(hourStr);
|
||||
const minute = Number(minuteStr);
|
||||
if (
|
||||
Number.isNaN(hour) ||
|
||||
Number.isNaN(minute) ||
|
||||
hour < 0 ||
|
||||
hour > 23 ||
|
||||
minute < 0 ||
|
||||
minute > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
const DAYS = [
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday"
|
||||
];
|
||||
|
||||
function dayIndex(day) {
|
||||
return DAYS.indexOf(String(day || "").toLowerCase());
|
||||
}
|
||||
|
||||
function buildWeeklyIntervals(menuPayload) {
|
||||
const menus = Array.isArray(menuPayload?.menus) ? menuPayload.menus : [];
|
||||
const intervals = [];
|
||||
|
||||
menus.forEach((menu, menuIndex) => {
|
||||
const availability = Array.isArray(menu?.service_availability) ? menu.service_availability : [];
|
||||
availability.forEach((dayConfig, dayConfigIndex) => {
|
||||
const dIdx = dayIndex(dayConfig?.day_of_week);
|
||||
if (dIdx < 0) {
|
||||
return;
|
||||
}
|
||||
const periods = Array.isArray(dayConfig?.time_periods) ? dayConfig.time_periods : [];
|
||||
periods.forEach((period, periodIndex) => {
|
||||
const start = parseTimeToMinutes(period?.start_time);
|
||||
const end = parseTimeToMinutes(period?.end_time);
|
||||
if (start === null || end === null || end < start) {
|
||||
return;
|
||||
}
|
||||
const base = dIdx * 24 * 60;
|
||||
intervals.push({
|
||||
start: base + start,
|
||||
end: base + end,
|
||||
menuIndex,
|
||||
dayConfigIndex,
|
||||
periodIndex,
|
||||
day: dayConfig?.day_of_week
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return intervals.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
function mergeContiguousIntervals(intervals) {
|
||||
if (!intervals.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const merged = [Object.assign({}, intervals[0])];
|
||||
for (let i = 1; i < intervals.length; i += 1) {
|
||||
const curr = intervals[i];
|
||||
const last = merged[merged.length - 1];
|
||||
if (curr.start <= last.end + 1) {
|
||||
last.end = Math.max(last.end, curr.end);
|
||||
} else {
|
||||
merged.push(Object.assign({}, curr));
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function collectVisibilityOverlapErrors(menuPayload) {
|
||||
const items = Array.isArray(menuPayload?.items) ? menuPayload.items : [];
|
||||
const errors = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
const visibilityHours = Array.isArray(item?.visibility_info?.hours)
|
||||
? item.visibility_info.hours
|
||||
: [];
|
||||
visibilityHours.forEach((hourRule) => {
|
||||
const hoursOfWeek = Array.isArray(hourRule?.hours_of_week) ? hourRule.hours_of_week : [];
|
||||
const perDay = new Map();
|
||||
hoursOfWeek.forEach((dayHours) => {
|
||||
const d = String(dayHours?.day_of_week || "").toLowerCase();
|
||||
if (!DAYS.includes(d)) {
|
||||
return;
|
||||
}
|
||||
const list = perDay.get(d) || [];
|
||||
const periods = Array.isArray(dayHours?.time_periods) ? dayHours.time_periods : [];
|
||||
periods.forEach((p) => {
|
||||
const s = parseTimeToMinutes(p?.start_time);
|
||||
const e = parseTimeToMinutes(p?.end_time);
|
||||
if (s === null || e === null || e < s) {
|
||||
return;
|
||||
}
|
||||
list.push({ start: s, end: e, raw: p });
|
||||
});
|
||||
perDay.set(d, list);
|
||||
});
|
||||
|
||||
perDay.forEach((periods, d) => {
|
||||
const sorted = periods.sort((a, b) => a.start - b.start);
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
const prev = sorted[i - 1];
|
||||
const curr = sorted[i];
|
||||
if (curr.start <= prev.end) {
|
||||
errors.push(
|
||||
`Invalid visibility overlap for item ${item?.id || "<unknown>"} on ${d}: ` +
|
||||
`${String(prev.raw?.start_time)}-${String(prev.raw?.end_time)} overlaps ` +
|
||||
`${String(curr.raw?.start_time)}-${String(curr.raw?.end_time)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateUploadMenuPayload(menuPayload) {
|
||||
const errors = [];
|
||||
const menus = Array.isArray(menuPayload?.menus) ? menuPayload.menus : [];
|
||||
if (menus.length === 0) {
|
||||
errors.push("No Menus Errors: menu.menus must contain at least one menu.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
const intervals = buildWeeklyIntervals(menuPayload);
|
||||
if (intervals.length === 0) {
|
||||
errors.push(
|
||||
"No Hours Errors: at least one service_availability interval is required across the week."
|
||||
);
|
||||
} else {
|
||||
const merged = mergeContiguousIntervals(intervals);
|
||||
const shortBlocks = merged.filter((block) => block.end - block.start + 1 < 60);
|
||||
if (shortBlocks.length > 0) {
|
||||
errors.push(
|
||||
"Short Hours Errors: each effective contiguous service_availability interval must be at least 60 minutes."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
errors.push(...collectVisibilityOverlapErrors(menuPayload));
|
||||
return errors;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateUploadMenuPayload
|
||||
};
|
||||
@ -1,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
|
||||
};
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
134
src/modules/webhooks/webhookProcessor.js
Normal file
134
src/modules/webhooks/webhookProcessor.js
Normal 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
|
||||
};
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
198
src/workers/asyncWorkers.js
Normal 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
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user