diff --git a/HANDOFF.md b/HANDOFF.md index ce610b2..912cd09 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -84,7 +84,8 @@ Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo - `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona: [Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi). -- Il commit successivo chiude i blocchi 2 e 3 della revisione (sotto). +- `426a952` — blocchi 2 e 3 della revisione (sotto); le ultime due righe + della tabella sono nel commit successivo. Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare. @@ -111,22 +112,20 @@ sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare. | Worker ucciso dopo 10 s al riavvio | `stop_grace_period: 16m` | | Redis `allkeys-lru` poteva sfrattare i job in coda | `volatile-lru` | | Cache statica di `Setting` vecchia nel worker | svuotata a ogni job (`Queue::before`) | +| Dopo un ripristino le piantine pubblicate risultavano "da pubblicare" (lo stato dipendeva dall'id del territorio) | impronta con il numero, non l'id; la piantina ricorda il PDF pubblicato (`pdf_pubblicato`, migrazione `2026_10_12_000020`) | +| `corrente()` / `liberaCorrente()` potevano cancellare il puntatore di un'operazione appena avviata | `corrente()` in sola lettura; `liberaCorrente()` sotto lo stesso mutex di `occupa()` | | Pulsanti profilo/menu che non si nascondevano (barra che trabocca su telefono) | `.tm-btn` sta fuori dai layer di Tailwind e vince su `hidden`: si nasconde il contenitore | Non cambiato, di proposito: `trustProxies('*')` (commento in `bootstrap/app.php`: con `real_ip` di nginx, restringerlo romperebbe HTTPS). -Suite: **427 test verdi**. Provati in browser in locale: file protetti (404 +Suite: **429 test verdi**. Provati in browser in locale: file protetti (404 senza login, PDF e miniature con login), caricamento a lotti (25 PDF in 2 lotti, `Thumbs.db` ignorato, nessun territorio toccato, posto liberato). **Non provato / aperto:** - chiusura delle sessioni con due browser veri; coda e worker con un riavvio reale; modifica vertici al tocco su tablet vero; -- dopo un ripristino le piantine pubblicate risultano "Modifiche da pubblicare" - (l'import ricrea i territori con id nuovi): verso sicuro, basta ripubblicare; -- `corrente()` / `liberaCorrente()` di `BackupOperationState` fanno get+forget - senza mutex (finestra di millisecondi); - l'upload Livewire ha `throttle:60,1`: oltre ~1200 file al minuto qualche lotto riceve 429 (il browser lo segnala); - numeri di territorio con lettere davanti ("A12") non si abbinano da nome diff --git a/app/Models/TerritorioMappa.php b/app/Models/TerritorioMappa.php index 9892f51..3475f05 100644 --- a/app/Models/TerritorioMappa.php +++ b/app/Models/TerritorioMappa.php @@ -33,6 +33,7 @@ class TerritorioMappa extends Model 'geojson', 'geojson_pubblicato', 'impronta_pubblicata', + 'pdf_pubblicato', 'pubblicata_at', 'image_path', 'rendered_at', @@ -110,9 +111,11 @@ class TerritorioMappa extends Model { $territorio = $this->territorio; + // Il territorio entra con il numero, non con l'id: il numero è unico e + // il ripristino di un backup ricrea i territori con id nuovi, che + // facevano risultare da pubblicare ogni piantina appena ripristinata. return hash('sha256', json_encode([ $this->geometria(), - $this->territorio_id, $territorio ? trim((string) $territorio->numero) : null, $territorio?->zona?->nome, $territorio ? trim((string) $territorio->confini) : null, @@ -131,15 +134,19 @@ class TerritorioMappa extends Model } /** - * Il PDF in vigore del territorio è uno composto da una piantina. Se nel - * frattempo è stato sostituito per altre vie (ripristino di una versione, - * caricamento a mano, import), quello che arriva ai proclamatori non è - * più questo disegno, anche se l'impronta torna. + * Il PDF in vigore del territorio è ancora quello prodotto dall'ultima + * pubblicazione. Se nel frattempo è stato sostituito per altre vie + * (ripristino di una versione, caricamento a mano, import), quello che + * arriva ai proclamatori non è più questo disegno, anche se l'impronta + * torna. Si confronta il percorso, che il backup conserva: prima si + * cercava l'id del territorio nel nome del file, e dopo un ripristino + * (id nuovi) non tornava più. */ public function pdfInUsoDallaPiantina(): bool { - return $this->territorio_id !== null - && str_starts_with((string) $this->territorio?->pdf_path, 'territori-pdf/piantina-' . $this->territorio_id . '-'); + return $this->pdf_pubblicato !== null + && $this->territorio !== null + && $this->territorio->pdf_path === $this->pdf_pubblicato; } /** Il testo dell'etichetta di stato, uguale in ogni pagina. */ diff --git a/app/Services/BackupOperationState.php b/app/Services/BackupOperationState.php index 06f9a2a..8e3f8e2 100644 --- a/app/Services/BackupOperationState.php +++ b/app/Services/BackupOperationState.php @@ -112,10 +112,12 @@ class BackupOperationState $stato = $this->get($id); // Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto - // e' libero, e il puntatore va tolto di mezzo. + // e' libero. Il puntatore pero' qui non si tocca: corrente() la + // chiamano anche le pagine che si aggiornano, fuori dal mutex di + // occupa(), e cancellarlo poteva togliere quello che un'altra + // richiesta aveva appena scritto per un'operazione nuova. Lo + // sovrascrive initialize() al prossimo avvio. if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) { - Cache::forget(self::CHIAVE_CORRENTE); - return null; } @@ -296,8 +298,17 @@ class BackupOperationState */ private function liberaCorrente(string $id): void { - if (Cache::get(self::CHIAVE_CORRENTE) === $id) { - Cache::forget(self::CHIAVE_CORRENTE); + // Leggi-e-cancella sotto lo stesso mutex di occupa(): fra i due passi + // un'altra richiesta poteva prendere il posto, e il forget le toglieva + // il puntatore. Se il mutex non arriva si lascia stare: il puntatore a + // un'operazione chiusa vale già come posto libero (vedi corrente()). + try { + Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id) { + if (Cache::get(self::CHIAVE_CORRENTE) === $id) { + Cache::forget(self::CHIAVE_CORRENTE); + } + }); + } catch (LockTimeoutException) { } } diff --git a/app/Services/Piantine/PubblicazionePiantina.php b/app/Services/Piantine/PubblicazionePiantina.php index f071a32..a5f7f93 100644 --- a/app/Services/Piantine/PubblicazionePiantina.php +++ b/app/Services/Piantine/PubblicazionePiantina.php @@ -176,6 +176,9 @@ class PubblicazionePiantina $mappa->update([ 'geojson_pubblicato' => $mappa->geojson, 'impronta_pubblicata' => $stato['impronta'], + // Il PDF appena messo in vigore: finché il territorio usa questo + // file, la piantina è "Pubblicata" (vedi pdfInUsoDallaPiantina()). + 'pdf_pubblicato' => $mappa->territorio->fresh()->pdf_path, 'pubblicata_at' => now(), ]); diff --git a/database/migrations/2026_10_12_000020_piantine_ricordano_il_pdf_pubblicato.php b/database/migrations/2026_10_12_000020_piantine_ricordano_il_pdf_pubblicato.php new file mode 100644 index 0000000..77431ab --- /dev/null +++ b/database/migrations/2026_10_12_000020_piantine_ricordano_il_pdf_pubblicato.php @@ -0,0 +1,68 @@ +string('pdf_pubblicato')->nullable()->after('impronta_pubblicata'); + }); + + TerritorioMappa::with('territorio.zona') + ->whereNotNull('pubblicata_at') + ->whereNotNull('territorio_id') + ->get() + ->each(function (TerritorioMappa $mappa) { + $territorio = $mappa->territorio; + $pdf = (string) $territorio?->pdf_path; + + $improntaVecchia = hash('sha256', json_encode([ + $mappa->geometria(), + $mappa->territorio_id, + $territorio ? trim((string) $territorio->numero) : null, + $territorio?->zona?->nome, + $territorio ? trim((string) $territorio->confini) : null, + ])); + $improntaNuova = $mappa->impronta(); + + $eraPubblicata = in_array($mappa->impronta_pubblicata, [$improntaVecchia, $improntaNuova], true) + && str_starts_with($pdf, 'territori-pdf/piantina-' . $mappa->territorio_id . '-'); + + if (! $eraPubblicata) { + return; + } + + $mappa->forceFill([ + 'impronta_pubblicata' => $improntaNuova, + 'pdf_pubblicato' => $pdf, + ])->saveQuietly(); + }); + } + + public function down(): void + { + Schema::table('territorio_mappe', function (Blueprint $table) { + $table->dropColumn('pdf_pubblicato'); + }); + } +}; diff --git a/tests/Feature/PiantineManagerTest.php b/tests/Feature/PiantineManagerTest.php index 1837df9..16712ba 100644 --- a/tests/Feature/PiantineManagerTest.php +++ b/tests/Feature/PiantineManagerTest.php @@ -212,6 +212,7 @@ XML; $mappa->update([ 'geojson_pubblicato' => $mappa->geojson, 'impronta_pubblicata' => $mappa->impronta(), + 'pdf_pubblicato' => $pdf, 'pubblicata_at' => now(), ]); diff --git a/tests/Feature/RipristinoBackupTest.php b/tests/Feature/RipristinoBackupTest.php index 935b44b..f362fd2 100644 --- a/tests/Feature/RipristinoBackupTest.php +++ b/tests/Feature/RipristinoBackupTest.php @@ -105,6 +105,26 @@ class RipristinoBackupTest extends TestCase $this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg')); } + /** + * Una piantina pubblicata resta "Pubblicata" anche se il ripristino ricrea + * il suo territorio con un id nuovo: prima lo stato dipendeva dall'id. + */ + public function test_una_piantina_pubblicata_resta_pubblicata_dopo_il_ripristino(): void + { + $this->installazioneConPiantine(); + $mappa = TerritorioMappa::with('territorio.zona')->sole(); + $mappa->update([ + 'impronta_pubblicata' => $mappa->impronta(), + 'pdf_pubblicato' => $mappa->territorio->pdf_path, + ]); + $this->assertSame(TerritorioMappa::STATO_PUBBLICATA, $mappa->fresh()->stato()); + + $stato = $this->ripristina($this->backup()); + + $this->assertSame('completed', $stato['status'], (string) $stato['error']); + $this->assertSame(TerritorioMappa::STATO_PUBBLICATA, TerritorioMappa::sole()->stato()); + } + /** * Compatibilita' all'indietro: un archivio di formato 1 si ripristina. * Non contiene piantine e storico, che restano vuoti — tenere quelle diff --git a/tests/Unit/BackupOperationStateTest.php b/tests/Unit/BackupOperationStateTest.php index d4b71e3..08cf3dc 100644 --- a/tests/Unit/BackupOperationStateTest.php +++ b/tests/Unit/BackupOperationStateTest.php @@ -65,15 +65,27 @@ class BackupOperationStateTest extends TestCase /** * Il caso che il TTL da solo non copre: lo stato dell'operazione e' scaduto - * ma il puntatore no. Senza questa pulizia il posto resterebbe occupato da - * un'operazione che non esiste piu'. + * ma il puntatore no. Il posto deve risultare libero e potersi prendere; + * il puntatore lo sovrascrive l'operazione nuova (corrente() non lo + * cancella: girando fuori dal mutex poteva togliere quello di un'altra). */ public function test_un_puntatore_orfano_non_tiene_occupato_il_posto(): void { Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600); $this->assertNull($this->stato->corrente()); - $this->assertNull(Cache::get(self::CHIAVE_CORRENTE), 'Il puntatore orfano va anche rimosso.'); + $this->assertNull($this->stato->occupa('op-nuova', BackupOperationState::TIPO_BACKUP, 'In coda')); + $this->assertSame('op-nuova', $this->stato->corrente()); + } + + /** Un corrente() fuori dal mutex non deve togliere il puntatore appena scritto. */ + public function test_corrente_non_cancella_il_puntatore(): void + { + Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600); + + $this->stato->corrente(); + + $this->assertSame('op-svanita', Cache::get(self::CHIAVE_CORRENTE)); } public function test_un_puntatore_a_un_operazione_gia_chiusa_non_tiene_occupato_il_posto(): void @@ -90,7 +102,7 @@ class BackupOperationStateTest extends TestCase Cache::put(self::CHIAVE_CORRENTE, 'op-1', 3600); $this->assertNull($this->stato->corrente()); - $this->assertNull(Cache::get(self::CHIAVE_CORRENTE)); + $this->assertNull($this->stato->occupa('op-2', BackupOperationState::TIPO_BACKUP, 'In coda')); } /**