From 8f58a64bc89beecc79df7e0fe341330cdd45ff71 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Sun, 29 Mar 2026 17:51:16 +0530 Subject: [PATCH] feat: implement integration configuration provisioning flows and pos_data endpoints --- ...3-integration-configuration-flows-audit.md | 31 ++++ docs/developer-portal/03-integration-flow.md | 13 +- .../03-merchant-onboarding.md | 5 +- docs/developer-portal/07-webhooks.md | 11 ++ docs/openapi/openapi.json | 88 ++++++++++++ postman/Uber_Wrapper.postman_collection.json | 133 ++++++++++++++++++ src/config/uberEndpoints.js | 5 +- src/db/repositories.js | 16 +++ src/modules/proxy/proxy.controller.js | 58 +++++++- src/modules/proxy/proxy.service.js | 56 +++++++- src/modules/webhooks/webhooks.controller.js | 26 +++- src/routes/proxy.routes.js | 66 +++++++++ 12 files changed, 497 insertions(+), 11 deletions(-) create mode 100644 docs/developer-portal/03-integration-configuration-flows-audit.md diff --git a/docs/developer-portal/03-integration-configuration-flows-audit.md b/docs/developer-portal/03-integration-configuration-flows-audit.md new file mode 100644 index 0000000..21b092d --- /dev/null +++ b/docs/developer-portal/03-integration-configuration-flows-audit.md @@ -0,0 +1,31 @@ +# 03 Integration Configuration Flows Audit + +Source checked: Uber Eats "Integration Configuration Flows" section shared by you. + +## Implemented Now + +- Merchant OAuth based store retrieval: + - `GET /api/v1/uber/stores/provisionable` + - Uses merchant token (`authorization_code` / `eats.pos_provisioning`) +- Store activation via POS data: + - `POST /api/v1/uber/stores/{storeId}/pos-data` +- Store integration update/deactivation: + - `PATCH /api/v1/uber/stores/{storeId}/pos-data` +- Store de-provision: + - `DELETE /api/v1/uber/stores/{storeId}/pos-data` +- Webhook provisioning-state reaction: + - `store.provisioned` marks mapped connection active + - `store.deprovisioned` marks mapped connection deprovisioned + +## Existing From Earlier + +- OAuth authorization URL + callback +- Merchant connection persistence +- Webhook ingestion and persistence + +## Pending + +- Strong store mapping workflow (location-data assisted matching UI flow) +- Typed POS data schema once exact request fields are finalized from endpoint reference +- Automated post-provisioning follow-up actions (e.g., mandatory menu upload jobs) + diff --git a/docs/developer-portal/03-integration-flow.md b/docs/developer-portal/03-integration-flow.md index 9635e23..625c4e0 100644 --- a/docs/developer-portal/03-integration-flow.md +++ b/docs/developer-portal/03-integration-flow.md @@ -29,15 +29,21 @@ Wrapper stores merchant token. `POST /api/v1/uber/menu/upsert` -## 5. Pull Orders +## 5. Provision Store Integration (OAuth user token flow) + +`GET /api/v1/uber/stores/provisionable?merchantId=...` + +`POST /api/v1/uber/stores/{storeId}/pos-data` + +## 6. Pull Orders `GET /api/v1/uber/orders?merchantId=...&storeId=...` -## 6. Receive Webhooks +## 7. Receive Webhooks `POST /api/v1/webhooks/uber` -## 7. Use Generic API for Any Missing Endpoint +## 8. Use Generic API for Any Missing Endpoint `POST /api/v1/uber/request` @@ -49,4 +55,3 @@ Wrapper stores merchant token. "body": {} } ``` - diff --git a/docs/developer-portal/03-merchant-onboarding.md b/docs/developer-portal/03-merchant-onboarding.md index 9a756b5..cc3beb3 100644 --- a/docs/developer-portal/03-merchant-onboarding.md +++ b/docs/developer-portal/03-merchant-onboarding.md @@ -5,7 +5,8 @@ Flow: 1. Create merchant in wrapper 2. Generate Uber OAuth URL 3. Merchant authorizes Uber account -4. Callback stores tokens + store identifiers +4. Callback stores tokens + merchant OAuth access +5. Retrieve merchant stores (`GET /api/v1/uber/stores/provisionable`) +6. Activate selected store (`POST /api/v1/uber/stores/{storeId}/pos-data`) Multi-client principle: each merchant has isolated credentials and mappings. - diff --git a/docs/developer-portal/07-webhooks.md b/docs/developer-portal/07-webhooks.md index a5a65f8..b977462 100644 --- a/docs/developer-portal/07-webhooks.md +++ b/docs/developer-portal/07-webhooks.md @@ -14,3 +14,14 @@ Acknowledgement behavior: - Valid webhook events are acknowledged with `200` and empty body - Duplicate retries are de-duplicated and still acknowledged with `200` + +Common event types handled: + +- `orders.notification` +- `orders.failure` +- `orders.release` +- `orders.scheduled.notification` +- `orders.cancel` +- `store.provisioned` +- `store.deprovisioned` +- `store.status.changed` diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index a7ec25c..69df0bf 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -280,6 +280,29 @@ } } }, + "/api/v1/uber/stores/provisionable": { + "get": { + "summary": "Retrieve stores associated with merchant OAuth token", + "tags": [ + "Uber Provisioning" + ], + "parameters": [ + { + "in": "query", + "name": "merchantId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Stores retrieved" + } + } + } + }, "/api/v1/uber/orders/{orderId}/action": { "post": { "summary": "Trigger order action (accept, deny, ready, cancel)", @@ -316,6 +339,71 @@ } } }, + "/api/v1/uber/stores/{storeId}/pos-data": { + "post": { + "summary": "Activate integration for selected store (POST /pos_data)", + "tags": [ + "Uber Provisioning" + ], + "parameters": [ + { + "in": "path", + "name": "storeId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Store provisioned" + } + } + }, + "patch": { + "summary": "Update integration settings for selected store (PATCH /pos_data)", + "tags": [ + "Uber Provisioning" + ], + "parameters": [ + { + "in": "path", + "name": "storeId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Store integration updated" + } + } + }, + "delete": { + "summary": "De-provision integration for selected store (DELETE /pos_data)", + "tags": [ + "Uber Provisioning" + ], + "parameters": [ + { + "in": "path", + "name": "storeId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Store integration removed" + } + } + } + }, "/api/v1/webhooks/uber": { "post": { "summary": "Ingest Uber webhook events", diff --git a/postman/Uber_Wrapper.postman_collection.json b/postman/Uber_Wrapper.postman_collection.json index 8416ef5..faf18b3 100644 --- a/postman/Uber_Wrapper.postman_collection.json +++ b/postman/Uber_Wrapper.postman_collection.json @@ -199,6 +199,139 @@ } } }, + { + "name": "List Provisionable Stores", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{baseUrl}}/api/v1/uber/stores/provisionable?merchantId={{merchantId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "uber", + "stores", + "provisionable" + ], + "query": [ + { + "key": "merchantId", + "value": "{{merchantId}}" + } + ] + } + } + }, + { + "name": "Create POS Data (Provision)", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"posData\": {\n \"partner_store_id\": \"POS_STORE_001\",\n \"integration_enabled\": true,\n \"store_configuration_data\": {}\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/v1/uber/stores/{{storeId}}/pos-data", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "uber", + "stores", + "{{storeId}}", + "pos-data" + ] + } + } + }, + { + "name": "Patch POS Data (Enable/Disable)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"posData\": {\n \"integration_enabled\": false\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/v1/uber/stores/{{storeId}}/pos-data", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "uber", + "stores", + "{{storeId}}", + "pos-data" + ] + } + } + }, + { + "name": "Delete POS Data (Deprovision)", + "request": { + "method": "DELETE", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"merchantId\": \"{{merchantId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/v1/uber/stores/{{storeId}}/pos-data", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "uber", + "stores", + "{{storeId}}", + "pos-data" + ] + } + } + }, { "name": "Webhook Ingest (Simulation)", "request": { diff --git a/src/config/uberEndpoints.js b/src/config/uberEndpoints.js index 93c89d2..a42f55b 100644 --- a/src/config/uberEndpoints.js +++ b/src/config/uberEndpoints.js @@ -12,12 +12,13 @@ module.exports = { cancel: "/v1/eats/orders/{orderId}/cancel" }, stores: { + list: "/v1/eats/stores", getById: "/v1/eats/stores/{storeId}", updateHours: "/v1/eats/stores/{storeId}/hours", - inventory: "/v1/eats/stores/{storeId}/inventory" + inventory: "/v1/eats/stores/{storeId}/inventory", + posData: "/v1/eats/stores/{storeId}/pos_data" }, webhooks: { events: "/v1/eats/stores/{storeId}/event_feed" } }; - diff --git a/src/db/repositories.js b/src/db/repositories.js index 9256781..fb216d8 100644 --- a/src/db/repositories.js +++ b/src/db/repositories.js @@ -83,6 +83,22 @@ const uberConnectionRepository = { return db.prepare("SELECT * FROM uber_connections WHERE merchant_id = ?").get(merchantId); }, + findByUberStoreId(uberStoreId) { + return db.prepare("SELECT * FROM uber_connections WHERE uber_store_id = ? LIMIT 1").get(uberStoreId); + }, + + setStatusByMerchantId(merchantId, status) { + const timestamp = nowIso(); + db.prepare( + ` + UPDATE uber_connections + SET status = ?, updated_at = ? + WHERE merchant_id = ? + ` + ).run(status, timestamp, merchantId); + return this.findByMerchantId(merchantId); + }, + list() { return db .prepare(` diff --git a/src/modules/proxy/proxy.controller.js b/src/modules/proxy/proxy.controller.js index 33791c9..34649ae 100644 --- a/src/modules/proxy/proxy.controller.js +++ b/src/modules/proxy/proxy.controller.js @@ -103,6 +103,58 @@ async function updateHours(req, res) { return res.json({ success: true, data }); } +async function listProvisionableStores(req, res) { + const schema = z.object({ + merchantId: z.string().min(1) + }); + const payload = schema.parse(req.query); + const data = await proxyService.listProvisionableStores({ + merchantId: payload.merchantId, + query: req.query + }); + return res.json({ success: true, data }); +} + +async function createPosData(req, res) { + const schema = z.object({ + merchantId: z.string().min(1), + posData: z.any() + }); + const payload = schema.parse(req.body); + const data = await proxyService.createPosData({ + merchantId: payload.merchantId, + storeId: req.params.storeId, + payload: payload.posData + }); + return res.json({ success: true, data }); +} + +async function patchPosData(req, res) { + const schema = z.object({ + merchantId: z.string().min(1), + posData: z.any() + }); + const payload = schema.parse(req.body); + const data = await proxyService.patchPosData({ + merchantId: payload.merchantId, + storeId: req.params.storeId, + payload: payload.posData + }); + return res.json({ success: true, data }); +} + +async function deletePosData(req, res) { + const schema = z.object({ + merchantId: z.string().min(1) + }); + const payload = schema.parse(req.body); + const data = await proxyService.deletePosData({ + merchantId: payload.merchantId, + storeId: req.params.storeId + }); + return res.json({ success: true, data }); +} + module.exports = { genericProxy, upsertMenu, @@ -110,5 +162,9 @@ module.exports = { getMenu, listOrders, orderAction, - updateHours + updateHours, + listProvisionableStores, + createPosData, + patchPosData, + deletePosData }; diff --git a/src/modules/proxy/proxy.service.js b/src/modules/proxy/proxy.service.js index ff69f14..b7b51c5 100644 --- a/src/modules/proxy/proxy.service.js +++ b/src/modules/proxy/proxy.service.js @@ -203,6 +203,56 @@ async function updateStoreHours({ merchantId, storeId, payload }) { }); } +async function listProvisionableStores({ merchantId, query }) { + return callUberApi({ + merchantId, + method: "GET", + uberPath: uberEndpoints.stores.list, + query, + wrapperRoute: "/api/v1/uber/stores/provisionable", + authMode: "merchant", + scopes: AUTH_SCOPES.POS_PROVISIONING + }); +} + +async function createPosData({ merchantId, storeId, payload }) { + const uberPath = interpolatePath(uberEndpoints.stores.posData, { storeId }); + return callUberApi({ + merchantId, + method: "POST", + uberPath, + body: payload, + wrapperRoute: "/api/v1/uber/stores/:storeId/pos-data", + authMode: "merchant", + scopes: AUTH_SCOPES.POS_PROVISIONING + }); +} + +async function patchPosData({ merchantId, storeId, payload }) { + const uberPath = interpolatePath(uberEndpoints.stores.posData, { storeId }); + return callUberApi({ + merchantId, + method: "PATCH", + uberPath, + body: payload, + wrapperRoute: "/api/v1/uber/stores/:storeId/pos-data", + authMode: "merchant", + scopes: AUTH_SCOPES.POS_PROVISIONING + }); +} + +async function deletePosData({ merchantId, storeId }) { + const uberPath = interpolatePath(uberEndpoints.stores.posData, { storeId }); + return callUberApi({ + merchantId, + method: "DELETE", + uberPath, + wrapperRoute: "/api/v1/uber/stores/:storeId/pos-data", + authMode: "merchant", + scopes: AUTH_SCOPES.POS_PROVISIONING + }); +} + module.exports = { genericProxy, menuUpsert, @@ -210,5 +260,9 @@ module.exports = { menuGet, ordersList, orderAction, - updateStoreHours + updateStoreHours, + listProvisionableStores, + createPosData, + patchPosData, + deletePosData }; diff --git a/src/modules/webhooks/webhooks.controller.js b/src/modules/webhooks/webhooks.controller.js index 6b27411..6aae8ed 100644 --- a/src/modules/webhooks/webhooks.controller.js +++ b/src/modules/webhooks/webhooks.controller.js @@ -1,6 +1,6 @@ const crypto = require("crypto"); const env = require("../../config/env"); -const { webhookRepository } = require("../../db/adapter"); +const { webhookRepository, uberConnectionRepository } = require("../../db/adapter"); function getSignatureFromHeaders(headers) { const signature = headers["x-uber-signature"]; @@ -65,6 +65,28 @@ function buildDedupeKey(signature, req) { return crypto.createHash("sha256").update(basis).digest("hex"); } +function extractStoreId(payload) { + return payload?.user_id || payload?.store_id || payload?.resource_id || payload?.store?.id || null; +} + +function applyProvisioningStateFromWebhook(eventType, payload) { + if (eventType !== "store.provisioned" && eventType !== "store.deprovisioned") { + return; + } + const storeId = extractStoreId(payload); + if (!storeId) { + return; + } + + const connection = uberConnectionRepository.findByUberStoreId(String(storeId)); + if (!connection) { + return; + } + + const nextStatus = eventType === "store.provisioned" ? "active" : "deprovisioned"; + uberConnectionRepository.setStatusByMerchantId(connection.merchant_id, nextStatus); +} + async function handleUberWebhook(req, res) { if (!verifyBasicAuthIfConfigured(req)) { return res.status(401).json({ @@ -104,6 +126,8 @@ async function handleUberWebhook(req, res) { headersJson: req.headers }); + applyProvisioningStateFromWebhook(eventType, req.body || {}); + return res.status(200).end(); } diff --git a/src/routes/proxy.routes.js b/src/routes/proxy.routes.js index 7fca7a0..5304eba 100644 --- a/src/routes/proxy.routes.js +++ b/src/routes/proxy.routes.js @@ -69,6 +69,25 @@ router.get("/uber/menu", asyncHandler(controller.getMenu)); */ router.get("/uber/orders", asyncHandler(controller.listOrders)); +/** + * @openapi + * /api/v1/uber/stores/provisionable: + * get: + * summary: Retrieve stores associated with merchant OAuth token + * tags: + * - Uber Provisioning + * parameters: + * - in: query + * name: merchantId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Stores retrieved + */ +router.get("/uber/stores/provisionable", asyncHandler(controller.listProvisionableStores)); + /** * @openapi * /api/v1/uber/orders/{orderId}/action: @@ -101,4 +120,51 @@ router.post("/uber/orders/:orderId/action", asyncHandler(controller.orderAction) */ router.put("/uber/stores/hours", asyncHandler(controller.updateHours)); +/** + * @openapi + * /api/v1/uber/stores/{storeId}/pos-data: + * post: + * summary: Activate integration for selected store (POST /pos_data) + * tags: + * - Uber Provisioning + * parameters: + * - in: path + * name: storeId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Store provisioned + * patch: + * summary: Update integration settings for selected store (PATCH /pos_data) + * tags: + * - Uber Provisioning + * parameters: + * - in: path + * name: storeId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Store integration updated + * delete: + * summary: De-provision integration for selected store (DELETE /pos_data) + * tags: + * - Uber Provisioning + * parameters: + * - in: path + * name: storeId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Store integration removed + */ +router.post("/uber/stores/:storeId/pos-data", asyncHandler(controller.createPosData)); +router.patch("/uber/stores/:storeId/pos-data", asyncHandler(controller.patchPosData)); +router.delete("/uber/stores/:storeId/pos-data", asyncHandler(controller.deletePosData)); + module.exports = router;