feat: expand order integration with order details, fulfillment resolution, and SLA metrics
This commit is contained in:
parent
8fb333918c
commit
6b768bfbf5
27
docs/developer-portal/06-order-integration-audit.md
Normal file
27
docs/developer-portal/06-order-integration-audit.md
Normal file
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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"
|
||||
],
|
||||
|
||||
@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user