feat(integration): administrar instancias y asociaciones con permisos

Agrega endpoints para crear, editar y asociar instancias a clientes y tipos de sitio. Valida las credenciales contra el esquema del catalogo y devuelve metadatos sin secretos.

Restringe la administracion al rol admin autenticado y mantiene inmutables los codigos de integracion. Incluye pruebas de API, permisos, prioridad de resolucion, edicion compartida y renovacion de tokens.
This commit is contained in:
2026-09-04 12:48:06 -03:00
parent c0057c237a
commit ab5ea7dd33
15 changed files with 457 additions and 16 deletions

View File

@@ -4,8 +4,12 @@ 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\Services\ClientIntegrationService;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -61,4 +65,18 @@ class ClientIntegrationController extends Controller
], 400);
}
}
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);
return response()->noContent();
}
}

View File

@@ -35,6 +35,7 @@ class IntegrationController extends Controller
public function destroy(Integration $integration)
{
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
$integration->delete();
return response()->noContent();

View File

@@ -0,0 +1,42 @@
<?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

@@ -0,0 +1,39 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Requests\AssociateIntegrationInstanceRequest;
use App\Domains\Integration\Resources\IntegrationAssociationResource;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Domains\Tenant\Models\WebsiteType;
use App\Http\Controllers\Controller;
class WebsiteTypeIntegrationController extends Controller
{
public function __construct(private readonly IntegrationAssociationService $service) {}
public function index(WebsiteType $websiteType)
{
return IntegrationAssociationResource::collection($websiteType->integrations()->with('integrationInstance')->get());
}
public function show(WebsiteType $websiteType, string $integrationCode)
{
return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
}
public function store(AssociateIntegrationInstanceRequest $request, WebsiteType $websiteType, string $integrationCode)
{
return new IntegrationAssociationResource($this->service->associate(
$websiteType, $integrationCode, IntegrationInstance::findOrFail($request->validated('integration_instance_id')),
));
}
public function destroy(WebsiteType $websiteType, string $integrationCode)
{
$this->service->detach($websiteType, $integrationCode);
return response()->noContent();
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Integration\Policies;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
class IntegrationPolicy
{
public function manage(User $user): bool
{
return $user->rol_codigo === RoleCode::Admin->value;
}
}

View File

@@ -0,0 +1,25 @@
<?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

@@ -12,7 +12,7 @@ class StoreClientIntegrationRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()?->can('manage', Integration::class) ?? false;
}
protected function prepareForValidation(): void
@@ -31,7 +31,7 @@ class StoreClientIntegrationRequest extends FormRequest
public function rules(): array
{
$rules = [];
$rules = ['integration_data' => ['present', 'array']];
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
$rules['integration_data.'.$field] = $rule;

View File

@@ -0,0 +1,35 @@
<?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

@@ -3,6 +3,7 @@
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateIntegrationRequest extends FormRequest
{
@@ -20,8 +21,7 @@ class UpdateIntegrationRequest extends FormRequest
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_configuration' => ['sometimes', 'boolean'],
// the code shouldn't ideally be updatable, but if it is:
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Integration\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IntegrationAssociationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'client_id' => $this->when(isset($this->client_id), $this->client_id),
'website_type_code' => $this->when(isset($this->website_type_code), $this->website_type_code),
'integration_code' => $this->integration_code,
'integration_instance_id' => $this->integration_instance_id,
'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Integration\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IntegrationInstanceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'integration_code' => $this->integration_code,
'name' => $this->name,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@@ -2,21 +2,37 @@
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::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::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
Route::apiResource('integration-instances', IntegrationInstanceController::class);
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::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void {
Route::get('/', [WebsiteTypeIntegrationController::class, 'index']);
Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']);
Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']);
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']);
});
});
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);

View File

@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Policies\IntegrationPolicy;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
@@ -13,6 +15,7 @@ use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
@@ -31,6 +34,10 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
Gate::policy(
Integration::class,
IntegrationPolicy::class,
);
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
Event::listen(UserRegistered::class, SendWelcomeEmail::class);