Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ PROYECTO_API_DATABASE_PASSWORD=admin
# Generar cada una con: rails secret. En dev/test caen a secret_key_base.
ENCRYPTION_KEY=
ENCRYPTION_KEY_DERIVATION_SALT=

# URL pública HTTPS de esta API, sin barra final: con ella se registran los
# webhooks en los proveedores. En desarrollo, la de un túnel, por ejemplo
# `ngrok http 3000` → https://xxxx.ngrok-free.app
PUBLIC_WEBHOOK_BASE_URL=
109 changes: 109 additions & 0 deletions app/avo/actions/configure_connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

module Avo
module Actions
# Carga la cuenta de una empresa en un proveedor: un campo por cada dato que
# declara la plantilla (`credential_fields`, `setting_fields`).
#
# Las credenciales las carga el equipo de OneStock, no la empresa (ADR-018):
# la empresa se las pasa al administrador y él las carga acá. La API de la
# empresa sólo lee el estado de sus integraciones.
#
# - Los secretos nunca se muestran ni se precargan: vacío quiere decir «dejá
# el que está» (Integrations::ApplyDeclaredFields, que también valida).
# - La configuración sí se precarga, y vaciarla la borra.
# - Después de guardar prueba la conexión si la plantilla sabe hacerlo: en
# Shopify eso completa la ubicación donde se publica el stock.
# - Si la prueba pasa, registra el webhook del proveedor (las ventas).
class ConfigureConnection < Avo::BaseAction
CREDENTIAL_PREFIX = 'credentials__'
SETTING_PREFIX = 'settings__'
STORED_HELP = 'Already set: leave blank to keep it.'

self.name = 'Configure connection'
self.message = 'Secrets are never shown. Leave a secret blank to keep the one already stored.'
self.confirm_button_label = 'Save'
self.visible = -> { view.show? && resource.record&.service&.declares_fields? }

def fields
service = record&.service
return if service.nil?

service.credential_fields.each { |spec| credential_field(spec) }
service.setting_fields.each { |spec| setting_field(spec) }
end

def handle(query:, fields:, **)
integration = query.first
save(integration, fields)
report_test(integration)
reload
rescue ::Integrations::InvalidIntegrationError => e
error(invalid_fields_message(integration.service, e.fields))
keep_modal_open
end

private

def credential_field(spec)
stored = record.credentials.is_a?(Hash) && record.credentials[spec['key']].present?
field :"#{CREDENTIAL_PREFIX}#{spec['key']}", as: :password, name: spec['label'],
help: (STORED_HELP if stored)
end

def setting_field(spec)
field :"#{SETTING_PREFIX}#{spec['key']}", as: :text, name: spec['label'],
default: record.settings[spec['key']]
end

def save(integration, fields)
credentials, settings = ::Integrations::ApplyDeclaredFields.new(
service: integration.service, integration: integration,
credentials: values_with_prefix(fields, CREDENTIAL_PREFIX),
settings: values_with_prefix(fields, SETTING_PREFIX)
).call
integration.update!(credentials:, settings:)
end

def values_with_prefix(fields, prefix)
fields.to_h
.select { |key, _value| key.start_with?(prefix) }
.transform_keys { |key| key.delete_prefix(prefix) }
end

def report_test(integration)
return succeed('Connection saved.') unless integration.service.connection_testable?

result = ::Integrations::TestConnection.new(company_integration: integration).call
if result[:ok]
succeed("Connection saved. #{result[:message]}.")
report_webhook(integration)
else
warn("Connection saved, but the test failed: #{result[:message]}")
end
end

# Con la cuenta probada, se suscribe a los eventos del proveedor si la
# plantilla sabe hacerlo: así las ventas entran solas desde el primer
# momento. Si falla (la URL pública no está configurada, por ejemplo) se
# puede reintentar con «Register webhook».
def report_webhook(integration)
return unless ::Integrations::RegisterWebhook.declared_by?(integration.service)

result = ::Integrations::RegisterWebhook.new(company_integration: integration).call
return succeed(result[:message]) if result[:ok]

warn("Webhook not registered: #{result[:message]}")
end

# «Client ID: required; Shop domain: invalid_format», con el nombre que ve
# el administrador en el formulario.
def invalid_fields_message(service, fields)
labels = (service.credential_fields.map { |spec| ['credentials', spec] } +
service.setting_fields.map { |spec| ['settings', spec] })
.to_h { |scope, spec| ["#{scope}.#{spec['key']}", spec['label']] }
fields.map { |key, codes| "#{labels.fetch(key, key)}: #{codes.join(', ')}" }.join('; ')
end
end
end
end
24 changes: 24 additions & 0 deletions app/avo/actions/register_webhook.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Avo
module Actions
# «Registrar webhook» desde el backoffice: le pide al proveedor que avise a
# OneStock de los eventos de esta cuenta (Integrations::RegisterWebhook).
# «Configure connection» ya lo hace al guardar; esta acción es para cuando
# cambió la URL pública de la API o el registro falló.
class RegisterWebhook < Avo::BaseAction
self.name = 'Register webhook'
self.message = 'Asks the provider to notify OneStock of this account events.'
self.confirm_button_label = 'Register'
self.visible = lambda {
view.show? && ::Integrations::RegisterWebhook.declared_by?(resource.record&.service)
}

def handle(query:, **)
result = ::Integrations::RegisterWebhook.new(company_integration: query.first).call
result[:ok] ? succeed(result[:message]) : error(result[:message])
reload
end
end
end
end
21 changes: 21 additions & 0 deletions app/avo/actions/test_connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module Avo
module Actions
# «Probar conexión» desde el backoffice: llama al proveedor con la cuenta de
# la empresa sin tocar datos de negocio (Integrations::TestConnection). Si el
# proveedor devuelve configuración que faltaba, la completa.
class TestConnection < Avo::BaseAction
self.name = 'Test connection'
self.message = 'Calls the provider with this account. No business data is changed.'
self.confirm_button_label = 'Test'
self.visible = -> { view.show? && resource.record&.service&.connection_testable? }

def handle(query:, **)
result = ::Integrations::TestConnection.new(company_integration: query.first).call
result[:ok] ? succeed(result[:message]) : error(result[:message])
reload
end
end
end
end
29 changes: 22 additions & 7 deletions app/avo/resources/company_integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,30 @@ class CompanyIntegration < Avo::BaseResource
self.title = :display_name
self.includes = %i[company service]

# Las credenciales las carga el equipo de OneStock desde acá (ADR-018): se
# crea la integración (empresa y plantilla) y los datos de la cuenta se
# cargan con la acción «Configure connection», que valida contra lo que
# declara la plantilla.
def fields
field :id, as: :id
field :company, as: :belongs_to
field :service, as: :belongs_to
field :is_active, as: :boolean
# Una plantilla hija no se conecta sola (CompanyIntegration valida lo mismo).
field :service, as: :belongs_to, attach_scope: -> { query.connectable }
# Nace inactiva: la empresa la vería como conectada antes de tener cuenta.
field :is_active, as: :boolean, default: false,
help: 'Activate it once the connection is configured and tested.'
credentials_field
field :settings, as: :code, language: 'json', only_on: :show, disabled: true,
format_using: -> { JSON.pretty_generate(value || {}) }
field :product_mappings, as: :has_many, name: 'Product Mappings'
end

def actions
action Avo::Actions::ConfigureConnection
action Avo::Actions::TestConnection
action Avo::Actions::RegisterWebhook
end

def filters
filter Avo::Filters::CompanyFilter
end
Expand All @@ -25,11 +40,11 @@ def filters

# Las credenciales son API keys y tokens de las cuentas de cada empresa, y
# el backoffice las mostraba descifradas en el detalle y en el formulario
# (TESIS-129). Ahora dice qué claves hay configuradas, nunca sus valores, y
# no las edita: las carga cada empresa por la API
# (PUT /api/v1/integrations/:service_id). Editarlas acá además las rompía:
# el campo de código manda un String y `serialize :credentials` lo
# guardaba como String, no como Hash.
# (TESIS-129). Ahora dice qué claves hay configuradas, nunca sus valores.
# El formulario del recurso no las edita: se cargan con «Configure
# connection», un campo por clave. Editarlas como JSON acá las rompía: el
# campo de código manda un String y `serialize :credentials` lo guardaba
# como String, no como Hash.
#
# Un valor que no es Hash (una fila que quedó guardada como String) se
# enmascara entero en vez de romper la página.
Expand Down
32 changes: 7 additions & 25 deletions app/controllers/api/v1/integrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ module V1
class IntegrationsController < ApplicationController
include Paginatable

# Sólo lectura. Las credenciales las carga el equipo de OneStock desde el
# backoffice (ADR-018): la empresa ve el estado de sus integraciones, no
# las configura.
#
# El listado no pasa por Pundit: lo usa el widget de nodos del panel
# aunque la empresa no tenga la feature `integrations`, y sólo muestra las
# plantillas globales con el estado de la propia empresa. El alta y la
# modificación sí: ver CompanyIntegrationPolicy.
# plantillas globales con el estado de la propia empresa.
skip_after_action :verify_policy_scoped

# Envuelto en `data` como el resto de las colecciones (ADR-015): era el
Expand All @@ -24,7 +27,8 @@ class IntegrationsController < ApplicationController
# nodos, no de a páginas.
def index
integrations = current_company.company_integrations.index_by(&:service_id)
services, meta = paginate(Service.order(:id), per_page: WHOLE_LIST_PER_PAGE)
services, meta = paginate(Service.connectable.order(:id),
per_page: WHOLE_LIST_PER_PAGE)

render json: {
data: IntegrationStatusSerializer.render_as_hash(
Expand All @@ -33,28 +37,6 @@ def index
meta: meta
}
end

def update
authorize CompanyIntegration
integration = Integrations::UpsertIntegration.new(
company: current_company,
service_id: params[:service_id],
credentials: credentials_params,
is_active: params.fetch(:is_active, true)
).call
render json: CompanyIntegrationSerializer.render(integration), status: :ok
end

private

def credentials_params
raw = params.require(:credentials)
unless raw.is_a?(ActionController::Parameters)
raise ActionController::ParameterMissing, :credentials
end

raw.to_unsafe_h
end
end
end
end
20 changes: 16 additions & 4 deletions app/controllers/api/v1/product_mappings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class ProductMappingsController < ApplicationController

rescue_from ActiveRecord::RecordNotSaved, with: :render_unprocessable
rescue_from ActiveRecord::RecordNotUnique, with: :render_conflict
rescue_from Catalog::ExternalProductNotFoundError, with: :render_unprocessable
rescue_from Integrations::AdapterExecutionError, with: :render_channel_failure

def index
scope = policy_scope(ProductMapping)
Expand All @@ -31,13 +33,17 @@ def index
def create
authorize ProductMapping

mapping = @product.product_mappings.create!(
company_integration: company_integration,
result = Catalog::LinkExternalProduct.new(
product: @product, company_integration: company_integration,
external_product_id: mapping_params[:external_product_id],
external_price: mapping_params[:external_price]
)
).call

render json: ProductMappingSerializer.render(mapping), status: :created
# `warnings` no bloquea el vínculo: avisa, por ejemplo, que el SKU del
# canal no coincide con el del producto.
render json: ProductMappingSerializer.render_as_hash(result.mapping)
.merge(warnings: result.warnings),
status: :created
end

def destroy
Expand Down Expand Up @@ -89,6 +95,12 @@ def mapping_params
def render_conflict(_exception)
render json: { error: ALREADY_LINKED }, status: :conflict
end

# El canal no contestó o rechazó la consulta: el problema es de arriba
# (502), no del dato que mandó el usuario.
def render_channel_failure(exception)
render json: { error: exception.message }, status: :bad_gateway
end
end
end
end
14 changes: 14 additions & 0 deletions app/controllers/concerns/api/webhooks/event_ingestion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ module EventIngestion
# tiene que dejar de mandar eventos a esa integración.
rescue_from ActiveRecord::InvalidForeignKey, with: :render_not_found

# Un evento que no prueba venir del proveedor no se persiste: guardarlo
# dejaría a cualquiera que conozca la URL llenando la auditoría de ventas
# falsas. 401 y una línea de log sin el payload ni la firma.
rescue_from ::Webhooks::InvalidSignatureError, with: :reject_unsigned_event

# El payload se lee del body crudo por dos razones: ParamsWrapper envolvería
# el JSON bajo la clave del controller, y tocar `params` dispararía el parseo
# del body en el middleware, que responde 400 ante un JSON malformado antes
Expand All @@ -46,6 +51,8 @@ def create
# si más adelante alguien setea Current antes de esta acción.
integration = CompanyIntegration.unscoped
.find(request.path_parameters[:company_integration_id])
::Webhooks::VerifySignature.new(company_integration: integration,
raw_body: request.raw_post, headers: request.headers).call

# Current queda explícitamente en nil durante el insert: si algún día
# llegara con valor, el assign_current_company de CompanyScoped pisaría
Expand All @@ -71,6 +78,13 @@ def create
# la conexión; qué worker procesa el evento lo decide cada endpoint.
def enqueue_processing(_log, _integration) = nil

def reject_unsigned_event(error)
integration_id = request.path_parameters[:company_integration_id]
Rails.logger.warn("[webhooks] rejected event for integration #{integration_id}: " \
"#{error.message}")
head :unauthorized
end

# Un body ilegible no se rechaza: se guarda tal cual para no perder el
# evento (el reproceso es responsabilidad de la épica de resiliencia).
def request_payload
Expand Down
13 changes: 11 additions & 2 deletions app/jobs/catalog/sync_stock_to_channels_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,24 @@ module Catalog
class SyncStockToChannelsJob < ApplicationJob
queue_as :low

def perform(product_id, company_id)
# `company_integration_id` acota el push a un canal (ver OutboundSync): sin
# él se publica en todos, que es lo que pide un cambio de stock.
def perform(product_id, company_id, company_integration_id = nil)
with_tenant(company_id) do
# El producto puede haberse borrado entre el encolado y la ejecución:
# sin producto no hay nada que propagar, porque sus mappings se fueron
# con él. find_by y no find: es un final esperado, no un fallo del job.
product = Product.find_by(id: product_id)
return if product.nil?

OutboundSync.new(product: product).call
# La integración pudo borrarse mientras el job esperaba: sin canal no hay
# nada que alinear.
if company_integration_id
integration = CompanyIntegration.find_by(id: company_integration_id)
return if integration.nil?
end

OutboundSync.new(product: product, company_integration: integration).call
end
end
end
Expand Down
12 changes: 12 additions & 0 deletions app/models/company_integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,20 @@ class CompanyIntegration < ApplicationRecord
encrypts :credentials

validates :service_id, uniqueness: { scope: :company_id }
validate :service_is_connectable

def display_name
"#{company&.name} \u2194 #{service&.service_name}"
end

private

# Una plantilla hija (ej. 'Shopify - Conexión') corre con la integración de su
# madre. Conectarla sola dejaría una integración que ninguna parte del sistema
# usa: el backoffice la ofrecería en el alta si no se validara acá.
def service_is_connectable
return if service.nil? || service.connectable?

errors.add(:service, 'is an operation of another template and cannot be connected on its own')
end
end
Loading
Loading