# 🧠 MEMORY.md — Stato Progetto ## Obiettivo App gestionale Laravel 13 con AdminLTE 4 per gestione Persone e Gruppi. ## Funzionalità Implementate ### Page-length selector - **4 pagine**: Individui, Eventi, Email, Gruppi - **Valori**: 10, 20, 25, 50, 100 (default 20) - Dropdown nel card-footer con campi hidden per preservare query params - GruppoController: usa `LengthAwarePaginator` per paginare la collezione ordinata gerarchicamente ### CSV Import Gruppi - Stesso pattern di `IndividuoController` (import GET, importStore POST, downloadTemplate GET) - Colonne: nome, descrizione, parent_id, diocesi_id, indirizzo_incontro, cap_incontro, città_incontro, sigla_provincia_incontro - Fault-tolerant: skip righe vuote, log errori, try/catch su ogni riga ### ICS Import Eventi - Usa `Sabre\VObject\Reader::read()` già in vendor/ - Mapping: SUMMARY→nome_evento, DESCRIPTION→descrizione, DTSTART→data_specifica+ora_inizio, DTEND→durata_minuti, LOCATION→luogo_indirizzo, UID→uid_esterno - Dedup via uid_esterno - RRULE: import come evento singolo con prima occorrenza ### build-dist.sh (script di distribuzione) - Genera `glastree-YYYYMMDD_HHMM.tar.gz` - Include: sorgenti, vendor (production) - Esclude: .git, node_modules, tests, cache, storage content, vendor/docs/tests, Docker - **Istruzioni post-estrazione complete**: mkdir, permessi, configurazione, cache clear ## Tag System ### Implementazione Completa (Opzione B) - **Migration**: `2026_06_08_194155_create_tags_tables.php` — tabelle `tags` + `taggables` - **Model `Tag.php`**: morphToMany a 5 entity types, auto-slug su creating, color badge - **Trait `HasTagsLight.php`**: `tags()` morphToMany, scopes `withAllTags`/`withAnyTags` - **5 Models aggiornati**: Individuo, Gruppo, Evento, Documento, MailingList — usano `HasTagsLight` - **ImpostazioniController**: CRUD completo per Tag (store, update, destroy, reorder) con tab nella pagina Impostazioni - **Tag selector**: `_tag-selector.blade.php` — search + pill badges con colori, incluso in create/edit di Individui, Gruppi, Eventi, MailingList - **Tag filtering**: `?tag[]=slug` supportato in `IndividuoController@index`, `GruppoController@index`, `EventoController@index`, `DocumentoController@index`, `MailingListController@index` via `withAnyTags` scope - **Tag column in index views**: Colonna "Tag" con badge colorati cliccabili (collegamento a index filtrato) in individui, gruppi, eventi, documenti, mailing-liste - **Tag filter widget**: `_tag-filter-bar.blade.php` — barra cliccabile con badge colorati sopra ogni index table (individui, gruppi, eventi, documenti, mailing-liste). Attiva/disattiva filtro `?tag[]=` con un click, evidenzia tag attivi, include "Cancella filtri" quando attivo - **RicercaController**: Controller per ricerca unificata per tag — mostra risultati aggregati da tutte 5 entity types con info-box counters - **Route `GET /ricerca`**: Registrata come `ricerca.index` → `RicercaController@index` - **Sidebar link**: "Ricerca per Tag" (icona `fas fa-tags`) sotto la sezione Report, visibile con permesso `individui` - **Mass tag action for Documenti**: `POST /documenti/mass-tag` → `DocumentoController@massTag` — modal with tag selector + assign/remove radio, processes selected documents in chunks of 100 via `syncWithoutDetaching()` (assign) or `detach()` (remove) - **Report tag columns**: `tags.nome` disponibile come colonna per individui/gruppi/eventi/documenti nei report personalizzati - **Report tag filters**: Custom report form include tag multi-select (Select2) + modalità OR/AND; `runCustomReport()` applica filtri via `withAllTags`/`withAnyTags` ## Bug Fix Recenti ### 2026-06-07 — Colonne mancanti in eventi e gruppo_individuo - `descrizione_evento` e `is_incontro_gruppo` mancanti in install.sql → creata migration + ALTER TABLE - `ruolo_nel_gruppo` mancante in install.sql per pivot `gruppo_individuo` - `VistaReport.php`: aggiunto `is_default` a `$fillable` ### 2026-06-08 — build-dist.sh: "provide valid cache path" su server remoto **Problema**: Le esclusioni in `build-dist.sh` usavano `--exclude='storage/framework/cache'` (e simili per sessions, views, logs), che escludevano **l'intera directory** dall'archivio. Dopo l'estrazione, le directory necessarie a Laravel non esistevano. **Fix**: 1. Cambiati exclude patterns per **contenuti soltanto** (non la directory): - `--exclude='storage/framework/cache/*'` (non `/cache`) - `--exclude='storage/framework/sessions/*'` - `--exclude='storage/framework/views/*'` - `--exclude='storage/logs/*'` - `--exclude='storage/debugbar/*'` 2. Aggiunte esclusioni per upload utente: - `--exclude='storage/app/public'` - `--exclude='storage/app/private'` 3. Creato `.gitignore` in `storage/framework/views/` (mancante) 4. Istruzioni post-estrazione riscritte con: - `mkdir -p` per tutte le directory necessarie (cache, sessions, views, logs, public, backups, documenti, bootstrap/cache) - `chmod -R 775` e `chown -R www-data:www-data` per permessi - `php artisan config:clear`, `route:clear`, `view:clear` ## Problema 419 (Page Expired) su Produzione Il codice NON ha errori di CSRF — tutte le form hanno `@csrf`. Il problema è configurazione sessione del server: 1. `storage/framework/sessions/` non scrivibile dal web server 2. `APP_URL` in `.env` non corrisponde all'URL reale 3. `SESSION_SECURE_COOKIE=true` ma sito in HTTP Soluzione: ```bash chmod -R 775 storage/framework/sessions php artisan config:clear ``` ### 2026-06-08 — build-dist.sh: impossibilità salvare logo su server remoto **Problema**: - `public/storage` era un symlink **assoluto** (`/var/www/html/glastree/storage/app/public`) incluso nell'archivio → all'estrazione su altro server puntava a path inesistente. - `php artisan storage:link` falliva perché il symlink rotto esisteva già. - Sottodirectory `logos/`, `documenti/eventi/`, `avatars/gruppi/` non create dal `mkdir -p`. **Fix**: 1. Aggiunto `--exclude='public/storage'` al tar — il symlink va ricreato sul target. 2. Aggiunto `rm -f public/storage` prima di `php artisan storage:link` per evitare conflitto. 3. Usato `ln -sf ../storage/app/public public/storage` (invece di `php artisan storage:link --relative` che richiede `symfony/filesystem` non sempre presente). 4. Aggiunte al `mkdir -p` le sottodirectory mancanti: - `storage/app/public/logos` (logo upload) - `storage/app/public/documenti/eventi` (event documenti) - `storage/app/private/avatars/gruppi` (avatar gruppi) 5. Aggiunta creazione `.gitignore` in `storage/app/public/` (placeholder Laravel). ### Fix form annidato salvataggio email **2026-06-08 — Problema**: Il form di DELETE era **annidato dentro** il form di SAVE in entrambe le pagine: - `resources/views/impostazioni/index.blade.php` - `resources/views/admin/email-settings/index.blade.php` - HTML invalido: il browser chiudeva implicitamente il form SAVE all'apertura del form DELETE, causando l'invio alla route sbagliata. **Fix**: Spostato il form DELETE fuori dal form SAVE, separato in un `div` autonomo. ### Fix campi mancanti in validazione EmailSettingsController@save **2026-06-08**: `signature`, `signature_enabled`, `smtp_username`, `smtp_password` non erano nelle regole di validazione di `save()`. Laravel scarta i campi non validati, quindi non venivano mai scritti nel DB. **Fix**: Aggiunte le regole mancanti a `$request->validate()`. ### DiagnoseEmail Command - `php artisan diagnose:email` — controlla APP_KEY, tabella email_settings, record, decrittazione password, sender_accounts, log errori, estensioni PHP. ### 2026-06-08 — Firma email ancora non salvata (secondo tentativo) **Problema**: La firma (campo `signature`) non veniva salvata nonostante le regole di validazione fossero state aggiunte. **Root cause analysis**: 1. HTML checkbox `signature_enabled` senza hidden fallback: quando non spuntato, il campo non veniva inviato → non presente in `$validated` → `updateOrCreate` non lo aggiornava mai a `false`. 2. La vecchia pagina `/impostazioni/` aveva l'handler `shown.bs.tab` per il tab Firma (`#email-firma`) in un solo punto su due, causando possibile mancata inizializzazione dell'editor Quill su alcuni flussi di navigazione. 3. La verifica nel controller controllava solo `imap_host` e `email_address` — anche se signature/signature_enabled fallivano, l'utente vedeva "salvato con successo". **Fix**: 1. `resources/views/admin/email-settings/index.blade.php:268` — Aggiunto `` prima della checkbox 2. `resources/views/impostazioni/index.blade.php:603` — Stesso hidden fallback 3. `resources/views/impostazioni/index.blade.php:1633` — Aggiunto `#email-firma` al primo handler `shown.bs.tab` (il secondo handler esisteva già ma il primo no) 4. `app/Http/Controllers/Admin/EmailSettingsController.php:70-82` — Aggiunto log di warning se signature o signature_enabled non corrispondono dopo il salvataggio ### 2026-06-08 — Sync email: "Nessuna configurazione email attiva" nonostante test IMAP funzioni **Problema**: Cliccando "Sincronizza Ora" nelle impostazioni email o "Ricevi/Invia" nella pagina email, viene mostrato "Nessuna configurazione email attiva". Il test IMAP funziona correttamente. **Root cause**: Stesso bug dei checkbox senza hidden fallback. Il campo `is_active` nel tab Sincronizzazione non veniva inviato quando non spuntato. Alla prima configurazione, se l'utente non visitava esplicitamente il tab Sync e attivava lo switch, `is_active` rimaneva `false` (default DB). Il test IMAP usa `EmailSetting::first()` (ignora `is_active`), mentre `syncEmails()` e `syncNow()` usano `EmailSetting::getActive()` che filtra per `is_active = true`. **Fix**: 1. `resources/views/admin/email-settings/index.blade.php:251` — Aggiunto `` prima della checkbox 2. `resources/views/impostazioni/index.blade.php:586` — Stesso hidden fallback 3. `EmailSetting::getActive()` ora riceve sempre `is_active` dal form (0 o 1) grazie all'hidden fallback ## 2026-06-08 — Help page: aggiunto tab "Calendario" con documentazione OAuth - **`resources/views/help/index.blade.php`**: Aggiunto tab "Calendario" nel menu laterale (dopo Google Drive) e relativo tab-pane content con: - Requisiti (account Google, API Calendar, OAuth credentials) - Passo 1: Google Cloud Console — abilitare Google Calendar API, creare OAuth Client ID (Web Application), redirect URI `https://developers.google.com/oauthplayground` - Passo 2: Ottenere Refresh Token via Google OAuth Playground (Use your own OAuth credentials, scope `calendar`) - Passo 3: Configurazione nell'app (Impostazioni → Calendario, nuova connessione Google Calendar, inserire Client ID/Secret/Refresh Token) - Test connessione e sincronizzazione - Nota: refresh token permanente finché non revocato - **`resources/views/help/pdf.blade.php`**: Aggiunta sezione "3. Google Calendar" nel TOC e contenuto, rinumerazione da 4 a 8 per sezioni successive ### Differenza chiave Google Calendar vs Google Drive - **Drive**: endpoint server `/auth/google-drive/callback` che gestisce automaticamente il callback OAuth e salva refresh token in `storage_repositories` - **Calendar**: **NON** ha endpoint server dedicato. Refresh token ottenuto manualmente via Google OAuth Playground e incollato nel campo "Refresh Token" del form ## 2026-06-08 — Pulsante Sync nella pagina calendario eventi - **`app/Http/Controllers/CalendarioConnessioneController.php`**: Aggiunto metodo `syncAll()` che itera tutte le connessioni attive (`is_active = true`), esegue `syncEvents()` per ognuna, e restituisce JSON con risultati aggregati. - **`routes/web.php`**: Aggiunta route `POST /calendario-connessioni/sync-all` → `syncAll` (name: `calendario-connessioni.sync-all`). - **`resources/views/eventi/calendar.blade.php`**: Aggiunto pulsante "Sync" nel card-tools che: - Invia POST AJAX a `sync-all` - Mostra spinner durante l'operazione - Al completamento mostra alert con riepilogo (connessioni, importati, esportati) - Richiama `window.calendar.refetchEvents()` per aggiornare la vista FullCalendar - Esposto `window.calendar` globalmente per accesso dall'handler sync ## 2026-06-08 — Fix logo non visibile su installazione remota **Problema**: Il logo uploadato non veniva visualizzato su installazioni remote, pur salvandosi correttamente su disco. **Root cause**: `getLogoPath()` e `getLogoSmallPath()` in `AppSetting.php` usavano `file_exists(public_path('storage/' . $path))` per verificare l'esistenza del file prima di restituire l'URL. Questo controllo dipendeva dal symlink `public/storage → storage/app/public`. Su installazioni remote dove il symlink era assente o mal configurato, `file_exists` restituiva `false` → metodo restituiva `null` → logo non mostrato. **Fix**: 1. **`app/Models/AppSetting.php`**: Sostituito `file_exists(public_path('storage/' . $path))` con `Storage::disk('public')->exists($path)` e `asset('storage/' . $path)` con `Storage::disk('public')->url($path)`. Il controllo ora avviene direttamente su `storage/app/public/` (disk root), bypassando il symlink. 2. **`app/Http/Controllers/ImpostazioniController.php`**: In `uploadLogo()`, aggiunta creazione automatica del symlink `public/storage → storage/app/public` se mancante, per garantire che il browser possa servire l'immagine. ## 2026-06-08 — Guardie Schema::hasColumn() aggiunte a 18 migration **Problema**: 18 migration files aggiungevano colonne a tabelle esistenti senza `Schema::hasColumn()` guard. Se eseguite su DB dove le colonne già esistevano (es. da install.sql), causavano errore "Duplicate column". **Fix**: Aggiunto `if (!Schema::hasColumn(...))` wrapper a ogni colonna aggiunta nelle seguenti migration: 1. `2026_06_02_000002_add_uid_esterno_to_eventi_table.php` — `uid_esterno` 2. `2026_05_12_000002_change_ruolo_to_multi.php` — `ruolo_ids` (up), `ruolo_id` (down) 3. `2026_05_28_000001_add_storage_disk_to_documenti.php` — `storage_disk` 4. `2026_05_27_113246_add_repository_id_to_documenti.php` — `repository_id` 5. `2026_05_26_000003_add_log_falliti_to_mailing_messaggi.php` — `log_falliti`, `mittente_nome`, `mittente_email` 6. `2026_05_27_000002_add_cartella_id_to_documenti.php` — `cartella_id` 7. `2026_05_16_000004_add_signature_enabled_to_email_settings.php` — `signature_enabled` 8. `2026_05_16_000003_add_signature_to_email_settings.php` — `signature` 9. `2024_01_01_000017_add_occorrenza_mese_to_eventi.php` — `occorrenza_mese` 10. `2024_01_01_000016_add_eventi_recurrence_fields.php` — `giorno_mese`, `mesi_recorrenza`, `mese_annuale` 11. `2026_05_10_000002_add_acl_tables.php` — `permissions` (users) 12. `2026_05_12_142009_add_location_to_eventi_table.php` — `luogo_indirizzo`, `luogo_url_maps` 13. `2026_05_12_000003_change_responsabile_to_multi.php` — `responsabile_ids` (up), `responsabile_id` (down) 14. `2024_01_01_000023_add_is_default_to_viste_report_table.php` — `is_default` 15. `2024_01_01_000024_add_user_id_to_mailing_lists_table.php` — `user_id` 16. `2026_05_12_000001_create_ruoli_table.php` — `ruolo_id` (gruppo_individuo) + data migration annessa 17. `2024_01_01_000021_add_user_id_to_documenti_table.php` — `user_id` 18. `2024_01_01_000013_add_numero_documento_to_individui_table.php` — `numero_documento` Tutte verificate con `php -l` (nessun errore di sintassi). ### 2026-06-09 — `@stack('scripts')` mancante nel layout **Problema**: Il partial `_tag-selector.blade.php` usa `@push('scripts')` per iniettare le funzioni JS `toggleTag`, `removeTag`, `filterTags`. Il layout `adminlte.blade.php` aveva solo `@yield('scripts')` (per `@section`), ma **non** `@stack('scripts')` (per `@push`). Di conseguenza il JavaScript interattivo del tag selector (clic sui badge, ricerca, rimozione) non veniva mai caricato su nessuna pagina create/edit. **Fix**: `resources/views/layouts/adminlte.blade.php:305` — Aggiunto `@stack('scripts')` subito prima di `@yield('scripts')`. ### 2026-06-09 — Tag support completato per MailingList + Report + Ricerca **MailingList — Controller & Views** (`app/Http/Controllers/MailingListController.php`): - `index()`: eager-load `tags`, supporto `?tag[]=` filter via `withAnyTags`, pass `$allTags` per filter bar - `create()`: pass `$tags` per tag selector - `store()`: validazione `tags.* exists:tags,id`, sync dopo create. Auto-fetch email da `Individuo::contatti()` quando email non fornita - `edit()`: eager-load `tags`, pass `$tags` + `$selectedTags` - `update()`: validazione `tags.*`, sync se presente, detach se assente. Auto-fetch email da `Individuo::contatti()` quando email non fornita. Diff logica: `$toRemove = array_diff($existingIds, $newIds)` corretto — rimuove solo contatti deselezionati/esplicitamente rimossi - `show()`: eager-load `tags` **MailingList — Views**: - `index.blade.php`: aggiunto `@include('partials._tag-filter-bar')`, colonna "Tag" con badge colorati cliccabili - `create.blade.php`: incluso `_tag-selector` dopo descrizione - `edit.blade.php`: incluso `_tag-selector` dopo descrizione (prima della sezione contatti). Submit handler include `#individui-select.selectedOptions` in `contatti_json` con `email: ''` (già presente da fix precedente) - `show.blade.php`: aggiunta card Tag nel pannello sinistro con badge colorati (o "Nessun tag assegnato") **ReportColumnRegistry** (`app/Helpers/ReportColumnRegistry.php`): - Aggiunta colonna `'tags.nome'` con label 'Tag' a 4 entity types: individui, gruppi, eventi, documenti **ReportController** (`app/Http/Controllers/ReportController.php`): - `runCustomReport()`: supporta `config['tag_filter']` (array di tag IDs) + `config['tag_filter_mode']` ('any'/'all') - `storeCustom()`: ora salva `tag_filter` e `tag_filter_mode` nella config del report salvato - Carica `$allTags` nell'index e passa alla vista - Converte IDs → slugs via `Tag::whereIn('id', ...)->pluck('slug')`, applica `withAllTags`/`withAnyTags` - Eager-load `tags` automaticamente quando la colonna `tags.nome` è selezionata **Report — View** (`resources/views/report/index.blade.php`): - Aggiunto filtro tag multi-select nel form "Crea Report Personalizzato" (Select2 con badge colorati) - Aggiunto select per modalità filtro (OR/AND) - JS: `initTagFilterSelect2()` / `destroyTagFilterSelect2()` con template colorato **RicercaController** (`app/Http/Controllers/RicercaController.php`): - Aggiunta MailingList alle query per tag slug - `results['mailingLists']` e `totals['mailingLists']` passati alla view **Ricerca — View** (`resources/views/ricerca/index.blade.php`): - Aggiunto info-box Mailing List (colore bg-purple) con link "Vedi tutti" → `mailing-liste.index?tag[]=` - Aggiunta tabella dettaglio Mailing List nel risultato di ricerca (nome, contatti, stato, azione show) - Fix: `route('documenti.edit')` → `url('/documenti/' . $doc->id . '/edit')` (route non esiste) ### 2026-06-09 — Fix: storeCustom() non salvava tag_filter **Problema**: `ReportController@storeCustom()` non salvava `tag_filter` e `tag_filter_mode` nella config del report personalizzato. Quando un report salvato veniva eseguito, `runCustomReport()` non trovava i filtri tag nella config e non li applicava. **Fix**: Aggiunte righe 143-148 in `ReportController.php`: ```php if ($request->has('tag_filter')) { $config['tag_filter'] = $request->input('tag_filter'); } if ($request->filled('tag_filter_mode')) { $config['tag_filter_mode'] = $request->input('tag_filter_mode'); } ``` ### 2026-06-09 — Fix: MailingList edit — "Carica contatti" non inseriva i selezionati **Problema**: Due bug nel JS `caricaSelezionati()` in `mailing-liste/edit.blade.php`: 1. **Typo**: `ind.cogname` invece di `ind.cognome` — il campo Cognome nella tabella era sempre vuoto/undefined (API restituisce `cognome`) 2. **Email gate**: `if (ind.emails && ind.emails.length > 0)` filtro bloccante — individui senza email salvata in `contatti` venivano SILENZIOSAMENTE scartati, mai aggiunti alla tabella **Fix**: 1. `ind.cogname` → `ind.cognome` (righe 227 e 256) 2. Rimossa condizione `if (ind.emails...)` — ora anche individui senza email vengono aggiunti alla tabella 3. `email: ind.emails[0]` → `email: (ind.emails && ind.emails.length > 0) ? ind.emails[0] : ''` — email vuota se nessuna presente 4. Controller già fixato per auto-risolvere email da `Individuo::contatti()` al salvataggio ### 2026-06-09 — Fix: MailingListController email gate bloccava nuovi contatti senza email **Problema**: `store()` e `update()` in `MailingListController` usavano `!empty($contatto['email'])` per decidere se creare un contatto. L'email era richiesta ma il MailingContact non memorizza l'email — la risolve dall'Individuo al momento del display/invio. Questo impediva di aggiungere individui senza email (o con email non nei contatti) alla mailing list. **Fix**: Rimossa completamente la verifica email sia in `store()` che in `update()`. Ora ogni `individuo_id` valido viene aggiunto come MailingContact. L'email viene risolta al display dal model `$contact->individuo->contatti->where('tipo', 'email')->first()?->valore`. Prima (bloccante): ```php if (!empty($contatto['individuo_id']) && !empty($contatto['email'])) { create } ``` Dopo (semplice): ```php if (!empty($contatto['individuo_id'])) { create } ``` ## Prossimi Passi - [DONE] Aggiungere filtro Tag nella vista Documenti — già presente (tag filter bar + colonna Tag in lista/griglia) - [DONE] Gestire il caso `tag[]` vuoto — già gestito da `$request->filled('tag')` in tutti i 4 controller index - [DONE] MailingList — tag support completato (controller, index/create/edit/show views, filter bar) - [DONE] Report — tag columns in ReportColumnRegistry + tag filter in custom reports + UI multi-select - [DONE] Ricerca per Tag — MailingList integrata nei risultati - [DONE] `storeCustom()` ora salva correttamente `tag_filter` e `tag_filter_mode` - [DONE] `MailingListController@store/update` — auto-fetch email da Individuo quando non fornita - [DONE] Verificata presenza `@stack('scripts')` nel layout — presente a riga 305 (prima di `@yield('scripts')`) - [DONE] Verificata mass tag action Documenti (`DocumentoController@massTag`) — funzionante - [DONE] Verificato: tutti i 5 entity controllers passano `$allTags` alle viste index - [DONE] La logica `$toRemove = array_diff($existingIds, $newIds)` in `update()` è corretta — rimuove solo contatti che l'utente ha esplicitamente deselezionato o rimosso dalla tabella. Non necessita modifiche. - [DONE] SweetAlert2 conferma su salvataggio mailing list (edit.blade.php) quando ci sono contatti senza email - [DONE] Normalizzata `invia()` da `flatMap`+`pluck` a `map`+`first()` (1 email per individuo, come `invioElabora()`) - [DONE] Aggiunto logging contatti saltati in `invia()` e `invioElabora()` (quando senza email) - [DONE] Fix: contattiCaricati initialization ora include codice_id/cognome/nome da righe DOM — contatti esistenti non più blank dopo Carica contatti - [DONE] Fix: EmailController@destroy ora redirect a inbox invece che back() (non rimane su email cancellata) - [DONE] Mass action Individui: Assegna Gruppo (route + controller + modal con select gruppi + Assign/Remove radio) - [DONE] Mass action Individui: Assegna Tag (route + controller + modal con _tag-selector) - [DONE] Mass action Gruppi: Assegna Tag (route + controller + modal con _tag-selector) - [DONE] Mass action Gruppi: Elimina (route + controller + modal con conferma, detach membri + delete) - [DONE] Fix: IndividuoController@massGruppo e @massTag ora convertono stringa ids in array (non funzionavano perché hidden input invia stringa, non array) - [DONE] Ricerca per Tag multipli: controller accetta `?tag[]=slug1&tag[]=slug2`, usa `withAllTags` (AND logic) per 2+ tag. Vista aggiornata: checkbox cliccabili per multi-selezione, info boxes unificate, "Vedi tutti" passa tutti i tag - Verificare end-to-end su remote: tutte le nuove mass action, mailing list con contatti senza email, ricerca multi-tag