diff --git a/app/Models/Seat.php b/app/Models/Seat.php index eb3577b..f70208f 100644 --- a/app/Models/Seat.php +++ b/app/Models/Seat.php @@ -14,11 +14,70 @@ class Seat extends Model public const ROLES = ['owner', 'admin', 'member', 'readonly']; - protected $fillable = ['customer_id', 'email', 'name', 'role', 'status', 'invited_at']; + /** + * Welche Nextcloud-Gruppe eine Rolle bedeutet. + * + * Rollen sind Gruppen im Gast, damit weitere spaeter eine Zeile sind und + * kein Umbau. `owner` und `admin` teilen sich `admin` — der Unterschied + * zwischen beiden ist eine CluPilot-Angelegenheit (der letzte owner darf + * nicht entfernt werden), keine Nextcloud-Angelegenheit. + */ + public const GROUPS = [ + 'owner' => 'admin', + 'admin' => 'admin', + 'member' => 'mitarbeiter', + 'readonly' => 'nur-lesen', + ]; + + public const STATE_NONE = 'none'; + + public const STATE_PENDING = 'pending'; + + public const STATE_SYNCED = 'synced'; + + public const STATE_FAILED = 'failed'; + + protected $fillable = [ + 'customer_id', 'email', 'name', 'role', 'status', 'invited_at', + 'nc_username', 'nc_state', 'nc_error', 'nc_synced_at', + ]; protected function casts(): array { - return ['invited_at' => 'datetime']; + return ['invited_at' => 'datetime', 'nc_synced_at' => 'datetime']; + } + + /** + * Die Rolle, die keinen eigenen Speicherplatz bekommt — und die einzige, + * bei der ein eigener Wert am Konto richtig ist. Siehe NextcloudUsers. + */ + public function isReadonly(): bool + { + return $this->role === 'readonly'; + } + + /** + * Verknuepft einen Inhaber-Sitz mit dem Admin-Konto seiner Instanz. + * + * Dieses Konto existiert in der Nextcloud tatsaechlich — CreateCustomerAdmin + * hat es beim Aufbau angelegt. Ein zweites Admin-Konto fuer dieselbe Person + * waere eines zu viel. + */ + public function linkToInstanceAdmin(): void + { + $ref = $this->customer?->instances() + ->whereIn('status', ['active', 'cancellation_scheduled']) + ->latest('id')->first()?->nc_admin_ref; + + if (blank($ref)) { + return; + } + + $this->forceFill([ + 'nc_username' => $ref, + 'nc_state' => self::STATE_SYNCED, + 'nc_synced_at' => now(), + ])->save(); } public function customer(): BelongsTo diff --git a/database/factories/SeatFactory.php b/database/factories/SeatFactory.php index 4ea4f6e..2017537 100644 --- a/database/factories/SeatFactory.php +++ b/database/factories/SeatFactory.php @@ -20,6 +20,11 @@ class SeatFactory extends Factory 'role' => 'member', 'status' => 'active', 'invited_at' => now(), + // Eloquents create() liest die DB-Vorgabe nicht ins In-Memory-Modell + // zurueck — ohne diese Zeile bliebe nc_state hier null statt 'none', + // wie bei jedem anderen Feld mit Spalten-Vorgabe in diesem Bestand + // (siehe CustomerFactory::status, InstanceFactory::status). + 'nc_state' => Seat::STATE_NONE, ]; } diff --git a/database/migrations/2026_08_03_180000_add_nextcloud_columns_to_seats.php b/database/migrations/2026_08_03_180000_add_nextcloud_columns_to_seats.php new file mode 100644 index 0000000..73ef568 --- /dev/null +++ b/database/migrations/2026_08_03_180000_add_nextcloud_columns_to_seats.php @@ -0,0 +1,53 @@ +string('nc_username')->nullable()->after('email'); + $table->string('nc_state', 16)->default('none')->after('status'); + $table->text('nc_error')->nullable()->after('nc_state'); + $table->timestamp('nc_synced_at')->nullable()->after('nc_error'); + }); + + // Bestandssitze sind ehrlich beschrieben: angelegt, nie eingeladen — + // denn eingeladen hat sie nie jemand, resend() war eine Attrappe. + // EINE Ausnahme: der Inhaber-Sitz IST das Admin-Konto der Instanz. + // + // with('customer') vor chunkById: linkToInstanceAdmin() liest + // $this->customer, und ohne Eager Load waere das eine Anfrage pro + // Sitz statt einer pro Bloecken von 100 — bei owner-Sitzen (einer + // je Kunde) ist der Bestand hier so gross wie der Kundenstamm. + Seat::query()->where('role', 'owner')->with('customer')->chunkById(100, function ($sitze) { + foreach ($sitze as $sitz) { + $sitz->linkToInstanceAdmin(); + } + }); + } + + public function down(): void + { + Schema::table('seats', function (Blueprint $table) { + $table->dropColumn(['nc_username', 'nc_state', 'nc_error', 'nc_synced_at']); + }); + } +}; diff --git a/tests/Feature/Seats/SeatModelTest.php b/tests/Feature/Seats/SeatModelTest.php new file mode 100644 index 0000000..3b11074 --- /dev/null +++ b/tests/Feature/Seats/SeatModelTest.php @@ -0,0 +1,73 @@ +create()->nc_state)->toBe(Seat::STATE_NONE); +}); + +it('bildet jede Rolle auf genau eine Nextcloud-Gruppe ab', function () { + // Keine Rolle ohne Gruppe: eine Rolle, die im Portal waehlbar ist und im + // Gast nichts bewirkt, ist genau die Attrappe, die hier abgeschafft wird. + foreach (Seat::ROLES as $rolle) { + expect(Seat::GROUPS)->toHaveKey($rolle) + ->and(Seat::GROUPS[$rolle])->not->toBe(''); + } +}); + +it('fuehrt owner und admin in die Admin-Gruppe', function () { + expect(Seat::GROUPS['owner'])->toBe('admin') + ->and(Seat::GROUPS['admin'])->toBe('admin'); +}); + +it('erkennt die Rolle, die keinen Speicherplatz bekommt', function () { + expect(Seat::factory()->create(['role' => 'readonly'])->isReadonly())->toBeTrue() + ->and(Seat::factory()->create(['role' => 'member'])->isReadonly())->toBeFalse(); +}); + +it('verknuepft den Inhaber-Sitz mit dem bestehenden Admin-Konto', function () { + // Die Wanderung. Der owner-Sitz eines Kunden, dessen Instanz ein + // nc_admin_ref traegt, IST dieses Konto — die Bereitstellung hat es + // angelegt. Stuende er auf 'none', boete das Panel dem Inhaber an, sich + // selbst einzuladen, und der Auftrag traefe auf einen Benutzer, den es + // laengst gibt. + $customer = Customer::factory()->create(); + Instance::factory()->for($customer)->create(['nc_admin_ref' => 'admin', 'status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'owner']); + + // Die Wanderung lief beim Anlegen der Testdatenbank; hier wird die + // Nachziehmethode geprueft, die sie benutzt. + $sitz->linkToInstanceAdmin(); + + expect($sitz->fresh()->nc_username)->toBe('admin') + ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED); +}); + +it('laesst einen Inhaber-Sitz ohne Instanz unangetastet', function () { + // Kein Auftrag hat je ein Admin-Konto angelegt — es gibt nichts zu + // verknuepfen, und es darf nichts krachen, weil customer->instances() + // leer ist. + $customer = Customer::factory()->create(); + $sitz = Seat::factory()->for($customer)->create(['role' => 'owner']); + + $sitz->linkToInstanceAdmin(); + + expect($sitz->fresh()->nc_username)->toBeNull() + ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE); +}); + +it('laesst einen Inhaber-Sitz ohne nc_admin_ref unangetastet', function () { + // Eine Instanz ohne Admin-Konto — etwa eine, deren Bereitstellung noch + // nicht bis dahin gekommen ist. blank() statt einer reinen Null-Pruefung, + // weil ein leerer String hier dieselbe Aussage ist wie kein Wert. + $customer = Customer::factory()->create(); + Instance::factory()->for($customer)->create(['nc_admin_ref' => null, 'status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'owner']); + + $sitz->linkToInstanceAdmin(); + + expect($sitz->fresh()->nc_username)->toBeNull() + ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE); +});