++ Modulo pubblico: nella riconsegna il territorio si sceglie con il completamento automatico

Elenco con solo numero e zona (territori attivi e quelli ancora fuori),
mostrato solo per la riconsegna; il server accetta solo un numero
dell'elenco. Privacy, README e HANDOFF aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-21 17:49:08 +02:00
parent 2e1da51080
commit d198ad0b54
6 changed files with 96 additions and 15 deletions

View File

@@ -56,7 +56,7 @@ hanno dati da convertire.
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
| Backup / ripristino / azzeramento | ✅ verificati end-to-end |
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
| Test automatici | ✅ **612 test PHP + 13 JavaScript, verdi** (21/10/2026); copertura ancora parziale |
| Test automatici | ✅ **613 test PHP + 13 JavaScript, verdi** (21/10/2026); copertura ancora parziale |
| Prove nel browser | ⚠️ le funzioni del 19-21 ottobre sono verificate dai test e sul server; nel browser le usa l'utente sulla demo (richieste, cartacei e S-13 non ancora provati da lui) |
La storia precedente (luglio-agosto: backup e ripristino, sotto-app Piantine,
@@ -76,8 +76,14 @@ Dal documento `_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx`
- **Modulo pubblico** `/richiesta` (rotta `richiesta`, fuori dal login,
`throttle:60,1`; componente `Richieste\ModuloRichiesta`, layout guest):
nome e cognome, *nuovo territorio* o *riconsegna* (con numero), nota. Non
mostra dati esistenti. Anti-spam: campo trappola `sito` (classe
nome e cognome, *nuovo territorio* o *riconsegna* (con numero), nota.
Nella riconsegna il numero si sceglie con `<x-scelta>` (richiesta
dell'utente del 21/10: "il territorio fallo inserire con completamento
automatico"): opzioni con solo numero e zona, valore = numero; i territori
attivi più quelli disattivati ma ancora fuori; l'elenco va nella pagina
solo con *riconsegna* scelta. Il server accetta solo un numero di
quell'elenco (confronto senza maiuscole). Nessun altro dato esistente: né
nomi né stato delle assegnazioni. Anti-spam: campo trappola `sito` (classe
`.tm-trappola`), tempo minimo `SECONDI_MINIMI` = 3 s (in questi due casi
risponde "inviata" senza salvare), `MASSIMO_ORA` = 5 per IP (chiave del
RateLimiter con lo sha1 dell'IP). Il tempo si legge con `now()`, così i
@@ -1754,7 +1760,7 @@ blocchi che impedivano di scrivere il primo test sono stati rimossi: stage
docker compose --profile test run --build --rm test
```
**Coperto oggi — 612 test PHP e 13 JavaScript, tutti verdi (21/10/2026).**
**Coperto oggi — 613 test PHP e 13 JavaScript, tutti verdi (21/10/2026).**
Senza Docker in locale si fanno girare sul server: vedi [Dove gira
davvero](#dove-gira-davvero). I file storici, con cosa proteggono:

View File

@@ -767,8 +767,12 @@ docker compose --profile dev up -d --build
### Richieste dal modulo pubblico
- **Modulo pubblico** all'indirizzo `/richiesta`, senza login (l'indirizzo
completo è scritto nella Home, da passare ai proclamatori): nome e cognome,
*nuovo territorio* o *riconsegna* (con il numero del territorio), una nota.
Non mostra nessun dato già presente
*nuovo territorio* o *riconsegna*, una nota. Nella riconsegna il territorio
si sceglie con il **completamento automatico**: si scrive il numero o la
zona e compaiono i territori (attivi, o disattivati ma ancora fuori), con
numero e zona soltanto; si accetta solo un territorio dell'elenco. A parte
questo elenco il modulo non mostra nessun dato già presente: né nomi, né a
chi è assegnato un territorio o se lo è
- **Contro lo spam**: un campo trappola nascosto, un tempo minimo fra apertura
e invio (in questi due casi risponde "inviata" senza salvare) e al massimo 5
richieste all'ora dallo stesso indirizzo

View File

@@ -4,15 +4,20 @@ namespace App\Livewire\Richieste;
use App\Models\Richiesta;
use App\Models\Setting;
use App\Models\Territorio;
use App\Services\RichiesteServizio;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
* territorio nuovo o annuncia una riconsegna. Non mostra nessun dato già
* presente: chi lo apre vede solo i campi da compilare.
* territorio nuovo o annuncia una riconsegna. L'unico dato già presente che
* mostra è l'elenco dei territori nella riconsegna, per il completamento
* automatico del numero: numero e zona, mai a chi è assegnato né se lo è.
* Nomi e altri dati non si vedono.
*
* Contro lo spam, tre difese che a chi compila davvero non costano niente:
* - un campo trappola ($sito) nascosto alle persone e riempito dai programmi;
@@ -67,12 +72,17 @@ class ModuloRichiesta extends Component
$this->validate([
'tipo' => 'required|in:' . Richiesta::NUOVO . ',' . Richiesta::RICONSEGNA,
'nome' => 'required|string|min:3|max:100',
'numero' => 'nullable|required_if:tipo,' . Richiesta::RICONSEGNA . '|string|max:20',
'numero' => ['nullable', 'required_if:tipo,' . Richiesta::RICONSEGNA, 'string', 'max:20',
function ($attributo, $valore, $errore) {
if ($this->tipo === Richiesta::RICONSEGNA && ! self::territori()->whereRaw('LOWER(numero) = ?', [Str::lower(trim((string) $valore))])->exists()) {
$errore("Scegli il territorio dall'elenco.");
}
}],
'note' => 'nullable|string|max:1000',
], [
'nome.required' => 'Scrivi nome e cognome.',
'nome.min' => 'Scrivi nome e cognome.',
'numero.required_if' => 'Scrivi il numero del territorio che riconsegni.',
'numero.required_if' => 'Scegli il territorio che riconsegni.',
]);
RateLimiter::hit($chiave, 3600);
@@ -88,10 +98,40 @@ class ModuloRichiesta extends Component
$this->apertoAlle = now()->timestamp;
}
/**
* I territori che si possono riconsegnare: quelli attivi e quelli ancora
* fuori anche se nel frattempo disattivati.
*/
private static function territori(): Builder
{
return Territorio::query()->where(fn ($q) => $q->where('attivo', true)
->orWhereHas('assegnazioni', fn ($a) => $a->whereNull('returned_at')));
}
/**
* Le proposte del campo con ricerca (<x-scelta>): solo numero e zona. Il
* valore è il numero, come lo salva la richiesta.
*/
private static function opzioniTerritori(): array
{
return self::territori()->with('zona')->get()
->sortBy('numero', SORT_NATURAL)
->map(fn (Territorio $t) => array_filter([
'valore' => (string) $t->numero,
'etichetta' => "N° {$t->numero}",
'dettaglio' => $t->zona?->nome,
'chiavi' => [(string) $t->numero, (string) $t->zona?->nome],
], fn ($v) => $v !== null))
->values()
->all();
}
public function render()
{
return view('livewire.richieste.modulo-richiesta', [
'congregazione' => Setting::instance()->congregazione_nome ?: 'TerManager2',
// Solo quando serve: chi chiede un territorio nuovo non vede l'elenco.
'opzioniTerritori' => $this->tipo === Richiesta::RICONSEGNA && ! $this->inviata ? self::opzioniTerritori() : [],
])->layout('components.layouts.guest', ['title' => 'Richiesta territorio']);
}
}

View File

@@ -61,7 +61,7 @@
@endforeach
</ul>
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (email fittizia e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p>
<p class="mt-2">Il modulo pubblico di richiesta non mostra nessun dato: chi lo compila scrive soltanto. Le richieste le vedono e le gestiscono, dalla Home, solo i ruoli con il permesso sulle richieste.</p>
<p class="mt-2">Il modulo pubblico di richiesta non mostra dati personali: chi annuncia una riconsegna sceglie il territorio da un elenco con solo numero e zona, senza sapere a chi è assegnato né se lo è. Le richieste le vedono e le gestiscono, dalla Home, solo i ruoli con il permesso sulle richieste.</p>
</section>
<section>

View File

@@ -1,6 +1,7 @@
<div class="bg-white shadow-xl rounded-2xl p-6 sm:p-8 my-8" style="border-top:4px solid #6366f1">
{{-- Modulo pubblico (ModuloRichiesta): nessun dato già presente si vede
qui, solo i campi da compilare. --}}
qui, tranne l'elenco dei territori (numero e zona) per scegliere
quello da riconsegnare. --}}
<div class="text-center mb-6">
<div class="mx-auto h-14 w-14 rounded-xl flex items-center justify-center text-white font-bold text-xl mb-4 shadow-lg" style="background:linear-gradient(135deg,#4338ca,#6366f1)">T2</div>
<h1 class="text-2xl font-bold text-gray-900">Richiesta territorio</h1>
@@ -40,9 +41,8 @@
@if($tipo === \App\Models\Richiesta::RICONSEGNA)
<div>
<label for="numero" class="block text-sm font-medium text-gray-700">N° del territorio *</label>
<input wire:model="numero" type="text" id="numero" inputmode="numeric" maxlength="20" autocomplete="off"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5">
<label for="numero" class="block text-sm font-medium text-gray-700">Territorio che riconsegni *</label>
<x-scelta id="numero" wire:model="numero" :opzioni="$opzioniTerritori" segnaposto="Scrivi il numero o la zona" class="mt-1" />
@error('numero') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif

View File

@@ -15,6 +15,7 @@ use App\Models\Richiesta;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use App\Models\Zona;
use App\Notifications\RichiestaRicevuta;
use App\Services\RichiesteServizio;
use Database\Seeders\RolesAndPermissionsSeeder;
@@ -146,6 +147,7 @@ class RichiesteTest extends TestCase
public function test_senza_posta_la_richiesta_resta_comunque(): void
{
Notification::fake();
$this->territorio('12');
auth()->logout();
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Mario Rossi', 'numero' => '12'])->assertHasNoErrors();
@@ -162,6 +164,35 @@ class RichiesteTest extends TestCase
$this->assertSame(0, Richiesta::count());
}
public function test_nella_riconsegna_il_territorio_si_sceglie_dall_elenco(): void
{
$zona = Zona::create(['nome' => 'Centro storico']);
Territorio::create(['numero' => '12', 'zona_id' => $zona->id, 'attivo' => true]);
Territorio::create(['numero' => '3B', 'attivo' => true]);
$fuori = Territorio::create(['numero' => '40', 'attivo' => true]);
$this->assegna($fuori, $this->proclamatore('Verdi', 'Anna'), '2026-10-01');
$fuori->update(['attivo' => false]);
Territorio::create(['numero' => '77', 'attivo' => false]);
auth()->logout();
// L'elenco c'è solo nella riconsegna: numero e zona, nient'altro.
$modulo = Livewire::test(ModuloRichiesta::class)
->assertViewHas('opzioniTerritori', [])
->set('tipo', Richiesta::RICONSEGNA)
->assertSee('Centro storico')
->assertDontSee('Verdi')
->assertDontSee('assegnat');
$this->assertSame(['3B', '12', '40'], array_column($modulo->viewData('opzioniTerritori'), 'valore'),
'In ordine di numero; c\'è anche quello disattivato ma ancora fuori, non quello disattivato e rientrato');
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Anna Verdi', 'numero' => '99'])->assertHasErrors('numero');
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Anna Verdi', 'numero' => '77'])->assertHasErrors('numero');
$this->assertSame(0, Richiesta::count());
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Anna Verdi', 'numero' => '3b'])->assertHasNoErrors();
$this->assertSame('3b', Richiesta::sole()->numero_territorio);
}
public function test_lo_spam_non_si_salva_e_non_se_ne_accorge(): void
{
// Il campo trappola riempito.