refactor(integration): streamline integration management by removing IntegrationInstanceController and updating related requests and services

This commit is contained in:
2026-09-04 16:00:19 -03:00
parent 85edea0661
commit 99fe94fa6a
11 changed files with 75 additions and 196 deletions

View File

@@ -4,10 +4,7 @@ namespace App\Domains\Integration\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Requests\AssociateIntegrationInstanceRequest;
use App\Domains\Integration\Requests\StoreClientIntegrationRequest;
use App\Domains\Integration\Resources\IntegrationAssociationResource;
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
use App\Domains\Integration\Services\ClientIntegrationService;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Http\Controllers\Controller;
@@ -39,7 +36,7 @@ class ClientIntegrationController extends Controller
}
public function store(
StoreClientIntegrationRequest $request,
ConfigureIntegrationRequest $request,
Client $client,
string $integrationCode,
): JsonResponse {
@@ -66,13 +63,6 @@ class ClientIntegrationController extends Controller
}
}
public function associate(AssociateIntegrationInstanceRequest $request, Client $client, string $integrationCode, IntegrationAssociationService $service)
{
return new IntegrationAssociationResource($service->associate(
$client, $integrationCode, IntegrationInstance::findOrFail($request->validated('integration_instance_id')),
));
}
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
{
$service->detach($client, $integrationCode);

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Requests\StoreIntegrationInstanceRequest;
use App\Domains\Integration\Resources\IntegrationInstanceResource;
use App\Domains\Integration\Services\IntegrationInstanceService;
use App\Http\Controllers\Controller;
class IntegrationInstanceController extends Controller
{
public function __construct(private readonly IntegrationInstanceService $service) {}
public function index()
{
return IntegrationInstanceResource::collection(IntegrationInstance::query()->orderBy('id')->paginate());
}
public function store(StoreIntegrationInstanceRequest $request)
{
return (new IntegrationInstanceResource($this->service->create($request->validated())))
->response()->setStatusCode(201);
}
public function show(IntegrationInstance $integrationInstance)
{
return new IntegrationInstanceResource($integrationInstance);
}
public function update(StoreIntegrationInstanceRequest $request, IntegrationInstance $integrationInstance)
{
return new IntegrationInstanceResource($this->service->update($integrationInstance, $request->validated()));
}
public function destroy(IntegrationInstance $integrationInstance)
{
$this->service->delete($integrationInstance);
return response()->noContent();
}
}

View File

@@ -2,8 +2,8 @@
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Requests\AssociateIntegrationInstanceRequest;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
use App\Domains\Integration\Resources\IntegrationAssociationResource;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Domains\Tenant\Models\WebsiteType;
@@ -23,10 +23,14 @@ class WebsiteTypeIntegrationController extends Controller
return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
}
public function store(AssociateIntegrationInstanceRequest $request, WebsiteType $websiteType, string $integrationCode)
public function store(ConfigureIntegrationRequest $request, WebsiteType $websiteType, string $integrationCode)
{
return new IntegrationAssociationResource($this->service->associate(
$websiteType, $integrationCode, IntegrationInstance::findOrFail($request->validated('integration_instance_id')),
$integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail();
return new IntegrationAssociationResource($this->service->configure(
$websiteType,
$integration,
$request->validated('integration_data'),
));
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Domains\Integration\Requests;
use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AssociateIntegrationInstanceRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage', Integration::class) ?? false;
}
public function rules(): array
{
return [
'integration_instance_id' => [
'required', 'integer',
Rule::exists('integration_instances', 'id')->where('integration_code', $this->route('integration_code')),
],
];
}
}

View File

@@ -6,7 +6,7 @@ use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class StoreClientIntegrationRequest extends FormRequest
class ConfigureIntegrationRequest extends FormRequest
{
protected ?Integration $integrationModel = null;
@@ -17,9 +17,8 @@ class StoreClientIntegrationRequest extends FormRequest
protected function prepareForValidation(): void
{
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::query()
->where('integration_code', $integrationCode)
->where('integration_code', $this->route('integration_code'))
->first();
if (! $this->integrationModel) {

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Domains\Integration\Requests;
use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest;
class StoreIntegrationInstanceRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage', Integration::class) ?? false;
}
public function rules(): array
{
$instance = $this->route('integration_instance');
$code = $instance?->integration_code ?? $this->input('integration_code');
$integration = is_string($code) ? Integration::where('integration_code', $code)->first() : null;
$rules = [
'integration_code' => $instance ? ['prohibited'] : ['required', 'string', 'exists:integrations,integration_code'],
'name' => [$instance ? 'sometimes' : 'required', 'required', 'string', 'max:255'],
// PUT/PATCH replaces the complete credential object when supplied.
'integration_data' => [$instance ? 'sometimes' : 'present', 'array'],
];
if (! $instance || $this->exists('integration_data')) {
foreach ($integration?->integration_data_schema ?? [] as $field => $rule) {
$rules['integration_data.'.$field] = $rule;
}
}
return $rules;
}
}

View File

@@ -30,19 +30,7 @@ class ClientIntegrationService
array $data,
): ClientIntegration {
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
// Legacy configuration endpoint replaces this client's instance only.
$instance = app(IntegrationInstanceService::class)->create([
'integration_code' => $integration->integration_code,
'name' => $integration->name.' / '.$client->name,
'integration_data' => $data,
]);
$clientIntegration = ClientIntegration::query()->updateOrCreate(
[
'client_id' => $client->id,
'integration_code' => $integration->integration_code,
],
['integration_instance_id' => $instance->id],
);
$clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
$service = $this->resolveService($integration->integration_code);
$service?->forClient($client)->onSetup();

View File

@@ -4,12 +4,27 @@ namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Models\WebsiteTypeIntegration;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Support\Facades\DB;
class IntegrationAssociationService
{
public function configure(Client|WebsiteType $owner, Integration $integration, array $integrationData): ClientIntegration|WebsiteTypeIntegration
{
return DB::transaction(function () use ($owner, $integration, $integrationData): ClientIntegration|WebsiteTypeIntegration {
$instance = IntegrationInstance::create([
'integration_code' => $integration->integration_code,
'name' => $integration->name.' / '.$this->ownerName($owner),
'integration_data' => $integrationData,
]);
return $this->associate($owner, $integration->integration_code, $instance);
});
}
public function associate(Client|WebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|WebsiteTypeIntegration
{
abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.');
@@ -24,4 +39,9 @@ class IntegrationAssociationService
{
$owner->integrations()->where('integration_code', $code)->delete();
}
private function ownerName(Client|WebsiteType $owner): string
{
return $owner instanceof Client ? $owner->name : $owner->nombre;
}
}

View File

@@ -3,10 +3,10 @@
## Modelo
- `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`.
- `IntegrationInstance`: configuración concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API.
- `IntegrationInstance`: configuración interna concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API.
- `ClientIntegration` y `WebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario.
Editar una instancia compartida afecta a todos sus consumidores. Desvincularla solo elimina la asociación. Las instancias vinculadas no se pueden eliminar (HTTP 409); las instancias sin asociaciones se conservan hasta su eliminación explícita. El código de una integración o instancia no cambia después de crearla.
Las instancias no se administran directamente por HTTP. Cada configuración enviada desde un cliente o tipo de sitio crea una instancia interna nueva y reemplaza únicamente la asociación de ese propietario. Desvincularla solo elimina la asociación.
## Resolución
@@ -14,29 +14,24 @@ Editar una instancia compartida afecta a todos sus consumidores. Desvincularla s
`forClient()` usa únicamente la asociación del cliente: sin un tenant concreto no se elige un tipo de sitio. Si no existe una instancia y `requires_configuration` es verdadero, se genera un error. Para correo opcional, `MailService` usa el mailer global si no encuentra una instancia; cuando la encuentra, construye un transporte SMTP aislado identificado como `integration-smtp`.
Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Compartir instancia permite reutilizar el token. Guardar otras credenciales cambia la clave; la edición explícita elimina la anterior después del commit. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual.
Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual.
## Administración
Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran credenciales compartidas.
Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran configuraciones.
| Método | Ruta | Operación |
| --- | --- | --- |
| CRUD | `/integrations` | Catálogo. |
| GET, POST | `/integration-instances` | Listar con paginación o crear. |
| GET, PUT, PATCH, DELETE | `/integration-instances/{id}` | Consultar, editar o eliminar. |
| GET | `/clients/{client}/integrations[/{integration_code}]` | Consultar asociaciones directas. |
| PUT | `/clients/{client}/integrations/{integration_code}` | Guardado compatible: crea una instancia nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. |
| PUT | `/clients/{client}/integrations/{integration_code}/instance` | Asociar una instancia existente. |
| PUT | `/clients/{client}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. |
| DELETE | `/clients/{client}/integrations/{integration_code}` | Desvincular. |
| GET | `/website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de sitio. |
| PUT, DELETE | `/website-types/{codigo}/integrations/{integration_code}` | Asociar una instancia existente o desvincular. |
| PUT | `/website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de sitio. |
| DELETE | `/website-types/{codigo}/integrations/{integration_code}` | Desvincular. |
Crear una instancia requiere `integration_code`, `name` e `integration_data` (objeto validado según el esquema del catálogo). Editar solo `name` conserva las credenciales. Enviar `integration_data` reemplaza el objeto completo y valida todo el esquema. El código de la instancia es inmutable.
Los dos `PUT` reciben `integration_data`, un objeto completo validado según el esquema de la integración. La integración debe existir previamente en el catálogo interno. No hay endpoints públicos para administrar el catálogo ni las instancias directamente.
Asociar requiere un payload como `{"integration_instance_id": 12}`. Se verifica que exista y pertenezca al código solicitado. La creación y edición explícita de instancias validan el esquema sin enviar mensajes ni llamar al proveedor. Para editar una configuración compartida se utiliza explícitamente el endpoint de la instancia.
Los nuevos endpoints usan resources con envoltorio `data`, sin credenciales. Las consultas históricas de cliente conservan su formato JSON e incluyen `integration_instance` sin credenciales.
Las respuestas y consultas incluyen metadatos de `integration_instance`, pero nunca sus credenciales. La configuración del cliente conserva su mensaje de respuesta histórico; la del tipo de sitio usa un resource con envoltorio `data`.
## Webhooks y contexto operativo

View File

@@ -1,16 +1,12 @@
<?php
use App\Domains\Integration\Controllers\ClientIntegrationController;
use App\Domains\Integration\Controllers\IntegrationController;
use App\Domains\Integration\Controllers\IntegrationInstanceController;
use App\Domains\Integration\Controllers\TelepagosWebhookController;
use App\Domains\Integration\Controllers\WebsiteTypeIntegrationController;
use App\Domains\Integration\Models\Integration;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
Route::apiResource('integration-instances', IntegrationInstanceController::class);
Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void {
Route::get('/', [WebsiteTypeIntegrationController::class, 'index']);
Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']);
@@ -18,19 +14,10 @@ Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(fun
Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']);
});
Route::group(['prefix' => 'integrations'], function () {
Route::get('/', [IntegrationController::class, 'index']);
Route::post('/', [IntegrationController::class, 'store']);
Route::get('/{integration}', [IntegrationController::class, 'show']);
Route::put('/{integration}', [IntegrationController::class, 'update']);
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
});
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
Route::get('/', [ClientIntegrationController::class, 'index']);
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
Route::put('/{integration_code}/instance', [ClientIntegrationController::class, 'associate']);
Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']);
});
});

View File

@@ -39,49 +39,47 @@ class IntegrationInstanceTest extends TestCase
public function test_management_requires_an_authenticated_global_admin(): void
{
$this->getJson('/api/integration-instances')->assertUnauthorized();
Client::create(['code' => 'acme', 'name' => 'Acme']);
WebsiteType::create(['codigo' => 'demo', 'nombre' => 'Demo']);
$this->getJson('/api/clients/acme/integrations')->assertUnauthorized();
Sanctum::actingAs(User::factory()->create());
$this->getJson('/api/integration-instances')->assertForbidden();
$this->postJson('/api/integration-instances', [])->assertForbidden();
$this->getJson('/api/integrations')->assertForbidden();
$this->getJson('/api/clients/acme/integrations')->assertForbidden();
$this->getJson('/api/website-types/demo/integrations')->assertForbidden();
}
public function test_instance_crud_validates_configuration_and_never_returns_secrets(): void
{
$this->admin();
$this->postJson('/api/integration-instances', [
'integration_code' => 'test', 'name' => 'Invalid', 'integration_data' => [],
])->assertUnprocessable()->assertJsonValidationErrors('integration_data.api_key');
$response = $this->postJson('/api/integration-instances', [
'integration_code' => 'test', 'name' => 'Shared', 'integration_data' => ['api_key' => 'secret'],
])->assertCreated()->assertJsonMissingPath('data.integration_data');
$id = $response->json('data.id');
$this->getJson('/api/integration-instances/'.$id)->assertOk()->assertJsonMissingPath('data.integration_data');
$this->patchJson('/api/integration-instances/'.$id, ['name' => 'Renamed'])->assertOk();
self::assertSame('secret', IntegrationInstance::findOrFail($id)->integration_data['api_key']);
$this->patchJson('/api/integration-instances/'.$id, ['integration_code' => 'test'])->assertUnprocessable();
$this->patchJson('/api/integration-instances/'.$id, ['integration_data' => []])->assertUnprocessable();
$this->patchJson('/api/integration-instances/'.$id, ['integration_data' => ['api_key' => 'new']])->assertOk();
self::assertSame('new', IntegrationInstance::findOrFail($id)->integration_data['api_key']);
$this->deleteJson('/api/integration-instances/'.$id)->assertNoContent();
}
public function test_association_api_checks_integration_and_protects_linked_instances(): void
public function test_only_owner_endpoints_can_manage_configuration(): void
{
$this->admin();
$client = Client::create(['code' => 'acme', 'name' => 'Acme']);
$type = WebsiteType::create(['codigo' => 'demo', 'nombre' => 'Demo']);
$instance = $this->makeInstance('shared');
$this->putJson('/api/clients/acme/integrations/wrong/instance', ['integration_instance_id' => $instance->id])->assertUnprocessable();
$this->putJson('/api/clients/acme/integrations/test/instance', ['integration_instance_id' => $instance->id])
->assertCreated()->assertJsonPath('data.integration_instance.name', 'shared')->assertJsonMissingPath('data.integration_instance.integration_data');
$this->putJson('/api/website-types/demo/integrations/test', ['integration_instance_id' => $instance->id])->assertCreated();
$this->getJson('/api/website-types/demo/integrations/test')->assertOk()->assertJsonPath('data.integration_instance_id', $instance->id);
$this->deleteJson('/api/integration-instances/'.$instance->id)->assertConflict();
$this->getJson('/api/integrations')->assertNotFound();
$this->getJson('/api/integration-instances')->assertNotFound();
$this->putJson('/api/clients/acme/integrations/test/instance', [])->assertNotFound();
$this->putJson('/api/clients/acme/integrations/test', ['integration_data' => []])
->assertUnprocessable()->assertJsonValidationErrors('integration_data.api_key');
$this->putJson('/api/website-types/demo/integrations/test', ['integration_data' => []])
->assertUnprocessable()->assertJsonValidationErrors('integration_data.api_key');
$this->putJson('/api/clients/acme/integrations/test', [
'integration_data' => ['api_key' => 'client-secret'],
])->assertOk()->assertJsonMissingPath('integration_data');
$clientInstanceId = $client->integrations()->firstOrFail()->integration_instance_id;
self::assertSame('client-secret', IntegrationInstance::findOrFail($clientInstanceId)->integration_data['api_key']);
$this->putJson('/api/website-types/demo/integrations/test', [
'integration_data' => ['api_key' => 'type-secret'],
])->assertCreated()
->assertJsonPath('data.integration_instance.name', 'Test / Demo')
->assertJsonMissingPath('data.integration_instance.integration_data');
$typeInstanceId = $type->integrations()->firstOrFail()->integration_instance_id;
$this->getJson('/api/website-types/demo/integrations/test')
->assertOk()->assertJsonPath('data.integration_instance_id', $typeInstanceId);
$this->deleteJson('/api/clients/acme/integrations/test')->assertNoContent();
$this->deleteJson('/api/website-types/demo/integrations/test')->assertNoContent();
self::assertTrue($instance->fresh()->exists);
$this->deleteJson('/api/integration-instances/'.$instance->id)->assertNoContent();
}
public function test_tenant_prefers_client_then_type_and_client_context_does_not_inherit(): void