diff --git a/app/Domains/Integration/Models/ClientIntegration.php b/app/Domains/Integration/Models/ClientIntegration.php index 676bddb..999b7ce 100644 --- a/app/Domains/Integration/Models/ClientIntegration.php +++ b/app/Domains/Integration/Models/ClientIntegration.php @@ -3,22 +3,15 @@ namespace App\Domains\Integration\Models; use App\Domains\Client\Models\Client; -use App\Domains\Integration\Casts\EncryptedIntegrationData; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class ClientIntegration extends Model { - protected $hidden = ['integration_data']; - protected $fillable = [ 'client_id', 'integration_code', - 'integration_data', - ]; - - protected $casts = [ - 'integration_data' => EncryptedIntegrationData::class, + 'integration_instance_id', ]; /** @return BelongsTo */ @@ -32,4 +25,10 @@ class ClientIntegration extends Model { return $this->belongsTo(Integration::class, 'integration_code', 'integration_code'); } + + /** @return BelongsTo */ + public function integrationInstance(): BelongsTo + { + return $this->belongsTo(IntegrationInstance::class); + } } diff --git a/app/Domains/Integration/Models/Integration.php b/app/Domains/Integration/Models/Integration.php index c384cbf..78b4d9f 100644 --- a/app/Domains/Integration/Models/Integration.php +++ b/app/Domains/Integration/Models/Integration.php @@ -3,6 +3,7 @@ namespace App\Domains\Integration\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class Integration extends Model { @@ -21,8 +22,21 @@ class Integration extends Model 'requires_client_configuration' => 'boolean', ]; - public function clientIntegrations() + /** @return HasMany */ + public function instances(): HasMany + { + return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code'); + } + + /** @return HasMany */ + public function clientIntegrations(): HasMany { return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code'); } + + /** @return HasMany */ + public function websiteTypeIntegrations(): HasMany + { + return $this->hasMany(WebsiteTypeIntegration::class, 'integration_code', 'integration_code'); + } } diff --git a/app/Domains/Integration/Models/IntegrationInstance.php b/app/Domains/Integration/Models/IntegrationInstance.php new file mode 100644 index 0000000..e2d7aed --- /dev/null +++ b/app/Domains/Integration/Models/IntegrationInstance.php @@ -0,0 +1,35 @@ + EncryptedIntegrationData::class]; + + /** @return BelongsTo */ + public function integration(): BelongsTo + { + return $this->belongsTo(Integration::class, 'integration_code', 'integration_code'); + } + + /** @return HasMany */ + public function clientIntegrations(): HasMany + { + return $this->hasMany(ClientIntegration::class); + } + + /** @return HasMany */ + public function websiteTypeIntegrations(): HasMany + { + return $this->hasMany(WebsiteTypeIntegration::class); + } +} diff --git a/app/Domains/Integration/Models/WebsiteTypeIntegration.php b/app/Domains/Integration/Models/WebsiteTypeIntegration.php new file mode 100644 index 0000000..6b791f3 --- /dev/null +++ b/app/Domains/Integration/Models/WebsiteTypeIntegration.php @@ -0,0 +1,30 @@ + */ + public function websiteType(): BelongsTo + { + return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo'); + } + + /** @return BelongsTo */ + public function integration(): BelongsTo + { + return $this->belongsTo(Integration::class, 'integration_code', 'integration_code'); + } + + /** @return BelongsTo */ + public function integrationInstance(): BelongsTo + { + return $this->belongsTo(IntegrationInstance::class); + } +} diff --git a/app/Domains/Tenant/Models/WebsiteType.php b/app/Domains/Tenant/Models/WebsiteType.php index 23500cc..91acc11 100644 --- a/app/Domains/Tenant/Models/WebsiteType.php +++ b/app/Domains/Tenant/Models/WebsiteType.php @@ -3,6 +3,7 @@ namespace App\Domains\Tenant\Models; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Integration\Models\WebsiteTypeIntegration; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -36,6 +37,12 @@ class WebsiteType extends Model protected $table = 'website_type'; + /** @return HasMany */ + public function integrations(): HasMany + { + return $this->hasMany(WebsiteTypeIntegration::class, 'website_type_code', 'codigo'); + } + /** * @return BelongsTo */ diff --git a/database/migrations/2026_09_04_000000_create_integration_instances_table.php b/database/migrations/2026_09_04_000000_create_integration_instances_table.php new file mode 100644 index 0000000..dfa36eb --- /dev/null +++ b/database/migrations/2026_09_04_000000_create_integration_instances_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('integration_code'); + $table->string('name'); + $table->longText('integration_data')->nullable(); // Encrypted JSON. + $table->timestamps(); + + $table->foreign('integration_code')->references('integration_code')->on('integrations')->restrictOnDelete(); + // Allows associations to enforce one instance per integration and owner. + $table->unique(['id', 'integration_code'], 'integration_instances_id_code_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_instances'); + } +}; diff --git a/database/migrations/2026_09_04_000001_move_client_configuration_to_integration_instances.php b/database/migrations/2026_09_04_000001_move_client_configuration_to_integration_instances.php new file mode 100644 index 0000000..9c40dbc --- /dev/null +++ b/database/migrations/2026_09_04_000001_move_client_configuration_to_integration_instances.php @@ -0,0 +1,81 @@ +unsignedBigInteger('integration_instance_id')->nullable(); + }); + } + + DB::table('client_integrations')->whereNull('integration_instance_id')->orderBy('id')->chunkById(100, function ($associations): void { + foreach ($associations as $association) { + DB::transaction(function () use ($association): void { + $instanceId = DB::table('integration_instances')->insertGetId([ + 'integration_code' => $association->integration_code, + 'name' => $association->integration_code.' / client '.$association->client_id, + // Copy ciphertext verbatim: no decryption or re-encryption during migration. + 'integration_data' => $association->integration_data, + 'created_at' => $association->created_at, + 'updated_at' => $association->updated_at, + ]); + + DB::table('client_integrations')->where('id', $association->id)->update([ + 'integration_instance_id' => $instanceId, + ]); + }); + } + }); + + Schema::table('client_integrations', function (Blueprint $table): void { + $table->unsignedBigInteger('integration_instance_id')->nullable(false)->change(); + }); + + $hasInstanceForeignKey = collect(Schema::getForeignKeys('client_integrations')) + ->contains(fn (array $key): bool => $key['columns'] === ['integration_instance_id', 'integration_code']); + + if (! $hasInstanceForeignKey) { + Schema::table('client_integrations', function (Blueprint $table): void { + $table->foreign(['integration_instance_id', 'integration_code'], 'client_integrations_instance_code_fk') + ->references(['id', 'integration_code'])->on('integration_instances')->restrictOnDelete(); + }); + } + + if (Schema::hasColumn('client_integrations', 'integration_data')) { + Schema::table('client_integrations', function (Blueprint $table): void { + $table->dropColumn('integration_data'); + }); + } + } + + public function down(): void + { + Schema::table('client_integrations', function (Blueprint $table): void { + $table->longText('integration_data')->nullable(); + }); + + DB::table('client_integrations')->orderBy('id')->chunkById(100, function ($associations): void { + foreach ($associations as $association) { + DB::table('client_integrations')->where('id', $association->id)->update([ + 'integration_data' => DB::table('integration_instances') + ->where('id', $association->integration_instance_id)->value('integration_data'), + ]); + } + }); + + Schema::table('client_integrations', function (Blueprint $table): void { + // MySQL uses the short name; SQLite needs the columns to rebuild the table. + $table->dropForeign('client_integrations_instance_code_fk') + ->columns(['integration_instance_id', 'integration_code']); + $table->dropColumn('integration_instance_id'); + }); + } +}; diff --git a/database/migrations/2026_09_04_000002_create_website_type_integrations_table.php b/database/migrations/2026_09_04_000002_create_website_type_integrations_table.php new file mode 100644 index 0000000..07fe0b5 --- /dev/null +++ b/database/migrations/2026_09_04_000002_create_website_type_integrations_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('website_type_code'); + $table->string('integration_code'); + $table->unsignedBigInteger('integration_instance_id'); + $table->timestamps(); + + $table->foreign('website_type_code')->references('codigo')->on('website_type')->cascadeOnDelete(); + $table->foreign(['integration_instance_id', 'integration_code'], 'website_type_integrations_instance_code_fk') + ->references(['id', 'integration_code'])->on('integration_instances')->restrictOnDelete(); + $table->unique(['website_type_code', 'integration_code'], 'website_type_integrations_owner_code_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('website_type_integrations'); + } +}; diff --git a/tests/Unit/IntegrationInstanceSchemaTest.php b/tests/Unit/IntegrationInstanceSchemaTest.php new file mode 100644 index 0000000..eddb3b9 --- /dev/null +++ b/tests/Unit/IntegrationInstanceSchemaTest.php @@ -0,0 +1,170 @@ +previousFacadeApplication = Facade::getFacadeApplication(); + $this->previousContainer = Container::getInstance(); + $container = new Container; + Container::setInstance($container); + $container->instance('config', new Repository([ + 'services' => ['integrations' => ['secret' => 'schema-test-secret']], + 'app' => ['cipher' => 'AES-256-CBC'], + ])); + $this->database = new Manager($container); + $this->database->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'foreign_key_constraints' => true]); + $this->database->bootEloquent(); + $container->instance('db', $this->database->getDatabaseManager()); + $container->bind('db.schema', fn () => $this->database->getConnection()->getSchemaBuilder()); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($container); + + $schema = $this->database->getConnection()->getSchemaBuilder(); + $schema->create('clients', function (Blueprint $table): void { + $table->id(); + }); + $schema->create('website_type', function (Blueprint $table): void { + $table->id(); + $table->string('codigo')->unique(); + }); + (require __DIR__.'/../../database/migrations/2026_07_03_000001_create_integrations_table.php')->up(); + $schema->create('client_integrations', function (Blueprint $table): void { + $table->id(); + $table->foreignId('client_id')->constrained('clients')->cascadeOnDelete(); + $table->string('integration_code'); + $table->longText('integration_data')->nullable(); + $table->timestamps(); + $table->foreign('integration_code')->references('integration_code')->on('integrations')->cascadeOnDelete(); + $table->unique(['client_id', 'integration_code']); + }); + $db = $this->database->getConnection(); + $db->table('clients')->insert(['id' => 1]); + $db->table('website_type')->insert(['codigo' => 'onticket']); + $db->table('integrations')->insert([ + ['integration_code' => 'email', 'name' => 'Email'], + ['integration_code' => 'telepagos', 'name' => 'Telepagos'], + ]); + $db->table('client_integrations')->insert([ + 'client_id' => 1, 'integration_code' => 'email', 'integration_data' => 'existing-ciphertext', + ]); + $this->migrations = array_map(fn (string $file) => require $file, glob(__DIR__.'/../../database/migrations/2026_09_04_*.php')); + foreach ($this->migrations as $migration) { + $migration->up(); + } + } + + protected function tearDown(): void + { + $this->database->getConnection()->disconnect(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($this->previousFacadeApplication); + Container::setInstance($this->previousContainer); + parent::tearDown(); + } + + public function test_migration_preserves_ciphertext_and_rollback_restores_it(): void + { + $db = $this->database->getConnection(); + $association = $db->table('client_integrations')->first(); + self::assertSame('existing-ciphertext', $db->table('integration_instances')->where('id', $association->integration_instance_id)->value('integration_data')); + self::assertFalse($db->getSchemaBuilder()->hasColumn('client_integrations', 'integration_data')); + + foreach (array_reverse($this->migrations) as $migration) { + $migration->down(); + } + self::assertSame('existing-ciphertext', $db->table('client_integrations')->value('integration_data')); + self::assertFalse($db->getSchemaBuilder()->hasTable('integration_instances')); + } + + public function test_instances_encrypt_data_and_can_be_shared_through_relations(): void + { + $instance = IntegrationInstance::firstOrFail(); + $instance->update(['integration_data' => ['password' => 'private-value']]); + self::assertNotSame('private-value', $instance->getRawOriginal('integration_data')); + self::assertSame(['password' => 'private-value'], $instance->fresh()->integration_data); + self::assertArrayNotHasKey('integration_data', $instance->toArray()); + WebsiteTypeIntegration::create([ + 'website_type_code' => 'onticket', 'integration_code' => 'email', 'integration_instance_id' => $instance->id, + ]); + self::assertTrue(ClientIntegration::firstOrFail()->integrationInstance->is($instance)); + self::assertTrue(WebsiteType::firstOrFail()->integrations->first()->integrationInstance->is($instance)); + self::assertSame(1, $instance->clientIntegrations()->count()); + self::assertSame(1, $instance->websiteTypeIntegrations()->count()); + self::assertTrue(Integration::where('integration_code', 'email')->firstOrFail()->instances->first()->is($instance)); + } + + public function test_retry_after_foreign_key_failure_preserves_existing_instances(): void + { + $db = $this->database->getConnection(); + $instanceId = $db->table('client_integrations')->value('integration_instance_id'); + // Reproduce the state left by MySQL when ADD CONSTRAINT fails after the backfill. + $db->getSchemaBuilder()->table('client_integrations', function (Blueprint $table): void { + $table->dropForeign('client_integrations_instance_code_fk') + ->columns(['integration_instance_id', 'integration_code']); + $table->longText('integration_data')->nullable(); + }); + $db->table('client_integrations')->update(['integration_data' => 'existing-ciphertext']); + + $this->migrations[1]->up(); + $this->migrations[1]->up(); + + self::assertSame(1, $db->table('integration_instances')->count()); + self::assertSame($instanceId, $db->table('client_integrations')->value('integration_instance_id')); + self::assertSame('existing-ciphertext', $db->table('integration_instances')->value('integration_data')); + self::assertFalse($db->getSchemaBuilder()->hasColumn('client_integrations', 'integration_data')); + $keys = $db->getSchemaBuilder()->getForeignKeys('client_integrations'); + self::assertCount(1, array_filter($keys, fn (array $key): bool => $key['columns'] === ['integration_instance_id', 'integration_code'])); + } + + public function test_association_rejects_an_instance_from_another_integration(): void + { + $this->expectException(QueryException::class); + WebsiteTypeIntegration::create([ + 'website_type_code' => 'onticket', 'integration_code' => 'telepagos', 'integration_instance_id' => IntegrationInstance::firstOrFail()->id, + ]); + } + + public function test_client_cannot_have_two_instances_of_the_same_integration(): void + { + $instance = IntegrationInstance::create(['integration_code' => 'email', 'name' => 'Second']); + $this->expectException(QueryException::class); + ClientIntegration::create(['client_id' => 1, 'integration_code' => 'email', 'integration_instance_id' => $instance->id]); + } + + public function test_website_type_cannot_have_two_instances_of_the_same_integration(): void + { + WebsiteTypeIntegration::create([ + 'website_type_code' => 'onticket', 'integration_code' => 'email', 'integration_instance_id' => IntegrationInstance::firstOrFail()->id, + ]); + $instance = IntegrationInstance::create(['integration_code' => 'email', 'name' => 'Second']); + $this->expectException(QueryException::class); + WebsiteTypeIntegration::create([ + 'website_type_code' => 'onticket', 'integration_code' => 'email', 'integration_instance_id' => $instance->id, + ]); + } +}