Marketplace
Visão Geral
Centraliza integrações com plataformas de delivery (iFood, Rappi, UberEats). Recebe webhooks de pedidos, normaliza para o formato interno e despacha para o KDS via WebSocket — tudo sem re-keying.
Filosofia: uma única fonte canônica de menu (Popinaflow), propagada para os marketplaces via fila assíncrona.
Localização
| Camada | Arquivo |
|---|---|
| Módulo | backend/src/marketplace/marketplace.module.ts |
| Webhooks | backend/src/marketplace/controllers/marketplace-webhook.controller.ts |
| Sync | backend/src/marketplace/marketplace-sync.service.ts |
| Processor | backend/src/marketplace/processors/marketplace-order.processor.ts |
| Normalizers | backend/src/marketplace/normalizers/{ifood,rappi,ubereats}.normalizer.ts |
| View Admin | frontend-react/src/views/admin/MarketplaceView.tsx |
| Inbox unificado | frontend-react/src/views/admin/orders/UnifiedInboxView.tsx |
Rotas
| Rota | Propósito |
|---|---|
/admin/marketplace | Configurar credenciais, status de cada integração |
/admin/orders/inbox | Inbox unificado de todos os canais |
/admin/marketplace/ifood/health | SLA dashboard (ver ifood-health.md) |
Webhooks Públicos
| Endpoint | Verificação | Header de assinatura |
|---|---|---|
POST /t/:slug/webhooks/ifood | HMAC-SHA256 | x-ifood-hmac-sha256 |
POST /t/:slug/webhooks/rappi | HMAC-SHA256 | x-rappi-signature |
POST /t/:slug/webhooks/ubereats | RS256 JWT | Authorization: Bearer <jwt> (JWKS) |
Cada controller verifica a assinatura sobre o raw body (capturado pelo verify callback do express.json em main.ts) — re-serializar via JSON.stringify quebraria HMAC por whitespace/ordering.
Quando IFOOD_WEBHOOK_SECRET (etc.) não está configurado, a verificação é fail-open (modo sandbox).
Idempotência (P2)
Middleware global IdempotencyKeyMiddleware (backend/src/common/middleware/idempotency-key.middleware.ts) aplicado nas três rotas de webhook:
- Lê header
idempotency-key(RFC-style) - Se ausente, extrai chave do body via per-provider extractor:
- iFood:
body.id - Rappi:
body.event.idoubody.eventId - UberEats:
body.event_id
- iFood:
- Usa
Redis.setnxcom TTL 24h (atomic SET NX EX) - Se a chave já existe → curto-circuita 200 com
{ deduplicated: true }
Fail-open em outage do Redis: preferimos aceitar duplicata a rejeitar webhook (iFood retiraria credenciais por falha repetida).
Fila BullMQ
Duas filas registradas em MarketplaceModule:
marketplace-orders— ingestão de pedidosmarketplace-catalog-sync— push de catálogo
Configuração: attempts: 3, exponential backoff 2s/5s, removeOnComplete: 100, removeOnFail: 50.
Job-level dedup via deduplication: { id } evita reprocessar o mesmo evento se o webhook chegar duas vezes (cinto + suspensórios com a idempotência HTTP).
Pipeline de Ordem
[iFood webhook] → verify HMAC → enqueue 'ifood' job (com receivedAt: ms)
│
▼
MarketplaceOrderProcessor.ingest()
├─ normalize (provider-specific)
├─ findByExternalId — skip se duplicate
├─ ordersService.createFromMarketplace()
├─ kitchenGateway.emit('newOrder')
└─ ifoodSla.record('ack', latencyMs) [iFood only]receivedAt é stamped no momento do webhook e usado pelo processor para calcular ack-latency (Sprint 2 SLA).
Sincronização de Catálogo
MarketplaceSyncService.enqueueSyncItem(tenantId, itemId) é chamado reativamente pelo MenuService em update — propaga para os 3 providers em paralelo.
enqueueFullSync(tenantId, provider?) força uma re-sincronização completa: enumera todos os menu items ativos e enfileira um job por (item × provider). Útil quando um marketplace fica fora do ar e seu catálogo divergiu do nosso.
Endpoints:
POST /t/:slug/marketplace/integrations/sync— full sync, todos os providersPOST /t/:slug/marketplace/integrations/:id/sync— single provider
Botão "Sincronizar" por integração na MarketplaceView dispara a versão single.
Status Push (Order → Marketplace)
pushOrderStatus(tenantId, externalOrderId, internalStatus, source) mapeia status interno para status do provider:
| Interno | iFood | Rappi | UberEats |
|---|---|---|---|
confirmed | CONFIRMED | TAKEN | accept_pos_order |
preparing | IN_PREPARATION | COOKING | (não suportado) |
ready | READY_TO_PICKUP | READY_TO_PICK | (não suportado) |
delivered | CONCLUDED | DELIVERED | (não suportado) |
cancelled | CANCELLED | REJECTED | cancel |
Para iFood, latência da chamada é capturada em IFoodSlaService como status_update ou cancellation (ver ifood-health.md).
Schema MarketplaceIntegration
| Campo | Tipo | Descrição |
|---|---|---|
tenant | ObjectId | Tenant |
provider | enum | ifood | rappi | ubereats |
active | boolean | Habilitada para uso |
credentials | object | { clientId, clientSecret, merchantId } (criptografado) |
tokens | object | { accessToken, expiresAt } (cache OAuth) |
lastSyncAt | Date | Última sync de catálogo bem-sucedida |
syncedItemCount | number | Items propagados na última sync |
OAuth tokens são criptografados em repouso via token-encrypt.ts (AES-256-GCM).