Wanderung friert das Nachziehen selbst ein, statt die Modellmethode zu rufen

up() rief bisher Seat::linkToInstanceAdmin() auf. Ein migrate:fresh auf
frischer Datenbank haette damit die dann aktuelle Fassung dieser Methode
mitgefahren, nicht die von heute. Die Nachbefuellung ist jetzt eine eigene,
dokumentiert duplizierte Fassung ueber den Query Builder.

nc_admin_ref ist am Modell verschluesselt gecastet - roher Spaltenzugriff
haette Chiffretext statt Klartext geliefert. Crypt::decryptString() macht
denselben Aufruf wie der Modell-Cast, ohne eine Modellklasse zu laden.

Kleinere Nachbesserung: fill() statt forceFill() in linkToInstanceAdmin()
(alle drei Felder stehen in $fillable), und die Gruppen-Zuordnungspruefung
verifiziert jetzt die konkreten Namen 'mitarbeiter'/'nur-lesen', nicht nur
"irgendein Wert".
feat/versandtakt
nexxo 2026-08-03 20:59:45 +02:00
parent 43d4c02353
commit 80974108b1
3 changed files with 59 additions and 9 deletions

View File

@ -73,7 +73,7 @@ class Seat extends Model
return;
}
$this->forceFill([
$this->fill([
'nc_username' => $ref,
'nc_state' => self::STATE_SYNCED,
'nc_synced_at' => now(),

View File

@ -1,8 +1,10 @@
<?php
use App\Models\Seat;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
@ -15,6 +17,26 @@ use Illuminate\Support\Facades\Schema;
* verschweigt dem Inhaber, dass sein Klick angekommen ist.
*
* Nur so laesst sich "fehlgeschlagen" ueberhaupt sagen.
*
* ## Warum das Nachziehen unten den Query Builder benutzt, nicht Seat::linkToInstanceAdmin()
*
* Eine Wanderung soll einfrieren, was zum Zeitpunkt ihres Schreibens galt.
* Riefe up() stattdessen die Modellmethode auf, wuerde jeder kuenftige Lauf
* gegen eine frische Datenbank CI, ein neuer Entwickler, `migrate:fresh`
* die DANN aktuelle Fassung dieser Methode ausfuehren, nicht die von heute.
* Heute ist sie harmlos; bekommt sie spaeter eine Nebenwirkung (einen
* API-Aufruf, einen Warteschlangen-Auftrag), faehrt jede Wanderung, die je
* ueber diese Zeile lief, sie rueckwirkend mit ohne dass es jemand so
* wollte. Der Block unten ist deshalb eine eigene, bewusst duplizierte
* Fassung derselben Auswahl (Instanz mit status in active/
* cancellation_scheduled, juengste zuerst, nur bei gesetztem nc_admin_ref).
* BITTE NICHT „aufraeumen" und durch den Modellaufruf ersetzen — das ist
* genau die Kopplung, die hier vermieden wird.
*
* `nc_admin_ref` ist am Instance-Modell 'encrypted' gecastet; roher
* DB::table()-Zugriff liefert den Chiffretext. Crypt::decryptString() ist
* exakt dieselbe Entschluesselung, die der Cast im Hintergrund aufruft
* ohne dafuer eine Modellklasse zu laden.
*/
return new class extends Migration
{
@ -32,14 +54,36 @@ return new class extends Migration
// 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) {
DB::table('seats')->where('role', 'owner')->chunkById(100, function ($sitze) {
foreach ($sitze as $sitz) {
$sitz->linkToInstanceAdmin();
$roh = DB::table('instances')
->where('customer_id', $sitz->customer_id)
->whereIn('status', ['active', 'cancellation_scheduled'])
->orderByDesc('id')
->value('nc_admin_ref');
if ($roh === null) {
continue;
}
try {
$ref = Crypt::decryptString($roh);
} catch (DecryptException) {
// Nicht entschluesselbar (z. B. ein Schluesselwechsel ohne
// Migration der Altwerte) wird wie „kein Konto" behandelt —
// ein Wanderungslauf darf daran nicht scheitern.
continue;
}
if (blank($ref)) {
continue;
}
DB::table('seats')->where('id', $sitz->id)->update([
'nc_username' => $ref,
'nc_state' => 'synced',
'nc_synced_at' => now(),
]);
}
});
}

View File

@ -15,6 +15,12 @@ it('bildet jede Rolle auf genau eine Nextcloud-Gruppe ab', function () {
expect(Seat::GROUPS)->toHaveKey($rolle)
->and(Seat::GROUPS[$rolle])->not->toBe('');
}
// Nicht nur "irgendein Wert" — die Namen selbst, denn sie wandern spaeter
// woertlich in `occ group:adduser`. Ein Vertipper darin faellt sonst
// erst auf einer echten Instanz auf, nicht hier.
expect(Seat::GROUPS['member'])->toBe('mitarbeiter')
->and(Seat::GROUPS['readonly'])->toBe('nur-lesen');
});
it('fuehrt owner und admin in die Admin-Gruppe', function () {