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)
|
## Group B (Extended / Optional / Can Be Added Incrementally)
|
||||||
|
|
||||||
- Promotions
|
- Promotions (Promotions API v1.0.0 route set available)
|
||||||
- Ads / sponsored listings
|
- Ads / sponsored listings
|
||||||
- Payout and financial reconciliation (Reporting API route now available)
|
- Payout and financial reconciliation (Reporting API route now available)
|
||||||
- Store holiday/special schedules
|
- 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:
|
- Retrieve menu:
|
||||||
- `GET /api/v1/uber/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:
|
- Full menu upload/replace:
|
||||||
- `PUT /api/v1/uber/menu/replace` (primary)
|
- `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:
|
- Individual item updates:
|
||||||
- `POST /api/v1/uber/menu/items`
|
- `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
|
## 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)
|
- Strict typed schemas for full menu payload entities (item, modifier group, category, menu)
|
||||||
- Validation rules for image metadata limits and alcoholic item classifications
|
- Validation rules for image metadata limits and alcoholic item classifications
|
||||||
- Dedicated mapper helpers for `core_price` and `bundled_items` enrichment
|
- 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:
|
Current wrapper route for full replacement:
|
||||||
|
|
||||||
- `PUT /api/v1/uber/menu/replace`
|
- `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:
|
Item update route:
|
||||||
|
|
||||||
- `POST /api/v1/uber/menu/items`
|
- `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:
|
Best-practice note:
|
||||||
|
|
||||||
- Use API-managed menus only for integrated stores (avoid manual Menu Maker edits to prevent drift).
|
- Use API-managed menus only for integrated stores (avoid manual Menu Maker edits to prevent drift).
|
||||||
|
- Alcoholic item flag is effectively sticky in Uber (`alcoholic_items > 0` cannot be reverted by API update).
|
||||||
|
- 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_id`
|
||||||
- `resource_href`
|
- `resource_href`
|
||||||
- signature and dedupe key
|
- 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
|
## Existing Before
|
||||||
|
|
||||||
@ -27,4 +35,3 @@ Source checked: Uber Eats "Webhook" section shared by you.
|
|||||||
- Per-event downstream job workers (accept/deny SLA orchestration).
|
- Per-event downstream job workers (accept/deny SLA orchestration).
|
||||||
- Alerting if order accept/deny not sent before timeout window.
|
- Alerting if order accept/deny not sent before timeout window.
|
||||||
- Full typed schema validation per webhook `event_type`.
|
- Full typed schema validation per webhook `event_type`.
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,17 @@ Common event types handled:
|
|||||||
- `delivery.state_changed`
|
- `delivery.state_changed`
|
||||||
- `store.provisioned`
|
- `store.provisioned`
|
||||||
- `store.deprovisioned`
|
- `store.deprovisioned`
|
||||||
|
- `store.menu_refresh_request`
|
||||||
- `store.status.changed`
|
- `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:
|
Retail fulfillment follow-up:
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ Source checked: Uber Eats "Reporting Guide" section shared by you.
|
|||||||
|
|
||||||
- Dedicated Reporting route:
|
- Dedicated Reporting route:
|
||||||
- `POST /api/v1/uber/reporting/fetch`
|
- `POST /api/v1/uber/reporting/fetch`
|
||||||
|
- `POST /api/v1/uber/reporting/create`
|
||||||
- Uses `eats.report` client-credentials scope.
|
- Uses `eats.report` client-credentials scope.
|
||||||
- Retry policy implemented for safe transient failures only:
|
- Retry policy implemented for safe transient failures only:
|
||||||
- `429`, `408`, `500`, `502`, `503`, `504`, and network errors
|
- `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
|
- ignore unknown extra columns
|
||||||
- tolerate missing columns with null/default behavior
|
- tolerate missing columns with null/default behavior
|
||||||
- report missing required headers in response metadata
|
- 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
|
## Existing Before
|
||||||
|
|
||||||
@ -24,7 +30,5 @@ Source checked: Uber Eats "Reporting Guide" section shared by you.
|
|||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
- Final typed endpoint wrappers for specific reporting reference endpoints once exact paths are shared
|
- Overnight polling scheduler/job orchestration for report section downloads
|
||||||
- Overnight polling scheduler/job orchestration
|
|
||||||
- Reconciliation materialization tables for settled vs provisional values
|
- Reconciliation materialization tables for settled vs provisional values
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ Reporting focus:
|
|||||||
Typed route:
|
Typed route:
|
||||||
|
|
||||||
- `POST /api/v1/uber/reporting/fetch`
|
- `POST /api/v1/uber/reporting/fetch`
|
||||||
|
- `POST /api/v1/uber/reporting/create`
|
||||||
|
|
||||||
Key behavior:
|
Key behavior:
|
||||||
|
|
||||||
@ -16,4 +17,6 @@ Key behavior:
|
|||||||
- Retries transient errors only (`408`, `429`, `500`, `502`, `503`, `504`, network timeouts)
|
- Retries transient errors only (`408`, `429`, `500`, `502`, `503`, `504`, network timeouts)
|
||||||
- Parses CSV by header names (not fixed column positions)
|
- Parses CSV by header names (not fixed column positions)
|
||||||
- Tolerates unknown columns and missing optional fields
|
- 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": {
|
"/api/v1/uber/menu/upsert": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Legacy upsert helper for store menu",
|
"summary": "Legacy upsert helper for store menu",
|
||||||
@ -305,26 +318,26 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/uber/menu/replace": {
|
"/api/v1/uber/menu/replace": {
|
||||||
"put": {
|
"put": {
|
||||||
"summary": "Replace store menu (full upload)",
|
"summary": "Upload/replace store menu (PUT /v2/eats/stores/{store_id}/menus)",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Uber Menu"
|
"Uber Menu"
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Menu replaced"
|
"description": "Menu replaced (Uber returns 204 No Content)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/uber/menu/items": {
|
"/api/v1/uber/menu/items": {
|
||||||
"post": {
|
"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": [
|
"tags": [
|
||||||
"Uber Menu"
|
"Uber Menu"
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Menu items updated"
|
"description": "Menu item sparsely updated (Uber returns 204 No Content)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -335,6 +348,38 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Uber Menu"
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Menu fetched"
|
"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": {
|
"/api/v1/uber/reporting/fetch": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Fetch Uber reporting CSV with retries and header-based parsing",
|
"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": {
|
"/api/v1/webhooks/uber": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Ingest Uber webhook events",
|
"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": {
|
"request": {
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"header": [
|
"header": [
|
||||||
@ -182,7 +207,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"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": {
|
"url": {
|
||||||
"raw": "{{baseUrl}}/api/v1/uber/menu/replace",
|
"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": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [
|
"header": [
|
||||||
@ -215,7 +339,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"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": {
|
"url": {
|
||||||
"raw": "{{baseUrl}}/api/v1/uber/menu/items",
|
"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",
|
"name": "List Provisionable Stores",
|
||||||
"request": {
|
"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",
|
"name": "Get Store By ID",
|
||||||
"request": {
|
"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",
|
"name": "Set Holiday Hours",
|
||||||
"request": {
|
"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",
|
"name": "Generic Uber Request",
|
||||||
"request": {
|
"request": {
|
||||||
@ -1491,6 +1871,10 @@
|
|||||||
{
|
{
|
||||||
"key": "orderId",
|
"key": "orderId",
|
||||||
"value": ""
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "promotionId",
|
||||||
|
"value": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,13 @@ const envSchema = z.object({
|
|||||||
WEBHOOK_BASIC_AUTH_USERNAME: z.string().optional(),
|
WEBHOOK_BASIC_AUTH_USERNAME: z.string().optional(),
|
||||||
WEBHOOK_BASIC_AUTH_PASSWORD: z.string().optional(),
|
WEBHOOK_BASIC_AUTH_PASSWORD: z.string().optional(),
|
||||||
SQLITE_PATH: z.string().default("./data/uber_wrapper.db"),
|
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);
|
const parsed = envSchema.safeParse(process.env);
|
||||||
@ -27,5 +33,6 @@ if (!parsed.success) {
|
|||||||
|
|
||||||
const env = parsed.data;
|
const env = parsed.data;
|
||||||
env.SQLITE_PATH = path.resolve(process.cwd(), env.SQLITE_PATH);
|
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;
|
module.exports = env;
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
menu: {
|
menu: {
|
||||||
upsert: "/v1/eats/stores/{storeId}/menus",
|
upsert: "/v1/eats/stores/{storeId}/menus",
|
||||||
get: "/v1/eats/stores/{storeId}/menus",
|
upload: "/v2/eats/stores/{storeId}/menus",
|
||||||
itemsUpdate: "/v1/eats/stores/{storeId}/menus/items"
|
get: "/v2/eats/stores/{storeId}/menus",
|
||||||
|
itemUpdate: "/v2/eats/stores/{storeId}/menus/items/{itemId}"
|
||||||
},
|
},
|
||||||
orders: {
|
orders: {
|
||||||
list: "/v1/eats/stores/{storeId}/orders",
|
list: "/v1/eats/stores/{storeId}/orders",
|
||||||
@ -49,6 +50,12 @@ module.exports = {
|
|||||||
deliveryByoc: {
|
deliveryByoc: {
|
||||||
ingestCourierLocation: "/v1/eats/byoc/restaurants/orders/event/location"
|
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: {
|
webhooks: {
|
||||||
events: "/v1/eats/stores/{storeId}/event_feed"
|
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,
|
webhookRepository: repositories.webhookRepository,
|
||||||
apiLogRepository: repositories.apiLogRepository,
|
apiLogRepository: repositories.apiLogRepository,
|
||||||
appTokenRepository: repositories.appTokenRepository,
|
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);
|
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() {
|
list() {
|
||||||
return db
|
return db
|
||||||
.prepare(`
|
.prepare(`
|
||||||
@ -120,6 +145,10 @@ const uberConnectionRepository = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const webhookRepository = {
|
const webhookRepository = {
|
||||||
|
findById(id) {
|
||||||
|
return db.prepare("SELECT * FROM webhook_events WHERE id = ? LIMIT 1").get(id);
|
||||||
|
},
|
||||||
|
|
||||||
findByDedupeKey(dedupeKey) {
|
findByDedupeKey(dedupeKey) {
|
||||||
if (!dedupeKey) {
|
if (!dedupeKey) {
|
||||||
return null;
|
return null;
|
||||||
@ -166,6 +195,31 @@ const webhookRepository = {
|
|||||||
`);
|
`);
|
||||||
stmt.run(row);
|
stmt.run(row);
|
||||||
return 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);
|
.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 = {
|
module.exports = {
|
||||||
merchantRepository,
|
merchantRepository,
|
||||||
uberConnectionRepository,
|
uberConnectionRepository,
|
||||||
webhookRepository,
|
webhookRepository,
|
||||||
apiLogRepository,
|
apiLogRepository,
|
||||||
appTokenRepository,
|
appTokenRepository,
|
||||||
tokenRequestLogRepository
|
tokenRequestLogRepository,
|
||||||
|
reportJobRepository,
|
||||||
|
asyncJobRepository,
|
||||||
|
reportSectionRepository
|
||||||
};
|
};
|
||||||
|
|||||||
@ -58,6 +58,7 @@ function initSchema() {
|
|||||||
headers_json TEXT,
|
headers_json TEXT,
|
||||||
received_at TEXT NOT NULL,
|
received_at TEXT NOT NULL,
|
||||||
processed_at TEXT,
|
processed_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
processing_status TEXT NOT NULL DEFAULT 'received',
|
processing_status TEXT NOT NULL DEFAULT 'received',
|
||||||
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
|
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
|
||||||
);
|
);
|
||||||
@ -97,6 +98,64 @@ function initSchema() {
|
|||||||
grant_type TEXT NOT NULL,
|
grant_type TEXT NOT NULL,
|
||||||
requested_at 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")) {
|
if (!tableHasColumn("webhook_events", "resource_id")) {
|
||||||
@ -120,6 +179,40 @@ function initSchema() {
|
|||||||
if (!tableHasColumn("api_logs", "order_id")) {
|
if (!tableHasColumn("api_logs", "order_id")) {
|
||||||
db.exec("ALTER TABLE api_logs ADD COLUMN order_id TEXT");
|
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 = {
|
module.exports = {
|
||||||
|
|||||||
@ -121,8 +121,9 @@ function parseExpiresAt(expiresInSeconds) {
|
|||||||
return new Date(Date.now() + seconds * 1000).toISOString();
|
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 normalizedScope = (scope || AUTH_SCOPES.ORDER).trim().split(/\s+/).sort().join(" ");
|
||||||
|
if (!forceRefresh) {
|
||||||
const cached = appTokenRepository.findValid({
|
const cached = appTokenRepository.findValid({
|
||||||
provider: "uber",
|
provider: "uber",
|
||||||
grantType: AUTH_GRANT_TYPES.CLIENT_CREDENTIALS,
|
grantType: AUTH_GRANT_TYPES.CLIENT_CREDENTIALS,
|
||||||
@ -137,6 +138,7 @@ async function getCachedClientCredentialsToken({ scope }) {
|
|||||||
source: "cache"
|
source: "cache"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const requestsLastHour = tokenRequestLogRepository.countInLastHour({
|
const requestsLastHour = tokenRequestLogRepository.countInLastHour({
|
||||||
provider: "uber",
|
provider: "uber",
|
||||||
|
|||||||
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 { z } = require("zod");
|
||||||
const proxyService = require("./proxy.service");
|
const proxyService = require("./proxy.service");
|
||||||
|
const { PRODUCT_TYPES, MIXIN_TYPES } = require("../../config/uberProductCatalog");
|
||||||
|
const { validateUploadMenuPayload } = require("./menuValidation");
|
||||||
|
|
||||||
const genericSchema = z.object({
|
const genericSchema = z.object({
|
||||||
merchantId: z.string().min(1).optional(),
|
merchantId: z.string().min(1).optional(),
|
||||||
@ -20,7 +22,7 @@ async function genericProxy(req, res) {
|
|||||||
async function upsertMenu(req, res) {
|
async function upsertMenu(req, res) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
merchantId: z.string().min(1),
|
merchantId: z.string().min(1),
|
||||||
storeId: z.string().min(1),
|
storeId: z.string().uuid(),
|
||||||
menu: z.any()
|
menu: z.any()
|
||||||
});
|
});
|
||||||
const payload = schema.parse(req.body);
|
const payload = schema.parse(req.body);
|
||||||
@ -35,20 +37,58 @@ async function upsertMenu(req, res) {
|
|||||||
async function getMenu(req, res) {
|
async function getMenu(req, res) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
merchantId: z.string().min(1),
|
merchantId: z.string().min(1),
|
||||||
storeId: z.string().min(1)
|
storeId: z.string().uuid(),
|
||||||
|
menu_type: z
|
||||||
|
.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 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 });
|
return res.json({ success: true, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceMenu(req, res) {
|
async function replaceMenu(req, res) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
merchantId: z.string().min(1),
|
merchantId: z.string().min(1),
|
||||||
storeId: z.string().min(1),
|
storeId: z.string().uuid(),
|
||||||
menu: z.any()
|
menu: z.any()
|
||||||
});
|
});
|
||||||
const payload = schema.parse(req.body);
|
const payload = schema.parse(req.body);
|
||||||
|
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({
|
const data = await proxyService.menuReplace({
|
||||||
merchantId: payload.merchantId,
|
merchantId: payload.merchantId,
|
||||||
storeId: payload.storeId,
|
storeId: payload.storeId,
|
||||||
@ -58,16 +98,60 @@ async function replaceMenu(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateMenuItems(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({
|
const schema = z.object({
|
||||||
merchantId: z.string().min(1),
|
merchantId: z.string().min(1),
|
||||||
storeId: z.string().min(1),
|
storeId: z.string().uuid(),
|
||||||
items: z.array(z.any()).min(1)
|
itemId: z.string().min(1),
|
||||||
|
update: updateSchema
|
||||||
});
|
});
|
||||||
const payload = schema.parse(req.body);
|
const payload = schema.parse(req.body);
|
||||||
|
if (
|
||||||
|
payload.update.price_info &&
|
||||||
|
payload.update.price_info.core_price !== undefined &&
|
||||||
|
payload.update.price_info.price !== undefined &&
|
||||||
|
payload.update.price_info.core_price < payload.update.price_info.price
|
||||||
|
) {
|
||||||
|
const error = new Error("Invalid Price Info Errors: core_price must be greater than or equal to price.");
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const data = await proxyService.updateMenuItems({
|
const data = await proxyService.updateMenuItems({
|
||||||
merchantId: payload.merchantId,
|
merchantId: payload.merchantId,
|
||||||
storeId: payload.storeId,
|
storeId: payload.storeId,
|
||||||
payload: { items: payload.items }
|
itemId: payload.itemId,
|
||||||
|
payload: payload.update
|
||||||
});
|
});
|
||||||
return res.json({ success: true, data });
|
return res.json({ success: true, data });
|
||||||
}
|
}
|
||||||
@ -635,6 +719,73 @@ async function deliveryByocIngestCourierLocation(req, res) {
|
|||||||
return res.json({ success: true, data });
|
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 = {
|
module.exports = {
|
||||||
genericProxy,
|
genericProxy,
|
||||||
upsertMenu,
|
upsertMenu,
|
||||||
@ -676,5 +827,10 @@ module.exports = {
|
|||||||
deliveryResolveFulfillmentIssues,
|
deliveryResolveFulfillmentIssues,
|
||||||
deliveryGetReplacementRecommendations,
|
deliveryGetReplacementRecommendations,
|
||||||
deliveryUpdatePartnerCount,
|
deliveryUpdatePartnerCount,
|
||||||
deliveryByocIngestCourierLocation
|
deliveryByocIngestCourierLocation,
|
||||||
|
deliveryCreatePromotion,
|
||||||
|
deliveryRevokePromotion,
|
||||||
|
deliveryGetPromotion,
|
||||||
|
deliveryListPromotions,
|
||||||
|
getUberProductTypes
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,15 @@
|
|||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
|
const zlib = require("zlib");
|
||||||
|
const { promisify } = require("util");
|
||||||
const env = require("../../config/env");
|
const env = require("../../config/env");
|
||||||
const uberEndpoints = require("../../config/uberEndpoints");
|
const uberEndpoints = require("../../config/uberEndpoints");
|
||||||
const { uberConnectionRepository, apiLogRepository } = require("../../db/adapter");
|
const { uberConnectionRepository, apiLogRepository, appTokenRepository } = require("../../db/adapter");
|
||||||
const { getCachedClientCredentialsToken, AUTH_SCOPES } = require("../auth/auth.service");
|
const {
|
||||||
|
getCachedClientCredentialsToken,
|
||||||
|
refreshToken,
|
||||||
|
AUTH_SCOPES,
|
||||||
|
AUTH_GRANT_TYPES
|
||||||
|
} = require("../auth/auth.service");
|
||||||
const { normalizeUberError, isRetryableUberError } = require("../common/errors/uberError");
|
const { normalizeUberError, isRetryableUberError } = require("../common/errors/uberError");
|
||||||
const { withExponentialBackoffRetry } = require("../common/http/retry");
|
const { withExponentialBackoffRetry } = require("../common/http/retry");
|
||||||
|
|
||||||
@ -11,6 +18,8 @@ const uberApiClient = axios.create({
|
|||||||
timeout: 30000
|
timeout: 30000
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gzipAsync = promisify(zlib.gzip);
|
||||||
|
|
||||||
function interpolatePath(pathTemplate, params = {}) {
|
function interpolatePath(pathTemplate, params = {}) {
|
||||||
let output = pathTemplate;
|
let output = pathTemplate;
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
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 }) {
|
function isUnauthorizedError(error) {
|
||||||
const resolvedAuth = await resolveAuthToken({ authMode, merchantId, scopes });
|
return Number(error?.response?.status || 0) === 401;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
async function refreshMerchantConnectionToken(merchantId) {
|
||||||
const response = await withExponentialBackoffRetry({
|
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 () =>
|
fn: async () =>
|
||||||
uberApiClient.request({
|
uberApiClient.request({
|
||||||
method,
|
method,
|
||||||
@ -68,13 +126,43 @@ async function callUberApi({ merchantId, method, uberPath, query, body, wrapperR
|
|||||||
data: body,
|
data: body,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: buildAuthHeader(resolvedAuth.tokenType, resolvedAuth.accessToken),
|
Authorization: buildAuthHeader(resolvedAuth.tokenType, resolvedAuth.accessToken),
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
|
...(headers || {})
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
maxAttempts: 4,
|
maxAttempts: 4,
|
||||||
baseDelayMs: 300,
|
baseDelayMs: 300,
|
||||||
shouldRetry: (error) => isRetryableUberError(error)
|
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({
|
apiLogRepository.insert({
|
||||||
merchantId,
|
merchantId,
|
||||||
@ -82,7 +170,7 @@ async function callUberApi({ merchantId, method, uberPath, query, body, wrapperR
|
|||||||
wrapperRoute,
|
wrapperRoute,
|
||||||
uberPath,
|
uberPath,
|
||||||
responseStatus: response.status,
|
responseStatus: response.status,
|
||||||
requestBody: body,
|
requestBody: logRequestBody !== undefined ? logRequestBody : body,
|
||||||
responseBody: response.data
|
responseBody: response.data
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -96,7 +184,7 @@ async function callUberApi({ merchantId, method, uberPath, query, body, wrapperR
|
|||||||
wrapperRoute,
|
wrapperRoute,
|
||||||
uberPath,
|
uberPath,
|
||||||
responseStatus: normalized.status,
|
responseStatus: normalized.status,
|
||||||
requestBody: body,
|
requestBody: logRequestBody !== undefined ? logRequestBody : body,
|
||||||
responseBody: {
|
responseBody: {
|
||||||
code: normalized.code,
|
code: normalized.code,
|
||||||
message: normalized.message,
|
message: normalized.message,
|
||||||
@ -136,32 +224,44 @@ async function menuUpsert({ merchantId, storeId, payload }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function menuReplace({ 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({
|
return callUberApi({
|
||||||
merchantId,
|
merchantId,
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
uberPath,
|
uberPath,
|
||||||
body: payload,
|
body: compressedPayload,
|
||||||
|
logRequestBody: payload,
|
||||||
|
headers: {
|
||||||
|
"Content-Encoding": "gzip",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
wrapperRoute: "/api/v1/uber/menu/replace",
|
wrapperRoute: "/api/v1/uber/menu/replace",
|
||||||
authMode: "app",
|
authMode: "app",
|
||||||
scopes: AUTH_SCOPES.STORE
|
scopes: AUTH_SCOPES.STORE
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function menuGet({ merchantId, storeId }) {
|
async function menuGet({ merchantId, storeId, menuType }) {
|
||||||
const uberPath = interpolatePath(uberEndpoints.menu.get, { storeId });
|
const uberPath = interpolatePath(uberEndpoints.menu.get, { storeId });
|
||||||
return callUberApi({
|
return callUberApi({
|
||||||
merchantId,
|
merchantId,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
uberPath,
|
uberPath,
|
||||||
|
query: {
|
||||||
|
menu_type: menuType || "MENU_TYPE_FULFILLMENT_DELIVERY"
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"Accept-Encoding": "gzip"
|
||||||
|
},
|
||||||
wrapperRoute: "/api/v1/uber/menu",
|
wrapperRoute: "/api/v1/uber/menu",
|
||||||
authMode: "app",
|
authMode: "app",
|
||||||
scopes: AUTH_SCOPES.STORE
|
scopes: AUTH_SCOPES.STORE
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateMenuItems({ merchantId, storeId, payload }) {
|
async function updateMenuItems({ merchantId, storeId, itemId, payload }) {
|
||||||
const uberPath = interpolatePath(uberEndpoints.menu.itemsUpdate, { storeId });
|
const uberPath = interpolatePath(uberEndpoints.menu.itemUpdate, { storeId, itemId });
|
||||||
return callUberApi({
|
return callUberApi({
|
||||||
merchantId,
|
merchantId,
|
||||||
method: "POST",
|
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 = {
|
module.exports = {
|
||||||
genericProxy,
|
genericProxy,
|
||||||
menuUpsert,
|
menuUpsert,
|
||||||
@ -653,5 +800,9 @@ module.exports = {
|
|||||||
deliveryResolveFulfillmentIssues,
|
deliveryResolveFulfillmentIssues,
|
||||||
deliveryGetReplacementRecommendations,
|
deliveryGetReplacementRecommendations,
|
||||||
deliveryUpdatePartnerCount,
|
deliveryUpdatePartnerCount,
|
||||||
deliveryByocIngestCourierLocation
|
deliveryByocIngestCourierLocation,
|
||||||
|
deliveryCreatePromotion,
|
||||||
|
deliveryRevokePromotion,
|
||||||
|
deliveryGetPromotion,
|
||||||
|
deliveryListPromotions
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,34 @@
|
|||||||
const { z } = require("zod");
|
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({
|
const fetchSchema = z.object({
|
||||||
method: z.enum(["GET", "POST"]).default("GET"),
|
method: z.enum(["GET", "POST"]).default("GET"),
|
||||||
@ -28,7 +57,109 @@ async function fetchReportingCsv(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
function parseDateLike(input, allowDateTime) {
|
||||||
fetchReportingCsv
|
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 axios = require("axios");
|
||||||
const env = require("../../config/env");
|
const env = require("../../config/env");
|
||||||
const { apiLogRepository } = require("../../db/adapter");
|
const { apiLogRepository, reportJobRepository, appTokenRepository } = require("../../db/adapter");
|
||||||
const { getCachedClientCredentialsToken, AUTH_SCOPES } = require("../auth/auth.service");
|
const {
|
||||||
|
getCachedClientCredentialsToken,
|
||||||
|
AUTH_SCOPES,
|
||||||
|
AUTH_GRANT_TYPES
|
||||||
|
} = require("../auth/auth.service");
|
||||||
const { normalizeUberError, isRetryableUberError } = require("../common/errors/uberError");
|
const { normalizeUberError, isRetryableUberError } = require("../common/errors/uberError");
|
||||||
const { withExponentialBackoffRetry } = require("../common/http/retry");
|
const { withExponentialBackoffRetry } = require("../common/http/retry");
|
||||||
|
|
||||||
@ -14,6 +18,43 @@ function buildAuthorizationHeader(tokenType, accessToken) {
|
|||||||
return `${tokenType || "Bearer"} ${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) {
|
function parseCsvLine(line) {
|
||||||
const output = [];
|
const output = [];
|
||||||
let current = "";
|
let current = "";
|
||||||
@ -89,14 +130,11 @@ async function fetchReport({
|
|||||||
requiredHeaders = [],
|
requiredHeaders = [],
|
||||||
wrapperRoute = "/api/v1/uber/reporting/fetch"
|
wrapperRoute = "/api/v1/uber/reporting/fetch"
|
||||||
}) {
|
}) {
|
||||||
const token = await getCachedClientCredentialsToken({
|
|
||||||
scope: AUTH_SCOPES.REPORT
|
|
||||||
});
|
|
||||||
const requestMethod = String(method || "GET").toUpperCase();
|
const requestMethod = String(method || "GET").toUpperCase();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await withExponentialBackoffRetry({
|
const response = await executeWithReportTokenRetry({
|
||||||
fn: async () =>
|
requestFactory: async (token) =>
|
||||||
reportingClient.request({
|
reportingClient.request({
|
||||||
method: requestMethod,
|
method: requestMethod,
|
||||||
url: upstreamPath,
|
url: upstreamPath,
|
||||||
@ -107,10 +145,7 @@ async function fetchReport({
|
|||||||
Authorization: buildAuthorizationHeader(token.token_type, token.access_token),
|
Authorization: buildAuthorizationHeader(token.token_type, token.access_token),
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
maxAttempts: 4,
|
|
||||||
baseDelayMs: 400,
|
|
||||||
shouldRetry: (error) => isRetryableUberError(error)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const textData =
|
const textData =
|
||||||
@ -155,6 +190,86 @@ async function fetchReport({
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
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,
|
fetchReport,
|
||||||
parseCsvByHeader
|
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 crypto = require("crypto");
|
||||||
const env = require("../../config/env");
|
const env = require("../../config/env");
|
||||||
const { webhookRepository, uberConnectionRepository } = require("../../db/adapter");
|
const {
|
||||||
|
webhookRepository,
|
||||||
|
asyncJobRepository
|
||||||
|
} = require("../../db/adapter");
|
||||||
|
|
||||||
function getSignatureFromHeaders(headers) {
|
function getSignatureFromHeaders(headers) {
|
||||||
const signature = headers["x-uber-signature"];
|
const signature = headers["x-uber-signature"];
|
||||||
@ -65,28 +68,6 @@ function buildDedupeKey(signature, req) {
|
|||||||
return crypto.createHash("sha256").update(basis).digest("hex");
|
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) {
|
async function handleUberWebhook(req, res) {
|
||||||
if (!verifyBasicAuthIfConfigured(req)) {
|
if (!verifyBasicAuthIfConfigured(req)) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@ -120,7 +101,7 @@ async function handleUberWebhook(req, res) {
|
|||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookRepository.insert({
|
const inserted = webhookRepository.insert({
|
||||||
provider: "uber",
|
provider: "uber",
|
||||||
merchantId,
|
merchantId,
|
||||||
eventType,
|
eventType,
|
||||||
@ -132,7 +113,13 @@ async function handleUberWebhook(req, res) {
|
|||||||
headersJson: req.headers
|
headersJson: req.headers
|
||||||
});
|
});
|
||||||
|
|
||||||
applyProvisioningStateFromWebhook(eventType, req.body || {});
|
asyncJobRepository.enqueue({
|
||||||
|
jobType: "uber_webhook_event",
|
||||||
|
payload: {
|
||||||
|
eventId: inserted.id
|
||||||
|
},
|
||||||
|
maxAttempts: 7
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,19 @@ const router = express.Router();
|
|||||||
*/
|
*/
|
||||||
router.post("/uber/request", asyncHandler(controller.genericProxy));
|
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
|
* @openapi
|
||||||
* /api/v1/uber/menu/upsert:
|
* /api/v1/uber/menu/upsert:
|
||||||
@ -34,12 +47,12 @@ router.post("/uber/menu/upsert", asyncHandler(controller.upsertMenu));
|
|||||||
* @openapi
|
* @openapi
|
||||||
* /api/v1/uber/menu/replace:
|
* /api/v1/uber/menu/replace:
|
||||||
* put:
|
* put:
|
||||||
* summary: Replace store menu (full upload)
|
* summary: Upload/replace store menu (PUT /v2/eats/stores/{store_id}/menus)
|
||||||
* tags:
|
* tags:
|
||||||
* - Uber Menu
|
* - Uber Menu
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Menu replaced
|
* description: Menu replaced (Uber returns 204 No Content)
|
||||||
*/
|
*/
|
||||||
router.put("/uber/menu/replace", asyncHandler(controller.replaceMenu));
|
router.put("/uber/menu/replace", asyncHandler(controller.replaceMenu));
|
||||||
|
|
||||||
@ -47,12 +60,12 @@ router.put("/uber/menu/replace", asyncHandler(controller.replaceMenu));
|
|||||||
* @openapi
|
* @openapi
|
||||||
* /api/v1/uber/menu/items:
|
* /api/v1/uber/menu/items:
|
||||||
* post:
|
* 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:
|
* tags:
|
||||||
* - Uber Menu
|
* - Uber Menu
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Menu items updated
|
* description: Menu item sparsely updated (Uber returns 204 No Content)
|
||||||
*/
|
*/
|
||||||
router.post("/uber/menu/items", asyncHandler(controller.updateMenuItems));
|
router.post("/uber/menu/items", asyncHandler(controller.updateMenuItems));
|
||||||
|
|
||||||
@ -63,6 +76,27 @@ router.post("/uber/menu/items", asyncHandler(controller.updateMenuItems));
|
|||||||
* summary: Fetch store menu
|
* summary: Fetch store menu
|
||||||
* tags:
|
* tags:
|
||||||
* - Uber Menu
|
* - 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:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Menu fetched
|
* description: Menu fetched
|
||||||
@ -688,4 +722,87 @@ router.post(
|
|||||||
asyncHandler(controller.deliveryByocIngestCourierLocation)
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const asyncHandler = require("../middleware/asyncHandler");
|
const asyncHandler = require("../middleware/asyncHandler");
|
||||||
const { fetchReportingCsv } = require("../modules/reporting/reporting.controller");
|
const {
|
||||||
|
fetchReportingCsv,
|
||||||
|
createMarketplaceReport
|
||||||
|
} = require("../modules/reporting/reporting.controller");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -17,5 +20,17 @@ const router = express.Router();
|
|||||||
*/
|
*/
|
||||||
router.post("/uber/reporting/fetch", asyncHandler(fetchReportingCsv));
|
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 app = require("./app");
|
||||||
const env = require("./config/env");
|
const env = require("./config/env");
|
||||||
const { initSchema } = require("./db/sqlite");
|
const { initSchema } = require("./db/sqlite");
|
||||||
|
const { startAsyncWorkers } = require("./workers/asyncWorkers");
|
||||||
|
|
||||||
initSchema();
|
initSchema();
|
||||||
|
startAsyncWorkers();
|
||||||
|
|
||||||
app.listen(env.PORT, () => {
|
app.listen(env.PORT, () => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Uber Wrapper listening on http://localhost:${env.PORT}`);
|
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