Ein Sitz fuehrt Absicht und Wirklichkeit getrennt

status = was der Inhaber will, nc_state = was in der Nextcloud ist. Ein
einzelnes Feld muesste luegen — und "fehlgeschlagen" liesse sich gar nicht
sagen.

Rollen werden Nextcloud-Gruppen. Der Inhaber-Sitz wird mit dem Admin-Konto
verknuepft, das die Bereitstellung laengst angelegt hat.

SeatFactory setzt nc_state jetzt explizit (wie status/locale bei Customer
und Instance): Eloquents create() liest DB-Spalten-Vorgaben nicht ins
In-Memory-Modell zurueck, sonst haette Seat::factory()->create()->nc_state
null statt 'none' geliefert.
feat/versandtakt
nexxo 2026-08-03 20:48:36 +02:00
parent 6513ccdb00
commit 94210a7176
4 changed files with 192 additions and 2 deletions

View File

@ -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

View File

@ -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,
];
}

View File

@ -0,0 +1,53 @@
<?php
use App\Models\Seat;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Ein Sitz fuehrt ab hier ZWEI Angaben nebeneinander.
*
* `status` ist, was der Inhaber WILL er aendert sich sofort beim Klick.
* `nc_state` ist, was in der Nextcloud WIRKLICH ist er aendert sich, wenn
* der Auftrag durch ist. Ein einzelnes Feld muesste luegen: entweder es
* behauptet einen Erfolg, den nur die Warteschlange bringen kann, oder es
* verschweigt dem Inhaber, dass sein Klick angekommen ist.
*
* Nur so laesst sich "fehlgeschlagen" ueberhaupt sagen.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('seats', function (Blueprint $table) {
// Einmal gesetzt, nie wieder geaendert: Nextcloud kann Benutzer
// nicht umbenennen.
$table->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']);
});
}
};

View File

@ -0,0 +1,73 @@
<?php // tests/Feature/Seats/SeatModelTest.php
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Seat;
it('startet jeden Sitz als noch nicht in der Nextcloud', function () {
expect(Seat::factory()->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);
});