++ 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:
14
HANDOFF.md
14
HANDOFF.md
@@ -56,7 +56,7 @@ hanno dati da convertire.
|
|||||||
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
|
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
|
||||||
| Backup / ripristino / azzeramento | ✅ verificati end-to-end |
|
| 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) |
|
| 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) |
|
| 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,
|
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,
|
- **Modulo pubblico** `/richiesta` (rotta `richiesta`, fuori dal login,
|
||||||
`throttle:60,1`; componente `Richieste\ModuloRichiesta`, layout guest):
|
`throttle:60,1`; componente `Richieste\ModuloRichiesta`, layout guest):
|
||||||
nome e cognome, *nuovo territorio* o *riconsegna* (con numero), nota. Non
|
nome e cognome, *nuovo territorio* o *riconsegna* (con numero), nota.
|
||||||
mostra dati esistenti. Anti-spam: campo trappola `sito` (classe
|
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
|
`.tm-trappola`), tempo minimo `SECONDI_MINIMI` = 3 s (in questi due casi
|
||||||
risponde "inviata" senza salvare), `MASSIMO_ORA` = 5 per IP (chiave del
|
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
|
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
|
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
|
Senza Docker in locale si fanno girare sul server: vedi [Dove gira
|
||||||
davvero](#dove-gira-davvero). I file storici, con cosa proteggono:
|
davvero](#dove-gira-davvero). I file storici, con cosa proteggono:
|
||||||
|
|
||||||
|
|||||||
@@ -767,8 +767,12 @@ docker compose --profile dev up -d --build
|
|||||||
### Richieste dal modulo pubblico
|
### Richieste dal modulo pubblico
|
||||||
- **Modulo pubblico** all'indirizzo `/richiesta`, senza login (l'indirizzo
|
- **Modulo pubblico** all'indirizzo `/richiesta`, senza login (l'indirizzo
|
||||||
completo è scritto nella Home, da passare ai proclamatori): nome e cognome,
|
completo è scritto nella Home, da passare ai proclamatori): nome e cognome,
|
||||||
*nuovo territorio* o *riconsegna* (con il numero del territorio), una nota.
|
*nuovo territorio* o *riconsegna*, una nota. Nella riconsegna il territorio
|
||||||
Non mostra nessun dato già presente
|
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
|
- **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
|
e invio (in questi due casi risponde "inviata" senza salvare) e al massimo 5
|
||||||
richieste all'ora dallo stesso indirizzo
|
richieste all'ora dallo stesso indirizzo
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ namespace App\Livewire\Richieste;
|
|||||||
|
|
||||||
use App\Models\Richiesta;
|
use App\Models\Richiesta;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use App\Models\Territorio;
|
||||||
use App\Services\RichiesteServizio;
|
use App\Services\RichiesteServizio;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
|
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
|
||||||
* territorio nuovo o annuncia una riconsegna. Non mostra nessun dato già
|
* territorio nuovo o annuncia una riconsegna. L'unico dato già presente che
|
||||||
* presente: chi lo apre vede solo i campi da compilare.
|
* 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:
|
* Contro lo spam, tre difese che a chi compila davvero non costano niente:
|
||||||
* - un campo trappola ($sito) nascosto alle persone e riempito dai programmi;
|
* - un campo trappola ($sito) nascosto alle persone e riempito dai programmi;
|
||||||
@@ -67,12 +72,17 @@ class ModuloRichiesta extends Component
|
|||||||
$this->validate([
|
$this->validate([
|
||||||
'tipo' => 'required|in:' . Richiesta::NUOVO . ',' . Richiesta::RICONSEGNA,
|
'tipo' => 'required|in:' . Richiesta::NUOVO . ',' . Richiesta::RICONSEGNA,
|
||||||
'nome' => 'required|string|min:3|max:100',
|
'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',
|
'note' => 'nullable|string|max:1000',
|
||||||
], [
|
], [
|
||||||
'nome.required' => 'Scrivi nome e cognome.',
|
'nome.required' => 'Scrivi nome e cognome.',
|
||||||
'nome.min' => '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);
|
RateLimiter::hit($chiave, 3600);
|
||||||
@@ -88,10 +98,40 @@ class ModuloRichiesta extends Component
|
|||||||
$this->apertoAlle = now()->timestamp;
|
$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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.richieste.modulo-richiesta', [
|
return view('livewire.richieste.modulo-richiesta', [
|
||||||
'congregazione' => Setting::instance()->congregazione_nome ?: 'TerManager2',
|
'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']);
|
])->layout('components.layouts.guest', ['title' => 'Richiesta territorio']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</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 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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -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">
|
<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
|
{{-- 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="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>
|
<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>
|
<h1 class="text-2xl font-bold text-gray-900">Richiesta territorio</h1>
|
||||||
@@ -40,9 +41,8 @@
|
|||||||
|
|
||||||
@if($tipo === \App\Models\Richiesta::RICONSEGNA)
|
@if($tipo === \App\Models\Richiesta::RICONSEGNA)
|
||||||
<div>
|
<div>
|
||||||
<label for="numero" class="block text-sm font-medium text-gray-700">N° del territorio *</label>
|
<label for="numero" class="block text-sm font-medium text-gray-700">Territorio che riconsegni *</label>
|
||||||
<input wire:model="numero" type="text" id="numero" inputmode="numeric" maxlength="20" autocomplete="off"
|
<x-scelta id="numero" wire:model="numero" :opzioni="$opzioniTerritori" segnaposto="Scrivi il numero o la zona" class="mt-1" />
|
||||||
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">
|
|
||||||
@error('numero') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
@error('numero') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Models\Richiesta;
|
|||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Zona;
|
||||||
use App\Notifications\RichiestaRicevuta;
|
use App\Notifications\RichiestaRicevuta;
|
||||||
use App\Services\RichiesteServizio;
|
use App\Services\RichiesteServizio;
|
||||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
@@ -146,6 +147,7 @@ class RichiesteTest extends TestCase
|
|||||||
public function test_senza_posta_la_richiesta_resta_comunque(): void
|
public function test_senza_posta_la_richiesta_resta_comunque(): void
|
||||||
{
|
{
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
|
$this->territorio('12');
|
||||||
auth()->logout();
|
auth()->logout();
|
||||||
|
|
||||||
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Mario Rossi', 'numero' => '12'])->assertHasNoErrors();
|
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Mario Rossi', 'numero' => '12'])->assertHasNoErrors();
|
||||||
@@ -162,6 +164,35 @@ class RichiesteTest extends TestCase
|
|||||||
$this->assertSame(0, Richiesta::count());
|
$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
|
public function test_lo_spam_non_si_salva_e_non_se_ne_accorge(): void
|
||||||
{
|
{
|
||||||
// Il campo trappola riempito.
|
// Il campo trappola riempito.
|
||||||
|
|||||||
Reference in New Issue
Block a user