From 6b768bfbf5e0faa998a7e2c3ddee8503fa47e0dd Mon Sep 17 00:00:00 2001 From: MOHAN Date: Sun, 29 Mar 2026 17:59:11 +0530 Subject: [PATCH] feat: expand order integration with order details, fulfillment resolution, and SLA metrics --- .../06-order-integration-audit.md | 27 +++++ docs/developer-portal/06-orders.md | 10 ++ docs/openapi/openapi.json | 56 ++++++++++- postman/Uber_Wrapper.postman_collection.json | 87 ++++++++++++++++ src/config/uberEndpoints.js | 1 + src/db/repositories.js | 99 ++++++++++++++++++- src/db/sqlite.js | 5 + src/modules/metrics/metrics.controller.js | 31 +++++- src/modules/proxy/proxy.controller.js | 10 +- src/modules/proxy/proxy.service.js | 13 +++ src/routes/metrics.routes.js | 30 +++++- src/routes/proxy.routes.js | 21 +++- 12 files changed, 380 insertions(+), 10 deletions(-) create mode 100644 docs/developer-portal/06-order-integration-audit.md diff --git a/docs/developer-portal/06-order-integration-audit.md b/docs/developer-portal/06-order-integration-audit.md new file mode 100644 index 0000000..5bfa11c --- /dev/null +++ b/docs/developer-portal/06-order-integration-audit.md @@ -0,0 +1,27 @@ +# 06 Order Integration Audit + +Source checked: Uber Eats "Order Integration" section shared by you. + +## Implemented Now + +- Retrieve full order details after webhook: + - `GET /api/v1/uber/orders/{orderId}` +- Core order actions: + - accept / deny / ready / cancel +- Resolve fulfillment issues action: + - `POST /api/v1/uber/orders/{orderId}/action` with `action=resolve` +- SLA metric for 11.5 minute response window: + - `GET /api/v1/metrics/order-response-sla` + +## Existing Before + +- Order listing by store +- Webhook ingestion for order events +- Injection success metric + +## Pending + +- Confirmation of exact upstream path for fulfillment-issue resolution endpoint (verify against final API reference) +- Scheduled-order specific business rules and timers +- Courier handoff update endpoint coverage once reference section is shared + diff --git a/docs/developer-portal/06-orders.md b/docs/developer-portal/06-orders.md index 56a0396..b47fd8b 100644 --- a/docs/developer-portal/06-orders.md +++ b/docs/developer-portal/06-orders.md @@ -5,6 +5,16 @@ Order flow for POS: - Receive order event - Fetch full order payload - Accept/deny +- Resolve fulfillment issues when item(s) cannot be fulfilled - Ready/handoff updates - Completion/cancellation reconciliation +Typed routes: + +- `GET /api/v1/uber/orders/{orderId}` (order details) +- `POST /api/v1/uber/orders/{orderId}/action` with action: + - `accept` + - `deny` + - `ready` + - `cancel` + - `resolve` diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index da4df80..ec51f49 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -215,6 +215,37 @@ } } }, + "/api/v1/metrics/order-response-sla": { + "get": { + "summary": "Get response SLA metric for order accept/deny timing", + "tags": [ + "Metrics" + ], + "parameters": [ + { + "in": "query", + "name": "merchantId", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "windowDays", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "SLA metrics" + } + } + } + }, "/api/v1/uber/request": { "post": { "summary": "Generic Uber passthrough for any Uber endpoint", @@ -293,6 +324,29 @@ } } }, + "/api/v1/uber/orders/{orderId}": { + "get": { + "summary": "Retrieve full order details by order ID", + "tags": [ + "Uber Orders" + ], + "parameters": [ + { + "in": "path", + "name": "orderId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Order details retrieved" + } + } + } + }, "/api/v1/uber/stores": { "get": { "summary": "Retrieve all stores provisioned to developer account", @@ -442,7 +496,7 @@ }, "/api/v1/uber/orders/{orderId}/action": { "post": { - "summary": "Trigger order action (accept, deny, ready, cancel)", + "summary": "Trigger order action (accept, deny, ready, cancel, resolve)", "tags": [ "Uber Orders" ], diff --git a/postman/Uber_Wrapper.postman_collection.json b/postman/Uber_Wrapper.postman_collection.json index a81d2dd..2c74916 100644 --- a/postman/Uber_Wrapper.postman_collection.json +++ b/postman/Uber_Wrapper.postman_collection.json @@ -398,6 +398,89 @@ } } }, + { + "name": "Get Order Details", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{baseUrl}}/api/v1/uber/orders/{{orderId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "uber", + "orders", + "{{orderId}}" + ] + } + } + }, + { + "name": "Resolve Fulfillment Issue", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"action\": \"resolve\",\n \"payload\": {\n \"issues\": []\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/v1/uber/orders/{{orderId}}/action", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "uber", + "orders", + "{{orderId}}", + "action" + ] + } + } + }, + { + "name": "Order Response SLA Metric", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{baseUrl}}/api/v1/metrics/order-response-sla", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "metrics", + "order-response-sla" + ] + } + } + }, { "name": "Set Holiday Hours", "request": { @@ -615,6 +698,10 @@ { "key": "storeId", "value": "" + }, + { + "key": "orderId", + "value": "" } ] } diff --git a/src/config/uberEndpoints.js b/src/config/uberEndpoints.js index f7bd78d..18b8ccc 100644 --- a/src/config/uberEndpoints.js +++ b/src/config/uberEndpoints.js @@ -7,6 +7,7 @@ module.exports = { orders: { list: "/v1/eats/stores/{storeId}/orders", getById: "/v1/eats/orders/{orderId}", + resolveFulfillmentIssue: "/v1/eats/orders/{orderId}/resolve_fulfillment_issues", accept: "/v1/eats/orders/{orderId}/accept_pos_order", deny: "/v1/eats/orders/{orderId}/deny_pos_order", readyForPickup: "/v1/eats/orders/{orderId}/pos_order_ready_for_pickup", diff --git a/src/db/repositories.js b/src/db/repositories.js index fb216d8..c88cd0c 100644 --- a/src/db/repositories.js +++ b/src/db/repositories.js @@ -5,6 +5,14 @@ function nowIso() { return new Date().toISOString(); } +function extractOrderIdFromPath(uberPath) { + if (!uberPath) { + return null; + } + const match = uberPath.match(/\/orders\/([^/]+)/i); + return match ? decodeURIComponent(match[1]) : null; +} + const merchantRepository = { upsert({ id, name, externalRef }) { const timestamp = nowIso(); @@ -163,9 +171,11 @@ const webhookRepository = { const apiLogRepository = { insert({ merchantId, method, wrapperRoute, uberPath, responseStatus, requestBody, responseBody }) { + const orderId = extractOrderIdFromPath(uberPath); const row = { id: uuidv4(), merchant_id: merchantId || null, + order_id: orderId, http_method: method, wrapper_route: wrapperRoute, uber_path: uberPath, @@ -177,11 +187,11 @@ const apiLogRepository = { const stmt = db.prepare(` INSERT INTO api_logs ( - id, merchant_id, http_method, wrapper_route, uber_path, + id, merchant_id, order_id, http_method, wrapper_route, uber_path, response_status, request_json, response_json, created_at ) VALUES ( - @id, @merchant_id, @http_method, @wrapper_route, @uber_path, + @id, @merchant_id, @order_id, @http_method, @wrapper_route, @uber_path, @response_status, @request_json, @response_json, @created_at ) `); @@ -227,6 +237,91 @@ const apiLogRepository = { failed: total - success, successRate }; + }, + + getOrderResponseSlaStats({ merchantId, sinceIso }) { + const webhookFilters = [ + "provider = 'uber'", + "(event_type = 'orders.notification' OR event_type = 'orders.scheduled.notification')", + "resource_id IS NOT NULL" + ]; + const webhookParams = []; + + if (merchantId) { + webhookFilters.push("merchant_id = ?"); + webhookParams.push(merchantId); + } + + if (sinceIso) { + webhookFilters.push("received_at >= ?"); + webhookParams.push(sinceIso); + } + + const webhookRows = db + .prepare( + ` + SELECT resource_id, merchant_id, received_at + FROM webhook_events + WHERE ${webhookFilters.join(" AND ")} + ORDER BY received_at ASC + ` + ) + .all(...webhookParams); + + const uniqueOrders = new Map(); + webhookRows.forEach((row) => { + if (!uniqueOrders.has(row.resource_id)) { + uniqueOrders.set(row.resource_id, row); + } + }); + + let withinSla = 0; + let responded = 0; + + uniqueOrders.forEach((row, orderId) => { + const action = db + .prepare( + ` + SELECT created_at + FROM api_logs + WHERE order_id = ? + AND ( + uber_path LIKE '%/accept_pos_order' + OR uber_path LIKE '%/deny_pos_order' + ) + AND response_status >= 200 + AND response_status < 300 + ORDER BY created_at ASC + LIMIT 1 + ` + ) + .get(orderId); + + if (!action) { + return; + } + + responded += 1; + const receivedAtMs = new Date(row.received_at).getTime(); + const actionAtMs = new Date(action.created_at).getTime(); + const elapsedMs = actionAtMs - receivedAtMs; + if (!Number.isNaN(elapsedMs) && elapsedMs <= 11.5 * 60 * 1000) { + withinSla += 1; + } + }); + + const totalNotifications = uniqueOrders.size; + const withinSlaRate = + totalNotifications === 0 ? 0 : Number(((withinSla / totalNotifications) * 100).toFixed(2)); + + return { + totalNotifications, + responded, + noResponseYet: totalNotifications - responded, + withinSla, + breachedSla: responded - withinSla, + withinSlaRate + }; } }; diff --git a/src/db/sqlite.js b/src/db/sqlite.js index cec85cf..20d304a 100644 --- a/src/db/sqlite.js +++ b/src/db/sqlite.js @@ -65,6 +65,7 @@ function initSchema() { CREATE TABLE IF NOT EXISTS api_logs ( id TEXT PRIMARY KEY, merchant_id TEXT, + order_id TEXT, http_method TEXT NOT NULL, wrapper_route TEXT NOT NULL, uber_path TEXT NOT NULL, @@ -115,6 +116,10 @@ function initSchema() { CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_events_dedupe_key ON webhook_events(dedupe_key); `); + + if (!tableHasColumn("api_logs", "order_id")) { + db.exec("ALTER TABLE api_logs ADD COLUMN order_id TEXT"); + } } module.exports = { diff --git a/src/modules/metrics/metrics.controller.js b/src/modules/metrics/metrics.controller.js index b80b172..f7b40fb 100644 --- a/src/modules/metrics/metrics.controller.js +++ b/src/modules/metrics/metrics.controller.js @@ -38,7 +38,32 @@ async function getInjectionSuccess(req, res) { }); } -module.exports = { - getInjectionSuccess -}; +async function getOrderResponseSla(req, res) { + const schema = z.object({ + merchantId: z.string().optional(), + windowDays: z.string().optional() + }); + const query = schema.parse(req.query); + const sinceIso = toSinceIso(query.windowDays); + const stats = apiLogRepository.getOrderResponseSlaStats({ + merchantId: query.merchantId, + sinceIso + }); + + return res.json({ + success: true, + data: { + metric: "order_response_within_11_5_minutes", + merchantId: query.merchantId || "all", + windowDays: query.windowDays ? Number(query.windowDays) : "all_time", + targetMinutes: 11.5, + ...stats + } + }); +} + +module.exports = { + getInjectionSuccess, + getOrderResponseSla +}; diff --git a/src/modules/proxy/proxy.controller.js b/src/modules/proxy/proxy.controller.js index 01265bf..a52394a 100644 --- a/src/modules/proxy/proxy.controller.js +++ b/src/modules/proxy/proxy.controller.js @@ -86,10 +86,17 @@ async function listOrders(req, res) { return res.json({ success: true, data }); } +async function getOrderById(req, res) { + const data = await proxyService.getOrderById({ + orderId: req.params.orderId + }); + return res.json({ success: true, data }); +} + async function orderAction(req, res) { const schema = z.object({ merchantId: z.string().min(1), - action: z.enum(["accept", "deny", "ready", "cancel"]), + action: z.enum(["accept", "deny", "ready", "cancel", "resolve"]), payload: z.any().optional() }); const parsed = schema.parse(req.body); @@ -226,6 +233,7 @@ module.exports = { updateMenuItems, getMenu, listOrders, + getOrderById, orderAction, updateHours, listProvisionableStores, diff --git a/src/modules/proxy/proxy.service.js b/src/modules/proxy/proxy.service.js index 21f8138..bc5f54b 100644 --- a/src/modules/proxy/proxy.service.js +++ b/src/modules/proxy/proxy.service.js @@ -177,8 +177,20 @@ async function ordersList({ merchantId, storeId, query }) { }); } +async function getOrderById({ orderId }) { + const uberPath = interpolatePath(uberEndpoints.orders.getById, { orderId }); + return callUberApi({ + method: "GET", + uberPath, + wrapperRoute: "/api/v1/uber/orders/:orderId", + authMode: "app", + scopes: AUTH_SCOPES.ORDER + }); +} + async function orderAction({ merchantId, orderId, action, payload }) { const routeMap = { + resolve: uberEndpoints.orders.resolveFulfillmentIssue, accept: uberEndpoints.orders.accept, deny: uberEndpoints.orders.deny, ready: uberEndpoints.orders.readyForPickup, @@ -341,6 +353,7 @@ module.exports = { menuGet, updateMenuItems, ordersList, + getOrderById, orderAction, updateStoreHours, listProvisionableStores, diff --git a/src/routes/metrics.routes.js b/src/routes/metrics.routes.js index 55b5b72..5ff2ada 100644 --- a/src/routes/metrics.routes.js +++ b/src/routes/metrics.routes.js @@ -1,6 +1,9 @@ const express = require("express"); const asyncHandler = require("../middleware/asyncHandler"); -const { getInjectionSuccess } = require("../modules/metrics/metrics.controller"); +const { + getInjectionSuccess, + getOrderResponseSla +} = require("../modules/metrics/metrics.controller"); const router = express.Router(); @@ -28,5 +31,28 @@ const router = express.Router(); */ router.get("/metrics/injection-success", asyncHandler(getInjectionSuccess)); -module.exports = router; +/** + * @openapi + * /api/v1/metrics/order-response-sla: + * get: + * summary: Get response SLA metric for order accept/deny timing + * tags: + * - Metrics + * parameters: + * - in: query + * name: merchantId + * required: false + * schema: + * type: string + * - in: query + * name: windowDays + * required: false + * schema: + * type: integer + * responses: + * 200: + * description: SLA metrics + */ +router.get("/metrics/order-response-sla", asyncHandler(getOrderResponseSla)); +module.exports = router; diff --git a/src/routes/proxy.routes.js b/src/routes/proxy.routes.js index a4d90a1..a8bdc3f 100644 --- a/src/routes/proxy.routes.js +++ b/src/routes/proxy.routes.js @@ -82,6 +82,25 @@ router.get("/uber/menu", asyncHandler(controller.getMenu)); */ router.get("/uber/orders", asyncHandler(controller.listOrders)); +/** + * @openapi + * /api/v1/uber/orders/{orderId}: + * get: + * summary: Retrieve full order details by order ID + * tags: + * - Uber Orders + * parameters: + * - in: path + * name: orderId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Order details retrieved + */ +router.get("/uber/orders/:orderId", asyncHandler(controller.getOrderById)); + /** * @openapi * /api/v1/uber/stores: @@ -203,7 +222,7 @@ router.post("/uber/stores/:storeId/holiday-hours", asyncHandler(controller.setHo * @openapi * /api/v1/uber/orders/{orderId}/action: * post: - * summary: Trigger order action (accept, deny, ready, cancel) + * summary: Trigger order action (accept, deny, ready, cancel, resolve) * tags: * - Uber Orders * parameters: