feat: implement integration management with CRUD operations and routes for tenant integration

This commit is contained in:
2026-07-03 14:44:18 -03:00
parent 0d4c6a4492
commit 3887a4fcba
16 changed files with 446 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
FRONTEND_URL=http://localhost:4200
INTEGRATION_SECRET=
APP_LOCALE=en
APP_FALLBACK_LOCALE=en

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Domains\Integration\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Encryption\Encrypter;
use Exception;
class EncryptedIntegrationData implements CastsAttributes
{
protected function getEncrypter(): Encrypter
{
$secret = env('INTEGRATION_SECRET');
if (empty($secret)) {
throw new Exception('INTEGRATION_SECRET is not set in the environment.');
}
// Laravel encrypter requires a key of exact length. Typically 32 bytes for AES-256-CBC.
// If the secret is base64 encoded like the APP_KEY:
if (str_starts_with($secret, 'base64:')) {
$key = base64_decode(substr($secret, 7));
} else {
// Otherwise, we hash it to ensure 32 bytes for AES-256-CBC.
$key = hash('sha256', $secret, true);
}
return new Encrypter($key, config('app.cipher', 'AES-256-CBC'));
}
/**
* Cast the given value.
*
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
if ($value === null) {
return null;
}
try {
$decrypted = $this->getEncrypter()->decryptString($value);
return json_decode($decrypted, true);
} catch (Exception $e) {
// Return null or throw depending on how strict we want to be.
return null;
}
}
/**
* Prepare the given value for storage.
*
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
if ($value === null) {
return null;
}
$json = json_encode($value);
return $this->getEncrypter()->encryptString($json);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreIntegrationRequest;
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
use Illuminate\Routing\Controller;
class IntegrationController extends Controller
{
public function index()
{
return response()->json(Integration::all());
}
public function store(StoreIntegrationRequest $request)
{
$integration = Integration::create($request->validated());
return response()->json($integration, 201);
}
public function show(Integration $integration)
{
return response()->json($integration);
}
public function update(UpdateIntegrationRequest $request, Integration $integration)
{
$integration->update($request->validated());
return response()->json($integration->fresh());
}
public function destroy(Integration $integration)
{
$integration->delete();
return response()->noContent();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreTenantIntegrationRequest;
use App\Domains\Integration\Services\TenantIntegrationService;
use Illuminate\Routing\Controller;
class TenantIntegrationController extends Controller
{
protected TenantIntegrationService $tenantIntegrationService;
public function __construct(TenantIntegrationService $tenantIntegrationService)
{
$this->tenantIntegrationService = $tenantIntegrationService;
}
public function index(string $tenantCode)
{
return response()->json($this->tenantIntegrationService->getAllForTenant($tenantCode));
}
public function show(string $tenantCode, string $integrationCode)
{
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
if (!$integration) {
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
}
return response()->json($integration);
}
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
{
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
$tenantIntegration = $this->tenantIntegrationService->updateOrCreateIntegration(
$tenantCode,
$integration,
$request->input('integration_data', [])
);
return response()->json($tenantIntegration);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Integration\Models;
use Illuminate\Database\Eloquent\Model;
class Integration extends Model
{
protected $table = 'integrations';
protected $fillable = [
'integration_code',
'name',
'integration_data_schema',
];
protected $casts = [
'integration_data_schema' => 'array',
];
public function tenantIntegrations()
{
return $this->hasMany(TenantIntegration::class, 'integration_code', 'integration_code');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model;
class TenantIntegration extends Model
{
protected $table = 'tenant_integration';
protected $fillable = [
'integration_code',
'tenant_code',
'integration_data',
];
protected $casts = [
'integration_data' => EncryptedIntegrationData::class,
];
public function integration()
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreIntegrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
'name' => ['required', 'string', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Integration\Requests;
use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class StoreTenantIntegrationRequest extends FormRequest
{
protected ?Integration $integrationModel = null;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation()
{
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
if (!$this->integrationModel) {
throw ValidationException::withMessages([
'integration_code' => 'Integration not found.'
]);
}
}
public function rules(): array
{
$rules = [];
// Dynamic validation rules based on the integration data schema
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
$rules['integration_data.' . $field] = $rule;
}
}
return $rules;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateIntegrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$integration = $this->route('integration');
return [
'name' => ['sometimes', 'required', 'string', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
// the code shouldn't ideally be updatable, but if it is:
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,' . ($integration->id ?? '')],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
class TenantIntegrationService
{
public function getTenantIntegration(string $tenantCode, string $integrationCode): ?TenantIntegration
{
return TenantIntegration::where('tenant_code', $tenantCode)
->where('integration_code', $integrationCode)
->first();
}
public function getAllForTenant(string $tenantCode)
{
return TenantIntegration::with('integration')
->where('tenant_code', $tenantCode)
->get();
}
public function updateOrCreateIntegration(string $tenantCode, Integration $integration, array $data): TenantIntegration
{
return TenantIntegration::updateOrCreate(
[
'tenant_code' => $tenantCode,
'integration_code' => $integration->integration_code,
],
[
'integration_data' => $data,
]
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
use App\Domains\Integration\Controllers\IntegrationController;
use App\Domains\Integration\Controllers\TenantIntegrationController;
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::group(['prefix' => '{tenant_code}/integrations'], function () {
Route::get('/', [TenantIntegrationController::class, 'index']);
Route::get('/{integration_code}', [TenantIntegrationController::class, 'show']);
Route::post('/{integration_code}', [TenantIntegrationController::class, 'store']);
});

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Tenant\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\BankAccount\Models\BankAccount;
use App\Domains\Catalog\Models\Product;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('integrations', function (Blueprint $table) {
$table->id();
$table->string('integration_code')->unique();
$table->string('name');
$table->json('integration_data_schema')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('integrations');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_integration', function (Blueprint $table) {
$table->id();
$table->string('integration_code');
$table->string('tenant_code');
$table->longText('integration_data')->nullable(); // Stored as longText because it's encrypted JSON
$table->timestamps();
// Foreign keys
$table->foreign('integration_code')->references('integration_code')->on('integrations')->onDelete('cascade');
$table->foreign('tenant_code')->references('codigo')->on('tenants')->onDelete('cascade');
$table->unique(['integration_code', 'tenant_code']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tenant_integration');
}
};

View File

@@ -0,0 +1,26 @@
<?php
namespace Database\Seeders;
use App\Domains\Integration\Models\Integration;
use Illuminate\Database\Seeder;
class TelepagosIntegrationSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
Integration::updateOrCreate(
['integration_code' => 'telepagos'],
[
'name' => 'Telepagos',
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
]
);
}
}

View File

@@ -10,3 +10,4 @@ require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
require __DIR__.'/../app/Domains/BankAccount/routes/api.php';
require __DIR__.'/../app/Domains/Integration/routes/api.php';