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`
## 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": {}
}
```

View File

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

View File

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

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": {
"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",

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)",
"request": {

View File

@ -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"
}
};

View File

@ -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(`

View File

@ -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
};

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

View File

@ -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();
}

View File

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