chore: bootstrap uber eats wrapper scaffold with docs and openapi
This commit is contained in:
commit
7de04ab4e0
16
.env.example
Normal file
16
.env.example
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Uber app credentials
|
||||||
|
UBER_CLIENT_ID=your_client_id
|
||||||
|
UBER_CLIENT_SECRET=your_client_secret
|
||||||
|
UBER_REDIRECT_URI=http://localhost:8080/api/v1/auth/uber/callback
|
||||||
|
UBER_OAUTH_BASE_URL=https://login.uber.com
|
||||||
|
UBER_API_BASE_URL=https://api.uber.com
|
||||||
|
|
||||||
|
# SQLite database path
|
||||||
|
SQLITE_PATH=./data/uber_wrapper.db
|
||||||
|
|
||||||
|
# Shared API key for wrapper clients (optional but recommended)
|
||||||
|
WRAPPER_API_KEY=change-me
|
||||||
|
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
76
README.md
Normal file
76
README.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Uber Wrapper (Node.js + Express + SQLite)
|
||||||
|
|
||||||
|
Generic multi-merchant Uber Eats API wrapper for POS integrations.
|
||||||
|
|
||||||
|
## What this gives you
|
||||||
|
|
||||||
|
- Multi-merchant model (each restaurant connects its own Uber account)
|
||||||
|
- OAuth flow support and manual token storage support
|
||||||
|
- Generic passthrough endpoint to cover all Uber APIs
|
||||||
|
- Business shortcuts for common menu/order/store flows
|
||||||
|
- Webhook ingestion endpoint
|
||||||
|
- SQLite persistence with adapter boundary for future MySQL/Postgres migration
|
||||||
|
- Swagger UI docs + Postman collection
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copy .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Open docs
|
||||||
|
|
||||||
|
- Swagger UI: `http://localhost:8080/docs`
|
||||||
|
- Health: `http://localhost:8080/health`
|
||||||
|
|
||||||
|
## Folder Structure
|
||||||
|
|
||||||
|
- `src/` application source
|
||||||
|
- `src/db/adapter.js` database abstraction boundary
|
||||||
|
- `docs/developer-portal/` human-friendly docs
|
||||||
|
- `docs/openapi/` exported OpenAPI artifacts
|
||||||
|
- `postman/` Postman collection
|
||||||
|
|
||||||
|
## Database Migration Strategy
|
||||||
|
|
||||||
|
All controllers/services consume repositories through `src/db/adapter.js`.
|
||||||
|
To switch from SQLite to MySQL/Postgres later:
|
||||||
|
|
||||||
|
1. Implement repository methods for new DB backend.
|
||||||
|
2. Replace exports in `src/db/adapter.js`.
|
||||||
|
3. Keep controllers/routes unchanged.
|
||||||
|
|
||||||
|
## Scope (Current Phase)
|
||||||
|
|
||||||
|
Current implementation focus is **Uber Eats Marketplace APIs only**:
|
||||||
|
|
||||||
|
- Authentication (OAuth)
|
||||||
|
- Stores
|
||||||
|
- Menus
|
||||||
|
- Orders
|
||||||
|
- Marketplace webhooks
|
||||||
|
|
||||||
|
Uber Direct is intentionally deferred to a later phase.
|
||||||
|
|
||||||
|
## Important Note About Endpoint Coverage
|
||||||
|
|
||||||
|
This wrapper includes:
|
||||||
|
|
||||||
|
- A generic endpoint (`POST /api/v1/uber/request`) for all current/future **Uber Eats** endpoints.
|
||||||
|
- Pre-built shortcuts for high-frequency flows (menu/orders/store hours).
|
||||||
|
|
||||||
|
When you finalize your Uber docs copy, update `src/config/uberEndpoints.js` with exact endpoint paths from your approved Uber docs.
|
||||||
20
docs/developer-portal/01-overview.md
Normal file
20
docs/developer-portal/01-overview.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Uber Wrapper Developer Portal
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Create one generic POS-facing wrapper that can integrate with Uber for many restaurants, each with its own Uber OAuth connection.
|
||||||
|
|
||||||
|
## Architecture Summary
|
||||||
|
|
||||||
|
1. Merchant is created in wrapper.
|
||||||
|
2. Merchant connects Uber account via OAuth.
|
||||||
|
3. Wrapper stores tokens and proxies requests to Uber APIs.
|
||||||
|
4. POS uses wrapper APIs only.
|
||||||
|
5. Uber webhooks are ingested by wrapper and routed to internal logic.
|
||||||
|
|
||||||
|
## Key Benefits
|
||||||
|
|
||||||
|
- Your POS is isolated from Uber API complexity.
|
||||||
|
- You can add custom business logic centrally.
|
||||||
|
- You can support many merchants with one standard integration path.
|
||||||
|
|
||||||
31
docs/developer-portal/02-api-groups.md
Normal file
31
docs/developer-portal/02-api-groups.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# API Groups
|
||||||
|
|
||||||
|
This file intentionally separates high-priority vs extended APIs for easier team onboarding.
|
||||||
|
|
||||||
|
## Group A (Important for POS Core)
|
||||||
|
|
||||||
|
- Merchant management
|
||||||
|
- OAuth connection flow
|
||||||
|
- Menu upsert/get
|
||||||
|
- Order pull/list
|
||||||
|
- Order actions (accept/deny/ready/cancel)
|
||||||
|
- Store hours updates
|
||||||
|
- Uber webhook ingestion
|
||||||
|
|
||||||
|
## Group B (Extended / Optional / Can Be Added Incrementally)
|
||||||
|
|
||||||
|
- Promotions
|
||||||
|
- Ads / sponsored listings
|
||||||
|
- Payout and financial reconciliation
|
||||||
|
- Store holiday/special schedules
|
||||||
|
- Catalog media sync
|
||||||
|
- Audit/event feed replay
|
||||||
|
|
||||||
|
## Group C (Generic Catch-All)
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `POST /api/v1/uber/request`
|
||||||
|
|
||||||
|
This endpoint is the fallback for any Uber endpoint that is not yet added as a typed shortcut route.
|
||||||
|
|
||||||
9
docs/developer-portal/02-auth-oauth.md
Normal file
9
docs/developer-portal/02-auth-oauth.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 02 Auth OAuth
|
||||||
|
|
||||||
|
Focus: Uber Eats OAuth 2.0 per merchant (multi-client onboarding).
|
||||||
|
|
||||||
|
- Generate authorize URL by merchant
|
||||||
|
- Handle callback and store tokens
|
||||||
|
- Refresh token flow
|
||||||
|
- Token expiry handling policy
|
||||||
|
|
||||||
52
docs/developer-portal/03-integration-flow.md
Normal file
52
docs/developer-portal/03-integration-flow.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Integration Flow
|
||||||
|
|
||||||
|
## 1. Create Merchant
|
||||||
|
|
||||||
|
`POST /api/v1/merchants`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "My Restaurant",
|
||||||
|
"externalRef": "pos_store_101"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Get OAuth URL
|
||||||
|
|
||||||
|
`GET /api/v1/auth/uber/authorize-url?merchantId=<merchant_id>`
|
||||||
|
|
||||||
|
Redirect merchant to returned URL.
|
||||||
|
|
||||||
|
## 3. OAuth Callback
|
||||||
|
|
||||||
|
Uber redirects to:
|
||||||
|
|
||||||
|
`GET /api/v1/auth/uber/callback?code=...&state=...`
|
||||||
|
|
||||||
|
Wrapper stores merchant token.
|
||||||
|
|
||||||
|
## 4. Push Menu
|
||||||
|
|
||||||
|
`POST /api/v1/uber/menu/upsert`
|
||||||
|
|
||||||
|
## 5. Pull Orders
|
||||||
|
|
||||||
|
`GET /api/v1/uber/orders?merchantId=...&storeId=...`
|
||||||
|
|
||||||
|
## 6. Receive Webhooks
|
||||||
|
|
||||||
|
`POST /api/v1/webhooks/uber`
|
||||||
|
|
||||||
|
## 7. Use Generic API for Any Missing Endpoint
|
||||||
|
|
||||||
|
`POST /api/v1/uber/request`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"merchantId": "<merchant_id>",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v1/eats/some/new/uber/endpoint",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
11
docs/developer-portal/03-merchant-onboarding.md
Normal file
11
docs/developer-portal/03-merchant-onboarding.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# 03 Merchant Onboarding
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. Create merchant in wrapper
|
||||||
|
2. Generate Uber OAuth URL
|
||||||
|
3. Merchant authorizes Uber account
|
||||||
|
4. Callback stores tokens + store identifiers
|
||||||
|
|
||||||
|
Multi-client principle: each merchant has isolated credentials and mappings.
|
||||||
|
|
||||||
25
docs/developer-portal/04-extended-api-catalog-template.md
Normal file
25
docs/developer-portal/04-extended-api-catalog-template.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Extended API Catalog Template
|
||||||
|
|
||||||
|
Use this file to paste the full Uber documentation mappings that are not in core POS flows.
|
||||||
|
|
||||||
|
## How to document each endpoint
|
||||||
|
|
||||||
|
1. Wrapper route
|
||||||
|
2. Uber upstream route
|
||||||
|
3. HTTP method
|
||||||
|
4. Required merchant/store identifiers
|
||||||
|
5. Request payload schema
|
||||||
|
6. Response schema
|
||||||
|
7. Retry/idempotency behavior
|
||||||
|
8. Error mapping
|
||||||
|
|
||||||
|
## Suggested Sections
|
||||||
|
|
||||||
|
- Catalog and item metadata
|
||||||
|
- Option groups and modifier sets
|
||||||
|
- Promotions and pricing campaigns
|
||||||
|
- Financial, payouts, settlements
|
||||||
|
- Delivery fulfillment edge cases
|
||||||
|
- Store operational settings
|
||||||
|
- Reporting and analytics data pulls
|
||||||
|
|
||||||
9
docs/developer-portal/04-stores.md
Normal file
9
docs/developer-portal/04-stores.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 04 Stores
|
||||||
|
|
||||||
|
Uber Eats store-focused integrations:
|
||||||
|
|
||||||
|
- Store details retrieval
|
||||||
|
- Store status
|
||||||
|
- Holiday/special hours
|
||||||
|
- Operational metadata sync
|
||||||
|
|
||||||
9
docs/developer-portal/05-menus.md
Normal file
9
docs/developer-portal/05-menus.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 05 Menus
|
||||||
|
|
||||||
|
Menu sync between POS and Uber Eats:
|
||||||
|
|
||||||
|
- Upload/replace menu
|
||||||
|
- Fetch menu from Uber
|
||||||
|
- Item and modifier mapping strategy
|
||||||
|
- Validation and publish error handling
|
||||||
|
|
||||||
10
docs/developer-portal/06-orders.md
Normal file
10
docs/developer-portal/06-orders.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# 06 Orders
|
||||||
|
|
||||||
|
Order flow for POS:
|
||||||
|
|
||||||
|
- Receive order event
|
||||||
|
- Fetch full order payload
|
||||||
|
- Accept/deny
|
||||||
|
- Ready/handoff updates
|
||||||
|
- Completion/cancellation reconciliation
|
||||||
|
|
||||||
11
docs/developer-portal/07-webhooks.md
Normal file
11
docs/developer-portal/07-webhooks.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# 07 Webhooks
|
||||||
|
|
||||||
|
Webhook design goals:
|
||||||
|
|
||||||
|
- Multi-merchant event routing
|
||||||
|
- Idempotent processing
|
||||||
|
- Retry-safe handlers
|
||||||
|
- Event logging and replay support
|
||||||
|
|
||||||
|
Current ingestion endpoint: `POST /api/v1/webhooks/uber`
|
||||||
|
|
||||||
8
docs/developer-portal/08-delivery-status.md
Normal file
8
docs/developer-portal/08-delivery-status.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 08 Delivery Status
|
||||||
|
|
||||||
|
Delivery-related updates consumed from Uber Eats order/webhook lifecycle:
|
||||||
|
|
||||||
|
- Courier assignment milestones
|
||||||
|
- Pickup and dropoff transitions
|
||||||
|
- Final completion state mapping
|
||||||
|
|
||||||
9
docs/developer-portal/09-errors-retries.md
Normal file
9
docs/developer-portal/09-errors-retries.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 09 Errors Retries
|
||||||
|
|
||||||
|
Standards:
|
||||||
|
|
||||||
|
- Normalize Uber error responses
|
||||||
|
- Define retryable vs non-retryable errors
|
||||||
|
- Enforce idempotent writes where possible
|
||||||
|
- Capture request/response logs for debugging
|
||||||
|
|
||||||
10
docs/developer-portal/10-sandbox-testing.md
Normal file
10
docs/developer-portal/10-sandbox-testing.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# 10 Sandbox Testing
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
|
||||||
|
- Test OAuth connect flow
|
||||||
|
- Test menu upload/get
|
||||||
|
- Test order lifecycle actions
|
||||||
|
- Test webhook receipt and persistence
|
||||||
|
- Validate retry and duplicate event handling
|
||||||
|
|
||||||
9
docs/developer-portal/11-production-go-live.md
Normal file
9
docs/developer-portal/11-production-go-live.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 11 Production Go Live
|
||||||
|
|
||||||
|
Go-live readiness:
|
||||||
|
|
||||||
|
- Production OAuth credentials configured
|
||||||
|
- Webhook URL reachable over HTTPS
|
||||||
|
- Alerting and log monitoring enabled
|
||||||
|
- Token refresh and failure runbooks ready
|
||||||
|
|
||||||
9
docs/developer-portal/12-sdk-wrapper.md
Normal file
9
docs/developer-portal/12-sdk-wrapper.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 12 SDK Wrapper
|
||||||
|
|
||||||
|
Internal SDK goals:
|
||||||
|
|
||||||
|
- Stable wrapper methods for POS services
|
||||||
|
- Central auth/token handling
|
||||||
|
- Typed request/response contracts
|
||||||
|
- Consistent retries and error translation
|
||||||
|
|
||||||
9
docs/developer-portal/13-pos-mapping-spec.md
Normal file
9
docs/developer-portal/13-pos-mapping-spec.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 13 POS Mapping Spec
|
||||||
|
|
||||||
|
Define canonical mappings:
|
||||||
|
|
||||||
|
- POS store ID <-> Uber store ID
|
||||||
|
- POS item/modifier IDs <-> Uber external IDs
|
||||||
|
- POS order status <-> Uber order state
|
||||||
|
- POS tax/fees fields <-> Uber charge lines
|
||||||
|
|
||||||
9
docs/developer-portal/14-openapi-swagger.md
Normal file
9
docs/developer-portal/14-openapi-swagger.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 14 OpenAPI Swagger
|
||||||
|
|
||||||
|
Docs sources:
|
||||||
|
|
||||||
|
- Live docs: `/docs`
|
||||||
|
- Static export: `docs/openapi/openapi.json`
|
||||||
|
|
||||||
|
Update route annotations whenever endpoints are added/changed.
|
||||||
|
|
||||||
12
docs/developer-portal/15-postman.md
Normal file
12
docs/developer-portal/15-postman.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# 15 Postman
|
||||||
|
|
||||||
|
Collection file:
|
||||||
|
|
||||||
|
- `postman/Uber_Wrapper.postman_collection.json`
|
||||||
|
|
||||||
|
Keep environments for:
|
||||||
|
|
||||||
|
- local
|
||||||
|
- sandbox
|
||||||
|
- production
|
||||||
|
|
||||||
8
docs/openapi/README.md
Normal file
8
docs/openapi/README.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# OpenAPI Export
|
||||||
|
|
||||||
|
Live OpenAPI spec is served from route annotations at runtime:
|
||||||
|
|
||||||
|
- `GET /docs`
|
||||||
|
|
||||||
|
If you need a static JSON export for CI or portal import, generate it from `src/docs/swagger.js` in your build script.
|
||||||
|
|
||||||
251
docs/openapi/openapi.json
Normal file
251
docs/openapi/openapi.json
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "Uber Wrapper API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Generic multi-merchant Uber API wrapper for POS platforms"
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8080"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"ApiKeyAuth": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"in": "header",
|
||||||
|
"name": "x-api-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/api/v1/auth/uber/authorize-url": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Generate Uber OAuth authorize URL for merchant",
|
||||||
|
"tags": [
|
||||||
|
"Auth"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "merchantId",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OAuth URL generated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/auth/uber/callback": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Uber OAuth callback endpoint",
|
||||||
|
"tags": [
|
||||||
|
"Auth"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Uber account connected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/auth/uber/{merchantId}/refresh-token": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Refresh Uber token for merchant",
|
||||||
|
"tags": [
|
||||||
|
"Auth"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "merchantId",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Token refreshed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/merchants": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Create or update merchant",
|
||||||
|
"tags": [
|
||||||
|
"Merchants"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Merchant upserted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"summary": "List merchants",
|
||||||
|
"tags": [
|
||||||
|
"Merchants"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Merchant list"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/connections/uber": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Save manual Uber tokens for merchant",
|
||||||
|
"tags": [
|
||||||
|
"Connections"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Connection stored"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"summary": "List Uber connections",
|
||||||
|
"tags": [
|
||||||
|
"Connections"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Connection list"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Health check",
|
||||||
|
"tags": [
|
||||||
|
"Health"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Service is healthy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uber/request": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Generic Uber passthrough for any Uber endpoint",
|
||||||
|
"tags": [
|
||||||
|
"Uber Generic"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Uber response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uber/menu/upsert": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Upsert store menu",
|
||||||
|
"tags": [
|
||||||
|
"Uber Menu"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Menu upserted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uber/menu": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Fetch store menu",
|
||||||
|
"tags": [
|
||||||
|
"Uber Menu"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Menu fetched"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uber/orders": {
|
||||||
|
"get": {
|
||||||
|
"summary": "List store orders",
|
||||||
|
"tags": [
|
||||||
|
"Uber Orders"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Orders fetched"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uber/orders/{orderId}/action": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Trigger order action (accept, deny, ready, cancel)",
|
||||||
|
"tags": [
|
||||||
|
"Uber Orders"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "orderId",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Order action sent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uber/stores/hours": {
|
||||||
|
"put": {
|
||||||
|
"summary": "Update store hours",
|
||||||
|
"tags": [
|
||||||
|
"Uber Stores"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Store hours updated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/webhooks/uber": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Ingest Uber webhook events",
|
||||||
|
"tags": [
|
||||||
|
"Webhooks"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "Webhook accepted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
7
docs/optional-direct-delivery/README.md
Normal file
7
docs/optional-direct-delivery/README.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Optional Direct Delivery (Later)
|
||||||
|
|
||||||
|
This folder is intentionally separate from Uber Eats Marketplace scope.
|
||||||
|
|
||||||
|
Do not implement Uber Direct in current phase.
|
||||||
|
Add Uber Direct documentation and endpoints here only when Uber Eats scope is complete.
|
||||||
|
|
||||||
2209
package-lock.json
generated
Normal file
2209
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "uber_wrapper",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Generic multi-merchant Uber API wrapper for POS integrations",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"openapi:export": "node scripts/export-openapi.js",
|
||||||
|
"test": "echo \"No tests configured yet\""
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.14.0",
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
138
postman/Uber_Wrapper.postman_collection.json
Normal file
138
postman/Uber_Wrapper.postman_collection.json
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"_postman_id": "3f2bc407-ec9d-4eef-b77d-74359eb281fe",
|
||||||
|
"name": "Uber Wrapper",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Health",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/health",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"health"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create Merchant",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-api-key",
|
||||||
|
"value": "{{apiKey}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"Demo Restaurant\",\n \"externalRef\": \"pos_001\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/merchants",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"merchants"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get OAuth URL",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-api-key",
|
||||||
|
"value": "{{apiKey}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/auth/uber/authorize-url?merchantId={{merchantId}}",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"auth",
|
||||||
|
"uber",
|
||||||
|
"authorize-url"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "merchantId",
|
||||||
|
"value": "{{merchantId}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Generic Uber Request",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-api-key",
|
||||||
|
"value": "{{apiKey}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"method\": \"GET\",\n \"path\": \"/v1/eats/stores/{{storeId}}/orders\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/uber/request",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"uber",
|
||||||
|
"request"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "baseUrl",
|
||||||
|
"value": "http://localhost:8080"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "apiKey",
|
||||||
|
"value": "change-me"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "merchantId",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "storeId",
|
||||||
|
"value": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
16
scripts/export-openapi.js
Normal file
16
scripts/export-openapi.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const spec = require("../src/docs/swagger");
|
||||||
|
|
||||||
|
const outputDir = path.resolve(process.cwd(), "docs", "openapi");
|
||||||
|
const outputFile = path.join(outputDir, "openapi.json");
|
||||||
|
|
||||||
|
if (!fs.existsSync(outputDir)) {
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(outputFile, JSON.stringify(spec, null, 2), "utf8");
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`OpenAPI exported to ${outputFile}`);
|
||||||
|
|
||||||
34
src/app.js
Normal file
34
src/app.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const express = require("express");
|
||||||
|
const helmet = require("helmet");
|
||||||
|
const cors = require("cors");
|
||||||
|
const morgan = require("morgan");
|
||||||
|
const swaggerUi = require("swagger-ui-express");
|
||||||
|
const spec = require("./docs/swagger");
|
||||||
|
const { requestContext, requireWrapperApiKey } = require("./middleware/requestContext");
|
||||||
|
const errorHandler = require("./middleware/errorHandler");
|
||||||
|
const healthRoutes = require("./routes/health.routes");
|
||||||
|
const authRoutes = require("./routes/auth.routes");
|
||||||
|
const connectionRoutes = require("./routes/connections.routes");
|
||||||
|
const proxyRoutes = require("./routes/proxy.routes");
|
||||||
|
const webhookRoutes = require("./routes/webhooks.routes");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: "5mb" }));
|
||||||
|
app.use(morgan("combined"));
|
||||||
|
app.use(requestContext);
|
||||||
|
|
||||||
|
app.use("/health", healthRoutes);
|
||||||
|
app.use("/api/v1/auth", requireWrapperApiKey, authRoutes);
|
||||||
|
app.use("/api/v1", requireWrapperApiKey, connectionRoutes);
|
||||||
|
app.use("/api/v1", requireWrapperApiKey, proxyRoutes);
|
||||||
|
app.use("/api/v1", webhookRoutes);
|
||||||
|
app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec));
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
|
|
||||||
27
src/config/env.js
Normal file
27
src/config/env.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const path = require("path");
|
||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.string().default("development"),
|
||||||
|
PORT: z.coerce.number().default(8080),
|
||||||
|
UBER_CLIENT_ID: z.string().min(1),
|
||||||
|
UBER_CLIENT_SECRET: z.string().min(1),
|
||||||
|
UBER_REDIRECT_URI: z.string().url(),
|
||||||
|
UBER_OAUTH_BASE_URL: z.string().url().default("https://login.uber.com"),
|
||||||
|
UBER_API_BASE_URL: z.string().url().default("https://api.uber.com"),
|
||||||
|
SQLITE_PATH: z.string().default("./data/uber_wrapper.db"),
|
||||||
|
WRAPPER_API_KEY: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = envSchema.safeParse(process.env);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
const issues = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
|
||||||
|
throw new Error(`Invalid environment variables:\n${issues.join("\n")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = parsed.data;
|
||||||
|
env.SQLITE_PATH = path.resolve(process.cwd(), env.SQLITE_PATH);
|
||||||
|
|
||||||
|
module.exports = env;
|
||||||
|
|
||||||
23
src/config/uberEndpoints.js
Normal file
23
src/config/uberEndpoints.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
module.exports = {
|
||||||
|
menu: {
|
||||||
|
upsert: "/v1/eats/stores/{storeId}/menus",
|
||||||
|
get: "/v1/eats/stores/{storeId}/menus"
|
||||||
|
},
|
||||||
|
orders: {
|
||||||
|
list: "/v1/eats/stores/{storeId}/orders",
|
||||||
|
getById: "/v1/eats/orders/{orderId}",
|
||||||
|
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",
|
||||||
|
cancel: "/v1/eats/orders/{orderId}/cancel"
|
||||||
|
},
|
||||||
|
stores: {
|
||||||
|
getById: "/v1/eats/stores/{storeId}",
|
||||||
|
updateHours: "/v1/eats/stores/{storeId}/hours",
|
||||||
|
inventory: "/v1/eats/stores/{storeId}/inventory"
|
||||||
|
},
|
||||||
|
webhooks: {
|
||||||
|
events: "/v1/eats/stores/{storeId}/event_feed"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
14
src/db/adapter.js
Normal file
14
src/db/adapter.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
const repositories = require("./repositories");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database adapter boundary.
|
||||||
|
* Replace exports here later with MySQL/Postgres implementations
|
||||||
|
* without changing route/controller code.
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
merchantRepository: repositories.merchantRepository,
|
||||||
|
uberConnectionRepository: repositories.uberConnectionRepository,
|
||||||
|
webhookRepository: repositories.webhookRepository,
|
||||||
|
apiLogRepository: repositories.apiLogRepository
|
||||||
|
};
|
||||||
|
|
||||||
160
src/db/repositories.js
Normal file
160
src/db/repositories.js
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
const { v4: uuidv4 } = require("uuid");
|
||||||
|
const { db } = require("./sqlite");
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const merchantRepository = {
|
||||||
|
upsert({ id, name, externalRef }) {
|
||||||
|
const timestamp = nowIso();
|
||||||
|
const merchantId = id || uuidv4();
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO merchants (id, name, external_ref, created_at, updated_at)
|
||||||
|
VALUES (@id, @name, @external_ref, @created_at, @updated_at)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
external_ref = excluded.external_ref,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`);
|
||||||
|
stmt.run({
|
||||||
|
id: merchantId,
|
||||||
|
name,
|
||||||
|
external_ref: externalRef || null,
|
||||||
|
created_at: timestamp,
|
||||||
|
updated_at: timestamp
|
||||||
|
});
|
||||||
|
return this.findById(merchantId);
|
||||||
|
},
|
||||||
|
|
||||||
|
findById(id) {
|
||||||
|
return db.prepare("SELECT * FROM merchants WHERE id = ?").get(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return db.prepare("SELECT * FROM merchants ORDER BY created_at DESC").all();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uberConnectionRepository = {
|
||||||
|
upsertByMerchantId(merchantId, payload) {
|
||||||
|
const existing = this.findByMerchantId(merchantId);
|
||||||
|
const timestamp = nowIso();
|
||||||
|
const row = {
|
||||||
|
id: existing?.id || uuidv4(),
|
||||||
|
merchant_id: merchantId,
|
||||||
|
uber_user_id: payload.uberUserId || existing?.uber_user_id || null,
|
||||||
|
uber_store_id: payload.uberStoreId || existing?.uber_store_id || null,
|
||||||
|
access_token: payload.accessToken || existing?.access_token,
|
||||||
|
refresh_token: payload.refreshToken ?? existing?.refresh_token ?? null,
|
||||||
|
token_type: payload.tokenType ?? existing?.token_type ?? "Bearer",
|
||||||
|
scope: payload.scope ?? existing?.scope ?? null,
|
||||||
|
expires_at: payload.expiresAt ?? existing?.expires_at ?? null,
|
||||||
|
status: payload.status ?? existing?.status ?? "active",
|
||||||
|
created_at: existing?.created_at || timestamp,
|
||||||
|
updated_at: timestamp
|
||||||
|
};
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO uber_connections (
|
||||||
|
id, merchant_id, uber_user_id, uber_store_id, access_token, refresh_token,
|
||||||
|
token_type, scope, expires_at, status, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@id, @merchant_id, @uber_user_id, @uber_store_id, @access_token, @refresh_token,
|
||||||
|
@token_type, @scope, @expires_at, @status, @created_at, @updated_at
|
||||||
|
)
|
||||||
|
ON CONFLICT(merchant_id) DO UPDATE SET
|
||||||
|
uber_user_id = excluded.uber_user_id,
|
||||||
|
uber_store_id = excluded.uber_store_id,
|
||||||
|
access_token = excluded.access_token,
|
||||||
|
refresh_token = excluded.refresh_token,
|
||||||
|
token_type = excluded.token_type,
|
||||||
|
scope = excluded.scope,
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
status = excluded.status,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`);
|
||||||
|
stmt.run(row);
|
||||||
|
return this.findByMerchantId(merchantId);
|
||||||
|
},
|
||||||
|
|
||||||
|
findByMerchantId(merchantId) {
|
||||||
|
return db.prepare("SELECT * FROM uber_connections WHERE merchant_id = ?").get(merchantId);
|
||||||
|
},
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return db
|
||||||
|
.prepare(`
|
||||||
|
SELECT uc.*, m.name AS merchant_name
|
||||||
|
FROM uber_connections uc
|
||||||
|
JOIN merchants m ON m.id = uc.merchant_id
|
||||||
|
ORDER BY uc.created_at DESC
|
||||||
|
`)
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const webhookRepository = {
|
||||||
|
insert({ provider, merchantId, eventType, payloadJson, headersJson }) {
|
||||||
|
const row = {
|
||||||
|
id: uuidv4(),
|
||||||
|
provider,
|
||||||
|
merchant_id: merchantId || null,
|
||||||
|
event_type: eventType || null,
|
||||||
|
payload_json: JSON.stringify(payloadJson || {}),
|
||||||
|
headers_json: JSON.stringify(headersJson || {}),
|
||||||
|
received_at: nowIso(),
|
||||||
|
processing_status: "received"
|
||||||
|
};
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO webhook_events (
|
||||||
|
id, provider, merchant_id, event_type, payload_json, headers_json,
|
||||||
|
received_at, processing_status
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@id, @provider, @merchant_id, @event_type, @payload_json, @headers_json,
|
||||||
|
@received_at, @processing_status
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
stmt.run(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiLogRepository = {
|
||||||
|
insert({ merchantId, method, wrapperRoute, uberPath, responseStatus, requestBody, responseBody }) {
|
||||||
|
const row = {
|
||||||
|
id: uuidv4(),
|
||||||
|
merchant_id: merchantId || null,
|
||||||
|
http_method: method,
|
||||||
|
wrapper_route: wrapperRoute,
|
||||||
|
uber_path: uberPath,
|
||||||
|
response_status: responseStatus,
|
||||||
|
request_json: JSON.stringify(requestBody || {}),
|
||||||
|
response_json: JSON.stringify(responseBody || {}),
|
||||||
|
created_at: nowIso()
|
||||||
|
};
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO api_logs (
|
||||||
|
id, merchant_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,
|
||||||
|
@response_status, @request_json, @response_json, @created_at
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
stmt.run(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
merchantRepository,
|
||||||
|
uberConnectionRepository,
|
||||||
|
webhookRepository,
|
||||||
|
apiLogRepository
|
||||||
|
};
|
||||||
|
|
||||||
75
src/db/sqlite.js
Normal file
75
src/db/sqlite.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
const env = require("../config/env");
|
||||||
|
|
||||||
|
const dbDir = path.dirname(env.SQLITE_PATH);
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(env.SQLITE_PATH);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
|
||||||
|
function initSchema() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS merchants (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
external_ref TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uber_connections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
merchant_id TEXT NOT NULL,
|
||||||
|
uber_user_id TEXT,
|
||||||
|
uber_store_id TEXT,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
token_type TEXT,
|
||||||
|
scope TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_uber_connections_merchant
|
||||||
|
ON uber_connections(merchant_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webhook_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
merchant_id TEXT,
|
||||||
|
event_type TEXT,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
headers_json TEXT,
|
||||||
|
received_at TEXT NOT NULL,
|
||||||
|
processed_at TEXT,
|
||||||
|
processing_status TEXT NOT NULL DEFAULT 'received',
|
||||||
|
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
merchant_id TEXT,
|
||||||
|
http_method TEXT NOT NULL,
|
||||||
|
wrapper_route TEXT NOT NULL,
|
||||||
|
uber_path TEXT NOT NULL,
|
||||||
|
response_status INTEGER,
|
||||||
|
request_json TEXT,
|
||||||
|
response_json TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
db,
|
||||||
|
initSchema
|
||||||
|
};
|
||||||
|
|
||||||
32
src/docs/swagger.js
Normal file
32
src/docs/swagger.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const swaggerJsdoc = require("swagger-jsdoc");
|
||||||
|
const env = require("../config/env");
|
||||||
|
|
||||||
|
const spec = swaggerJsdoc({
|
||||||
|
definition: {
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: {
|
||||||
|
title: "Uber Wrapper API",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "Generic multi-merchant Uber API wrapper for POS platforms"
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: `http://localhost:${env.PORT}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
ApiKeyAuth: {
|
||||||
|
type: "apiKey",
|
||||||
|
in: "header",
|
||||||
|
name: "x-api-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
security: [{ ApiKeyAuth: [] }]
|
||||||
|
},
|
||||||
|
apis: ["./src/routes/*.js"]
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = spec;
|
||||||
|
|
||||||
10
src/middleware/asyncHandler.js
Normal file
10
src/middleware/asyncHandler.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
module.exports = function asyncHandler(fn) {
|
||||||
|
return async function wrapped(req, res, next) {
|
||||||
|
try {
|
||||||
|
await fn(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
20
src/middleware/errorHandler.js
Normal file
20
src/middleware/errorHandler.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
module.exports = function errorHandler(err, req, res, next) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
const payload = {
|
||||||
|
success: false,
|
||||||
|
message: err.message || "Internal server error",
|
||||||
|
requestId: req.requestId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
payload.stack = err.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(status).json(payload);
|
||||||
|
};
|
||||||
|
|
||||||
30
src/middleware/requestContext.js
Normal file
30
src/middleware/requestContext.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
const { v4: uuidv4 } = require("uuid");
|
||||||
|
const env = require("../config/env");
|
||||||
|
|
||||||
|
function requestContext(req, res, next) {
|
||||||
|
req.requestId = req.headers["x-request-id"] || uuidv4();
|
||||||
|
res.setHeader("x-request-id", req.requestId);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireWrapperApiKey(req, res, next) {
|
||||||
|
if (!env.WRAPPER_API_KEY) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = req.headers["x-api-key"];
|
||||||
|
if (apiKey !== env.WRAPPER_API_KEY) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "Invalid API key"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
requestContext,
|
||||||
|
requireWrapperApiKey
|
||||||
|
};
|
||||||
|
|
||||||
92
src/modules/auth/auth.controller.js
Normal file
92
src/modules/auth/auth.controller.js
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
const { merchantRepository, uberConnectionRepository } = require("../../db/adapter");
|
||||||
|
const { buildAuthorizeUrl, exchangeCodeForToken, refreshToken } = require("./auth.service");
|
||||||
|
|
||||||
|
function parseExpiresAt(expiresInSeconds) {
|
||||||
|
if (!expiresInSeconds) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Date(Date.now() + Number(expiresInSeconds) * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthorizeUrl(req, res) {
|
||||||
|
const { merchantId, scope } = req.query;
|
||||||
|
if (!merchantId) {
|
||||||
|
return res.status(400).json({ success: false, message: "merchantId query param is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const merchant = merchantRepository.findById(merchantId);
|
||||||
|
if (!merchant) {
|
||||||
|
return res.status(404).json({ success: false, message: "Merchant not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = Buffer.from(JSON.stringify({ merchantId })).toString("base64url");
|
||||||
|
const url = buildAuthorizeUrl({ state, scope });
|
||||||
|
return res.json({ success: true, data: { url } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function oauthCallback(req, res) {
|
||||||
|
const { code, state } = req.query;
|
||||||
|
if (!code || !state) {
|
||||||
|
return res.status(400).json({ success: false, message: "code and state are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedState = JSON.parse(Buffer.from(state, "base64url").toString("utf8"));
|
||||||
|
const merchant = merchantRepository.findById(decodedState.merchantId);
|
||||||
|
if (!merchant) {
|
||||||
|
return res.status(404).json({ success: false, message: "Merchant not found from state" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await exchangeCodeForToken(code);
|
||||||
|
|
||||||
|
const connection = uberConnectionRepository.upsertByMerchantId(merchant.id, {
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
refreshToken: tokenData.refresh_token,
|
||||||
|
tokenType: tokenData.token_type || "Bearer",
|
||||||
|
scope: tokenData.scope || null,
|
||||||
|
expiresAt: parseExpiresAt(tokenData.expires_in),
|
||||||
|
status: "active"
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Uber account connected successfully",
|
||||||
|
data: {
|
||||||
|
merchantId: merchant.id,
|
||||||
|
connectionId: connection.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMerchantToken(req, res) {
|
||||||
|
const { merchantId } = req.params;
|
||||||
|
const connection = uberConnectionRepository.findByMerchantId(merchantId);
|
||||||
|
if (!connection) {
|
||||||
|
return res.status(404).json({ success: false, message: "Uber connection not found for merchant" });
|
||||||
|
}
|
||||||
|
if (!connection.refresh_token) {
|
||||||
|
return res.status(400).json({ success: false, message: "Refresh token not available for merchant" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await refreshToken(connection.refresh_token);
|
||||||
|
const updated = uberConnectionRepository.upsertByMerchantId(merchantId, {
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
refreshToken: tokenData.refresh_token || connection.refresh_token,
|
||||||
|
tokenType: tokenData.token_type || "Bearer",
|
||||||
|
scope: tokenData.scope || connection.scope,
|
||||||
|
expiresAt: parseExpiresAt(tokenData.expires_in),
|
||||||
|
status: "active"
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Token refreshed",
|
||||||
|
data: updated
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAuthorizeUrl,
|
||||||
|
oauthCallback,
|
||||||
|
refreshMerchantToken
|
||||||
|
};
|
||||||
|
|
||||||
61
src/modules/auth/auth.service.js
Normal file
61
src/modules/auth/auth.service.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
const axios = require("axios");
|
||||||
|
const env = require("../../config/env");
|
||||||
|
|
||||||
|
const uberAuthClient = axios.create({
|
||||||
|
baseURL: env.UBER_OAUTH_BASE_URL,
|
||||||
|
timeout: 20000
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildAuthorizeUrl({ state, scope = "eats.store eats.order" }) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: env.UBER_CLIENT_ID,
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: env.UBER_REDIRECT_URI,
|
||||||
|
scope,
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${env.UBER_OAUTH_BASE_URL}/oauth/v2/authorize?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCodeForToken(code) {
|
||||||
|
const payload = new URLSearchParams({
|
||||||
|
client_id: env.UBER_CLIENT_ID,
|
||||||
|
client_secret: env.UBER_CLIENT_SECRET,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: env.UBER_REDIRECT_URI,
|
||||||
|
code
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await uberAuthClient.post("/oauth/v2/token", payload.toString(), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshToken(refreshToken) {
|
||||||
|
const payload = new URLSearchParams({
|
||||||
|
client_id: env.UBER_CLIENT_ID,
|
||||||
|
client_secret: env.UBER_CLIENT_SECRET,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await uberAuthClient.post("/oauth/v2/token", payload.toString(), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildAuthorizeUrl,
|
||||||
|
exchangeCodeForToken,
|
||||||
|
refreshToken
|
||||||
|
};
|
||||||
|
|
||||||
54
src/modules/connections/connections.controller.js
Normal file
54
src/modules/connections/connections.controller.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
const { merchantRepository, uberConnectionRepository } = require("../../db/adapter");
|
||||||
|
|
||||||
|
const merchantSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
externalRef: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const manualConnectionSchema = z.object({
|
||||||
|
merchantId: z.string().min(1),
|
||||||
|
uberUserId: z.string().optional(),
|
||||||
|
uberStoreId: z.string().optional(),
|
||||||
|
accessToken: z.string().min(1),
|
||||||
|
refreshToken: z.string().optional(),
|
||||||
|
tokenType: z.string().optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
expiresAt: z.string().datetime().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
async function upsertMerchant(req, res) {
|
||||||
|
const payload = merchantSchema.parse(req.body);
|
||||||
|
const merchant = merchantRepository.upsert(payload);
|
||||||
|
return res.status(201).json({ success: true, data: merchant });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMerchants(req, res) {
|
||||||
|
const merchants = merchantRepository.list();
|
||||||
|
return res.json({ success: true, data: merchants });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertManualConnection(req, res) {
|
||||||
|
const payload = manualConnectionSchema.parse(req.body);
|
||||||
|
const merchant = merchantRepository.findById(payload.merchantId);
|
||||||
|
if (!merchant) {
|
||||||
|
return res.status(404).json({ success: false, message: "Merchant not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = uberConnectionRepository.upsertByMerchantId(payload.merchantId, payload);
|
||||||
|
return res.status(201).json({ success: true, data: connection });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listConnections(req, res) {
|
||||||
|
const rows = uberConnectionRepository.list();
|
||||||
|
return res.json({ success: true, data: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
upsertMerchant,
|
||||||
|
listMerchants,
|
||||||
|
upsertManualConnection,
|
||||||
|
listConnections
|
||||||
|
};
|
||||||
|
|
||||||
15
src/modules/health/health.controller.js
Normal file
15
src/modules/health/health.controller.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
async function healthCheck(req, res) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
service: "uber-wrapper",
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
healthCheck
|
||||||
|
};
|
||||||
|
|
||||||
97
src/modules/proxy/proxy.controller.js
Normal file
97
src/modules/proxy/proxy.controller.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
const proxyService = require("./proxy.service");
|
||||||
|
|
||||||
|
const genericSchema = z.object({
|
||||||
|
merchantId: z.string().min(1),
|
||||||
|
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("GET"),
|
||||||
|
path: z.string().min(1),
|
||||||
|
query: z.record(z.string(), z.any()).optional(),
|
||||||
|
body: z.any().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
async function genericProxy(req, res) {
|
||||||
|
const payload = genericSchema.parse(req.body);
|
||||||
|
const data = await proxyService.genericProxy(payload);
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertMenu(req, res) {
|
||||||
|
const schema = z.object({
|
||||||
|
merchantId: z.string().min(1),
|
||||||
|
storeId: z.string().min(1),
|
||||||
|
menu: z.any()
|
||||||
|
});
|
||||||
|
const payload = schema.parse(req.body);
|
||||||
|
const data = await proxyService.menuUpsert({
|
||||||
|
merchantId: payload.merchantId,
|
||||||
|
storeId: payload.storeId,
|
||||||
|
payload: payload.menu
|
||||||
|
});
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMenu(req, res) {
|
||||||
|
const schema = z.object({
|
||||||
|
merchantId: z.string().min(1),
|
||||||
|
storeId: z.string().min(1)
|
||||||
|
});
|
||||||
|
const payload = schema.parse(req.query);
|
||||||
|
const data = await proxyService.menuGet(payload);
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listOrders(req, res) {
|
||||||
|
const schema = z.object({
|
||||||
|
merchantId: z.string().min(1),
|
||||||
|
storeId: z.string().min(1)
|
||||||
|
});
|
||||||
|
const payload = schema.parse(req.query);
|
||||||
|
const data = await proxyService.ordersList({
|
||||||
|
merchantId: payload.merchantId,
|
||||||
|
storeId: payload.storeId,
|
||||||
|
query: req.query
|
||||||
|
});
|
||||||
|
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"]),
|
||||||
|
payload: z.any().optional()
|
||||||
|
});
|
||||||
|
const parsed = schema.parse(req.body);
|
||||||
|
|
||||||
|
const data = await proxyService.orderAction({
|
||||||
|
merchantId: parsed.merchantId,
|
||||||
|
orderId: req.params.orderId,
|
||||||
|
action: parsed.action,
|
||||||
|
payload: parsed.payload
|
||||||
|
});
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateHours(req, res) {
|
||||||
|
const schema = z.object({
|
||||||
|
merchantId: z.string().min(1),
|
||||||
|
storeId: z.string().min(1),
|
||||||
|
hours: z.any()
|
||||||
|
});
|
||||||
|
const payload = schema.parse(req.body);
|
||||||
|
const data = await proxyService.updateStoreHours({
|
||||||
|
merchantId: payload.merchantId,
|
||||||
|
storeId: payload.storeId,
|
||||||
|
payload: payload.hours
|
||||||
|
});
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
genericProxy,
|
||||||
|
upsertMenu,
|
||||||
|
getMenu,
|
||||||
|
listOrders,
|
||||||
|
orderAction,
|
||||||
|
updateHours
|
||||||
|
};
|
||||||
|
|
||||||
161
src/modules/proxy/proxy.service.js
Normal file
161
src/modules/proxy/proxy.service.js
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
const axios = require("axios");
|
||||||
|
const env = require("../../config/env");
|
||||||
|
const uberEndpoints = require("../../config/uberEndpoints");
|
||||||
|
const { uberConnectionRepository, apiLogRepository } = require("../../db/adapter");
|
||||||
|
|
||||||
|
const uberApiClient = axios.create({
|
||||||
|
baseURL: env.UBER_API_BASE_URL,
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
function interpolatePath(pathTemplate, params = {}) {
|
||||||
|
let output = pathTemplate;
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
output = output.replaceAll(`{${key}}`, encodeURIComponent(value));
|
||||||
|
});
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthHeader(connection) {
|
||||||
|
return `${connection.token_type || "Bearer"} ${connection.access_token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callUberApi({ merchantId, method, uberPath, query, body, wrapperRoute }) {
|
||||||
|
const connection = uberConnectionRepository.findByMerchantId(merchantId);
|
||||||
|
if (!connection || connection.status !== "active") {
|
||||||
|
const error = new Error("Active Uber connection not found for merchant");
|
||||||
|
error.status = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await uberApiClient.request({
|
||||||
|
method,
|
||||||
|
url: uberPath,
|
||||||
|
params: query,
|
||||||
|
data: body,
|
||||||
|
headers: {
|
||||||
|
Authorization: buildAuthHeader(connection),
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
apiLogRepository.insert({
|
||||||
|
merchantId,
|
||||||
|
method,
|
||||||
|
wrapperRoute,
|
||||||
|
uberPath,
|
||||||
|
responseStatus: response.status,
|
||||||
|
requestBody: body,
|
||||||
|
responseBody: response.data
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.response?.status || 500;
|
||||||
|
const responseBody = error.response?.data || { message: error.message };
|
||||||
|
|
||||||
|
apiLogRepository.insert({
|
||||||
|
merchantId,
|
||||||
|
method,
|
||||||
|
wrapperRoute,
|
||||||
|
uberPath,
|
||||||
|
responseStatus: status,
|
||||||
|
requestBody: body,
|
||||||
|
responseBody
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapped = new Error(`Uber API request failed: ${status}`);
|
||||||
|
wrapped.status = status;
|
||||||
|
wrapped.details = responseBody;
|
||||||
|
throw wrapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function genericProxy({ merchantId, method, path, query, body }) {
|
||||||
|
return callUberApi({
|
||||||
|
merchantId,
|
||||||
|
method,
|
||||||
|
uberPath: path,
|
||||||
|
query,
|
||||||
|
body,
|
||||||
|
wrapperRoute: "/api/v1/uber/request"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function menuUpsert({ merchantId, storeId, payload }) {
|
||||||
|
const uberPath = interpolatePath(uberEndpoints.menu.upsert, { storeId });
|
||||||
|
return callUberApi({
|
||||||
|
merchantId,
|
||||||
|
method: "POST",
|
||||||
|
uberPath,
|
||||||
|
body: payload,
|
||||||
|
wrapperRoute: "/api/v1/uber/menu/upsert"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function menuGet({ merchantId, storeId }) {
|
||||||
|
const uberPath = interpolatePath(uberEndpoints.menu.get, { storeId });
|
||||||
|
return callUberApi({
|
||||||
|
merchantId,
|
||||||
|
method: "GET",
|
||||||
|
uberPath,
|
||||||
|
wrapperRoute: "/api/v1/uber/menu"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ordersList({ merchantId, storeId, query }) {
|
||||||
|
const uberPath = interpolatePath(uberEndpoints.orders.list, { storeId });
|
||||||
|
return callUberApi({
|
||||||
|
merchantId,
|
||||||
|
method: "GET",
|
||||||
|
uberPath,
|
||||||
|
query,
|
||||||
|
wrapperRoute: "/api/v1/uber/orders"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function orderAction({ merchantId, orderId, action, payload }) {
|
||||||
|
const routeMap = {
|
||||||
|
accept: uberEndpoints.orders.accept,
|
||||||
|
deny: uberEndpoints.orders.deny,
|
||||||
|
ready: uberEndpoints.orders.readyForPickup,
|
||||||
|
cancel: uberEndpoints.orders.cancel
|
||||||
|
};
|
||||||
|
const template = routeMap[action];
|
||||||
|
if (!template) {
|
||||||
|
const error = new Error("Unsupported order action");
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const uberPath = interpolatePath(template, { orderId });
|
||||||
|
|
||||||
|
return callUberApi({
|
||||||
|
merchantId,
|
||||||
|
method: "POST",
|
||||||
|
uberPath,
|
||||||
|
body: payload || {},
|
||||||
|
wrapperRoute: "/api/v1/uber/orders/:orderId/action"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStoreHours({ merchantId, storeId, payload }) {
|
||||||
|
const uberPath = interpolatePath(uberEndpoints.stores.updateHours, { storeId });
|
||||||
|
return callUberApi({
|
||||||
|
merchantId,
|
||||||
|
method: "PUT",
|
||||||
|
uberPath,
|
||||||
|
body: payload,
|
||||||
|
wrapperRoute: "/api/v1/uber/stores/hours"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
genericProxy,
|
||||||
|
menuUpsert,
|
||||||
|
menuGet,
|
||||||
|
ordersList,
|
||||||
|
orderAction,
|
||||||
|
updateStoreHours
|
||||||
|
};
|
||||||
|
|
||||||
25
src/modules/webhooks/webhooks.controller.js
Normal file
25
src/modules/webhooks/webhooks.controller.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const { webhookRepository } = require("../../db/adapter");
|
||||||
|
|
||||||
|
async function handleUberWebhook(req, res) {
|
||||||
|
const merchantId = req.query.merchantId || req.body?.merchant_id || null;
|
||||||
|
const eventType = req.body?.event_type || req.body?.type || "unknown";
|
||||||
|
|
||||||
|
const row = webhookRepository.insert({
|
||||||
|
provider: "uber",
|
||||||
|
merchantId,
|
||||||
|
eventType,
|
||||||
|
payloadJson: req.body,
|
||||||
|
headersJson: req.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(202).json({
|
||||||
|
success: true,
|
||||||
|
message: "Webhook received",
|
||||||
|
data: { webhookEventId: row.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handleUberWebhook
|
||||||
|
};
|
||||||
|
|
||||||
59
src/routes/auth.routes.js
Normal file
59
src/routes/auth.routes.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const asyncHandler = require("../middleware/asyncHandler");
|
||||||
|
const controller = require("../modules/auth/auth.controller");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/auth/uber/authorize-url:
|
||||||
|
* get:
|
||||||
|
* summary: Generate Uber OAuth authorize URL for merchant
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: merchantId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OAuth URL generated
|
||||||
|
*/
|
||||||
|
router.get("/uber/authorize-url", asyncHandler(controller.getAuthorizeUrl));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/auth/uber/callback:
|
||||||
|
* get:
|
||||||
|
* summary: Uber OAuth callback endpoint
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Uber account connected
|
||||||
|
*/
|
||||||
|
router.get("/uber/callback", asyncHandler(controller.oauthCallback));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/auth/uber/{merchantId}/refresh-token:
|
||||||
|
* post:
|
||||||
|
* summary: Refresh Uber token for merchant
|
||||||
|
* tags:
|
||||||
|
* - Auth
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: merchantId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Token refreshed
|
||||||
|
*/
|
||||||
|
router.post("/uber/:merchantId/refresh-token", asyncHandler(controller.refreshMerchantToken));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
50
src/routes/connections.routes.js
Normal file
50
src/routes/connections.routes.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const asyncHandler = require("../middleware/asyncHandler");
|
||||||
|
const controller = require("../modules/connections/connections.controller");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/merchants:
|
||||||
|
* post:
|
||||||
|
* summary: Create or update merchant
|
||||||
|
* tags:
|
||||||
|
* - Merchants
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Merchant upserted
|
||||||
|
* get:
|
||||||
|
* summary: List merchants
|
||||||
|
* tags:
|
||||||
|
* - Merchants
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Merchant list
|
||||||
|
*/
|
||||||
|
router.post("/merchants", asyncHandler(controller.upsertMerchant));
|
||||||
|
router.get("/merchants", asyncHandler(controller.listMerchants));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/connections/uber:
|
||||||
|
* post:
|
||||||
|
* summary: Save manual Uber tokens for merchant
|
||||||
|
* tags:
|
||||||
|
* - Connections
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Connection stored
|
||||||
|
* get:
|
||||||
|
* summary: List Uber connections
|
||||||
|
* tags:
|
||||||
|
* - Connections
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Connection list
|
||||||
|
*/
|
||||||
|
router.post("/connections/uber", asyncHandler(controller.upsertManualConnection));
|
||||||
|
router.get("/connections/uber", asyncHandler(controller.listConnections));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
21
src/routes/health.routes.js
Normal file
21
src/routes/health.routes.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const asyncHandler = require("../middleware/asyncHandler");
|
||||||
|
const { healthCheck } = require("../modules/health/health.controller");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /health:
|
||||||
|
* get:
|
||||||
|
* summary: Health check
|
||||||
|
* tags:
|
||||||
|
* - Health
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Service is healthy
|
||||||
|
*/
|
||||||
|
router.get("/", asyncHandler(healthCheck));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
92
src/routes/proxy.routes.js
Normal file
92
src/routes/proxy.routes.js
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const asyncHandler = require("../middleware/asyncHandler");
|
||||||
|
const controller = require("../modules/proxy/proxy.controller");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/uber/request:
|
||||||
|
* post:
|
||||||
|
* summary: Generic Uber passthrough for any Uber endpoint
|
||||||
|
* tags:
|
||||||
|
* - Uber Generic
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Uber response
|
||||||
|
*/
|
||||||
|
router.post("/uber/request", asyncHandler(controller.genericProxy));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/uber/menu/upsert:
|
||||||
|
* post:
|
||||||
|
* summary: Upsert store menu
|
||||||
|
* tags:
|
||||||
|
* - Uber Menu
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Menu upserted
|
||||||
|
*/
|
||||||
|
router.post("/uber/menu/upsert", asyncHandler(controller.upsertMenu));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/uber/menu:
|
||||||
|
* get:
|
||||||
|
* summary: Fetch store menu
|
||||||
|
* tags:
|
||||||
|
* - Uber Menu
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Menu fetched
|
||||||
|
*/
|
||||||
|
router.get("/uber/menu", asyncHandler(controller.getMenu));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/uber/orders:
|
||||||
|
* get:
|
||||||
|
* summary: List store orders
|
||||||
|
* tags:
|
||||||
|
* - Uber Orders
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Orders fetched
|
||||||
|
*/
|
||||||
|
router.get("/uber/orders", asyncHandler(controller.listOrders));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/uber/orders/{orderId}/action:
|
||||||
|
* post:
|
||||||
|
* summary: Trigger order action (accept, deny, ready, cancel)
|
||||||
|
* tags:
|
||||||
|
* - Uber Orders
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: orderId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Order action sent
|
||||||
|
*/
|
||||||
|
router.post("/uber/orders/:orderId/action", asyncHandler(controller.orderAction));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/uber/stores/hours:
|
||||||
|
* put:
|
||||||
|
* summary: Update store hours
|
||||||
|
* tags:
|
||||||
|
* - Uber Stores
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Store hours updated
|
||||||
|
*/
|
||||||
|
router.put("/uber/stores/hours", asyncHandler(controller.updateHours));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
21
src/routes/webhooks.routes.js
Normal file
21
src/routes/webhooks.routes.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const asyncHandler = require("../middleware/asyncHandler");
|
||||||
|
const { handleUberWebhook } = require("../modules/webhooks/webhooks.controller");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/v1/webhooks/uber:
|
||||||
|
* post:
|
||||||
|
* summary: Ingest Uber webhook events
|
||||||
|
* tags:
|
||||||
|
* - Webhooks
|
||||||
|
* responses:
|
||||||
|
* 202:
|
||||||
|
* description: Webhook accepted
|
||||||
|
*/
|
||||||
|
router.post("/webhooks/uber", asyncHandler(handleUberWebhook));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
11
src/server.js
Normal file
11
src/server.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
const app = require("./app");
|
||||||
|
const env = require("./config/env");
|
||||||
|
const { initSchema } = require("./db/sqlite");
|
||||||
|
|
||||||
|
initSchema();
|
||||||
|
|
||||||
|
app.listen(env.PORT, () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Uber Wrapper listening on http://localhost:${env.PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user