feat: implement integration configuration provisioning flows and pos_data endpoints

This commit is contained in:
MOHAN 2026-03-29 17:51:16 +05:30
parent 490d2e77b7
commit 8f58a64bc8
12 changed files with 497 additions and 11 deletions

View File

@ -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)

View File

@ -29,15 +29,21 @@ Wrapper stores merchant token.
`POST /api/v1/uber/menu/upsert` `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=...` `GET /api/v1/uber/orders?merchantId=...&storeId=...`
## 6. Receive Webhooks ## 7. Receive Webhooks
`POST /api/v1/webhooks/uber` `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` `POST /api/v1/uber/request`
@ -49,4 +55,3 @@ Wrapper stores merchant token.
"body": {} "body": {}
} }
``` ```

View File

@ -5,7 +5,8 @@ Flow:
1. Create merchant in wrapper 1. Create merchant in wrapper
2. Generate Uber OAuth URL 2. Generate Uber OAuth URL
3. Merchant authorizes Uber account 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. Multi-client principle: each merchant has isolated credentials and mappings.

View File

@ -14,3 +14,14 @@ Acknowledgement behavior:
- Valid webhook events are acknowledged with `200` and empty body - Valid webhook events are acknowledged with `200` and empty body
- Duplicate retries are de-duplicated and still acknowledged with `200` - 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`

View File

@ -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": { "/api/v1/uber/orders/{orderId}/action": {
"post": { "post": {
"summary": "Trigger order action (accept, deny, ready, cancel)", "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": { "/api/v1/webhooks/uber": {
"post": { "post": {
"summary": "Ingest Uber webhook events", "summary": "Ingest Uber webhook events",

View File

@ -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)", "name": "Webhook Ingest (Simulation)",
"request": { "request": {

View File

@ -12,12 +12,13 @@ module.exports = {
cancel: "/v1/eats/orders/{orderId}/cancel" cancel: "/v1/eats/orders/{orderId}/cancel"
}, },
stores: { stores: {
list: "/v1/eats/stores",
getById: "/v1/eats/stores/{storeId}", getById: "/v1/eats/stores/{storeId}",
updateHours: "/v1/eats/stores/{storeId}/hours", 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: { webhooks: {
events: "/v1/eats/stores/{storeId}/event_feed" events: "/v1/eats/stores/{storeId}/event_feed"
} }
}; };

View File

@ -83,6 +83,22 @@ const uberConnectionRepository = {
return db.prepare("SELECT * FROM uber_connections WHERE merchant_id = ?").get(merchantId); 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() { list() {
return db return db
.prepare(` .prepare(`

View File

@ -103,6 +103,58 @@ async function updateHours(req, res) {
return res.json({ success: true, data }); 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 = { module.exports = {
genericProxy, genericProxy,
upsertMenu, upsertMenu,
@ -110,5 +162,9 @@ module.exports = {
getMenu, getMenu,
listOrders, listOrders,
orderAction, orderAction,
updateHours updateHours,
listProvisionableStores,
createPosData,
patchPosData,
deletePosData
}; };

View File

@ -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 = { module.exports = {
genericProxy, genericProxy,
menuUpsert, menuUpsert,
@ -210,5 +260,9 @@ module.exports = {
menuGet, menuGet,
ordersList, ordersList,
orderAction, orderAction,
updateStoreHours updateStoreHours,
listProvisionableStores,
createPosData,
patchPosData,
deletePosData
}; };

View File

@ -1,6 +1,6 @@
const crypto = require("crypto"); const crypto = require("crypto");
const env = require("../../config/env"); const env = require("../../config/env");
const { webhookRepository } = require("../../db/adapter"); const { webhookRepository, uberConnectionRepository } = require("../../db/adapter");
function getSignatureFromHeaders(headers) { function getSignatureFromHeaders(headers) {
const signature = headers["x-uber-signature"]; const signature = headers["x-uber-signature"];
@ -65,6 +65,28 @@ 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({
@ -104,6 +126,8 @@ async function handleUberWebhook(req, res) {
headersJson: req.headers headersJson: req.headers
}); });
applyProvisioningStateFromWebhook(eventType, req.body || {});
return res.status(200).end(); return res.status(200).end();
} }

View File

@ -69,6 +69,25 @@ router.get("/uber/menu", asyncHandler(controller.getMenu));
*/ */
router.get("/uber/orders", asyncHandler(controller.listOrders)); 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 * @openapi
* /api/v1/uber/orders/{orderId}/action: * /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)); 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; module.exports = router;