Compare commits

52 Commits

Author SHA1 Message Date
Mariano 005fa5b4a3 sistemazione migrate 2026-06-17 19:13:40 +02:00
inext 7302146a77 Merge branch 'dev'
merge dev
2026-06-10 14:04:40 +02:00
inext 43a2c81312 setup viste 2026-06-10 14:03:43 +02:00
inext 0dd567cf7a email documenti 2026-06-10 06:42:49 +02:00
inext d9421c9159 ver 2.0.1.1 2026-06-09 12:04:03 +02:00
inext c959f72c66 Merge branch 'dev'
da dev version 2
2026-06-09 11:46:09 +02:00
inext 7999c663fe ver 2.0.1 2026-06-09 11:44:40 +02:00
inext 708cadfac2 Merge branch 'dev'
-
2026-06-09 11:27:06 +02:00
inext a35b0056e1 finale 2.0.0.0 2026-06-09 11:26:32 +02:00
inext b7842bf2e0 finale 2.0.0 2026-06-09 11:22:56 +02:00
inext 06387568cb finale 2.0.0 2026-06-09 11:09:00 +02:00
inext d82b123bfd versione 2.0.0 2026-06-09 11:02:01 +02:00
inext 6f106e3130 vers 1.2.10 2026-06-08 21:04:06 +02:00
inext ce48481bc8 fix 1.2.9 2026-06-08 21:02:12 +02:00
inext 7d7d384a0d fix 1.2.8 - logo app 2026-06-08 20:10:33 +02:00
inext 8194b8c034 fix 1.2.7 2026-06-08 20:03:14 +02:00
inext 90093a086b fix 1.2.6 2026-06-08 12:09:45 +02:00
inext cbe3470c8c 1.2.5 - fix for email settings 2026-06-08 09:47:24 +02:00
inext 469e8c014f 9Merge branch 'dev'
fix piccoli
2026-06-07 23:06:37 +02:00
inext 4dd4da7f4b fix 1.2.4 2026-06-07 23:05:48 +02:00
inext cd10d100ec final 1.2.3 - file sh per generare zip 2026-06-07 20:56:38 +02:00
inext 406985eb1e Merge branch 'dev'
final 1.2.2
2026-06-07 17:27:25 +02:00
inext f9c1268466 finale 1.2.2 2026-06-07 17:27:00 +02:00
inext 89382fe4ff latest fix 2026-06-07 16:28:13 +02:00
inext dd76b473f7 Merge branch 'dev'
1.2.1 fix gitignore
2026-06-06 23:44:56 +02:00
inext cf0ca170ce 1.2.1 fix gitignore 2026-06-06 23:41:16 +02:00
inext 82c54002e4 Merge branch 'dev'
versione finale 1.2
2026-06-06 23:20:49 +02:00
inext bc57d0dcd0 final 1.2 2026-06-06 23:19:55 +02:00
inext 139098bffc fix 1.1.1 2026-06-06 23:16:51 +02:00
inext a8c841bdbe - import gruppi ed eventi + docker 2026-06-06 23:00:07 +02:00
inext 00004d8b50 sistemazione installa e tabelle - ver 1.1 2026-06-06 20:43:56 +02:00
inext 2fd1ed8295 commit finale 662026 2026-06-06 18:13:11 +02:00
inext 8aa1d3d031 reset password user 2026-06-06 11:50:22 +02:00
inext d57431b10b fix -last 2026-06-05 12:23:13 +02:00
inext 05f1a4c396 git ignore image tart 2026-06-04 08:49:42 +02:00
inext 2f454ab749 fix install 1.0.11 2026-06-04 08:33:02 +02:00
inext da022bdf4a fix - install 1.0.10 2026-06-03 16:32:24 +02:00
inext 6b637fc12b fix - install 1.0.9 2026-06-03 16:23:27 +02:00
inext 72c48d301d fix - install 1.0.8 2026-06-03 16:15:17 +02:00
inext abb39e62f9 1.0.7 2026-06-03 14:21:20 +02:00
inext f36a9906a2 1.0.6 fix modifica install.php 2026-06-03 13:32:04 +02:00
inext fcbb6fb061 1.0.5 fix first login after install .php 2026-06-03 11:59:44 +02:00
inext 25168debc0 1.0.4 - fix instal.php 2026-06-03 11:18:20 +02:00
inext 7381d86362 1.0.3 - php cli install 2026-06-03 10:20:49 +02:00
inext 8404cda475 1.0.2 2026-06-03 09:15:51 +02:00
inext 75f6665252 Version 1.0.1 - add install.sh 2026-06-03 09:09:46 +02:00
inext 81541a3af5 versione 1.0.0 2026-06-03 08:21:04 +02:00
inext c6f8403d1a vesione staibile - debug fix 2026-06-03 08:06:09 +02:00
inext 6b913f330e fix email 2026-06-02 22:30:49 +02:00
inext 1e9eb29428 last 2026-06-02 22:18:29 +02:00
inext c9d66c2a80 aggiunta calendario 2026-06-02 22:07:16 +02:00
inext aa4e582925 fix error 2026-06-02 20:29:47 +02:00
2582 changed files with 9602 additions and 329302 deletions
+3
View File
@@ -0,0 +1,3 @@
glastree*.tar.gz
glastree*.tar
.env
+415 -38
View File
@@ -1,48 +1,425 @@
# 🧠 MEMORY.md — Stato Progetto
## Goal
- Stabilizzare il modale "Carica Documento" e migliorare la gestione cartelle durante l'upload.
## Obiettivo
App gestionale Laravel 13 con AdminLTE 4 per gestione Persone e Gruppi.
## Constraints & Preferences
- (none)
## Funzionalità Implementate
## Progress
### Done
- Aggiunto pulsante "Scarica Guida PDF" nella sidebar di `/help` con nuovo metodo `HelpController::downloadPdf()` e route `GET /help/pdf/download`
- Aggiunta card "Backup" in dashboard (small-box bg-navy, icona fa-hdd) con conteggio backup, visibile solo a utenti con `canManage('settings')`
- Corretto `modal-body` nel modale `#uploadModal` che chiudeva prematuramente (pulsanti Annulla/Carica finivano fuori dal popup)
- Aggiunto campo `visibilita` (select Pubblico/Individuo/Gruppo) nel form di upload documento, mancante e richiesto dal controller
- Aggiunto selettore cartelle visibile (`<select name="cartella_id" id="uploadCartellaId">`) nel modale di upload (sostituisce il vecchio hidden input)
- Aggiunto pulsante "Nuova cartella" sia nel modale upload (`#uploadNewFolderBtn`) che nel modale "Sposta" (`#massMoveNewFolderBtn`)
- Sostituito SweetAlert2 per creazione cartella con il modale Bootstrap `#newFolderModal` già esistente (risolve focus trap Bootstrap 4 che bloccava l'input)
- Aggiornato `toggleUploadRepo()` per nascondere/mostrare il gruppo cartella in base alla selezione del repository remoto
- Aggiunto flag `window._uploadNewFolder` per comportamento differenziato: da upload aggiunge opzione al select senza ricaricare, da tooltip ricarica la pagina
- **Fix backdrop grigio persistente dopo creazione cartella**: ora `#uploadModal` viene prima nascosto (quando si clicca "Nuova cartella") e poi ri-mostrato dopo la creazione nel success handler, risolvendo il conflitto di backdrop tra modali annidati Bootstrap 4
- **Fix scroll modal upload**: ristrutturato `<form>` per non avvolgere `.modal-body` e `.modal-footer` (ora è dentro `.modal-body`), così `modal-dialog-scrollable` funziona correttamente. Pulsante submit ha `form="uploadForm"` per collegamento cross-DOM
### 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
### In Progress
- (none)
### 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
### Blocked
- (none)
### 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
## Key Decisions
- Replicato il codice di creazione cartella con `SweetAlert2` → sostituito con modale Bootstrap `#newFolderModal` perché Bootstrap 4 impedisce il focus su elementi fuori dal `.modal` (focus trap)
- Pulsante "Nuova cartella" nello stesso `<form>` del modal di upload riceve `e.preventDefault()` per sicurezza
- Per evitare conflitti di backdrop Bootstrap 4 con modali annidati, si nasconde `#uploadModal` prima di mostrare `#newFolderModal` e lo si ripristina dopo il success
### 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
## Next Steps
- (none)
## Tag System
## Critical Context
- Bootstrap 4.6.2 con focus trap: impedisce focus su elementi fuori dal `.modal` — SweetAlert2 aggiunge dialog al `<body>`, quindi il suo input non riceve focus
- `#newFolderModal` già esisteva con submit handler AJAX a `/documenti/cartelle` e ricaricava pagina — ora con `window._uploadNewFolder` evita reload quando chiamato dall'upload
- Bug: backdrop grigio non spariva dopo creazione cartella perché Bootstrap 4 gestisce male i modali annidati (due backdrop concorrenti)
### 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`
## Relevant Files
- `app/Http/Controllers/HelpController.php`: metodo `downloadPdf()` + route `/help/pdf/download`
- `app/Http/Controllers/HomeController.php`: `$stats['backups']` via `BackupService::list()`
- `routes/web.php`: route `/help/pdf/download`
- `resources/views/help/index.blade.php`: card-footer con pulsante download PDF
- `resources/views/home/dashboard.blade.php`: small-box Backup condizionale
- `resources/views/documenti/index.blade.php`: intero modale `#uploadModal` riscritto (visibilita, cartella select, nuova cartella via `#newFolderModal`, toggleUploadRepo); fix backdrop persistente nascondendo/rimostrando `#uploadModal`
## 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 `<input type="hidden" name="signature_enabled" value="0">` 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 `<input type="hidden" name="is_active" value="0">` 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] ... (existing items)
- Verificare end-to-end su remote: tutte le nuove mass action, mailing list con contatti senza email, ricerca multi-tag
- [DONE] Fix performance: spostata query `VistaReport::where(...)->get()` da `@php` nel partial `table-settings.blade.php` a tutti i 5 controller (prima veniva eseguita 1 query per ogni pagina load per ogni entity, anche senza mai aprire la modale)
- [DONE] Rimosse vecchie modal legacy (`#saveVistaModal`, `#colonneModal`, `#vistaListModal`) da `individui` e `gruppi` — duplicate rispetto al nuovo modal unificato
- [DONE] Rimosse vecchie funzioni JS (`saveVista()`, `toggleColumn()`, `showSaveVistaModal()`) da `individui` e `gruppi`
- [DONE] Individui: `$allColumns` espanse da 6 a 17 colonne (aggiunte: data_nascita, genere, indirizzo, cap, citta, provincia, tipo_documento, numero_documento, scadenza_documento, note, created_at) — tutte con `<th>`/`<td>` condizionali nella vista
- [DONE] Individui: `getColumnIndex()` introdotta per lookup dinamico dell'indice colonna (sostituisce hardcoded `{codice:1, cognome:2, ...}` in `sortTable()` e `applyColumnFilter()`)
- [DONE] I controller per eventi, documenti, mailing-liste hanno già `$tableColumns` con flag `visible` — stesso meccanismo di gruppi. Nessuna modifica necessaria.
- [DONE] Fix: pulsante edit vista usa `data-*` attributi invece di `onclick` con `addslashes(json_encode(...))` — previene rottura HTML per JSON contenente `"`
- [DA FARE] Test browser: caricare ogni pagina entity, verificare default vista, toggle colonne, resize, drag-reorder, print
- [DA FARE] Test browser: flusso edit vista (✏️ → popola form → modifica → Aggiorna → reload)
- [DA FARE] Test browser: salvare nuova vista, switching tra viste, cancellazione vista
## 2026-06-10 — Per-User Column Views (colonne visibili, larghezze, ordine)
**Obiettivo**: Implementare viste colonne personalizzabili per utente su tutte le 5 entity list pages (individui, gruppi, eventi, documenti, mailing-liste), con persistenza tra login.
### Cosa è stato fatto
**Migration** (`2026_06_10_044946_add_column_widths_and_order_to_viste_report_table.php`):
- Aggiunge `colonne_larghezze` (json) e `colonne_ordine` (json) a `viste_report`
- Guardia `Schema::hasColumn` per compatibilità cross-DB (MySQL ↔ SQLite)
- Già eseguita in batch 38
**Backend**:
- `VistaReport.php`: `$fillable` + `$casts` (array) per entrambi i nuovi campi
- `VistaReportController@store`/`@update`: validazione estesa con `colonne_larghezze`, `colonne_ordine` (array)
- Tutti i 5 controller passano alla vista: `$tableColumns` (array `['key','label','visible']`), `$entityType`, `$columnWidths`, `$visibleColumns`, `$allColumns` (varia nome in base al controller)
- Tutti i 5 controller ora passano `$userVistas` (precaricate) invece di eseguire query nella view
- `IndividuoController`: `$allColumns` espanse con tutte le colonne DB disponibili (da 6 a 17)
- `IndividuoController`: aggiunto `use App\Models\VistaReport` per la query `$userVistas`
**Frontend JS** (`public/js/column-manager.js`):
- Classe `ColumnManager`: resize via mouse drag su handle, reorder via HTML5 DnD, persistenza larghezze in `sessionStorage`, fallback da `vista-data` DOM element, `printWithSettings()` che clona la tabella in finestra print-ready
**Modal** (`resources/views/partials/table-settings.blade.php`):
- Modal unificato con 3 tab: Colonne (checkbox visibilità + input larghezza + drag-reorder list), Viste (salva/carica/cancella), Stampa (pulsante print)
- Definisce `initTableSettings()` (NON auto-esegue) per evitare race condition con ColumnManager
- **RIMOSSA** query `VistaReport::where(...)->get()` dal `@php` block — ora usa `$userVistas` passato dal controller
- Non usa più `Auth::id()` direttamente — riceve `$userVistas` già popolato
**Views** — tutte e 5 aggiornate:
- `individui/index.blade.php`:
- `$allColumns` nella vista ora ha 17 colonne (vs 6 prima)
- `<th>` e `<td>` condizionali per tutte le nuove colonne (data_nascita, genere, indirizzo, cap, citta, provincia, tipo_documento, numero_documento, scadenza_documento, note, created_at)
- RIMOSSE modal legacy: `#saveVistaModal`, `#colonneModal`
- RIMOSSE funzioni JS legacy: `saveVista()`, `toggleColumn()`
- RIMOSSO sync checkboxes nel `DOMContentLoaded` (non più necessari)
- NUOVA: `getColumnIndex(colKey)` — lookup dinamico dell'indice colonna basato su `data-column`
- `sortTable()` e `applyColumnFilter()` ora usano `getColumnIndex()` invece di hardcoded mappa
- `gruppi/index.blade.php`:
- RIMOSSE modal legacy: `#saveVistaModal`, `#vistaListModal`
- RIMOSSO pulsante "Salva Vista" legacy
- RIMOSSE funzioni JS legacy: `showSaveVistaModal()`, `saveVista()`
- `eventi/index.blade.php`: colonne dinamiche via `$visibleColumns`, `@section('scripts')` con ColumnManager + `initTableSettings()`
- `mailing-liste/index.blade.php`: stessa struttura
- `documenti/index.blade.php`: ColumnManager init (non reorderable), column-manager.js incluso
- Tutti i `<th>` hanno `data-column` attributo per mapping JS
- Tutte chiamano `initTableSettings()` dopo `new ColumnManager()` nel `DOMContentLoaded`
**Performance fix**:
- Rimossa query `VistaReport::where(...)->get()` dal `@php` block in `table-settings.blade.php` (1 query extra per ogni pagina load su 5 entità)
- Spostata nei 5 controller come `$userVistas` passata via `compact()`
- Totale: -5 query per pagina load (una per ogni entity, anche su pagine non di index)
**Verifica**:
- JS brace balance: OK su tutti i 5 file (node check)
- PHP lint: OK su tutti i 5 controller
- Presenza `data-column`: individui=19, gruppi=14, eventi=9, mailing-liste=7, documenti=8
- Fallback `$visibleColumns` quando `$vista === null`: presente e corretto in tutti i 5 controller
- Nessun residuo di vecchie modal legacy in individui e gruppi
- Rimosso bottone "Salva Vista" orfano da `individui/index.blade.php` (chiamava `showSaveVistaModal()` non più esistente)
- Aggiunto pulsante modifica ✏️ nel tab Viste per ogni vista salvata: popola il form "Salva vista corrente" con nome, default flag e colonne visibili; cambia bottone in "Aggiorna" e fa PUT `/viste/{id}` invece di POST; zero query extra
### 2026-06-10 — Fix: editVista onclick → data-* attributes
**Problema**: Il pulsante modifica vista usava `onclick` con `addslashes(json_encode(...))`. In un attributo HTML delimitato da `"`, il backslash-escaping di `\"` non è gestito in modo standard dai browser — il JSON contenente `"` poteva rompere l'attributo HTML.
**Fix**:
1. Sostituito `onclick` con `data-*` attributi (`data-vista-id`, `data-vista-nome`, `data-vista-default`, `data-vista-colonne`) usando `{{ }}` di Blade (che applica `htmlspecialchars`, codificando `"``&quot;` sicuro in HTML).
2. `editVista()` ora accetta un array direttamente (non più JSON string da parsare).
3. Click handler registrato in `initTableSettings()` via `document.querySelectorAll('.edit-vista-btn')` + `dataset` (browser decodifica automaticamente `&quot;``"`).
### 2026-06-10 — Fix: 405 Method Not Allowed su update vista (via AJAX fetch)
**Problema**: Modificando una vista e cliccando "Aggiorna", `fetch()` inviava `PUT /viste/{id}` ma il controller `VistaReportController@update` restituiva `return back()` (302 redirect). `fetch()` seguiva il redirect — in alcuni browser il metodo PUT veniva preservato, ma la route `gruppi` accetta solo GET → 405 Method Not Allowed.
**Fix**:
1. `VistaReportController@update` (linea 101-103): aggiunto `if ($request->expectsJson()) { return response()->json([...]); }` — così il fetch riceve JSON 200, non un redirect 302.
2. Fetch headers in `table-settings.blade.php`: aggiunto `'Accept': 'application/json'` — necessario perché `expectsJson()` controlla l'header `Accept`, non `Content-Type`.
## 2026-06-10 — Fix: tutte le migration idempotenti + seeders idempotenti
**Problema**: `php artisan migrate` falliva su tabelle già esistenti. `php artisan db:seed` creava duplicati su re-run.
### Migration: hasTable guard su tutti i 27 file mancanti
Aggiunto `if (!Schema::hasTable('table_name')) { ... }` wrapper a tutte le 27 migration file (44 `Schema::create()` calls) che ne erano prive, usando script Node.js di trasformazione automatica:
- `2024_01_01_000002_create_comuni_diocesi_tables.php`
- `2024_01_01_000003_create_individui_table.php`
- `2024_01_01_000004_create_contatti_table.php`
- `2024_01_01_000005_create_gruppi_table.php`
- `2024_01_01_000006_create_gruppo_individuo_table.php`
- `2024_01_01_000007_create_eventi_table.php`
- `2024_01_01_000008_create_documenti_table.php`
- `2024_01_01_000009_create_mailing_tables.php`
- `2024_01_01_000010_create_notifiche_table.php`
- `2024_01_01_000011_create_users_table.php`
- `2024_01_01_000012_create_cache_and_jobs_tables.php`
- `2024_01_01_000019_create_eventi_documenti_table.php`
- `2024_01_01_000022_create_table_viste_report.php`
- `2026_05_10_000001_create_tipologie_documenti_table.php`
- `2026_05_10_000002_add_acl_tables.php`
- `2026_05_10_125210_create_permission_tables.php` (usa `$tableNames[...]` dinamici)
- `2026_05_11_000001_create_email_settings_table.php`
- `2026_05_11_000002_create_email_folders_table.php`
- `2026_05_11_000003_create_email_messages_table.php`
- `2026_05_11_000007_create_email_attachments_table.php`
- `2026_05_12_000001_create_ruoli_table.php`
- `2026_05_25_000001_create_report_custom_table.php`
- `2026_05_26_000001_create_tipologie_eventi_table.php`
- `2026_05_26_000002_create_sender_accounts_table.php`
- `2026_05_27_000001_create_documenti_cartelle_table.php`
- `2026_05_27_113245_create_storage_repositories_table.php`
- `2026_06_02_000001_create_calendario_connessioni_table.php`
### Seeder: firstOrCreate per idempotenza
- `DiocesiSeeder.php`: `Diocesi::create()``Diocesi::firstOrCreate(['nome' => $d['nome']], $d)`
- `ComuniSeeder.php`: `Comune::create()``Comune::firstOrCreate(['codice_istat' => $c['codice_istat']], $c)`
- `DatabaseSeeder.php`: `User::create()``User::firstOrCreate(['email' => 'admin@glastree.local'], [...])`, con guardia su `role_preset_id`
**Risultato**: `php artisan migrate --seed` è ora completamente idempotente. Zero errori su 62 migration + 6 seeder.
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\EmailSetting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class DiagnoseEmail extends Command
{
protected $signature = 'diagnose:email';
protected $description = 'Diagnostica configurazione email e credenziali';
public function handle(): int
{
$this->info('=== Diagnostica Email Settings ===');
$this->newLine();
// 1. APP_KEY
$this->section('APP_KEY');
$appKey = config('app.key');
if ($appKey && str_starts_with($appKey, 'base64:')) {
$this->line('APP_KEY: presente e valida ✓');
} elseif ($appKey) {
$this->warn('APP_KEY: presente ma non in formato base64 (potrebbe funzionare lo stesso)');
} else {
$this->error('APP_KEY: NON IMPOSTATA — crittografia password non funzionerà!');
}
// 2. Tabella email_settings
$this->newLine();
$this->section('Tabella email_settings');
if (Schema::hasTable('email_settings')) {
$this->line('Tabella email_settings: esiste ✓');
$columns = Schema::getColumnListing('email_settings');
$this->line('Colonne: ' . implode(', ', $columns));
$required = ['imap_host', 'imap_username', 'imap_password', 'email_address'];
$missing = array_diff($required, $columns);
if ($missing) {
$this->error('Colonne mancanti: ' . implode(', ', $missing));
} else {
$this->line('Colonne richieste presenti ✓');
}
} else {
$this->error('Tabella email_settings NON ESISTE! Esegui: php artisan migrate');
}
// 3. Record email_settings
$this->newLine();
$this->section('Record email_settings');
$settings = EmailSetting::first();
if ($settings) {
$this->line('Record trovato (ID: ' . $settings->id . ')');
$this->line(' imap_host: ' . ($settings->imap_host ?: 'VUOTO'));
$this->line(' imap_username: ' . ($settings->imap_username ?: 'VUOTO'));
$this->line(' imap_password: ' . ($settings->imap_password ? '[CRITTATO]' : 'VUOTO'));
$this->line(' email_address: ' . ($settings->email_address ?: 'VUOTO'));
$this->line(' smtp_host: ' . ($settings->smtp_host ?: 'VUOTO'));
// Prova decrittazione
if ($settings->imap_password) {
try {
$decrypted = Crypt::decryptString($settings->imap_password);
$this->line(' -> password IMAP decrittata: ' . (strlen($decrypted) > 0 ? 'OK (' . strlen($decrypted) . ' caratteri)' : 'VUOTA'));
} catch (\Exception $e) {
$this->error(' -> ERRORE decrittazione password IMAP: ' . $e->getMessage());
$this->warn(' Causa probabile: APP_KEY diversa da quando è stata salvata.');
}
}
} else {
$this->warn('NESSUN record in email_settings — nessuna configurazione salvata.');
$this->line('Vai su /impostazioni#email-imap, compila e clicca "Salva Impostazioni Email"');
}
// 4. Tabella sender_accounts
$this->newLine();
$this->section('Tabella sender_accounts');
if (Schema::hasTable('sender_accounts')) {
$this->line('Tabella sender_accounts: esiste ✓');
$count = DB::table('sender_accounts')->count();
$this->line('Record presenti: ' . $count);
} else {
$this->error('Tabella sender_accounts NON ESISTE!');
}
// 5. Log recenti
$this->newLine();
$this->section('Errori recenti (storage/logs/laravel.log)');
$logPath = storage_path('logs/laravel.log');
if (file_exists($logPath)) {
$lines = $this->getLastErrorLines($logPath, 20);
if ($lines) {
foreach ($lines as $line) {
$this->line($line);
}
} else {
$this->line('Nessun errore recente trovato.');
}
} else {
$this->warn('File di log non trovato: ' . $logPath);
}
// 6. Estensioni PHP
$this->newLine();
$this->section('Estensioni PHP');
$requiredExts = ['openssl', 'json', 'mbstring', 'pdo', 'fileinfo'];
foreach ($requiredExts as $ext) {
if (extension_loaded($ext)) {
$this->line($ext . ': OK ✓');
} else {
$this->error($ext . ': MANCANTE!');
}
}
$this->newLine();
$this->info('=== Diagnostica completata ===');
return self::SUCCESS;
}
private function section(string $title): void
{
$this->line("--- {$title} ---");
}
private function getLastErrorLines(string $path, int $maxLines): array
{
$lines = [];
$fp = fopen($path, 'r');
if (!$fp) {
return $lines;
}
fseek($fp, -1, SEEK_END);
$pos = ftell($fp);
$buffer = '';
while ($pos > 0 && count($lines) < $maxLines) {
$char = fgetc($fp);
if ($char === "\n") {
if (trim($buffer) !== '') {
$lines[] = trim($buffer);
}
$buffer = '';
} else {
$buffer = $char . $buffer;
}
$pos--;
fseek($fp, $pos);
}
if (trim($buffer) !== '') {
$lines[] = trim($buffer);
}
fclose($fp);
return $lines;
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class ResetUserPassword extends Command
{
protected $signature = 'user:reset-password
{email? : Email dell\'utente}
{--password= : Nuova password (se omessa, richiesta interattiva)}';
protected $description = 'Cambia la password di un utente tramite email';
public function handle(): int
{
$email = $this->argument('email');
if (empty($email)) {
$email = $this->ask('Email dell\'utente');
}
$user = User::where('email', $email)->first();
if ($user === null) {
$this->error("Nessun utente trovato con email: {$email}");
return self::FAILURE;
}
$this->line("Utente trovato: {$user->name} ({$user->email})");
$password = $this->option('password');
if (empty($password)) {
$password = $this->secret('Nuova password');
$confirm = $this->secret('Conferma nuova password');
if ($password !== $confirm) {
$this->error('Le password non corrispondono');
return self::FAILURE;
}
}
$validator = Validator::make(
['password' => $password],
['password' => ['required', 'string', 'min:12']]
);
if ($validator->fails()) {
$this->error('Password troppo corta: minimo 12 caratteri');
return self::FAILURE;
}
$user->password = Hash::make($password);
$user->save();
if ($user->wasChanged('password')) {
$this->info("Password aggiornata con successo per {$user->email}");
return self::SUCCESS;
}
$this->error('Errore durante l\'aggiornamento della password');
return self::FAILURE;
}
}
+4
View File
@@ -132,6 +132,7 @@ class ReportColumnRegistry
'contatti.telefono' => ['label' => 'Telefono (da contatti)', 'type' => 'relation'],
'contatti.cellulare' => ['label' => 'Cellulare (da contatti)', 'type' => 'relation'],
'gruppi.nome' => ['label' => 'Gruppi', 'type' => 'relation'],
'tags.nome' => ['label' => 'Tag', 'type' => 'relation'],
],
'gruppi' => [
@@ -151,6 +152,7 @@ class ReportColumnRegistry
'parent.nome' => ['label' => 'Gruppo Padre', 'type' => 'relation'],
'diocesi.nome' => ['label' => 'Diocesi', 'type' => 'relation'],
'individui_count' => ['label' => 'Numero Membri', 'type' => 'relation'],
'tags.nome' => ['label' => 'Tag', 'type' => 'relation'],
],
'eventi' => [
@@ -171,6 +173,7 @@ class ReportColumnRegistry
'info_ricorrenza' => ['label' => 'Info Ricorrenza', 'type' => 'accessor'],
'gruppi.nome' => ['label' => 'Gruppi', 'type' => 'relation'],
'responsabili.nome_completo' => ['label' => 'Responsabili', 'type' => 'relation'],
'tags.nome' => ['label' => 'Tag', 'type' => 'relation'],
],
'documenti' => [
@@ -183,6 +186,7 @@ class ReportColumnRegistry
'note' => ['label' => 'Note', 'type' => 'direct'],
'created_at' => ['label' => 'Data Caricamento', 'type' => 'direct'],
'user.name' => ['label' => 'Caricato Da', 'type' => 'relation'],
'tags.nome' => ['label' => 'Tag', 'type' => 'relation'],
],
'contatti' => [
@@ -39,4 +39,35 @@ class ActivityLogController extends Controller
return view('admin.activity-logs.index', compact('logs', 'users', 'modules'));
}
public function destroy(Request $request)
{
$query = ActivityLog::query();
if ($request->filled('user_id')) {
$query->where('user_id', $request->user_id);
}
if ($request->filled('module')) {
$query->where('module', $request->module);
}
if ($request->filled('action')) {
$query->where('action', $request->action);
}
if ($request->filled('from')) {
$query->whereDate('created_at', '>=', $request->from);
}
if ($request->filled('to')) {
$query->whereDate('created_at', '<=', $request->to);
}
$count = $query->count();
$query->delete();
return redirect()->route('admin.activity-logs.index')
->with('success', "Eliminati {$count} log.");
}
}
@@ -3,10 +3,14 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\EmailAttachment;
use App\Models\EmailFolder;
use App\Models\EmailSetting;
use App\Models\SenderAccount;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
class EmailSettingsController extends Controller
{
@@ -30,11 +34,15 @@ class EmailSettingsController extends Controller
'smtp_host' => 'nullable|string|max:255',
'smtp_port' => 'nullable|integer|min:1|max:65535',
'smtp_encryption' => 'nullable|in:ssl,tls,none',
'smtp_username' => 'nullable|string|max:255',
'smtp_password' => 'nullable|string',
'email_address' => 'required|email',
'email_name' => 'nullable|string|max:255',
'reply_to' => 'nullable|email',
'sync_interval_minutes' => 'nullable|integer|min:1|max:60',
'is_active' => 'boolean',
'signature' => 'nullable|string',
'signature_enabled' => 'boolean',
]);
if (!empty($validated['imap_password'])) {
@@ -58,6 +66,24 @@ class EmailSettingsController extends Controller
if ($verify &&
$verify->imap_host === $validated['imap_host'] &&
$verify->email_address === $validated['email_address']) {
$signatureMatch = !array_key_exists('signature', $validated) || $verify->signature === $validated['signature'];
$signatureEnabledMatch = !array_key_exists('signature_enabled', $validated) || (bool)$verify->signature_enabled === (bool)$validated['signature_enabled'];
if (!$signatureMatch) {
\Illuminate\Support\Facades\Log::warning('Signature mismatch after save', [
'expected' => $validated['signature'] ?? null,
'actual' => $verify->signature,
]);
}
if (!$signatureEnabledMatch) {
\Illuminate\Support\Facades\Log::warning('Signature enabled mismatch after save', [
'expected' => $validated['signature_enabled'] ?? null,
'actual' => $verify->signature_enabled,
]);
}
return back()->with('success', 'Impostazioni email salvate e verificate nel database.');
} else {
return back()->with('error', 'Errore nel salvataggio: i dati non sono stati salvati correttamente.');
@@ -257,6 +283,30 @@ class EmailSettingsController extends Controller
return back()->with('success', 'Mittente eliminato.');
}
public function destroy(): RedirectResponse
{
$this->authorizeDelete('settings');
EmailAttachment::chunk(100, function ($attachments) {
foreach ($attachments as $attachment) {
if ($attachment->file_path && Storage::exists($attachment->file_path)) {
Storage::delete($attachment->file_path);
}
}
});
EmailFolder::whereIn('type', ['inbox', 'sent', 'drafts', 'trash', 'archive', 'starred'])->each(function ($folder) {
$folder->delete();
});
$settings = EmailSetting::first();
if ($settings) {
$settings->delete();
}
return redirect('/impostazioni/email')->with('success', 'Configurazione email eliminata. Messaggi, cartelle e allegati rimossi completamente.');
}
public function senderTestSmtp(Request $request, int $id)
{
$this->authorizeWrite('settings');
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\CalendarioConnessione;
use App\Services\CalDavService;
use App\Services\GoogleCalendarSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CalendarioConnessioneController extends Controller
{
public function store(Request $request): RedirectResponse
{
$this->authorizeWrite('settings');
$data = $request->validate([
'nome' => 'required|string|max:255',
'tipo' => 'required|in:google_calendar,caldav',
'sync_direction' => 'nullable|in:import,export,bidirectional',
'sync_interval_minutes' => 'nullable|integer|min:5|max:1440',
'is_active' => 'boolean',
'config' => 'nullable|array',
]);
$maxOrdine = CalendarioConnessione::max('ordine') ?? 0;
$connessione = new CalendarioConnessione();
$connessione->nome = $data['nome'];
$connessione->tipo = $data['tipo'];
$connessione->sync_direction = $data['sync_direction'] ?? 'bidirectional';
$connessione->sync_interval_minutes = $data['sync_interval_minutes'] ?? 60;
$connessione->is_active = $request->boolean('is_active', true);
$connessione->stato = 'disconnesso';
$connessione->ordine = $maxOrdine + 1;
$connessione->encryptAndSetConfig($data['config'] ?? []);
$connessione->save();
return redirect('/impostazioni#calendario')->with('success', 'Connessione calendario "' . $connessione->nome . '" creata.');
}
public function update(Request $request, int $id): RedirectResponse
{
$this->authorizeWrite('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$data = $request->validate([
'nome' => 'required|string|max:255',
'tipo' => 'required|in:google_calendar,caldav',
'sync_direction' => 'nullable|in:import,export,bidirectional',
'sync_interval_minutes' => 'nullable|integer|min:5|max:1440',
'is_active' => 'boolean',
'config' => 'nullable|array',
]);
$connessione->nome = $data['nome'];
$connessione->tipo = $data['tipo'];
$connessione->sync_direction = $data['sync_direction'] ?? 'bidirectional';
$connessione->sync_interval_minutes = $data['sync_interval_minutes'] ?? 60;
$connessione->is_active = $request->boolean('is_active', true);
if (!empty($data['config'])) {
$connessione->encryptAndSetConfig($data['config']);
}
$connessione->save();
return redirect('/impostazioni#calendario')->with('success', 'Connessione calendario "' . $connessione->nome . '" aggiornata.');
}
public function destroy(int $id): JsonResponse
{
$this->authorizeDelete('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$connessione->delete();
return response()->json(['success' => true, 'message' => 'Connessione calendario eliminata.']);
}
public function test(int $id): JsonResponse
{
$this->authorizeWrite('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$result = match ($connessione->tipo) {
'caldav' => app(CalDavService::class)->testConnection($connessione),
'google_calendar' => app(GoogleCalendarSyncService::class)->testConnection($connessione),
default => ['success' => false, 'message' => 'Tipo sconosciuto'],
};
if ($result['success']) {
$connessione->update(['stato' => 'connesso', 'last_error_at' => null, 'last_error_message' => null]);
} else {
$connessione->update(['stato' => 'errore', 'last_error_at' => now(), 'last_error_message' => $result['message'] ?? 'Errore sconosciuto']);
}
return response()->json($result);
}
public function sync(int $id): RedirectResponse
{
$this->authorizeWrite('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$result = match ($connessione->tipo) {
'caldav' => app(CalDavService::class)->syncEvents($connessione),
'google_calendar' => app(GoogleCalendarSyncService::class)->syncEvents($connessione),
default => ['success' => false, 'imported' => 0, 'exported' => 0, 'errors' => ['Tipo sconosciuto']],
};
$connessione->update(['last_sync_at' => now()]);
if ($result['success']) {
$msg = 'Sincronizzazione completata: ' . $result['imported'] . ' importati, ' . $result['exported'] . ' esportati.';
$connessione->update(['stato' => 'connesso']);
return redirect('/impostazioni#calendario')->with('success', $msg);
}
$errorMsg = implode('; ', $result['errors']);
$connessione->update(['stato' => 'errore', 'last_error_at' => now(), 'last_error_message' => $errorMsg]);
return redirect('/impostazioni#calendario')->with('error', 'Errore sync: ' . $errorMsg);
}
public function syncAll(): JsonResponse
{
$this->authorizeWrite('settings');
$connections = CalendarioConnessione::where('is_active', true)->get();
$results = [];
foreach ($connections as $connessione) {
$result = match ($connessione->tipo) {
'caldav' => app(CalDavService::class)->syncEvents($connessione),
'google_calendar' => app(GoogleCalendarSyncService::class)->syncEvents($connessione),
default => ['success' => false, 'imported' => 0, 'exported' => 0, 'errors' => ['Tipo sconosciuto']],
};
$connessione->update(['last_sync_at' => now()]);
if ($result['success']) {
$connessione->update(['stato' => 'connesso']);
} else {
$errorMsg = implode('; ', $result['errors']);
$connessione->update(['stato' => 'errore', 'last_error_at' => now(), 'last_error_message' => $errorMsg]);
}
$results[] = [
'id' => $connessione->id,
'nome' => $connessione->nome,
'success' => $result['success'],
'imported' => $result['imported'],
'exported' => $result['exported'],
'errors' => $result['errors'],
];
}
$totalImported = array_sum(array_column($results, 'imported'));
$totalExported = array_sum(array_column($results, 'exported'));
$hasErrors = in_array(false, array_column($results, 'success'), true);
return response()->json([
'success' => !$hasErrors,
'results' => $results,
'total_imported' => $totalImported,
'total_exported' => $totalExported,
'connections' => count($results),
]);
}
public function reorder(Request $request): JsonResponse
{
$this->authorizeWrite('settings');
$order = $request->input('order', []);
foreach ($order as $index => $id) {
CalendarioConnessione::where('id', $id)->update(['ordine' => $index]);
}
return response()->json(['success' => true]);
}
}
+96 -6
View File
@@ -11,6 +11,7 @@ use App\Models\Individuo;
use App\Models\Gruppo;
use App\Models\Evento;
use App\Models\StorageRepository;
use App\Models\Tag;
use App\Models\TipologiaDocumento;
use App\Services\StorageRepositoryService;
use Illuminate\Http\JsonResponse;
@@ -42,9 +43,14 @@ class DocumentoController extends Controller
$folderId = $request->get('folder_id');
$repoId = $request->get('repository_id');
$query = Documento::with(['user', 'target', 'cartella', 'repository'])
$query = Documento::with(['user', 'target', 'cartella', 'repository', 'tags'])
->orderByDesc('created_at');
if ($request->filled('tag')) {
$tagSlugs = (array) $request->input('tag');
$query->withAnyTags($tagSlugs);
}
if ($repoId) {
$query->where('repository_id', $repoId);
} elseif ($folderId) {
@@ -54,6 +60,7 @@ class DocumentoController extends Controller
}
$documenti = $query->paginate(20);
$allTags = Tag::orderBy('name')->get();
$cartelle = DocumentoCartella::with('children')
->whereNull('parent_id')
->orderBy('nome')
@@ -85,11 +92,45 @@ class DocumentoController extends Controller
'children' => $f->children->map(fn($c) => ['id' => $c->id, 'nome' => $c->nome]),
]);
$vista = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'documenti')
->where('is_default', true)
->first();
$userVistas = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'documenti')
->orderBy('is_default', 'desc')
->orderBy('nome')
->get();
$allColumnDefs = [
['key' => 'nome_file', 'label' => 'Nome'],
['key' => 'tipologia', 'label' => 'Tipologia'],
['key' => 'dimensione', 'label' => 'Dimensione'],
['key' => 'visibilita', 'label' => 'Contesto'],
['key' => 'tag', 'label' => 'Tag'],
['key' => 'created_at', 'label' => 'Data'],
];
$defaultVisible = ['nome_file', 'tipologia', 'dimensione', 'visibilita', 'tag', 'created_at'];
$visibleColumns = $vista && !empty($vista->colonne_visibili)
? $vista->colonne_visibili
: $defaultVisible;
$tableColumns = array_map(fn($col) => [
'key' => $col['key'],
'label' => $col['label'],
'visible' => in_array($col['key'], $visibleColumns),
], $allColumnDefs);
$entityType = 'documenti';
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
return view('documenti.index', compact(
'documenti', 'cartelle', 'currentFolder', 'breadcrumb', 'sottoCartelle',
'currentRepo', 'repositories',
'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie',
'cartelleMoveOptions'
'cartelleMoveOptions', 'allTags',
'vista', 'visibleColumns', 'tableColumns', 'entityType', 'columnWidths',
'userVistas'
));
}
@@ -106,13 +147,15 @@ class DocumentoController extends Controller
public function edit($documento)
{
$this->authorizeWrite('documenti');
$documento = Documento::with('target')->findOrFail($documento);
$documento = Documento::with(['target', 'tags'])->findOrFail($documento);
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$gruppi = Gruppo::orderBy('nome')->get();
$eventi = Evento::orderBy('nome_evento')->get();
$mailingLists = \App\Models\MailingList::orderBy('nome')->get();
$tipologie = TipologiaDocumento::attive();
return view('documenti.edit', compact('documento', 'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie'));
$tags = Tag::orderBy('name')->get();
$selectedTags = $documento->tags->pluck('id')->toArray();
return view('documenti.edit', compact('documento', 'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie', 'tags', 'selectedTags'));
}
public function options(Request $request)
@@ -165,6 +208,8 @@ class DocumentoController extends Controller
'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string',
'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
$data['visibilita'] = $request->contesto_tipo ?: 'pubblico';
@@ -185,6 +230,12 @@ class DocumentoController extends Controller
$documento->update($data);
if ($request->has('tags')) {
$documento->tags()->sync($request->tags);
} else {
$documento->tags()->detach();
}
return redirect('/documenti')->with('success', 'Documento aggiornato.');
}
@@ -661,9 +712,13 @@ class DocumentoController extends Controller
'evento' => Evento::class,
'mailing' => \App\Models\MailingList::class,
];
$updateData['visibilita'] = $data['visibilita_target_type'];
$targetType = $data['visibilita_target_type'];
if (!isset($typeMap[$targetType])) {
return back()->with('error', 'Tipo destinazione non valido.');
}
$updateData['visibilita'] = $targetType;
$updateData['visibilita_target_id'] = (int) $data['visibilita_target_id'];
$updateData['visibilita_target_type'] = $typeMap[$data['visibilita_target_type']] ?? $data['visibilita_target_type'];
$updateData['visibilita_target_type'] = $typeMap[$targetType];
}
Documento::whereIn('id', $ids)->update($updateData);
@@ -702,6 +757,41 @@ class DocumentoController extends Controller
return back()->with('success', count($ids) . ' documenti associati.');
}
public function massTag(Request $request)
{
$this->authorizeWrite('documenti');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return back()->with('error', 'Nessun documento selezionato.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
Documento::whereIn('id', $ids)->chunk(100, function ($documenti) use ($tagIds, $mode, &$count) {
foreach ($documenti as $documento) {
if ($mode === 'assign') {
$documento->tags()->syncWithoutDetaching($tagIds);
} else {
$documento->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return back()->with('success', "Tag $actionLabel per $count documenti.");
}
public function massMove(Request $request): RedirectResponse
{
$this->authorizeWrite('documenti');
+19 -3
View File
@@ -59,7 +59,8 @@ class EmailController extends Controller
$query->orderBy($sortField === 'sent_at' ? 'received_at' : $sortField, $sortDir);
$messages = $query->paginate(20)->withQueryString();
$perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$messages = $query->paginate($perPage)->withQueryString();
$folders = EmailFolder::orderBy('sort_order')->get();
$mailingLists = MailingList::where('attiva', true)->get();
$gruppi = Gruppo::orderBy('nome')->get();
@@ -93,6 +94,8 @@ class EmailController extends Controller
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$senderAccounts = SenderAccount::active()->get();
$documenti = Documento::orderBy('nome_file')->get();
$prefill = [];
if ($request->reply_to) {
$original = EmailMessage::find($request->reply_to);
@@ -110,7 +113,7 @@ class EmailController extends Controller
}
}
return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'prefill', 'senderAccounts'));
return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'documenti', 'prefill', 'senderAccounts'));
}
public function send(Request $request)
@@ -340,7 +343,7 @@ class EmailController extends Controller
$message->delete();
}
return back()->with('success', 'Email spostata nel cestino.');
return redirect()->route('email.index', ['folder' => 'inbox'])->with('success', 'Email spostata nel cestino.');
}
public function downloadAttachment($id)
@@ -564,6 +567,7 @@ class EmailController extends Controller
private function processAttachments(Request $request): array
{
$paths = [];
if ($request->hasFile('allegati')) {
foreach ($request->file('allegati') as $file) {
if ($file->isValid()) {
@@ -571,6 +575,18 @@ class EmailController extends Controller
}
}
}
$documentIds = $request->input('documenti_selezionati');
if (!empty($documentIds)) {
$ids = is_array($documentIds) ? $documentIds : explode(',', $documentIds);
$docs = Documento::whereIn('id', $ids)->get();
foreach ($docs as $doc) {
if ($doc->file_path && Storage::exists($doc->file_path)) {
$paths[] = Storage::path($doc->file_path);
}
}
}
return $paths;
}
+160 -7
View File
@@ -5,9 +5,11 @@ namespace App\Http\Controllers;
use App\Models\Evento;
use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\Tag;
use App\Models\TipologiaEvento;
use App\Services\IcsExportService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EventoController extends Controller
@@ -15,7 +17,12 @@ class EventoController extends Controller
public function index(Request $request)
{
$this->authorizeRead('eventi');
$query = Evento::with(['gruppi', 'responsabili']);
$query = Evento::with(['gruppi', 'responsabili', 'tags']);
if ($request->filled('tag')) {
$tagSlugs = (array) $request->input('tag');
$query->withAnyTags($tagSlugs);
}
if ($request->filled('search')) {
$query->where('nome_evento', 'like', '%' . $request->search . '%');
@@ -43,10 +50,47 @@ class EventoController extends Controller
$query->orderByDesc('created_at');
}
$eventi = $query->paginate(20);
$perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$eventi = $query->paginate($perPage);
$gruppi = Gruppo::orderBy('nome')->get();
$allTags = Tag::orderBy('name')->get();
return view('eventi.index', compact('eventi', 'gruppi'));
$vista = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'eventi')
->where('is_default', true)
->first();
$userVistas = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'eventi')
->orderBy('is_default', 'desc')
->orderBy('nome')
->get();
$allColumnDefs = [
['key' => 'nome_evento', 'label' => 'Nome Evento'],
['key' => 'descrizione_evento', 'label' => 'Descrizione'],
['key' => 'tipo_evento', 'label' => 'Tipologia'],
['key' => 'tipo_recorrenza', 'label' => 'Ricorrenza'],
['key' => 'gruppi', 'label' => 'Gruppi'],
['key' => 'responsabili', 'label' => 'Responsabili'],
['key' => 'tag', 'label' => 'Tag'],
['key' => 'data_specifica', 'label' => 'Data'],
['key' => 'created_at', 'label' => 'Creato'],
];
$defaultVisible = ['nome_evento', 'descrizione_evento', 'tipo_evento', 'tipo_recorrenza', 'gruppi', 'responsabili', 'tag', 'created_at'];
$visibleColumns = $vista && !empty($vista->colonne_visibili)
? $vista->colonne_visibili
: $defaultVisible;
$tableColumns = array_map(fn($col) => [
'key' => $col['key'],
'label' => $col['label'],
'visible' => in_array($col['key'], $visibleColumns),
], $allColumnDefs);
$entityType = 'eventi';
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
return view('eventi.index', compact('eventi', 'gruppi', 'allTags', 'vista', 'visibleColumns', 'tableColumns', 'entityType', 'columnWidths', 'userVistas'));
}
public function create(Request $request)
@@ -56,8 +100,9 @@ class EventoController extends Controller
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$selectedGruppo = $request->query('gruppo_id') ? Gruppo::find($request->query('gruppo_id')) : null;
$tipologieEventi = TipologiaEvento::attive();
$tags = Tag::orderBy('name')->get();
return view('eventi.create', compact('gruppi', 'individui', 'selectedGruppo', 'tipologieEventi'));
return view('eventi.create', compact('gruppi', 'individui', 'selectedGruppo', 'tipologieEventi', 'tags'));
}
public function store(Request $request)
@@ -86,6 +131,8 @@ class EventoController extends Controller
'responsabili.*' => 'exists:individui,id',
'luogo_indirizzo' => 'nullable|string|max:500',
'luogo_url_maps' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
$evento = Evento::create([
@@ -108,6 +155,10 @@ class EventoController extends Controller
'luogo_url_maps' => $data['luogo_url_maps'] ?? null,
]);
if (!empty($data['tags'])) {
$evento->tags()->attach($data['tags']);
}
if (!empty($data['gruppi'])) {
$evento->gruppi()->attach($data['gruppi']);
}
@@ -122,19 +173,21 @@ class EventoController extends Controller
public function show($evento)
{
$this->authorizeRead('eventi');
$evento = Evento::with(['gruppi', 'responsabili.contatti', 'documenti'])->findOrFail($evento);
$evento = Evento::with(['gruppi', 'responsabili.contatti', 'documenti', 'tags'])->findOrFail($evento);
return view('eventi.show', compact('evento'));
}
public function edit($evento)
{
$this->authorizeWrite('eventi');
$evento = Evento::with(['gruppi', 'responsabili', 'documenti'])->findOrFail($evento);
$evento = Evento::with(['gruppi', 'responsabili', 'documenti', 'tags'])->findOrFail($evento);
$gruppi = Gruppo::orderBy('nome')->get();
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$tipologieEventi = TipologiaEvento::attive();
$tags = Tag::orderBy('name')->get();
$selectedTags = $evento->tags->pluck('id')->toArray();
return view('eventi.edit', compact('evento', 'gruppi', 'individui', 'tipologieEventi'));
return view('eventi.edit', compact('evento', 'gruppi', 'individui', 'tipologieEventi', 'tags', 'selectedTags'));
}
public function update(Request $request, $evento)
@@ -165,6 +218,8 @@ class EventoController extends Controller
'responsabili.*' => 'exists:individui,id',
'luogo_indirizzo' => 'nullable|string|max:500',
'luogo_url_maps' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
$evento->update([
@@ -187,6 +242,7 @@ class EventoController extends Controller
'luogo_url_maps' => $data['luogo_url_maps'] ?? null,
]);
$evento->tags()->sync($data['tags'] ?? []);
$evento->gruppi()->sync($data['gruppi'] ?? []);
$evento->responsabili()->sync($data['responsabili'] ?? []);
@@ -477,4 +533,101 @@ class EventoController extends Controller
return $colors['primary'];
}
public function importIcs()
{
$this->authorizeWrite('eventi');
return view('eventi.import');
}
public function importIcsStore(Request $request)
{
$this->authorizeWrite('eventi');
$request->validate([
'file' => 'required|file|mimes:ics,text/calendar',
]);
$file = $request->file('file');
$content = file_get_contents($file->path());
try {
$vcalendar = \Sabre\VObject\Reader::read($content);
} catch (\Exception $e) {
Log::warning('Errore lettura file ICS: ' . $e->getMessage());
return back()->with('error', 'Il file ICS non è valido: ' . $e->getMessage());
}
$imported = 0;
$skipped = 0;
$errors = [];
foreach ($vcalendar->VEVENT as $vevent) {
try {
$uid = (string) ($vevent->UID ?? '');
if (!empty($uid)) {
$existing = Evento::where('uid_esterno', $uid)->first();
if ($existing) {
$skipped++;
continue;
}
}
$nomeEvento = (string) ($vevent->SUMMARY ?? '');
if (empty($nomeEvento)) {
$skipped++;
continue;
}
$dtStart = $vevent->DTSTART ?? null;
$dtEnd = $vevent->DTEND ?? null;
$dataSpecifica = null;
$oraInizio = null;
$durataMinuti = null;
if ($dtStart) {
$dtStartDateTime = new \DateTime((string) $dtStart);
$dataSpecifica = $dtStartDateTime->format('Y-m-d');
$oraInizio = $dtStartDateTime->format('H:i');
if ($dtEnd) {
$dtEndDateTime = new \DateTime((string) $dtEnd);
$durataMinuti = (int) ($dtEndDateTime->getTimestamp() - $dtStartDateTime->getTimestamp()) / 60;
if ($durataMinuti < 1) {
$durataMinuti = null;
}
}
}
$descrizione = (string) ($vevent->DESCRIPTION ?? '');
$luogo = (string) ($vevent->LOCATION ?? '');
Evento::create([
'nome_evento' => $nomeEvento,
'descrizione' => !empty($descrizione) ? $descrizione : null,
'tipo_recorrenza' => 'singolo',
'data_specifica' => $dataSpecifica,
'ora_inizio' => $oraInizio,
'durata_minuti' => $durataMinuti,
'luogo_indirizzo' => !empty($luogo) ? $luogo : null,
'uid_esterno' => !empty($uid) ? $uid : null,
]);
$imported++;
} catch (\Exception $e) {
Log::warning('Errore import evento ICS: ' . $e->getMessage());
$errors[] = 'Errore: ' . $e->getMessage();
}
}
$message = 'Importati ' . $imported . ' eventi.';
if ($skipped > 0) {
$message .= ' Saltati ' . $skipped . ' eventi (già presenti o senza nome).';
}
if (count($errors) > 0) {
return back()->with('error', $message . ' Errori: ' . implode('; ', $errors));
}
return redirect('/eventi')->with('success', $message);
}
}
+234 -11
View File
@@ -7,7 +7,11 @@ namespace App\Http\Controllers;
use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\Diocesi;
use App\Models\Tag;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Log;
class GruppoController extends Controller
{
@@ -16,6 +20,7 @@ class GruppoController extends Controller
$this->authorizeRead('gruppi');
$viewMode = request('view', 'table');
$perPage = in_array((int) request('perPage'), [10, 20, 25, 50, 100]) ? (int) request('perPage') : 20;
$user = auth()->user();
$vista = \App\Models\VistaReport::where('user_id', $user->id)
@@ -23,8 +28,28 @@ class GruppoController extends Controller
->where('is_default', true)
->first();
$allColumns = ['nome', 'descrizione', 'diocesi', 'livello', 'padre', 'membri', 'responsabili', 'indirizzo', 'citta', 'telefono', 'email'];
$defaultVisible = ['nome', 'livello', 'padre', 'membri', 'responsabili'];
$userVistas = \App\Models\VistaReport::where('user_id', $user->id)
->where('tipo', 'gruppi')
->orderBy('is_default', 'desc')
->orderBy('nome')
->get();
$allColumnDefs = [
['key' => 'nome', 'label' => 'Nome'],
['key' => 'descrizione', 'label' => 'Descrizione'],
['key' => 'diocesi', 'label' => 'Diocesi'],
['key' => 'livello', 'label' => 'Livello'],
['key' => 'padre', 'label' => 'Gruppo Padre'],
['key' => 'membri', 'label' => 'Membri'],
['key' => 'responsabili', 'label' => 'Responsabili'],
['key' => 'indirizzo', 'label' => 'Indirizzo'],
['key' => 'citta', 'label' => 'Città'],
['key' => 'telefono', 'label' => 'Telefono'],
['key' => 'email', 'label' => 'Email'],
['key' => 'tag', 'label' => 'Tag'],
];
$allColumns = array_column($allColumnDefs, 'key');
$defaultVisible = ['nome', 'livello', 'padre', 'membri', 'responsabili', 'tag'];
if ($vista && !empty($vista->colonne_visibili)) {
$visibleColumns = $vista->colonne_visibili;
@@ -32,9 +57,22 @@ class GruppoController extends Controller
$visibleColumns = $defaultVisible;
}
$allGruppi = Gruppo::with(['diocesi', 'parent', 'children', 'individui', 'avatar'])
->orderBy('nome')
->get()
$tableColumns = array_map(fn($col) => [
'key' => $col['key'],
'label' => $col['label'],
'visible' => in_array($col['key'], $visibleColumns),
], $allColumnDefs);
$entityType = 'gruppi';
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
$gruppiQuery = Gruppo::with(['diocesi', 'parent', 'children', 'individui', 'avatar', 'tags']);
if (request()->filled('tag')) {
$tagSlugs = (array) request()->input('tag');
$gruppiQuery->withAnyTags($tagSlugs);
}
$allGruppi = $gruppiQuery->orderBy('nome')->get()
->map(function ($gruppo) {
$depth = 0;
$parent = $gruppo->parent;
@@ -47,17 +85,33 @@ class GruppoController extends Controller
});
if ($viewMode === 'table') {
$gruppi = $this->sortGruppiHierarchically($allGruppi);
$sorted = $this->sortGruppiHierarchically($allGruppi);
$page = Paginator::resolveCurrentPage();
$gruppi = new LengthAwarePaginator(
$sorted->forPage($page, $perPage)->values(),
$sorted->count(),
$perPage,
$page,
['path' => Paginator::resolveCurrentPath()]
);
$gruppi->appends(request()->except('page'));
} else {
$gruppi = $allGruppi;
}
$allTags = Tag::orderBy('name')->get();
return view('gruppi.index', compact(
'gruppi',
'allColumns',
'visibleColumns',
'viewMode',
'vista'
'vista',
'allTags',
'tableColumns',
'entityType',
'columnWidths',
'userVistas'
));
}
@@ -91,7 +145,8 @@ class GruppoController extends Controller
$allGruppi = Gruppo::orderBy('nome')->get();
$selectedParent = $request->query('parent_id') ? Gruppo::find($request->query('parent_id')) : null;
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
return view('gruppi.create', compact('diocesi', 'allGruppi', 'selectedParent', 'individui'));
$tags = Tag::orderBy('name')->get();
return view('gruppi.create', compact('diocesi', 'allGruppi', 'selectedParent', 'individui', 'tags'));
}
public function store(Request $request)
@@ -109,6 +164,8 @@ class GruppoController extends Controller
'città_incontro' => 'nullable|string|max:255',
'sigla_provincia_incontro' => 'nullable|string|max:2',
'mappa_posizione' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
if (!empty($data['responsabile_ids'])) {
@@ -117,6 +174,10 @@ class GruppoController extends Controller
$gruppo = Gruppo::create($data);
if ($request->has('tags')) {
$gruppo->tags()->sync($request->tags);
}
if ($request->has('individui')) {
foreach ($request->individui as $individuoData) {
if (!empty($individuoData['individuo_id'])) {
@@ -136,7 +197,7 @@ class GruppoController extends Controller
$this->authorizeRead('gruppi');
$gruppo = Gruppo::with(['parent', 'children', 'diocesi', 'individui.contatti', 'individui.documenti', 'individui' => function ($q) {
$q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione');
}, 'avatar', 'eventi' => function ($q) {
}, 'avatar', 'tags', 'eventi' => function ($q) {
$q->where('is_incontro_gruppo', true);
}, 'eventi.responsabili.contatti'])->findOrFail($id);
return view('gruppi.show', compact('gruppo'));
@@ -145,11 +206,13 @@ class GruppoController extends Controller
public function edit($id)
{
$this->authorizeWrite('gruppi');
$gruppo = Gruppo::with(['individui.contatti', 'individui.documenti', 'avatar'])->findOrFail($id);
$gruppo = Gruppo::with(['individui.contatti', 'individui.documenti', 'avatar', 'tags'])->findOrFail($id);
$diocesi = Diocesi::orderBy('nome')->get();
$gruppi = Gruppo::where('id', '!=', $gruppo->id)->orderBy('nome')->get();
$membri = $gruppo->individui()->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione')->orderBy('cognome')->orderBy('nome')->get();
return view('gruppi.edit', compact('gruppo', 'diocesi', 'gruppi', 'membri'));
$tags = Tag::orderBy('name')->get();
$selectedTags = $gruppo->tags->pluck('id')->toArray();
return view('gruppi.edit', compact('gruppo', 'diocesi', 'gruppi', 'membri', 'tags', 'selectedTags'));
}
public function update(Request $request, $id)
@@ -168,6 +231,8 @@ class GruppoController extends Controller
'città_incontro' => 'nullable|string|max:255',
'sigla_provincia_incontro' => 'nullable|string|max:2',
'mappa_posizione' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
if (!empty($data['responsabile_ids'])) {
@@ -178,6 +243,12 @@ class GruppoController extends Controller
$gruppo->update($data);
if ($request->has('tags')) {
$gruppo->tags()->sync($request->tags);
} else {
$gruppo->tags()->detach();
}
if ($request->has('individui')) {
$gruppo->individui()->detach();
foreach ($request->individui as $individuoData) {
@@ -300,4 +371,156 @@ class GruppoController extends Controller
return redirect()->route('gruppi.index')->with('success', 'Vista eliminata.');
}
public function import()
{
$this->authorizeWrite('gruppi');
return view('gruppi.import');
}
public function importStore(Request $request)
{
$this->authorizeWrite('gruppi');
$request->validate([
'file' => 'required|file|mimes:csv,txt',
]);
$file = $request->file('file');
$handle = fopen($file->path(), 'r');
$header = fgetcsv($handle, 1000, ',');
if ($header === false) {
fclose($handle);
return back()->with('error', 'Il file CSV non è valido.');
}
$header = array_map('trim', $header);
$imported = 0;
$skipped = 0;
$errors = [];
$rowNumber = 1;
while (($row = fgetcsv($handle, 1000, ',')) !== false) {
$rowNumber++;
if (count($row) < count($header)) {
$skipped++;
continue;
}
$data = array_combine($header, $row);
if ($data === false) {
$skipped++;
continue;
}
$data = array_map('trim', $data);
$nome = $data['nome'] ?? '';
if (empty($nome)) {
$skipped++;
continue;
}
try {
Gruppo::create([
'nome' => $nome,
'descrizione' => $data['descrizione'] ?? null,
'parent_id' => !empty($data['parent_id']) ? (int) $data['parent_id'] : null,
'diocesi_id' => !empty($data['diocesi_id']) ? (int) $data['diocesi_id'] : null,
'indirizzo_incontro' => $data['indirizzo_incontro'] ?? null,
'cap_incontro' => $data['cap_incontro'] ?? null,
'città_incontro' => $data['città_incontro'] ?? null,
'sigla_provincia_incontro' => $data['sigla_provincia_incontro'] ?? null,
]);
$imported++;
} catch (\Exception $e) {
Log::warning('Errore import gruppo riga ' . $rowNumber . ': ' . $e->getMessage());
$errors[] = 'Errore riga ' . $rowNumber . ': ' . $e->getMessage();
}
}
fclose($handle);
$message = 'Importati ' . $imported . ' gruppi.';
if ($skipped > 0) {
$message .= ' Saltate ' . $skipped . ' righe (vuote o incomplete).';
}
if (count($errors) > 0) {
return back()->with('error', $message . ' Errori: ' . implode('; ', $errors));
}
return redirect()->route('gruppi.index')->with('success', $message);
}
public function downloadTemplate()
{
$this->authorizeRead('gruppi');
$headers = ['nome', 'descrizione', 'parent_id', 'diocesi_id', 'indirizzo_incontro', 'cap_incontro', 'città_incontro', 'sigla_provincia_incontro'];
$example = ['Gruppo Giovani', 'Giovani dai 18 ai 30 anni', '', '', 'Via Roma 10', '00100', 'Roma', 'RM'];
$csv = implode(',', $headers) . "\n" . implode(',', $example);
return response($csv, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="template_gruppi.csv"',
]);
}
public function massTag(Request $request)
{
$this->authorizeWrite('gruppi');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return redirect()->route('gruppi.index')->with('error', 'Nessun gruppo selezionato.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
Gruppo::whereIn('id', $ids)->chunk(100, function ($gruppi) use ($tagIds, $mode, &$count) {
foreach ($gruppi as $gruppo) {
if ($mode === 'assign') {
$gruppo->tags()->syncWithoutDetaching($tagIds);
} else {
$gruppo->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return redirect()->route('gruppi.index')->with('success', "Tag {$actionLabel} per {$count} gruppi.");
}
public function massElimina(Request $request)
{
$this->authorizeDelete('gruppi');
$ids = $request->input('ids', []);
if (!is_array($ids) || empty($ids)) {
return redirect()->route('gruppi.index')->with('error', 'Nessun gruppo selezionato.');
}
$gruppi = Gruppo::whereIn('id', $ids)->get();
$count = 0;
foreach ($gruppi as $gruppo) {
$gruppo->individui()->detach();
$gruppo->delete();
$count++;
}
return redirect()->route('gruppi.index')->with('success', "{$count} gruppi eliminati con successo.");
}
}
@@ -3,10 +3,13 @@
namespace App\Http\Controllers;
use App\Models\AppSetting;
use App\Models\CalendarioConnessione;
use App\Models\Documento;
use App\Models\EmailSetting;
use App\Models\Ruolo;
use App\Models\SenderAccount;
use App\Models\StorageRepository;
use App\Models\Tag;
use App\Models\TipologiaDocumento;
use App\Models\TipologiaEvento;
use Illuminate\Http\RedirectResponse;
@@ -24,13 +27,15 @@ class ImpostazioniController extends Controller
$tipologie = TipologiaDocumento::orderBy('ordine')->get();
$tipologieEventi = TipologiaEvento::orderBy('ordine')->get();
$ruoli = Ruolo::orderBy('ordine')->get();
$tags = Tag::orderBy('order_column')->get();
$appSettings = AppSetting::first();
$emailSettings = EmailSetting::first() ?? new EmailSetting();
$senderAccounts = SenderAccount::orderBy('email_address')->get();
$repositories = StorageRepository::orderBy('ordine')->get();
$googleDriveNewToken = session('google_drive_new_token');
$calendarioConnessioni = CalendarioConnessione::orderBy('ordine')->get();
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken'));
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'tags', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni'));
}
public function saveAppSettings(Request $request)
@@ -268,10 +273,17 @@ class ImpostazioniController extends Controller
$ruolo = Ruolo::findOrFail($id);
$count = \DB::table('gruppo_individuo')
$allRows = \DB::table('gruppo_individuo')
->whereNotNull('ruolo_ids')
->whereRaw("JSON_CONTAINS(ruolo_ids, ?)", [json_encode($id)])
->count();
->get(['id', 'ruolo_ids']);
$count = 0;
foreach ($allRows as $row) {
$ids = json_decode($row->ruolo_ids, true);
if (is_array($ids) && in_array((int) $id, $ids)) {
$count++;
}
}
if ($count > 0) {
return redirect('/impostazioni#ruoli')->with('error', 'Impossibile eliminare: ' . $count . ' membri associati a questo ruolo.');
@@ -361,6 +373,70 @@ class ImpostazioniController extends Controller
return response()->json(['success' => true]);
}
public function tagStore(Request $request)
{
$this->authorizeWrite('settings');
$validated = $request->validate([
'name' => 'required|string|max:100|unique:tags,name',
'color' => 'nullable|string|max:7',
]);
$maxOrdine = Tag::max('order_column') ?? 0;
Tag::create([
'name' => $validated['name'],
'slug' => str()->slug($validated['name']),
'color' => $validated['color'] ?? null,
'order_column' => $maxOrdine + 1,
]);
return redirect('/impostazioni#tag')->with('success', 'Tag aggiunto.');
}
public function tagUpdate(Request $request, $id)
{
$this->authorizeWrite('settings');
$tag = Tag::findOrFail($id);
$validated = $request->validate([
'name' => 'required|string|max:100|unique:tags,name,' . $id,
'color' => 'nullable|string|max:7',
]);
$tag->update([
'name' => $validated['name'],
'slug' => str()->slug($validated['name']),
'color' => $validated['color'] ?? null,
]);
return redirect('/impostazioni#tag')->with('success', 'Tag aggiornato.');
}
public function tagDestroy($id)
{
$this->authorizeDelete('settings');
$tag = Tag::findOrFail($id);
$tag->delete();
return redirect('/impostazioni#tag')->with('success', 'Tag eliminato.');
}
public function tagReorder(Request $request)
{
$this->authorizeWrite('settings');
$order = $request->input('order', []);
foreach ($order as $index => $id) {
Tag::where('id', $id)->update(['order_column' => $index]);
}
return response()->json(['success' => true]);
}
public function uploadLogo(Request $request)
{
$this->authorizeWrite('settings');
@@ -388,6 +464,15 @@ class ImpostazioniController extends Controller
$settings->save();
}
$linkPath = public_path('storage');
if (!file_exists($linkPath) && !is_link($linkPath)) {
try {
symlink(storage_path('app/public'), $linkPath);
} catch (\Throwable $e) {
Log::warning('Impossibile creare symlink storage: ' . $e->getMessage());
}
}
return redirect('/impostazioni#logo')->with('success', 'Logo caricato con successo.');
}
+148 -18
View File
@@ -5,19 +5,32 @@ namespace App\Http\Controllers;
use App\Models\Individuo;
use App\Models\Contatto;
use App\Models\Documento;
use App\Models\Comune;
use App\Models\Diocesi;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
use App\Models\Gruppo;
use App\Models\Comune;
use App\Models\Diocesi;
use App\Models\Tag;
use App\Models\VistaReport;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
class IndividuoController extends Controller
{
public function index(Request $request)
{
$this->authorizeRead('individui');
$individui = Individuo::with('contatti')->orderBy('cognome')->orderBy('nome')->paginate(20);
$perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$query = Individuo::with('contatti', 'tags');
if ($request->filled('tag')) {
$tagSlugs = (array) $request->input('tag');
$query->withAnyTags($tagSlugs);
}
$individui = $query->orderBy('cognome')->orderBy('nome')->paginate($perPage);
$allTags = Tag::orderBy('name')->get();
$vista = null;
@@ -38,16 +51,43 @@ class IndividuoController extends Controller
['key' => 'codice', 'label' => 'Codice'],
['key' => 'cognome', 'label' => 'Cognome'],
['key' => 'nome', 'label' => 'Nome'],
['key' => 'data_nascita', 'label' => 'Data Nascita'],
['key' => 'genere', 'label' => 'Genere'],
['key' => 'indirizzo', 'label' => 'Indirizzo'],
['key' => 'cap', 'label' => 'CAP'],
['key' => 'citta', 'label' => 'Città'],
['key' => 'provincia', 'label' => 'Provincia'],
['key' => 'tipo_documento', 'label' => 'Tipo Documento'],
['key' => 'numero_documento', 'label' => 'Numero Documento'],
['key' => 'scadenza_documento', 'label' => 'Scadenza Documento'],
['key' => 'email', 'label' => 'Email'],
['key' => 'telefono', 'label' => 'Telefono'],
['key' => 'note', 'label' => 'Note'],
['key' => 'tag', 'label' => 'Tag'],
['key' => 'created_at', 'label' => 'Creato il'],
];
$defaultVisible = ['codice', 'cognome', 'nome', 'email', 'telefono'];
$defaultVisible = ['codice', 'cognome', 'nome', 'email', 'telefono', 'tag'];
$visibleColumns = $vista && !empty($vista->colonne_visibili)
? $vista->colonne_visibili
: $defaultVisible;
return view('individui.index', compact('individui', 'vista', 'allColumns', 'visibleColumns'));
$entityType = 'individui';
$tableColumns = array_map(fn($col) => [
'key' => $col['key'],
'label' => $col['label'],
'visible' => in_array($col['key'], $visibleColumns),
], $allColumns);
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
$userVistas = VistaReport::where('user_id', auth()->id())
->where('tipo', 'individui')
->orderBy('is_default', 'desc')
->orderBy('nome')
->get();
$gruppi = Gruppo::orderBy('nome')->get(['id', 'nome']);
return view('individui.index', compact('individui', 'vista', 'allColumns', 'visibleColumns', 'allTags', 'gruppi', 'entityType', 'tableColumns', 'columnWidths', 'userVistas'));
}
public function exportCSV(Request $request)
@@ -109,7 +149,8 @@ public function create()
$this->authorizeWrite('individui');
$comuni = Comune::orderBy('nome')->get();
$diocesi = Diocesi::orderBy('nome')->get();
return view('individui.create', compact('comuni', 'diocesi'));
$tags = Tag::orderBy('name')->get();
return view('individui.create', compact('comuni', 'diocesi', 'tags'));
}
public function store(Request $request)
@@ -128,10 +169,16 @@ public function create()
'numero_documento' => 'nullable|string|max:50',
'scadenza_documento' => 'nullable|date',
'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
$individuo = Individuo::create($data);
if ($request->has('tags')) {
$individuo->tags()->sync($request->tags);
}
if ($request->has('contatti')) {
foreach ($request->contatti as $contatto) {
if (!empty($contatto['tipo']) && !empty($contatto['valore'])) {
@@ -153,7 +200,7 @@ public function create()
$this->authorizeRead('individui');
$individuo = Individuo::with(['contatti', 'gruppi' => function ($q) {
$q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione');
}, 'avatar'])->findOrFail($individuo);
}, 'avatar', 'tags'])->findOrFail($individuo);
return view('individui.show', compact('individuo'));
}
@@ -179,8 +226,10 @@ public function create()
public function edit($individuo)
{
$this->authorizeWrite('individui');
$individuo = Individuo::with(['contatti', 'gruppi', 'avatar'])->findOrFail($individuo);
return view('individui.edit', compact('individuo'));
$individuo = Individuo::with(['contatti', 'gruppi', 'avatar', 'tags'])->findOrFail($individuo);
$tags = Tag::orderBy('name')->get();
$selectedTags = $individuo->tags->pluck('id')->toArray();
return view('individui.edit', compact('individuo', 'tags', 'selectedTags'));
}
public function update(Request $request, $individuo)
@@ -204,17 +253,29 @@ public function create()
'numero_documento' => 'nullable|string|max:50',
'scadenza_documento' => 'nullable|date',
'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
$individuo->update($data);
if ($request->has('tags')) {
$individuo->tags()->sync($request->tags);
} else {
$individuo->tags()->detach();
}
\Log::info('Individuo dati salvati', ['id' => $individuo->id]);
$contattiData = $request->input('contatti');
\Log::info('Contatti ricevuti:', ['contatti' => $contattiData, 'tipo' => gettype($contattiData)]);
$individuo->contatti()->delete();
\Log::info('Contatti precedenti eliminati, count ora:', ['count' => $individuo->contatti()->count()]);
if ($request->has('contatti')) {
$contattiData = $request->input('contatti');
\Log::info('Contatti ricevuti:', ['contatti' => $contattiData, 'tipo' => gettype($contattiData)]);
$individuo->contatti()->delete();
\Log::info('Contatti precedenti eliminati, count ora:', ['count' => $individuo->contatti()->count()]);
} else {
$contattiData = null;
}
if (!empty($contattiData) && is_array($contattiData)) {
$created = 0;
@@ -303,6 +364,75 @@ public function create()
return redirect()->route('individui.index')->with('success', count($individui) . ' individui eliminati con successo.');
}
public function massGruppo(Request $request)
{
$this->authorizeWrite('individui');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return redirect()->route('individui.index')->with('error', 'Nessun individuo selezionato.');
}
$data = $request->validate([
'gruppi' => 'required|array',
'gruppi.*' => 'exists:gruppi,id',
'mode' => 'required|in:assign,remove',
]);
$gruppiIds = $data['gruppi'];
$mode = $data['mode'];
$count = 0;
foreach ($ids as $id) {
$individuo = Individuo::find($id);
if (!$individuo) continue;
if ($mode === 'assign') {
$individuo->gruppi()->syncWithoutDetaching($gruppiIds);
} else {
$individuo->gruppi()->detach($gruppiIds);
}
$count++;
}
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return redirect()->route('individui.index')->with('success', "Gruppi {$actionLabel} per {$count} individui.");
}
public function massTag(Request $request)
{
$this->authorizeWrite('individui');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return redirect()->route('individui.index')->with('error', 'Nessun individuo selezionato.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
Individuo::whereIn('id', $ids)->chunk(100, function ($individui) use ($tagIds, $mode, &$count) {
foreach ($individui as $individuo) {
if ($mode === 'assign') {
$individuo->tags()->syncWithoutDetaching($tagIds);
} else {
$individuo->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return redirect()->route('individui.index')->with('success', "Tag {$actionLabel} per {$count} individui.");
}
public function elementiCollegati($individuo)
{
$individuo = Individuo::findOrFail($individuo);
+17 -1
View File
@@ -95,9 +95,17 @@ class MailingController extends Controller
}
}
$emails = $destinatari->flatMap(fn($ind) => $ind->contatti->where('tipo', 'email')->pluck('valore'))
$totaleDestinatari = $destinatari->count();
$emails = $destinatari->map(fn($ind) => $ind->contatti->where('tipo', 'email')->first()?->valore)
->filter()->unique()->values();
$skipped = $totaleDestinatari - $emails->count();
if ($skipped > 0) {
Log::info("Mailing invia: {$skipped} contatti saltati perché senza email", [
'lista_id' => $data['lista_id'] ?? null,
]);
}
if ($emails->isEmpty()) {
return back()->with('error', 'Nessun destinatario email valido trovato.')->withInput();
}
@@ -229,9 +237,17 @@ class MailingController extends Controller
->with('individuo.contatti')
->get();
$totaleContatti = $contatti->count();
$emails = $contatti->map(fn($c) => $c->individuo?->contatti->where('tipo', 'email')->first()?->valore)
->filter()->unique()->values();
$skipped = $totaleContatti - $emails->count();
if ($skipped > 0) {
Log::info("Mailing invio elabora: {$skipped} contatti saltati perché senza email", [
'liste_ids' => $data['liste'],
]);
}
if ($emails->isEmpty()) {
return back()->with('error', 'Nessun destinatario email valido trovato nelle liste selezionate.');
}
+66 -9
View File
@@ -3,23 +3,64 @@
namespace App\Http\Controllers;
use App\Models\MailingList;
use App\Models\Tag;
use Illuminate\Http\Request;
class MailingListController extends Controller
{
public function index()
public function index(Request $request)
{
$this->authorizeRead('mailing');
$mailingLists = MailingList::with(['user', 'contatti'])
$query = MailingList::with(['user', 'contatti', 'tags']);
if ($request->filled('tag')) {
$tagSlugs = (array) $request->input('tag');
$query->withAnyTags($tagSlugs);
}
$mailingLists = $query->orderBy('nome')->get();
$allTags = Tag::orderBy('name')->get();
$vista = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'mailing-liste')
->where('is_default', true)
->first();
$userVistas = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'mailing-liste')
->orderBy('is_default', 'desc')
->orderBy('nome')
->get();
return view('mailing-liste.index', compact('mailingLists'));
$allColumnDefs = [
['key' => 'nome', 'label' => 'Nome'],
['key' => 'descrizione', 'label' => 'Descrizione'],
['key' => 'contatti_count', 'label' => 'Contatti'],
['key' => 'tag', 'label' => 'Tag'],
['key' => 'attiva', 'label' => 'Stato'],
['key' => 'created_at', 'label' => 'Creata il'],
];
$defaultVisible = ['nome', 'descrizione', 'contatti_count', 'tag', 'attiva', 'created_at'];
$visibleColumns = $vista && !empty($vista->colonne_visibili)
? $vista->colonne_visibili
: $defaultVisible;
$tableColumns = array_map(fn($col) => [
'key' => $col['key'],
'label' => $col['label'],
'visible' => in_array($col['key'], $visibleColumns),
], $allColumnDefs);
$entityType = 'mailing-liste';
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
return view('mailing-liste.index', compact('mailingLists', 'allTags', 'vista', 'visibleColumns', 'tableColumns', 'entityType', 'columnWidths', 'userVistas'));
}
public function create()
{
$this->authorizeWrite('mailing');
return view('mailing-liste.create');
$tags = Tag::orderBy('name')->get();
return view('mailing-liste.create', compact('tags'));
}
public function store(Request $request)
@@ -30,6 +71,8 @@ class MailingListController extends Controller
'descrizione' => 'nullable|string',
'attiva' => 'boolean',
'contatti_json' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
$data['user_id'] = auth()->id();
@@ -42,11 +85,15 @@ class MailingListController extends Controller
'user_id' => $data['user_id'],
]);
if ($request->has('tags')) {
$lista->tags()->sync($request->tags);
}
if (!empty($data['contatti_json'])) {
$contatti = json_decode($data['contatti_json'], true);
if (is_array($contatti)) {
foreach ($contatti as $contatto) {
if (!empty($contatto['individuo_id']) && !empty($contatto['email'])) {
if (!empty($contatto['individuo_id'])) {
$lista->contatti()->create([
'individuo_id' => $contatto['individuo_id'],
'opt_in' => true,
@@ -63,15 +110,17 @@ class MailingListController extends Controller
public function show($mailing_liste)
{
$this->authorizeRead('mailing');
$mailingList = MailingList::with(['contatti.individuo.contatti'])->findOrFail($mailing_liste);
$mailingList = MailingList::with(['contatti.individuo.contatti', 'tags'])->findOrFail($mailing_liste);
return view('mailing-liste.show', compact('mailingList'));
}
public function edit($mailingList)
{
$this->authorizeWrite('mailing');
$mailingList = MailingList::with('contatti.individuo.contatti')->findOrFail($mailingList);
return view('mailing-liste.edit', compact('mailingList'));
$mailingList = MailingList::with(['contatti.individuo.contatti', 'tags'])->findOrFail($mailingList);
$tags = Tag::orderBy('name')->get();
$selectedTags = $mailingList->tags->pluck('id')->toArray();
return view('mailing-liste.edit', compact('mailingList', 'tags', 'selectedTags'));
}
public function update(Request $request, $mailingList)
@@ -84,6 +133,8 @@ class MailingListController extends Controller
'descrizione' => 'nullable|string',
'attiva' => 'boolean',
'contatti_json' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
$data['attiva'] = $data['attiva'] ?? false;
@@ -94,6 +145,12 @@ class MailingListController extends Controller
'attiva' => $data['attiva'],
]);
if ($request->has('tags')) {
$mailingList->tags()->sync($request->tags);
} else {
$mailingList->tags()->detach();
}
if (!empty($data['contatti_json'])) {
$newContatti = json_decode($data['contatti_json'], true);
if (is_array($newContatti)) {
@@ -107,7 +164,7 @@ class MailingListController extends Controller
$toAdd = array_diff($newIds, $existingIds);
foreach ($newContatti as $contatto) {
if (in_array($contatto['individuo_id'], $toAdd) && !empty($contatto['email'])) {
if (in_array($contatto['individuo_id'], $toAdd)) {
$mailingList->contatti()->create([
'individuo_id' => $contatto['individuo_id'],
'opt_in' => true,
+159 -3
View File
@@ -11,6 +11,7 @@ use App\Models\Evento;
use App\Models\Documento;
use App\Models\MailingList;
use App\Models\Contatto;
use App\Models\Tag;
use App\Models\TipologiaDocumento;
use App\Models\ReportCustom;
use Illuminate\Http\Request;
@@ -47,12 +48,15 @@ class ReportController extends Controller
$columnOptions[$type] = ReportColumnRegistry::getColumns($type);
}
$allTags = Tag::orderBy('name')->get();
return view('report.index', compact(
'stats',
'prebuiltReports',
'customReports',
'tipologieDocumento',
'columnOptions'
'columnOptions',
'allTags'
));
}
@@ -136,6 +140,14 @@ class ReportController extends Controller
$config['search_columns'] = $request->input('search_columns');
}
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');
}
ReportCustom::create([
'user_id' => auth()->id(),
'nome' => $data['nome'],
@@ -252,6 +264,9 @@ class ReportController extends Controller
'documenti_completo' => $this->reportDocumentiCompleto($tipologiaFilter),
'contatti_completo' => $this->reportContattiCompleto(),
'scadenze_documenti' => $this->reportScadenzeDocumenti($tipologiaFilter),
'tag_statistiche' => $this->reportTagStatistiche(),
'individui_per_tag' => $this->reportIndividuiPerTag(),
'documenti_per_tag' => $this->reportDocumentiPerTag(),
default => null,
};
}
@@ -403,6 +418,30 @@ class ReportController extends Controller
'colore' => 'teal',
'has_tipologia_filter' => false,
],
[
'id' => 'tag_statistiche',
'nome' => 'Statistiche Tag',
'descrizione' => 'Distribuzione degli elementi per tag (individui, gruppi, eventi, documenti, mailing list)',
'icona' => 'fa-tags',
'colore' => 'indigo',
'has_tipologia_filter' => false,
],
[
'id' => 'individui_per_tag',
'nome' => 'Individui per Tag',
'descrizione' => 'Elenco completo degli individui raggruppati per tag assegnato',
'icona' => 'fa-user-tag',
'colore' => 'info',
'has_tipologia_filter' => false,
],
[
'id' => 'documenti_per_tag',
'nome' => 'Documenti per Tag',
'descrizione' => 'Elenco completo dei documenti raggruppati per tag assegnato',
'icona' => 'fa-tags',
'colore' => 'secondary',
'has_tipologia_filter' => false,
],
];
}
@@ -602,7 +641,7 @@ class ReportController extends Controller
'lista' => $lista->nome,
'stato' => $lista->attiva ? 'Attiva' : 'Disattiva',
'individuo' => $contatto->individuo?->nome_completo ?? 'N/D',
'email' => $contatto->email ?? '-',
'email' => $contatto->valore ?? '-',
];
}
}
@@ -927,6 +966,102 @@ class ReportController extends Controller
];
}
protected function reportTagStatistiche(): array
{
$tags = Tag::orderBy('name')->get();
$rows = $tags->map(function ($tag) {
return [
'name' => $tag->name,
'color' => $tag->color ?? '#6c757d',
'individui' => $tag->individui()->count(),
'gruppi' => $tag->gruppi()->count(),
'eventi' => $tag->eventi()->count(),
'documenti' => $tag->documenti()->count(),
'mailing' => $tag->mailingLists()->count(),
'totale' => $tag->count,
];
})->toArray();
$totalIndividui = array_sum(array_column($rows, 'individui'));
$totalGruppi = array_sum(array_column($rows, 'gruppi'));
$totalEventi = array_sum(array_column($rows, 'eventi'));
$totalDocumenti = array_sum(array_column($rows, 'documenti'));
$totalMailing = array_sum(array_column($rows, 'mailing'));
return [
'title' => 'Statistiche Tag',
'headers' => ['Tag', 'Individui', 'Gruppi', 'Eventi', 'Documenti', 'Mailing List', 'Totale Elementi'],
'rows' => array_map(fn($r) => [$r['name'], $r['individui'], $r['gruppi'], $r['eventi'], $r['documenti'], $r['mailing'], $r['totale']], $rows),
'summary' => 'Tag: ' . count($tags) . ' | Individui: ' . $totalIndividui . ' | Gruppi: ' . $totalGruppi . ' | Eventi: ' . $totalEventi . ' | Documenti: ' . $totalDocumenti . ' | Mailing List: ' . $totalMailing,
'detail_rows' => $rows,
];
}
protected function reportIndividuiPerTag(): array
{
$individui = Individuo::with('tags')
->whereHas('tags')
->orderBy('cognome')
->orderBy('nome')
->get();
$rows = $individui->flatMap(function ($ind) {
if ($ind->tags->isEmpty()) {
return collect();
}
return $ind->tags->map(function ($tag) use ($ind) {
return [
'tag' => $tag->name,
'tag_color' => $tag->color ?? '#6c757d',
'codice' => $ind->codice_id,
'cognome' => $ind->cognome,
'nome' => $ind->nome,
'email' => $ind->email_primaria ?? '-',
];
});
})->toArray();
return [
'title' => 'Individui per Tag',
'headers' => ['Tag', 'Codice', 'Cognome', 'Nome', 'Email'],
'rows' => array_map(fn($r) => [$r['tag'], $r['codice'], $r['cognome'], $r['nome'], $r['email']], $rows),
'summary' => 'Totale righe (individuo × tag): ' . count($rows),
'detail_rows' => $rows,
];
}
protected function reportDocumentiPerTag(): array
{
$documenti = Documento::with('tags')
->whereHas('tags')
->orderBy('nome_file')
->get();
$rows = $documenti->flatMap(function ($doc) {
if ($doc->tags->isEmpty()) {
return collect();
}
return $doc->tags->map(function ($tag) use ($doc) {
return [
'tag' => $tag->name,
'tag_color' => $tag->color ?? '#6c757d',
'nome_file' => $doc->nome_file,
'tipologia' => ucfirst((string) $doc->tipologia),
'data' => $doc->created_at->format('d/m/Y'),
];
});
})->toArray();
return [
'title' => 'Documenti per Tag',
'headers' => ['Tag', 'Nome File', 'Tipologia', 'Data Caricamento'],
'rows' => array_map(fn($r) => [$r['tag'], $r['nome_file'], $r['tipologia'], $r['data']], $rows),
'summary' => 'Totale righe (documento × tag): ' . count($rows),
'detail_rows' => $rows,
];
}
protected function runCustomReport(ReportCustom $report): array
{
$tipo = $report->tipo_report;
@@ -954,11 +1089,32 @@ class ReportController extends Controller
$columns = $config['columns'] ?? [];
$hasRelations = false;
// Always load tags if tags column or tag filter is present
$needsTags = in_array('tags.nome', $columns) || !empty($config['tag_filter']);
if ($needsTags) {
$query->with('tags');
}
foreach ($columns as $col) {
if (str_contains($col, '.')) {
$hasRelations = true;
$relation = explode('.', $col)[0];
$query->with($relation);
if ($relation !== 'tags') {
$query->with($relation);
}
}
}
// Apply tag filter
if (!empty($config['tag_filter'])) {
$tagSlugs = Tag::whereIn('id', (array) $config['tag_filter'])->pluck('slug')->toArray();
if (!empty($tagSlugs)) {
$mode = $config['tag_filter_mode'] ?? 'any';
if ($mode === 'all') {
$query->withAllTags($tagSlugs);
} else {
$query->withAnyTags($tagSlugs);
}
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tag;
use App\Models\Individuo;
use App\Models\Gruppo;
use App\Models\Evento;
use App\Models\Documento;
use App\Models\MailingList;
use Illuminate\Http\Request;
class RicercaController extends Controller
{
public function index(Request $request)
{
$this->authorizeRead('individui');
$allTags = Tag::orderBy('name')->get();
$tagSlugs = (array) $request->input('tag', []);
if (empty($tagSlugs)) {
return view('ricerca.index', compact('allTags'));
}
$tagSlugs = array_filter($tagSlugs);
$selectedTags = Tag::whereIn('slug', $tagSlugs)->get();
$results = [];
$totals = [];
if (count($tagSlugs) === 1) {
$tag = $selectedTags->first();
$results['individui'] = $tag->individui()->orderBy('cognome')->orderBy('nome')->get();
$results['gruppi'] = $tag->gruppi()->orderBy('nome')->get();
$results['eventi'] = $tag->eventi()->orderBy('data_specifica')->orderBy('nome_evento')->get();
$results['documenti'] = $tag->documenti()->orderBy('nome_file')->get();
$results['mailingLists'] = $tag->mailingLists()->orderBy('nome')->get();
} else {
$results['individui'] = Individuo::withAllTags($tagSlugs)->orderBy('cognome')->orderBy('nome')->get();
$results['gruppi'] = Gruppo::withAllTags($tagSlugs)->orderBy('nome')->get();
$results['eventi'] = Evento::withAllTags($tagSlugs)->orderBy('data_specifica')->orderBy('nome_evento')->get();
$results['documenti'] = Documento::withAllTags($tagSlugs)->orderBy('nome_file')->get();
$results['mailingLists'] = MailingList::withAllTags($tagSlugs)->orderBy('nome')->get();
}
$totals = [
'individui' => $results['individui']->count(),
'gruppi' => $results['gruppi']->count(),
'eventi' => $results['eventi']->count(),
'documenti' => $results['documenti']->count(),
'mailingLists' => $results['mailingLists']->count(),
];
return view('ricerca.index', compact('selectedTags', 'tagSlugs', 'results', 'totals', 'allTags'));
}
}
+11 -1
View File
@@ -33,9 +33,11 @@ class VistaReportController extends Controller
$this->authorizeWrite('viste');
$data = $request->validate([
'nome' => 'required|string|max:255',
'tipo' => 'required|in:individui,gruppi,documenti,eventi',
'tipo' => 'required|in:individui,gruppi,documenti,eventi,mailing-liste',
'colonne_visibili' => 'nullable|array',
'colonne_ordinamento' => 'nullable|array',
'colonne_larghezze' => 'nullable|array',
'colonne_ordine' => 'nullable|array',
'filtri' => 'nullable|array',
'ricerca' => 'nullable|string',
'is_default' => 'nullable|boolean',
@@ -55,6 +57,8 @@ class VistaReportController extends Controller
'tipo' => $data['tipo'],
'colonne_visibili' => $data['colonne_visibili'] ?? null,
'colonne_ordinamento' => $data['colonne_ordinamento'] ?? null,
'colonne_larghezze' => $data['colonne_larghezze'] ?? null,
'colonne_ordine' => $data['colonne_ordine'] ?? null,
'filtri' => $data['filtri'] ?? null,
'ricerca' => $data['ricerca'] ?? null,
'is_default' => $isDefault,
@@ -78,6 +82,8 @@ class VistaReportController extends Controller
'nome' => 'required|string|max:255',
'colonne_visibili' => 'nullable|array',
'colonne_ordinamento' => 'nullable|array',
'colonne_larghezze' => 'nullable|array',
'colonne_ordine' => 'nullable|array',
'filtri' => 'nullable|array',
'ricerca' => 'nullable|string',
'is_default' => 'nullable|boolean',
@@ -92,6 +98,10 @@ class VistaReportController extends Controller
$vistaReport->update($data);
if ($request->expectsJson()) {
return response()->json(['success' => true, 'message' => 'Vista aggiornata.']);
}
return back()->with('success', 'Vista aggiornata.');
}
+29 -30
View File
@@ -1,31 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
class AppSetting extends Model
{
protected $table = 'app_settings';
protected $fillable = [
'logo',
'logo_small',
'logo_path',
'logo_small_path',
'nome_applicazione',
'nome_organizzazione',
'footer_text',
'app_version',
'dashboard_welcome',
'show_version',
'backup_path',
'backup_retention_days',
'backup_include_files',
'backup_include_env',
'backup_auto_enabled',
'backup_auto_frequency',
'backup_auto_hour',
'logo', 'logo_small', 'logo_path', 'logo_small_path',
'nome_applicazione', 'nome_organizzazione',
'footer_text', 'app_version', 'dashboard_welcome', 'show_version',
'backup_path', 'backup_retention_days', 'backup_include_files',
'backup_include_env', 'backup_auto_enabled', 'backup_auto_frequency', 'backup_auto_hour',
'documenti_storage_disk', 'documenti_storage_path',
];
protected function casts(): array
@@ -36,11 +30,16 @@ class AppSetting extends Model
'backup_include_env' => 'boolean',
'backup_auto_enabled' => 'boolean',
'backup_auto_hour' => 'integer',
'show_version' => 'boolean',
];
}
public static function getSetting(string $key, $default = null)
public static function getSetting(string $key, mixed $default = null): mixed
{
if (!Schema::hasTable('app_settings')) {
return $default;
}
$setting = self::first();
return $setting?->{$key} ?? $default;
}
@@ -110,38 +109,38 @@ class AppSetting extends Model
public function getLogoUrlInstance(): ?string
{
if ($this->logo && file_exists(public_path('storage/' . $this->logo))) {
return asset('storage/' . $this->logo);
if ($this->logo && Storage::disk('public')->exists($this->logo)) {
return Storage::disk('public')->url($this->logo);
}
return null;
}
public function getLogoSmallUrlInstance(): ?string
{
if ($this->logo_small && file_exists(public_path('storage/' . $this->logo_small))) {
return asset('storage/' . $this->logo_small);
if ($this->logo_small && Storage::disk('public')->exists($this->logo_small)) {
return Storage::disk('public')->url($this->logo_small);
}
return null;
}
public function getLogoPath(): ?string
{
if ($this->logo_path && file_exists(public_path('storage/' . $this->logo_path))) {
return asset('storage/' . $this->logo_path);
if ($this->logo_path && Storage::disk('public')->exists($this->logo_path)) {
return Storage::disk('public')->url($this->logo_path);
}
if ($this->logo && file_exists(public_path('storage/' . $this->logo))) {
return asset('storage/' . $this->logo);
if ($this->logo && Storage::disk('public')->exists($this->logo)) {
return Storage::disk('public')->url($this->logo);
}
return null;
}
public function getLogoSmallPath(): ?string
{
if ($this->logo_small_path && file_exists(public_path('storage/' . $this->logo_small_path))) {
return asset('storage/' . $this->logo_small_path);
if ($this->logo_small_path && Storage::disk('public')->exists($this->logo_small_path)) {
return Storage::disk('public')->url($this->logo_small_path);
}
if ($this->logo_small && file_exists(public_path('storage/' . $this->logo_small))) {
return asset('storage/' . $this->logo_small);
if ($this->logo_small && Storage::disk('public')->exists($this->logo_small)) {
return Storage::disk('public')->url($this->logo_small);
}
return null;
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class CalendarioConnessione extends Model
{
protected $table = 'calendario_connessioni';
protected $fillable = [
'nome',
'tipo',
'config',
'stato',
'sync_direction',
'sync_interval_minutes',
'last_sync_at',
'last_error_at',
'last_error_message',
'is_active',
'ordine',
];
protected $casts = [
'config' => 'array',
'is_active' => 'boolean',
'sync_interval_minutes' => 'integer',
'last_sync_at' => 'datetime',
'last_error_at' => 'datetime',
'ordine' => 'integer',
];
public function scopeAttivi($query)
{
return $query->where('is_active', true)->orderBy('ordine');
}
public function getDecryptedConfig(): array
{
$config = $this->config;
foreach (['username', 'password', 'client_secret', 'refresh_token', 'api_key'] as $field) {
if (isset($config[$field])) {
try {
$config[$field] = Crypt::decryptString($config[$field]);
} catch (\Exception) {
}
}
}
return $config;
}
public function encryptAndSetConfig(array $config): void
{
foreach (['username', 'password', 'client_secret', 'refresh_token', 'api_key'] as $field) {
if (isset($config[$field]) && $config[$field] !== '' && !str_starts_with($config[$field], 'eyJpdiI')) {
$config[$field] = Crypt::encryptString($config[$field]);
}
}
$this->config = $config;
}
public static function opzioniTipo(): array
{
return ['google_calendar', 'caldav'];
}
public static function etichettaTipo(string $tipo): string
{
return match ($tipo) {
'google_calendar' => 'Google Calendar',
'caldav' => 'CalDAV (Infomaniak/NextCloud)',
default => ucfirst($tipo),
};
}
}
+2
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Documento extends Model
{
use HasTagsLight;
protected $table = 'documenti';
protected $fillable = [
'tenant_id', 'user_id', 'cartella_id', 'repository_id', 'storage_disk',
+3
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Evento extends Model
{
use HasTagsLight;
protected $table = 'eventi';
protected $fillable = [
@@ -30,6 +32,7 @@ class Evento extends Model
'is_incontro_gruppo',
'luogo_indirizzo',
'luogo_url_maps',
'uid_esterno',
];
protected $casts = [
+2
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Gruppo extends Model
{
use HasTagsLight;
protected $table = 'gruppi';
protected $fillable = [
'tenant_id', 'parent_id', 'diocesi_id', 'responsabile_ids',
+2
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
class Individuo extends Model
{
use HasTagsLight;
protected $table = 'individui';
protected $fillable = [
'tenant_id', 'codice_id', 'cognome', 'nome', 'data_nascita',
+3
View File
@@ -5,9 +5,12 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Traits\HasTagsLight;
class MailingList extends Model
{
use HasTagsLight;
protected $table = 'mailing_lists';
protected $fillable = ['tenant_id', 'user_id', 'nome', 'descrizione', 'attiva'];
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
class Tag extends Model
{
protected $table = 'tags';
protected $fillable = ['name', 'slug', 'color', 'order_column'];
protected $casts = [
'order_column' => 'integer',
];
protected static function boot(): void
{
parent::boot();
static::creating(function (self $tag) {
if (empty($tag->slug)) {
$tag->slug = str()->slug($tag->name);
}
});
}
public function individui(): MorphToMany
{
return $this->morphedByMany(Individuo::class, 'taggable');
}
public function gruppi(): MorphToMany
{
return $this->morphedByMany(Gruppo::class, 'taggable');
}
public function eventi(): MorphToMany
{
return $this->morphedByMany(Evento::class, 'taggable');
}
public function documenti(): MorphToMany
{
return $this->morphedByMany(Documento::class, 'taggable');
}
public function mailingLists(): MorphToMany
{
return $this->morphedByMany(MailingList::class, 'taggable');
}
public function getCountAttribute(): int
{
return $this->individui()->count()
+ $this->gruppi()->count()
+ $this->eventi()->count()
+ $this->documenti()->count()
+ $this->mailingLists()->count();
}
}
+5 -2
View File
@@ -9,14 +9,17 @@ class VistaReport extends Model
{
protected $table = 'viste_report';
protected $fillable = [
'user_id', 'nome', 'tipo', 'colonne_visibili',
'colonne_ordinamento', 'filtri', 'ricerca'
'user_id', 'nome', 'tipo', 'colonne_visibili',
'colonne_ordinamento', 'filtri', 'ricerca', 'is_default',
'colonne_larghezze', 'colonne_ordine',
];
protected $casts = [
'colonne_visibili' => 'array',
'colonne_ordinamento' => 'array',
'filtri' => 'array',
'colonne_larghezze' => 'array',
'colonne_ordine' => 'array',
];
public function user(): BelongsTo
+389
View File
@@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\CalendarioConnessione;
use App\Models\Evento;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class CalDavService
{
private const CALDAV_NS = 'urn:ietf:params:xml:ns:caldav';
private const DAV_NS = 'DAV:';
public function testConnection(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$client = $this->buildClient($config);
try {
$response = $client->request('PROPFIND', $config['url'], [
'headers' => ['Depth' => '0'],
'body' => '<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
<d:prop>
<d:displayname/>
<cs:getctag/>
</d:prop>
</d:propfind>',
]);
if ($response->getStatusCode() < 300) {
return ['success' => true, 'message' => 'Connessione CalDAV riuscita.'];
}
return ['success' => false, 'message' => 'Errore HTTP: ' . $response->getStatusCode()];
} catch (\Exception $e) {
return ['success' => false, 'message' => 'Errore: ' . $e->getMessage()];
}
}
public function listCalendars(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$client = $this->buildClient($config);
try {
$response = $client->request('PROPFIND', $config['url'], [
'headers' => ['Depth' => '1'],
'body' => '<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:resourcetype/>
<d:displayname/>
<c:supported-calendar-component-set/>
</d:prop>
</d:propfind>',
]);
$body = (string) $response->getBody();
return $this->parseCalendarList($body);
} catch (\Exception $e) {
Log::error('CalDAV listCalendars: ' . $e->getMessage());
return [];
}
}
public function syncEvents(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$client = $this->buildClient($config);
$calendarUrl = $config['calendar_url'] ?? $config['url'];
$imported = 0;
$exported = 0;
$errors = [];
$start = Carbon::now()->subYear()->format('Ymd\THis\Z');
$end = Carbon::now()->addYear()->format('Ymd\THis\Z');
$direction = $connessione->sync_direction ?? 'bidirectional';
try {
if ($direction === 'import' || $direction === 'bidirectional') {
$result = $this->fetchRemoteEvents($client, $calendarUrl, $start, $end);
$imported = $this->importEvents($result, $errors);
}
if ($direction === 'export' || $direction === 'bidirectional') {
$localEvents = Evento::where(function ($q) use ($connessione) {
$q->whereNotNull('uid_esterno')
->orWhereNull('uid_esterno');
})->get();
$exported = $this->exportEvents($client, $calendarUrl, $localEvents, $connessione, $errors);
}
} catch (\Exception $e) {
$errors[] = $e->getMessage();
Log::error('CalDAV syncEvents: ' . $e->getMessage());
}
return [
'success' => empty($errors),
'imported' => $imported,
'exported' => $exported,
'errors' => $errors,
];
}
private function fetchRemoteEvents($client, string $url, string $start, string $end): array
{
$body = '<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="' . $start . '" end="' . $end . '"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>';
$response = $client->request('REPORT', $url, [
'headers' => ['Depth' => '1', 'Content-Type' => 'application/xml; charset=utf-8'],
'body' => $body,
]);
return $this->parseCalendarData((string) $response->getBody());
}
private function importEvents(array $remoteEvents, array &$errors): int
{
$count = 0;
foreach ($remoteEvents as $event) {
try {
$vcalendar = \Sabre\VObject\Reader::read($event['data']);
foreach ($vcalendar->VEVENT as $vevent) {
$uid = (string) ($vevent->UID ?? Str::uuid());
$existing = Evento::where('uid_esterno', $uid)->first();
if ($existing) {
continue;
}
$summary = (string) ($vevent->SUMMARY ?? 'Evento importato');
$description = (string) ($vevent->DESCRIPTION ?? '');
$location = (string) ($vevent->LOCATION ?? '');
$dtstart = $vevent->DTSTART ? $this->parseDateTime($vevent->DTSTART) : null;
$dtend = $vevent->DTEND ? $this->parseDateTime($vevent->DTEND) : null;
$data = [
'nome_evento' => $summary,
'descrizione_evento' => mb_substr($description, 0, 255),
'tipo_recorrenza' => 'singolo',
'luogo_indirizzo' => $location,
'uid_esterno' => $uid,
];
if ($dtstart) {
$data['data_specifica'] = $dtstart->format('Y-m-d');
$data['ora_inizio'] = $dtstart->format('H:i');
}
if ($dtend && $dtstart) {
$data['durata_minuti'] = (int) $dtstart->diffInMinutes($dtend);
}
$rrule = $vevent->RRULE ? (string) $vevent->RRULE : null;
if ($rrule) {
$parsed = $this->parseRRule($rrule);
$data = array_merge($data, $parsed);
}
Evento::create($data);
$count++;
}
} catch (\Exception $e) {
$errors[] = 'Errore parsing evento: ' . $e->getMessage();
}
}
return $count;
}
private function exportEvents($client, string $url, iterable $localEvents, CalendarioConnessione $connessione, array &$errors): int
{
$count = 0;
$existingUids = $this->getExistingEventUids($client, $url);
foreach ($localEvents as $evento) {
try {
if ($evento->uid_esterno && !in_array($evento->uid_esterno, $existingUids)) {
continue;
}
$uid = $evento->uid_esterno ?? ('local-' . $evento->id . '-' . Str::uuid());
$icsData = $this->buildIcsForExport($evento, $uid);
$eventUrl = rtrim($url, '/') . '/' . $uid . '.ics';
$response = $client->request('PUT', $eventUrl, [
'headers' => ['Content-Type' => 'text/calendar; charset=utf-8'],
'body' => $icsData,
]);
if ($response->getStatusCode() < 300) {
if (!$evento->uid_esterno) {
$evento->updateQuietly(['uid_esterno' => $uid]);
}
$count++;
}
} catch (\Exception $e) {
$errors[] = 'Errore export ' . $evento->nome_evento . ': ' . $e->getMessage();
}
}
return $count;
}
private function getExistingEventUids($client, string $url): array
{
try {
$response = $client->request('REPORT', $url, [
'headers' => ['Depth' => '1'],
'body' => '<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<d:resourcetype/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT"/>
</c:comp-filter>
</c:filter>
</c:calendar-query>',
]);
$body = (string) $response->getBody();
$xml = simplexml_load_string($body);
$xml->registerXPathNamespace('d', self::DAV_NS);
$uids = [];
foreach ($xml->xpath('//d:href') as $href) {
$path = (string) $href;
$name = basename($path);
if (str_ends_with($name, '.ics')) {
$uids[] = str_replace('.ics', '', $name);
}
}
return $uids;
} catch (\Exception) {
return [];
}
}
private function buildIcsForExport(Evento $evento, string $uid): string
{
$service = app(IcsExportService::class);
return $service->generateSingle($evento);
}
private function parseCalendarList(string $xml): array
{
$calendars = [];
try {
$dom = new \DOMDocument();
$dom->loadXML($xml);
$xpath = new \DOMXPath($dom);
$xpath->registerNamespace('d', self::DAV_NS);
$xpath->registerNamespace('c', self::CALDAV_NS);
$responses = $xpath->query('//d:multistatus/d:response');
foreach ($responses as $response) {
$href = $xpath->query('d:href', $response)->item(0)?->textContent ?? '';
$displayName = $xpath->query('d:propstat/d:prop/d:displayname', $response)->item(0)?->textContent ?? $href;
$hasCalendar = false;
$resourcetypes = $xpath->query('d:propstat/d:prop/d:resourcetype/*', $response);
foreach ($resourcetypes as $type) {
if ($type->localName === 'calendar' && $type->namespaceURI === self::CALDAV_NS) {
$hasCalendar = true;
break;
}
}
if ($hasCalendar && $href) {
$calendars[] = [
'href' => $href,
'displayname' => $displayName,
];
}
}
} catch (\Exception $e) {
Log::error('CalDAV parseCalendarList: ' . $e->getMessage());
}
return $calendars;
}
private function parseCalendarData(string $xml): array
{
$events = [];
try {
$dom = new \DOMDocument();
$dom->loadXML($xml);
$xpath = new \DOMXPath($dom);
$xpath->registerNamespace('d', self::DAV_NS);
$xpath->registerNamespace('c', self::CALDAV_NS);
$responses = $xpath->query('//d:multistatus/d:response');
foreach ($responses as $response) {
$dataNodes = $xpath->query('d:propstat/d:prop/c:calendar-data', $response);
foreach ($dataNodes as $node) {
$events[] = ['data' => $node->textContent];
}
}
} catch (\Exception $e) {
Log::error('CalDAV parseCalendarData: ' . $e->getMessage());
}
return $events;
}
private function parseDateTime($dt): ?Carbon
{
try {
if ($dt instanceof \Sabre\VObject\Property\ICalendar\DateTime) {
$dtValue = $dt->getDateTime();
return Carbon::instance($dtValue);
}
} catch (\Exception) {
}
return null;
}
private function parseRRule(string $rrule): array
{
$result = [];
try {
$parts = explode(';', $rrule);
foreach ($parts as $part) {
[$key, $value] = explode('=', $part, 2);
if ($key === 'FREQ') {
$result['tipo_recorrenza'] = match (strtolower($value)) {
'weekly' => 'settimanale',
'monthly' => 'mensile',
'yearly' => 'annuale',
default => 'singolo',
};
} elseif ($key === 'BYDAY' && isset($result['tipo_recorrenza'])) {
$dayMap = ['SU' => 0, 'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6];
$byDay = strtoupper($value);
if (preg_match('/^(-?\d+)?([A-Z]+)$/', $byDay, $m)) {
$result['giorno_settimana'] = $dayMap[$m[2]] ?? null;
if (!empty($m[1]) && $result['tipo_recorrenza'] === 'mensile') {
$result['occorrenza_mese'] = $m[1] . ',' . ($dayMap[$m[2]] ?? '1');
}
}
} elseif ($key === 'BYMONTHDAY') {
$result['giorno_mese'] = (int) $value;
} elseif ($key === 'BYMONTH') {
if ($result['tipo_recorrenza'] ?? '' === 'annuale') {
$result['mese_annuale'] = (int) $value;
} else {
$result['mesi_recorrenza'] = explode(',', $value);
}
}
}
} catch (\Exception) {
}
return $result;
}
private function buildClient(array $config): \GuzzleHttp\Client
{
$options = [
'verify' => false,
'timeout' => 30,
'http_errors' => false,
];
if (!empty($config['username']) && !empty($config['password'])) {
$options['auth'] = [$config['username'], $config['password'], 'basic'];
}
return new \GuzzleHttp\Client($options);
}
}
+350
View File
@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\CalendarioConnessione;
use App\Models\Evento;
use Carbon\Carbon;
use Google\Client as GoogleClient;
use Google\Service\Calendar as GoogleCalendar;
use Google\Service\Calendar\Event as GoogleEvent;
use Illuminate\Support\Facades\Log;
class GoogleCalendarSyncService
{
private ?GoogleCalendar $calendarService = null;
private ?string $calendarId = null;
public function testConnection(CalendarioConnessione $connessione): array
{
try {
$client = $this->buildClient($connessione);
$service = new GoogleCalendar($client);
$calendarList = $service->calendarList->listCalendarList();
return ['success' => true, 'message' => 'Connessione Google Calendar riuscita. Trovati ' . count($calendarList->getItems()) . ' calendari.'];
} catch (\Exception $e) {
return ['success' => false, 'message' => 'Errore: ' . $e->getMessage()];
}
}
public function listCalendars(CalendarioConnessione $connessione): array
{
try {
$client = $this->buildClient($connessione);
$service = new GoogleCalendar($client);
$calendarList = $service->calendarList->listCalendarList();
$calendars = [];
foreach ($calendarList->getItems() as $cal) {
$calendars[] = [
'id' => $cal->getId(),
'summary' => $cal->getSummary(),
];
}
return $calendars;
} catch (\Exception $e) {
Log::error('GoogleCalendar listCalendars: ' . $e->getMessage());
return [];
}
}
public function syncEvents(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$imported = 0;
$exported = 0;
$errors = [];
try {
$client = $this->buildClient($connessione);
$this->calendarService = new GoogleCalendar($client);
$this->calendarId = $config['calendar_id'] ?? 'primary';
$direction = $connessione->sync_direction ?? 'bidirectional';
if ($direction === 'import' || $direction === 'bidirectional') {
$result = $this->importRemoteEvents($connessione);
$imported = $result['count'];
foreach ($result['errors'] as $err) {
$errors[] = $err;
}
}
if ($direction === 'export' || $direction === 'bidirectional') {
$result = $this->exportLocalEvents($connessione);
$exported = $result['count'];
foreach ($result['errors'] as $err) {
$errors[] = $err;
}
}
} catch (\Exception $e) {
$errors[] = $e->getMessage();
Log::error('GoogleCalendar syncEvents: ' . $e->getMessage());
}
return [
'success' => empty($errors),
'imported' => $imported,
'exported' => $exported,
'errors' => $errors,
];
}
private function importRemoteEvents(CalendarioConnessione $connessione): array
{
$count = 0;
$errors = [];
$service = $this->calendarService;
try {
$timeMin = Carbon::now()->subYear()->toRfc3339String();
$timeMax = Carbon::now()->addYear()->toRfc3339String();
$optParams = [
'timeMin' => $timeMin,
'timeMax' => $timeMax,
'singleEvents' => true,
'orderBy' => 'startTime',
];
$events = $service->events->listEvents($this->calendarId, $optParams);
while (true) {
foreach ($events->getItems() as $event) {
try {
$uid = $event->getId();
$existing = Evento::where('uid_esterno', $uid)->first();
if ($existing) {
continue;
}
$summary = $event->getSummary() ?? 'Evento importato';
$description = strip_tags($event->getDescription() ?? '');
$location = $event->getLocation() ?? '';
$start = $event->getStart();
$end = $event->getEnd();
$data = [
'nome_evento' => mb_substr($summary, 0, 255),
'descrizione_evento' => mb_substr($description, 0, 255),
'tipo_recorrenza' => 'singolo',
'luogo_indirizzo' => $location,
'uid_esterno' => $uid,
];
if ($start && $start->getDateTime()) {
$dt = Carbon::parse($start->getDateTime());
$data['data_specifica'] = $dt->format('Y-m-d');
$data['ora_inizio'] = $dt->format('H:i');
} elseif ($start && $start->getDate()) {
$data['data_specifica'] = $start->getDate();
}
if ($end && $start && $end->getDateTime() && $start->getDateTime()) {
$data['durata_minuti'] = (int) Carbon::parse($start->getDateTime())->diffInMinutes(Carbon::parse($end->getDateTime()));
}
if ($event->getRecurrence()) {
foreach ($event->getRecurrence() as $rrule) {
if (str_starts_with($rrule, 'RRULE:')) {
$parsed = $this->parseRRule(substr($rrule, 6));
$data = array_merge($data, $parsed);
}
}
}
Evento::create($data);
$count++;
} catch (\Exception $e) {
$errors[] = 'Errore import evento: ' . $e->getMessage();
}
}
$pageToken = $events->getNextPageToken();
if ($pageToken) {
$optParams['pageToken'] = $pageToken;
$events = $service->events->listEvents($this->calendarId, $optParams);
} else {
break;
}
}
} catch (\Exception $e) {
$errors[] = 'Errore import Google Calendar: ' . $e->getMessage();
}
return ['count' => $count, 'errors' => $errors];
}
private function exportLocalEvents(CalendarioConnessione $connessione): array
{
$count = 0;
$errors = [];
$service = $this->calendarService;
try {
$localEvents = Evento::all();
$remoteEventMap = $this->getRemoteEventMap($service);
foreach ($localEvents as $evento) {
try {
$googleEvent = new GoogleEvent();
$googleEvent->setSummary($evento->nome_evento);
if ($evento->descrizione_evento) {
$googleEvent->setDescription($evento->descrizione_evento);
}
if ($evento->luogo_indirizzo) {
$googleEvent->setLocation($evento->luogo_indirizzo);
}
$startData = $this->buildGoogleDateTime($evento, 'start');
$endData = $this->buildGoogleDateTime($evento, 'end');
$googleEvent->setStart($startData);
$googleEvent->setEnd($endData);
if ($evento->tipo_recorrenza !== 'singolo') {
$rrule = $this->buildGoogleRRule($evento);
if ($rrule) {
$googleEvent->setRecurrence(['RRULE:' . $rrule]);
}
}
if ($evento->uid_esterno && isset($remoteEventMap[$evento->uid_esterno])) {
$service->events->update($this->calendarId, $evento->uid_esterno, $googleEvent);
} else {
$created = $service->events->insert($this->calendarId, $googleEvent);
if (!$evento->uid_esterno) {
$evento->updateQuietly(['uid_esterno' => $created->getId()]);
}
}
$count++;
} catch (\Exception $e) {
$errors[] = 'Errore export ' . $evento->nome_evento . ': ' . $e->getMessage();
}
}
} catch (\Exception $e) {
$errors[] = 'Errore export Google Calendar: ' . $e->getMessage();
}
return ['count' => $count, 'errors' => $errors];
}
private function getRemoteEventMap(GoogleCalendar $service): array
{
$map = [];
try {
$events = $service->events->listEvents($this->calendarId);
foreach ($events->getItems() as $event) {
$map[$event->getId()] = true;
}
} catch (\Exception) {
}
return $map;
}
private function buildGoogleDateTime(Evento $evento, string $type): \Google\Service\Calendar\EventDateTime
{
$dt = new \Google\Service\Calendar\EventDateTime();
if ($type === 'start') {
if ($evento->data_specifica) {
$date = $evento->data_specifica instanceof Carbon ? $evento->data_specifica : Carbon::parse($evento->data_specifica);
if ($evento->ora_inizio) {
$time = $evento->ora_inizio instanceof Carbon ? $evento->ora_inizio : Carbon::parse($evento->ora_inizio);
$dt->setDateTime($date->format('Y-m-d') . 'T' . $time->format('H:i:s'));
$dt->setTimeZone('Europe/Rome');
} else {
$dt->setDate($date->format('Y-m-d'));
}
}
} else {
if ($evento->data_specifica) {
$date = $evento->data_specifica instanceof Carbon ? $evento->data_specifica : Carbon::parse($evento->data_specifica);
$durata = (int) ($evento->durata_minuti ?? 60);
if ($evento->ora_inizio) {
$time = $evento->ora_inizio instanceof Carbon ? $evento->ora_inizio : Carbon::parse($evento->ora_inizio);
$endTime = $time->copy()->addMinutes($durata);
$dt->setDateTime($date->format('Y-m-d') . 'T' . $endTime->format('H:i:s'));
$dt->setTimeZone('Europe/Rome');
} else {
$dt->setDate($date->copy()->addDay()->format('Y-m-d'));
}
}
}
return $dt;
}
private function buildGoogleRRule(Evento $evento): ?string
{
$dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
return match ($evento->tipo_recorrenza) {
'settimanale' => $evento->giorno_settimana !== null
? 'FREQ=WEEKLY;BYDAY=' . ($dayMap[(int) $evento->giorno_settimana] ?? 'MO')
: null,
'mensile' => $evento->occorrenza_mese
? 'FREQ=MONTHLY;BYSETPOS=' . $evento->occorrenza_mese
: null,
'annuale' => $evento->mese_annuale
? 'FREQ=YEARLY;BYMONTH=' . $evento->mese_annuale
: null,
default => null,
};
}
private function parseRRule(string $rrule): array
{
$result = [];
$parts = explode(';', $rrule);
foreach ($parts as $part) {
[$key, $value] = explode('=', $part, 2);
if ($key === 'FREQ') {
$result['tipo_recorrenza'] = match (strtolower($value)) {
'weekly' => 'settimanale',
'monthly' => 'mensile',
'yearly' => 'annuale',
default => 'singolo',
};
} elseif ($key === 'BYDAY') {
$dayMap = ['SU' => 0, 'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6];
if (preg_match('/^(-?\d+)?([A-Z]+)$/', $value, $m)) {
$result['giorno_settimana'] = $dayMap[$m[2]] ?? null;
if (!empty($m[1])) {
$result['occorrenza_mese'] = $m[1] . ',' . ($dayMap[$m[2]] ?? '1');
}
}
} elseif ($key === 'BYMONTHDAY') {
$result['giorno_mese'] = (int) $value;
} elseif ($key === 'BYMONTH') {
$result['mese_annuale'] = (int) $value;
}
}
return $result;
}
private function buildClient(CalendarioConnessione $connessione): GoogleClient
{
$config = $connessione->getDecryptedConfig();
$client = new GoogleClient();
$client->setApplicationName(config('app.name'));
$client->setScopes([GoogleCalendar::CALENDAR]);
$client->setAuthConfig([
'web' => [
'client_id' => $config['client_id'] ?? '',
'client_secret' => $config['client_secret'] ?? '',
],
]);
$client->setAccessType('offline');
$client->setPrompt('consent');
if (!empty($config['refresh_token'])) {
$client->fetchAccessTokenWithRefreshToken($config['refresh_token']);
}
return $client;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Traits;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
trait HasTagsLight
{
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
public function scopeWithAllTags($query, array $tags)
{
foreach ($tags as $tag) {
$query->whereHas('tags', fn ($q) => $q->where('slug', $tag));
}
return $query;
}
public function scopeWithAnyTags($query, array $tags)
{
$query->whereHas('tags', fn ($q) => $q->whereIn('slug', $tags));
return $query;
}
}
Executable
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
set -euo pipefail
NAME="${APP_NAME:-glastree}"
VERSION="$(date +%Y%m%d_%H%M)"
ARCHIVE="${NAME}-${VERSION}.tar.gz"
echo "==> Installazione dipendenze PHP production..."
composer install --no-dev --optimize-autoloader --no-scripts --quiet
echo " OK"
echo "==> Build asset Vite..."
if [ -f "node_modules/.package-lock.json" ] || [ -d "node_modules" ]; then
npm run build --quiet 2>/dev/null && echo " OK" || echo " (skip — node_modules non presente)"
else
echo " (skip — node_modules assente, build Vite non eseguita)"
fi
echo "==> Pulizia cache..."
rm -rf bootstrap/cache/*.php storage/framework/cache/data/* storage/framework/sessions/* storage/framework/views/* storage/logs/* 2>/dev/null || true
echo " OK"
echo "==> Creazione archivio: ${ARCHIVE}"
tar czf "${ARCHIVE}" \
--exclude='.git' \
--exclude='.gitignore' \
--exclude='.env' \
--exclude='.env.example' \
--exclude='node_modules' \
--exclude='tests' \
--exclude='vendor/bin' \
--exclude='vendor/*/tests' \
--exclude='vendor/*/test' \
--exclude='vendor/*/.git' \
--exclude='vendor/*/docs' \
--exclude='vendor/*/doc' \
--exclude='storage/app/backups' \
--exclude='storage/app/documenti' \
--exclude='storage/app/public' \
--exclude='storage/app/private' \
--exclude='public/storage' \
--exclude='storage/framework/cache/*' \
--exclude='storage/framework/sessions/*' \
--exclude='storage/framework/views/*' \
--exclude='storage/logs/*' \
--exclude='storage/debugbar/*' \
--exclude='bootstrap/cache/*.php' \
--exclude='public/hot' \
--exclude='*.tar.gz' \
--exclude='*.tar' \
--exclude='MEMORY.md' \
--exclude='AGENTS.md' \
--exclude='build-dist.sh' \
--exclude='Dockerfile' \
--exclude='docker-compose.yml' \
--exclude='docker-compose.mysql.yml' \
--exclude='docker-entrypoint.sh' \
--exclude='.dockerignore' \
--warning=no-file-changed \
.
echo ""
echo "============================================"
echo " Archivio creato: ${ARCHIVE}"
echo " Dimensione: $(du -h "${ARCHIVE}" | cut -f1)"
echo "============================================"
echo ""
echo "Per estrarre sul server di destinazione:"
echo " tar xzf ${ARCHIVE}"
echo ""
echo "Poi esegui:"
echo " # 1. Crea directory necessarie (se non esistono già)"
echo ' mkdir -p storage/framework/cache/data storage/framework/sessions storage/framework/views storage/logs bootstrap/cache storage/app/public storage/app/public/logos storage/app/public/documenti/eventi storage/app/backups storage/app/documenti storage/app/private/avatars/gruppi'
echo ""
echo " # 2. Crea file .gitignore in storage/app/public (serve a Laravel)"
echo ' echo "*" > storage/app/public/.gitignore'
echo ' echo "!.gitignore" >> storage/app/public/.gitignore'
echo ""
echo " # 3. Permessi (web server = www-data)"
echo ' chmod -R 775 storage bootstrap/cache'
echo ' chown -R www-data:www-data storage bootstrap/cache'
echo ""
echo " # 4. Configura ambiente"
echo " cp .env.example .env # modifica DB, APP_URL, etc."
echo " php artisan key:generate"
echo ""
echo " # 5. Pulisci cache e ricrea symlink storage (relativo per portabilità)"
echo ' rm -f public/storage'
echo ' ln -sf ../storage/app/public public/storage'
echo " php artisan config:clear"
echo " php artisan route:clear"
echo " php artisan view:clear"
echo ""
echo " # 6. Avvia installazione MySQL"
echo " php install.php"
+2
View File
@@ -9,9 +9,11 @@
"php": "^8.3",
"as247/flysystem-google-drive": "^3.0",
"directorytree/imapengine-laravel": "^1.2",
"google/apiclient": "^2.19",
"laravel/framework": "^13.7",
"laravel/tinker": "^3.0",
"league/flysystem-webdav": "^3.31",
"sabre/vobject": "^4.6",
"spatie/laravel-permission": "^7.4",
"webklex/php-imap": "^6.2"
},
Generated
+6 -6
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c6ab297804fe432e1792b166459e6469",
"content-hash": "c594ad5c5f9ff96dc8644231135f694e",
"packages": [
{
"name": "as247/cloud-storages",
@@ -4271,16 +4271,16 @@
},
{
"name": "sabre/vobject",
"version": "4.5.8",
"version": "4.6.0",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/vobject.git",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1"
"reference": "9432544fc369851fb8202c5d91159b2e669f0c88"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/9432544fc369851fb8202c5d91159b2e669f0c88",
"reference": "9432544fc369851fb8202c5d91159b2e669f0c88",
"shasum": ""
},
"require": {
@@ -4371,7 +4371,7 @@
"issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject"
},
"time": "2026-01-12T10:45:19+00:00"
"time": "2026-05-31T13:04:55+00:00"
},
{
"name": "sabre/xml",
+1062
View File
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('tenants')) {
return;
}
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('nome');
@@ -8,26 +8,30 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('diocesi', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('regione')->nullable();
$table->timestamps();
});
if (!Schema::hasTable('diocesi')) {
Schema::create('diocesi', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('regione')->nullable();
$table->timestamps();
});
}
Schema::create('comuni', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('codice_istat', 10)->nullable();
$table->string('cap')->nullable();
$table->string('sigla_provincia', 2)->nullable();
$table->string('regione')->nullable();
$table->float('latitudine')->nullable();
$table->float('longitudine')->nullable();
$table->timestamps();
$table->index('nome');
$table->index('sigla_provincia');
});
if (!Schema::hasTable('comuni')) {
Schema::create('comuni', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('codice_istat', 10)->nullable();
$table->string('cap')->nullable();
$table->string('sigla_provincia', 2)->nullable();
$table->string('regione')->nullable();
$table->float('latitudine')->nullable();
$table->float('longitudine')->nullable();
$table->timestamps();
$table->index('nome');
$table->index('sigla_provincia');
});
}
}
public function down(): void
@@ -8,27 +8,29 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('individui', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('codice_id', 5)->unique();
$table->string('cognome');
$table->string('nome');
$table->date('data_nascita')->nullable();
$table->string('indirizzo')->nullable();
$table->string('cap', 10)->nullable();
$table->string('città')->nullable();
$table->string('sigla_provincia', 2)->nullable();
$table->enum('genere', ['M', 'F'])->nullable();
$table->enum('tipo_documento', ['carta_identita', 'patente'])->nullable();
$table->date('scadenza_documento')->nullable();
$table->text('note')->nullable();
$table->string('avatar_path')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('codice_id');
$table->index('cognome');
});
if (!Schema::hasTable('individui')) {
Schema::create('individui', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('codice_id', 5)->unique();
$table->string('cognome');
$table->string('nome');
$table->date('data_nascita')->nullable();
$table->string('indirizzo')->nullable();
$table->string('cap', 10)->nullable();
$table->string('città')->nullable();
$table->string('sigla_provincia', 2)->nullable();
$table->enum('genere', ['M', 'F'])->nullable();
$table->enum('tipo_documento', ['carta_identita', 'patente'])->nullable();
$table->date('scadenza_documento')->nullable();
$table->text('note')->nullable();
$table->string('avatar_path')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('codice_id');
$table->index('cognome');
});
}
}
public function down(): void
@@ -8,16 +8,18 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('contatti', function (Blueprint $table) {
$table->id();
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->enum('tipo', ['telefono', 'cellulare', 'email', 'fax', 'web', 'telegram', 'whatsapp', 'altro']);
$table->string('valore');
$table->string('etichetta')->nullable();
$table->boolean('is_primary')->default(false);
$table->timestamps();
$table->index('individuo_id');
});
if (!Schema::hasTable('contatti')) {
Schema::create('contatti', function (Blueprint $table) {
$table->id();
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->enum('tipo', ['telefono', 'cellulare', 'email', 'fax', 'web', 'telegram', 'whatsapp', 'altro']);
$table->string('valore');
$table->string('etichetta')->nullable();
$table->boolean('is_primary')->default(false);
$table->timestamps();
$table->index('individuo_id');
});
}
}
public function down(): void
@@ -8,24 +8,26 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('gruppi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->foreignId('parent_id')->nullable()->constrained('gruppi')->onDelete('set null');
$table->foreignId('diocesi_id')->nullable()->constrained('diocesi')->onDelete('set null');
$table->foreignId('responsabile_id')->nullable()->constrained('individui')->onDelete('set null');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->string('indirizzo_incontro')->nullable();
$table->string('cap_incontro', 10)->nullable();
$table->string('città_incontro')->nullable();
$table->string('sigla_provincia_incontro', 2)->nullable();
$table->string('mappa_posizione')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('parent_id');
});
if (!Schema::hasTable('gruppi')) {
Schema::create('gruppi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->foreignId('parent_id')->nullable()->constrained('gruppi')->onDelete('set null');
$table->foreignId('diocesi_id')->nullable()->constrained('diocesi')->onDelete('set null');
$table->foreignId('responsabile_id')->nullable()->constrained('individui')->onDelete('set null');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->string('indirizzo_incontro')->nullable();
$table->string('cap_incontro', 10)->nullable();
$table->string('città_incontro')->nullable();
$table->string('sigla_provincia_incontro', 2)->nullable();
$table->string('mappa_posizione')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('parent_id');
});
}
}
public function down(): void
@@ -8,15 +8,17 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('gruppo_individuo', function (Blueprint $table) {
$table->id();
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->string('ruolo_nel_gruppo')->nullable();
$table->date('data_adesione')->nullable();
$table->timestamps();
$table->unique(['gruppo_id', 'individuo_id']);
});
if (!Schema::hasTable('gruppo_individuo')) {
Schema::create('gruppo_individuo', function (Blueprint $table) {
$table->id();
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->string('ruolo_nel_gruppo')->nullable();
$table->date('data_adesione')->nullable();
$table->timestamps();
$table->unique(['gruppo_id', 'individuo_id']);
});
}
}
public function down(): void
@@ -8,35 +8,41 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('eventi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome_evento');
$table->string('tipo_evento')->nullable();
$table->enum('tipo_recorrenza', ['singolo', 'ricorrente'])->default('singolo');
$table->unsignedTinyInteger('giorno_settimana')->nullable();
$table->time('ora_inizio')->nullable();
$table->date('data_specifica')->nullable();
$table->unsignedInteger('durata_minuti')->nullable();
$table->text('descrizione')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index('tenant_id');
});
if (!Schema::hasTable('eventi')) {
Schema::create('eventi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome_evento');
$table->string('tipo_evento')->nullable();
$table->enum('tipo_recorrenza', ['singolo', 'ricorrente'])->default('singolo');
$table->unsignedTinyInteger('giorno_settimana')->nullable();
$table->time('ora_inizio')->nullable();
$table->date('data_specifica')->nullable();
$table->unsignedInteger('durata_minuti')->nullable();
$table->text('descrizione')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index('tenant_id');
});
}
Schema::create('eventi_gruppi', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->timestamps();
});
if (!Schema::hasTable('eventi_gruppi')) {
Schema::create('eventi_gruppi', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->timestamps();
});
}
Schema::create('eventi_responsabili', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->timestamps();
});
if (!Schema::hasTable('eventi_responsabili')) {
Schema::create('eventi_responsabili', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->timestamps();
});
}
}
public function down(): void
@@ -8,22 +8,24 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('documenti', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome_file');
$table->string('file_path');
$table->string('tipo')->nullable();
$table->enum('tipologia', ['avatar', 'galleria', 'documento', 'statuto', 'altro'])->default('documento');
$table->enum('visibilita', ['pubblico', 'gruppo', 'individuo', 'associazione', 'federazione'])->default('gruppo');
$table->unsignedBigInteger('visibilita_target_id')->nullable();
$table->string('visibilita_target_type')->nullable();
$table->string('mime_type')->nullable();
$table->unsignedBigInteger('dimensione')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index('tenant_id');
});
if (!Schema::hasTable('documenti')) {
Schema::create('documenti', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome_file');
$table->string('file_path');
$table->string('tipo')->nullable();
$table->enum('tipologia', ['avatar', 'galleria', 'documento', 'statuto', 'altro'])->default('documento');
$table->enum('visibilita', ['pubblico', 'gruppo', 'individuo', 'associazione', 'federazione'])->default('gruppo');
$table->unsignedBigInteger('visibilita_target_id')->nullable();
$table->string('visibilita_target_type')->nullable();
$table->string('mime_type')->nullable();
$table->unsignedBigInteger('dimensione')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index('tenant_id');
});
}
}
public function down(): void
@@ -8,43 +8,49 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('mailing_lists', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->boolean('attiva')->default(true);
$table->timestamps();
$table->index('tenant_id');
});
if (!Schema::hasTable('mailing_lists')) {
Schema::create('mailing_lists', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->boolean('attiva')->default(true);
$table->timestamps();
$table->index('tenant_id');
});
}
Schema::create('mailing_contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('mailing_list_id')->constrained('mailing_lists')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->boolean('opt_in')->default(true);
$table->dateTime('opt_in_data')->nullable();
$table->dateTime('opt_out_data')->nullable();
$table->timestamps();
$table->unique(['mailing_list_id', 'individuo_id']);
});
if (!Schema::hasTable('mailing_contacts')) {
Schema::create('mailing_contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('mailing_list_id')->constrained('mailing_lists')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->boolean('opt_in')->default(true);
$table->dateTime('opt_in_data')->nullable();
$table->dateTime('opt_out_data')->nullable();
$table->timestamps();
$table->unique(['mailing_list_id', 'individuo_id']);
});
}
Schema::create('mailing_messaggi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->foreignId('mailing_list_id')->nullable()->constrained('mailing_lists')->onDelete('set null');
$table->unsignedBigInteger('user_id')->nullable();
$table->string('oggetto');
$table->text('corpo')->nullable();
$table->enum('canale', ['email', 'telegram'])->default('email');
$table->enum('stato', ['bozza', 'in_coda', 'inviato', 'fallito'])->default('bozza');
$table->timestamp('inviato_al')->nullable();
$table->integer('totale_destinatari')->default(0);
$table->integer('invii_ok')->default(0);
$table->integer('invii_falliti')->default(0);
$table->timestamps();
$table->index('tenant_id');
});
if (!Schema::hasTable('mailing_messaggi')) {
Schema::create('mailing_messaggi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->foreignId('mailing_list_id')->nullable()->constrained('mailing_lists')->onDelete('set null');
$table->unsignedBigInteger('user_id')->nullable();
$table->string('oggetto');
$table->text('corpo')->nullable();
$table->enum('canale', ['email', 'telegram'])->default('email');
$table->enum('stato', ['bozza', 'in_coda', 'inviato', 'fallito'])->default('bozza');
$table->timestamp('inviato_al')->nullable();
$table->integer('totale_destinatari')->default(0);
$table->integer('invii_ok')->default(0);
$table->integer('invii_falliti')->default(0);
$table->timestamps();
$table->index('tenant_id');
});
}
}
public function down(): void
@@ -8,18 +8,20 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('notifiche', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('tipo');
$table->string('titolo');
$table->text('messaggio');
$table->string('link')->nullable();
$table->boolean('is_read')->default(false);
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
if (!Schema::hasTable('notifiche')) {
Schema::create('notifiche', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('tipo');
$table->string('titolo');
$table->text('messaggio');
$table->string('link')->nullable();
$table->boolean('is_read')->default(false);
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
}
}
public function down(): void
@@ -8,34 +8,40 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->boolean('is_admin')->default(false);
$table->rememberToken();
$table->timestamps();
$table->index('tenant_id');
$table->index('email');
});
if (!Schema::hasTable('users')) {
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->boolean('is_admin')->default(false);
$table->rememberToken();
$table->timestamps();
$table->index('tenant_id');
$table->index('email');
});
}
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
if (!Schema::hasTable('password_reset_tokens')) {
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
if (!Schema::hasTable('sessions')) {
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
}
public function down(): void
@@ -8,50 +8,60 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
if (!Schema::hasTable('cache')) {
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
}
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
if (!Schema::hasTable('cache_locks')) {
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
if (!Schema::hasTable('jobs')) {
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
if (!Schema::hasTable('job_batches')) {
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
}
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at');
});
if (!Schema::hasTable('failed_jobs')) {
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at');
});
}
}
public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('individui', function (Blueprint $table) {
$table->string('numero_documento', 50)->nullable()->after('tipo_documento');
});
if (!Schema::hasColumn('individui', 'numero_documento')) {
Schema::table('individui', function (Blueprint $table) {
$table->string('numero_documento', 50)->nullable()->after('tipo_documento');
});
}
}
public function down(): void
@@ -8,11 +8,13 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('eventi', function (Blueprint $table) {
$table->string('giorno_mese')->nullable()->after('giorno_settimana');
$table->string('mesi_recorrenza')->nullable()->after('giorno_mese');
$table->string('mese_annuale')->nullable()->after('mesi_recorrenza');
});
if (!Schema::hasColumn('eventi', 'giorno_mese')) {
Schema::table('eventi', function (Blueprint $table) {
$table->string('giorno_mese')->nullable()->after('giorno_settimana');
$table->string('mesi_recorrenza')->nullable()->after('giorno_mese');
$table->string('mese_annuale')->nullable()->after('mesi_recorrenza');
});
}
}
public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('eventi', function (Blueprint $table) {
$table->unsignedTinyInteger('occorrenza_mese')->nullable()->after('giorno_mese');
});
if (!Schema::hasColumn('eventi', 'occorrenza_mese')) {
Schema::table('eventi', function (Blueprint $table) {
$table->unsignedTinyInteger('occorrenza_mese')->nullable()->after('giorno_mese');
});
}
}
public function down(): void
@@ -8,12 +8,14 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('eventi_documenti', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('documento_id')->constrained('documenti')->onDelete('cascade');
$table->timestamps();
});
if (!Schema::hasTable('eventi_documenti')) {
Schema::create('eventi_documenti', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('documento_id')->constrained('documenti')->onDelete('cascade');
$table->timestamps();
});
}
}
public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id');
});
if (!Schema::hasColumn('documenti', 'user_id')) {
Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id');
});
}
}
public function down(): void
@@ -8,19 +8,21 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('viste_report', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('nome');
$table->string('tipo'); // individui, gruppi, documenti, eventi
$table->json('colonne_visibili')->nullable();
$table->json('colonne_ordinamento')->nullable(); // [['colonna', 'direzione']]
$table->json('filtri')->nullable(); // [['colonna', 'operatore', 'valore']]
$table->string('ricerca')->nullable();
$table->timestamps();
$table->index('tipo');
$table->index('user_id');
});
if (!Schema::hasTable('viste_report')) {
Schema::create('viste_report', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('nome');
$table->string('tipo'); // individui, gruppi, documenti, eventi
$table->json('colonne_visibili')->nullable();
$table->json('colonne_ordinamento')->nullable(); // [['colonna', 'direzione']]
$table->json('filtri')->nullable(); // [['colonna', 'operatore', 'valore']]
$table->string('ricerca')->nullable();
$table->timestamps();
$table->index('tipo');
$table->index('user_id');
});
}
}
public function down(): void
@@ -8,10 +8,12 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('viste_report', function (Blueprint $table) {
$table->boolean('is_default')->default(false)->after('nome');
$table->index('is_default');
});
if (!Schema::hasColumn('viste_report', 'is_default')) {
Schema::table('viste_report', function (Blueprint $table) {
$table->boolean('is_default')->default(false)->after('nome');
$table->index('is_default');
});
}
}
public function down(): void
@@ -8,10 +8,12 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('mailing_lists', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id');
$table->index('user_id');
});
if (!Schema::hasColumn('mailing_lists', 'user_id')) {
Schema::table('mailing_lists', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id');
$table->index('user_id');
});
}
}
public function down(): void
@@ -8,14 +8,16 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('tipologie_documenti', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
if (!Schema::hasTable('tipologie_documenti')) {
Schema::create('tipologie_documenti', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
}
$defaultTipologie = ['avatar', 'galleria', 'documento', 'statuto', 'altro'];
foreach ($defaultTipologie as $i => $nome) {
@@ -8,32 +8,38 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->json('permissions')->nullable()->after('is_admin');
});
if (!Schema::hasColumn('users', 'permissions')) {
Schema::table('users', function (Blueprint $table) {
$table->json('permissions')->nullable()->after('is_admin');
});
}
Schema::create('activity_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('action');
$table->string('module');
$table->string('description')->nullable();
$table->json('details')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamp('created_at')->useCurrent();
if (!Schema::hasTable('activity_logs')) {
Schema::create('activity_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('action');
$table->string('module');
$table->string('description')->nullable();
$table->json('details')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['user_id', 'module']);
$table->index(['created_at']);
$table->index(['action']);
});
}
$table->index(['user_id', 'module']);
$table->index(['created_at']);
$table->index(['action']);
});
Schema::create('role_presets', function (Blueprint $table) {
$table->id();
$table->string('name', 50)->unique();
$table->string('description')->nullable();
$table->json('permissions');
$table->timestamps();
});
if (!Schema::hasTable('role_presets')) {
Schema::create('role_presets', function (Blueprint $table) {
$table->id();
$table->string('name', 50)->unique();
$table->string('description')->nullable();
$table->json('permissions');
$table->timestamps();
});
}
}
public function down(): void
@@ -23,96 +23,106 @@ return new class extends Migration
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
if (!Schema::hasTable($tableNames['permissions'])) {
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
}
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
if (!Schema::hasTable($tableNames['roles'])) {
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams || config('permission.testing')) {
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
} else {
$table->unique(['name', 'guard_name']);
}
});
}
});
}
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
if (!Schema::hasTable($tableNames['model_has_permissions'])) {
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
if ($teams) {
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
}
});
}
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
if (!Schema::hasTable($tableNames['model_has_roles'])) {
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
if ($teams) {
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
}
});
}
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
if (!Schema::hasTable($tableNames['role_has_permissions'])) {
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
$table->foreign($pivotRole)
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
}
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
@@ -8,26 +8,28 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('email_settings', function (Blueprint $table) {
$table->id();
$table->string('imap_host')->nullable();
$table->integer('imap_port')->nullable()->default(993);
$table->string('imap_encryption')->nullable()->default('ssl');
$table->string('imap_username')->nullable();
$table->string('imap_password')->nullable();
$table->string('smtp_host')->nullable();
$table->integer('smtp_port')->nullable()->default(587);
$table->string('smtp_encryption')->nullable()->default('tls');
$table->string('smtp_username')->nullable();
$table->string('smtp_password')->nullable();
$table->string('email_address')->nullable();
$table->string('email_name')->nullable();
$table->string('reply_to')->nullable();
$table->integer('sync_interval_minutes')->default(5);
$table->timestamp('last_sync_at')->nullable();
$table->boolean('is_active')->default(false);
$table->timestamps();
});
if (!Schema::hasTable('email_settings')) {
Schema::create('email_settings', function (Blueprint $table) {
$table->id();
$table->string('imap_host')->nullable();
$table->integer('imap_port')->nullable()->default(993);
$table->string('imap_encryption')->nullable()->default('ssl');
$table->string('imap_username')->nullable();
$table->string('imap_password')->nullable();
$table->string('smtp_host')->nullable();
$table->integer('smtp_port')->nullable()->default(587);
$table->string('smtp_encryption')->nullable()->default('tls');
$table->string('smtp_username')->nullable();
$table->string('smtp_password')->nullable();
$table->string('email_address')->nullable();
$table->string('email_name')->nullable();
$table->string('reply_to')->nullable();
$table->integer('sync_interval_minutes')->default(5);
$table->timestamp('last_sync_at')->nullable();
$table->boolean('is_active')->default(false);
$table->timestamps();
});
}
}
public function down(): void
@@ -8,17 +8,19 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('email_folders', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('imap_folder_name')->nullable();
$table->string('type')->default('custom');
$table->string('icon')->default('fa-folder');
$table->string('color')->nullable();
$table->integer('sort_order')->default(0);
$table->boolean('is_system')->default(false);
$table->timestamps();
});
if (!Schema::hasTable('email_folders')) {
Schema::create('email_folders', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('imap_folder_name')->nullable();
$table->string('type')->default('custom');
$table->string('icon')->default('fa-folder');
$table->string('color')->nullable();
$table->integer('sort_order')->default(0);
$table->boolean('is_system')->default(false);
$table->timestamps();
});
}
}
public function down(): void
@@ -8,35 +8,37 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('email_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('email_folder_id')->constrained('email_folders')->onDelete('cascade');
$table->string('message_id')->nullable()->index();
$table->string('subject')->nullable();
$table->string('from_name')->nullable();
$table->string('from_email')->nullable();
$table->string('to_name')->nullable();
$table->string('to_email')->nullable();
$table->text('cc')->nullable();
$table->text('bcc')->nullable();
$table->longText('body_text')->nullable();
$table->longText('body_html')->nullable();
$table->boolean('is_read')->default(false);
$table->boolean('is_starred')->default(false);
$table->boolean('is_important')->default(false);
$table->boolean('is_sent')->default(false);
$table->boolean('is_draft')->default(false);
$table->boolean('is_trash')->default(false);
$table->timestamp('received_at')->nullable();
$table->timestamp('sent_at')->nullable();
$table->unsignedBigInteger('imap_uid')->nullable();
$table->timestamps();
$table->index('is_read');
$table->index('is_starred');
$table->index('is_sent');
$table->index('received_at');
});
if (!Schema::hasTable('email_messages')) {
Schema::create('email_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('email_folder_id')->constrained('email_folders')->onDelete('cascade');
$table->string('message_id')->nullable()->index();
$table->string('subject')->nullable();
$table->string('from_name')->nullable();
$table->string('from_email')->nullable();
$table->string('to_name')->nullable();
$table->string('to_email')->nullable();
$table->text('cc')->nullable();
$table->text('bcc')->nullable();
$table->longText('body_text')->nullable();
$table->longText('body_html')->nullable();
$table->boolean('is_read')->default(false);
$table->boolean('is_starred')->default(false);
$table->boolean('is_important')->default(false);
$table->boolean('is_sent')->default(false);
$table->boolean('is_draft')->default(false);
$table->boolean('is_trash')->default(false);
$table->timestamp('received_at')->nullable();
$table->timestamp('sent_at')->nullable();
$table->unsignedBigInteger('imap_uid')->nullable();
$table->timestamps();
$table->index('is_read');
$table->index('is_starred');
$table->index('is_sent');
$table->index('received_at');
});
}
}
public function down(): void
@@ -8,17 +8,19 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('email_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('email_message_id')->constrained('email_messages')->onDelete('cascade');
$table->string('filename');
$table->string('mime_type')->nullable();
$table->bigInteger('size')->default(0);
$table->string('file_path');
$table->boolean('is_saved_to_documenti')->default(false);
$table->foreignId('documenti_id')->nullable()->constrained('documenti')->onDelete('set null');
$table->timestamps();
});
if (!Schema::hasTable('email_attachments')) {
Schema::create('email_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('email_message_id')->constrained('email_messages')->onDelete('cascade');
$table->string('filename');
$table->string('mime_type')->nullable();
$table->bigInteger('size')->default(0);
$table->string('file_path');
$table->boolean('is_saved_to_documenti')->default(false);
$table->foreignId('documenti_id')->nullable()->constrained('documenti')->onDelete('set null');
$table->timestamps();
});
}
}
public function down(): void
@@ -8,14 +8,16 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('ruoli', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
if (!Schema::hasTable('ruoli')) {
Schema::create('ruoli', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
}
$defaultRuoli = [
['nome' => 'Membro', 'descrizione' => 'Membro del gruppo'],
@@ -38,25 +40,27 @@ return new class extends Migration
]);
}
Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->unsignedBigInteger('ruolo_id')->nullable()->after('ruolo_nel_gruppo');
$table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null');
$table->index('ruolo_id');
});
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_id')) {
Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->unsignedBigInteger('ruolo_id')->nullable()->after('ruolo_nel_gruppo');
$table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null');
$table->index('ruolo_id');
});
$ruoliMap = DB::table('ruoli')->pluck('id', 'nome')->toArray();
$ruoliMap = DB::table('ruoli')->pluck('id', 'nome')->toArray();
$pivotRecords = DB::table('gruppo_individuo')
->whereNotNull('ruolo_nel_gruppo')
->where('ruolo_nel_gruppo', '!=', '')
->distinct()
->pluck('ruolo_nel_gruppo');
$pivotRecords = DB::table('gruppo_individuo')
->whereNotNull('ruolo_nel_gruppo')
->where('ruolo_nel_gruppo', '!=', '')
->distinct()
->pluck('ruolo_nel_gruppo');
foreach ($pivotRecords as $ruoloNome) {
if (isset($ruoliMap[$ruoloNome])) {
DB::table('gruppo_individuo')
->where('ruolo_nel_gruppo', $ruoloNome)
->update(['ruolo_id' => $ruoliMap[$ruoloNome]]);
foreach ($pivotRecords as $ruoloNome) {
if (isset($ruoliMap[$ruoloNome])) {
DB::table('gruppo_individuo')
->where('ruolo_nel_gruppo', $ruoloNome)
->update(['ruolo_id' => $ruoliMap[$ruoloNome]]);
}
}
}
}
@@ -13,9 +13,11 @@ return new class extends Migration
$table->dropColumn('ruolo_id');
});
Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->json('ruolo_ids')->nullable()->after('individuo_id');
});
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_ids')) {
Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->json('ruolo_ids')->nullable()->after('individuo_id');
});
}
}
public function down(): void
@@ -24,9 +26,11 @@ return new class extends Migration
$table->dropColumn('ruolo_ids');
});
Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->unsignedBigInteger('ruolo_id')->nullable()->after('individuo_id');
$table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null');
});
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_id')) {
Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->unsignedBigInteger('ruolo_id')->nullable()->after('individuo_id');
$table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null');
});
}
}
};
@@ -13,9 +13,11 @@ return new class extends Migration
$table->dropColumn('responsabile_id');
});
Schema::table('gruppi', function (Blueprint $table) {
$table->json('responsabile_ids')->nullable()->after('diocesi_id');
});
if (!Schema::hasColumn('gruppi', 'responsabile_ids')) {
Schema::table('gruppi', function (Blueprint $table) {
$table->json('responsabile_ids')->nullable()->after('diocesi_id');
});
}
}
public function down(): void
@@ -24,9 +26,11 @@ return new class extends Migration
$table->dropColumn('responsabile_ids');
});
Schema::table('gruppi', function (Blueprint $table) {
$table->unsignedBigInteger('responsabile_id')->nullable()->after('diocesi_id');
$table->foreign('responsabile_id')->references('id')->on('individui')->onDelete('set null');
});
if (!Schema::hasColumn('gruppi', 'responsabile_id')) {
Schema::table('gruppi', function (Blueprint $table) {
$table->unsignedBigInteger('responsabile_id')->nullable()->after('diocesi_id');
$table->foreign('responsabile_id')->references('id')->on('individui')->onDelete('set null');
});
}
}
};
@@ -8,10 +8,12 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('eventi', function (Blueprint $table) {
$table->string('luogo_indirizzo', 500)->nullable()->after('note');
$table->text('luogo_url_maps')->nullable()->after('luogo_indirizzo');
});
if (!Schema::hasColumn('eventi', 'luogo_indirizzo')) {
Schema::table('eventi', function (Blueprint $table) {
$table->string('luogo_indirizzo', 500)->nullable()->after('note');
$table->text('luogo_url_maps')->nullable()->after('luogo_indirizzo');
});
}
}
public function down(): void
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('app_settings')) {
return;
}
Schema::create('app_settings', function (Blueprint $table) {
$table->id();
$table->string('nome_applicazione')->nullable();
$table->string('nome_organizzazione')->nullable();
$table->string('logo')->nullable();
$table->string('logo_small')->nullable();
$table->string('logo_path')->nullable();
$table->string('logo_small_path')->nullable();
$table->string('footer_text')->nullable();
$table->string('app_version')->nullable();
$table->string('dashboard_welcome')->nullable();
$table->boolean('show_version')->default(false);
$table->string('documenti_storage_disk')->default('local');
$table->string('documenti_storage_path')->default('documenti');
$table->string('backup_path')->default('backups');
$table->integer('backup_retention_days')->default(30);
$table->boolean('backup_include_files')->default(true);
$table->boolean('backup_include_env')->default(true);
$table->boolean('backup_auto_enabled')->default(false);
$table->string('backup_auto_frequency', 20)->default('daily');
$table->integer('backup_auto_hour')->default(3);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('app_settings');
}
};
@@ -8,10 +8,21 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('app_settings', function (Blueprint $table) {
$table->string('logo_path')->nullable()->after('logo');
$table->string('logo_small_path')->nullable()->after('logo_small');
});
if (!Schema::hasTable('app_settings')) {
return;
}
if (!Schema::hasColumn('app_settings', 'logo_path')) {
Schema::table('app_settings', function (Blueprint $table) {
$table->string('logo_path')->nullable()->after('logo');
});
}
if (!Schema::hasColumn('app_settings', 'logo_small_path')) {
Schema::table('app_settings', function (Blueprint $table) {
$table->string('logo_small_path')->nullable()->after('logo_small');
});
}
}
public function down(): void
@@ -8,12 +8,24 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('app_settings', function (Blueprint $table) {
$table->string('footer_text')->nullable()->after('nome_organizzazione');
$table->string('app_version')->nullable()->after('footer_text');
$table->string('dashboard_welcome')->nullable()->after('app_version');
$table->boolean('show_version')->default(false)->after('dashboard_welcome');
});
if (!Schema::hasTable('app_settings')) {
return;
}
$columns = ['footer_text', 'app_version', 'dashboard_welcome', 'show_version'];
foreach ($columns as $column) {
if (!Schema::hasColumn('app_settings', $column)) {
Schema::table('app_settings', function (Blueprint $table) use ($column) {
match ($column) {
'footer_text' => $table->string('footer_text')->nullable()->after('nome_organizzazione'),
'app_version' => $table->string('app_version')->nullable()->after('footer_text'),
'dashboard_welcome' => $table->string('dashboard_welcome')->nullable()->after('app_version'),
'show_version' => $table->boolean('show_version')->default(false)->after('dashboard_welcome'),
};
});
}
}
}
public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('email_settings', function (Blueprint $table) {
$table->longText('signature')->nullable()->after('reply_to');
});
if (!Schema::hasColumn('email_settings', 'signature')) {
Schema::table('email_settings', function (Blueprint $table) {
$table->longText('signature')->nullable()->after('reply_to');
});
}
}
public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('email_settings', function (Blueprint $table) {
$table->boolean('signature_enabled')->default(true)->after('signature');
});
if (!Schema::hasColumn('email_settings', 'signature_enabled')) {
Schema::table('email_settings', function (Blueprint $table) {
$table->boolean('signature_enabled')->default(true)->after('signature');
});
}
}
public function down(): void
@@ -8,15 +8,17 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('report_custom', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->string('tipo_report');
$table->json('config')->nullable();
$table->timestamps();
});
if (!Schema::hasTable('report_custom')) {
Schema::create('report_custom', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->string('tipo_report');
$table->json('config')->nullable();
$table->timestamps();
});
}
}
public function down(): void
@@ -10,14 +10,16 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('tipologie_eventi', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
if (!Schema::hasTable('tipologie_eventi')) {
Schema::create('tipologie_eventi', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
}
DB::table('tipologie_eventi')->insert([
['nome' => 'catechesi', 'descrizione' => 'Catechesi', 'ordine' => 0, 'attiva' => true],
@@ -10,21 +10,23 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('sender_accounts', function (Blueprint $table) {
$table->id();
$table->string('email_address')->unique();
$table->string('email_name')->nullable();
$table->string('smtp_host')->nullable();
$table->integer('smtp_port')->default(587);
$table->string('smtp_encryption')->default('tls');
$table->string('smtp_username')->nullable();
$table->text('smtp_password')->nullable();
$table->string('reply_to')->nullable();
$table->string('verify_email')->nullable();
$table->text('note')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
if (!Schema::hasTable('sender_accounts')) {
Schema::create('sender_accounts', function (Blueprint $table) {
$table->id();
$table->string('email_address')->unique();
$table->string('email_name')->nullable();
$table->string('smtp_host')->nullable();
$table->integer('smtp_port')->default(587);
$table->string('smtp_encryption')->default('tls');
$table->string('smtp_username')->nullable();
$table->text('smtp_password')->nullable();
$table->string('reply_to')->nullable();
$table->string('verify_email')->nullable();
$table->text('note')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
}
public function down(): void
@@ -10,11 +10,13 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('mailing_messaggi', function (Blueprint $table) {
$table->json('log_falliti')->nullable()->after('invii_falliti');
$table->string('mittente_nome')->nullable()->after('log_falliti');
$table->string('mittente_email')->nullable()->after('mittente_nome');
});
if (!Schema::hasColumn('mailing_messaggi', 'log_falliti')) {
Schema::table('mailing_messaggi', function (Blueprint $table) {
$table->json('log_falliti')->nullable()->after('invii_falliti');
$table->string('mittente_nome')->nullable()->after('log_falliti');
$table->string('mittente_email')->nullable()->after('mittente_nome');
});
}
}
public function down(): void
@@ -8,12 +8,14 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('documenti_cartelle', function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')->nullable()->constrained('documenti_cartelle')->onDelete('cascade');
$table->string('nome');
$table->timestamps();
});
if (!Schema::hasTable('documenti_cartelle')) {
Schema::create('documenti_cartelle', function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')->nullable()->constrained('documenti_cartelle')->onDelete('cascade');
$table->string('nome');
$table->timestamps();
});
}
}
public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('cartella_id')->nullable()->constrained('documenti_cartelle')->onDelete('set null');
});
if (!Schema::hasColumn('documenti', 'cartella_id')) {
Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('cartella_id')->nullable()->constrained('documenti_cartelle')->onDelete('set null');
});
}
}
public function down(): void
@@ -8,10 +8,21 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('app_settings', function (Blueprint $table) {
$table->string('documenti_storage_disk')->default('local')->after('show_version');
$table->string('documenti_storage_path')->default('documenti')->after('documenti_storage_disk');
});
if (!Schema::hasTable('app_settings')) {
return;
}
if (!Schema::hasColumn('app_settings', 'documenti_storage_disk')) {
Schema::table('app_settings', function (Blueprint $table) {
$table->string('documenti_storage_disk')->default('local')->after('show_version');
});
}
if (!Schema::hasColumn('app_settings', 'documenti_storage_path')) {
Schema::table('app_settings', function (Blueprint $table) {
$table->string('documenti_storage_path')->default('documenti')->after('documenti_storage_disk');
});
}
}
public function down(): void
@@ -8,15 +8,17 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('storage_repositories', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('tipo', 50);
$table->text('config');
$table->boolean('is_active')->default(true);
$table->integer('ordine')->default(0);
$table->timestamps();
});
if (!Schema::hasTable('storage_repositories')) {
Schema::create('storage_repositories', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('tipo', 50);
$table->text('config');
$table->boolean('is_active')->default(true);
$table->integer('ordine')->default(0);
$table->timestamps();
});
}
}
public function down(): void
@@ -8,13 +8,15 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('repository_id')
->nullable()
->constrained('storage_repositories')
->nullOnDelete()
->after('cartella_id');
});
if (!Schema::hasColumn('documenti', 'repository_id')) {
Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('repository_id')
->nullable()
->constrained('storage_repositories')
->nullOnDelete()
->after('cartella_id');
});
}
}
public function down(): void
@@ -10,9 +10,11 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->string('storage_disk', 50)->nullable()->after('repository_id');
});
if (!Schema::hasColumn('documenti', 'storage_disk')) {
Schema::table('documenti', function (Blueprint $table) {
$table->string('storage_disk', 50)->nullable()->after('repository_id');
});
}
}
public function down(): void
@@ -10,15 +10,31 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('app_settings', function (Blueprint $table) {
$table->string('backup_path')->default('backups')->after('show_version');
$table->integer('backup_retention_days')->default(30)->after('backup_path');
$table->boolean('backup_include_files')->default(true)->after('backup_retention_days');
$table->boolean('backup_include_env')->default(true)->after('backup_include_files');
$table->boolean('backup_auto_enabled')->default(false)->after('backup_include_env');
$table->string('backup_auto_frequency', 20)->default('daily')->after('backup_auto_enabled');
$table->integer('backup_auto_hour')->default(3)->after('backup_auto_frequency');
});
if (!Schema::hasTable('app_settings')) {
return;
}
$columns = [
'backup_path' => ['type' => 'string', 'default' => 'backups'],
'backup_retention_days' => ['type' => 'integer', 'default' => 30],
'backup_include_files' => ['type' => 'boolean', 'default' => true],
'backup_include_env' => ['type' => 'boolean', 'default' => true],
'backup_auto_enabled' => ['type' => 'boolean', 'default' => false],
'backup_auto_frequency' => ['type' => 'string', 'length' => 20, 'default' => 'daily'],
'backup_auto_hour' => ['type' => 'integer', 'default' => 3],
];
foreach ($columns as $column => $config) {
if (!Schema::hasColumn('app_settings', $column)) {
Schema::table('app_settings', function (Blueprint $table) use ($column, $config) {
match ($config['type']) {
'string' => $table->string($column, $config['length'] ?? 255)->default($config['default'])->after('show_version'),
'integer' => $table->integer($column)->default($config['default'])->after('show_version'),
'boolean' => $table->boolean($column)->default($config['default'])->after('show_version'),
};
});
}
}
}
public function down(): void
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('calendario_connessioni')) {
Schema::create('calendario_connessioni', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('tipo'); // google_calendar, caldav
$table->text('config'); // JSON
$table->string('stato')->default('disconnesso'); // connesso, disconnesso, errore
$table->string('sync_direction')->default('bidirectional'); // import, export, bidirectional
$table->integer('sync_interval_minutes')->default(60);
$table->timestamp('last_sync_at')->nullable();
$table->timestamp('last_error_at')->nullable();
$table->text('last_error_message')->nullable();
$table->boolean('is_active')->default(true);
$table->integer('ordine')->default(0);
$table->timestamps();
});
}
}
public function down(): void
{
Schema::dropIfExists('calendario_connessioni');
}
};
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasColumn('eventi', 'uid_esterno')) {
Schema::table('eventi', function (Blueprint $table) {
$table->string('uid_esterno', 500)->nullable()->after('luogo_url_maps');
$table->index('uid_esterno');
});
}
}
public function down(): void
{
Schema::table('eventi', function (Blueprint $table) {
$table->dropIndex(['uid_esterno']);
$table->dropColumn('uid_esterno');
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasColumn('eventi', 'descrizione_evento')) {
Schema::table('eventi', function (Blueprint $table) {
$table->text('descrizione_evento')->nullable()->after('nome_evento');
});
}
if (!Schema::hasColumn('eventi', 'is_incontro_gruppo')) {
Schema::table('eventi', function (Blueprint $table) {
$table->boolean('is_incontro_gruppo')->default(false)->after('uid_esterno');
});
}
}
public function down(): void
{
}
};
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_nel_gruppo')) {
Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->string('ruolo_nel_gruppo')->nullable()->after('ruolo_ids');
});
}
}
public function down(): void
{
}
};
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('tags')) {
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->string('slug', 100)->unique();
$table->string('color', 7)->nullable();
$table->integer('order_column')->default(0);
$table->timestamps();
});
}
if (!Schema::hasTable('taggables')) {
Schema::create('taggables', function (Blueprint $table) {
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->morphs('taggable');
$table->primary(['tag_id', 'taggable_id', 'taggable_type']);
});
}
}
public function down(): void
{
Schema::dropIfExists('taggables');
Schema::dropIfExists('tags');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('viste_report', function (Blueprint $table) {
if (!Schema::hasColumn('viste_report', 'colonne_larghezze')) {
$table->json('colonne_larghezze')->nullable()->after('colonne_visibili');
}
if (!Schema::hasColumn('viste_report', 'colonne_ordine')) {
$table->json('colonne_ordine')->nullable()->after('colonne_larghezze');
}
});
}
public function down(): void
{
Schema::table('viste_report', function (Blueprint $table) {
if (Schema::hasColumn('viste_report', 'colonne_larghezze')) {
$table->dropColumn('colonne_larghezze');
}
if (Schema::hasColumn('viste_report', 'colonne_ordine')) {
$table->dropColumn('colonne_ordine');
}
});
}
};
+1 -1
View File
@@ -68,7 +68,7 @@ class ComuniSeeder extends Seeder
];
foreach ($comuni as $c) {
Comune::create($c);
Comune::firstOrCreate(['codice_istat' => $c['codice_istat']], $c);
}
}
}
+21 -17
View File
@@ -20,23 +20,27 @@ class DatabaseSeeder extends Seeder
DockerBaseDataSeeder::class,
]);
$admin = User::create([
'name' => 'Admin',
'email' => 'admin@glastree.local',
'password' => bcrypt('password'),
'is_admin' => true,
'status' => 'active',
'permissions' => [
'individui' => 2,
'gruppi' => 2,
'eventi' => 2,
'documenti' => 2,
'mailing' => 2,
'viste' => 2,
],
]);
$admin = User::firstOrCreate(
['email' => 'admin@glastree.local'],
[
'name' => 'Admin',
'password' => bcrypt('password'),
'is_admin' => true,
'status' => 'active',
'permissions' => [
'individui' => 2,
'gruppi' => 2,
'eventi' => 2,
'documenti' => 2,
'mailing' => 2,
'viste' => 2,
],
]
);
$admin->role_preset_id = 1;
$admin->save();
if (!$admin->role_preset_id) {
$admin->role_preset_id = 1;
$admin->save();
}
}
}
+1 -1
View File
@@ -149,7 +149,7 @@ class DiocesiSeeder extends Seeder
];
foreach ($diocesi as $d) {
Diocesi::create($d);
Diocesi::firstOrCreate(['nome' => $d['nome']], $d);
}
}
}
Binary file not shown.
+848
View File
@@ -0,0 +1,848 @@
#!/usr/bin/env php
<?php
/**
* Glastree — Installer interattivo per amministratori (PHP CLI)
*
* Uso:
* php install.php
*
* Richiede: PHP 8.3+, Composer, MySQL o SQLite
* Opzionale: npm (per asset frontend)
*
* Modalità:
* 1) Fresh Install su Apache (LAMP)
* 2) Fresh Install via Docker
* 3) Restore da Backup (Apache)
*/
// ── Safety ──────────────────────────────────────────
declare(strict_types=1);
// ── Constants ────────────────────────────────────────
const REQUIREMENTS = ['pdo_mysql', 'mbstring', 'xml', 'curl', 'zip', 'gd', 'fileinfo'];
const MIN_PHP = '8.3.0';
// ── Utilities ────────────────────────────────────────
function println(string $msg = ''): void { echo $msg . PHP_EOL; }
function color(string $text, string $code): string {
$map = [
'red' => '0;31',
'green' => '0;32',
'yellow' => '1;33',
'cyan' => '0;36',
'white' => '1;37',
];
$c = $map[$code] ?? '0';
return "\033[{$c}m{$text}\033[0m";
}
function info(string $msg): void { println(color('[INFO]', 'cyan') . ' ' . $msg); }
function ok(string $msg): void { println(color('[OK]', 'green') . ' ' . $msg); }
function warn(string $msg): void { println(color('[WARN]', 'yellow') . ' ' . $msg); }
function error(string $msg): void { println(color('[ERR]', 'red') . ' ' . $msg); }
function fail(string $msg): never { error($msg); exit(1); }
function ask(string $prompt, ?string $default = null): string {
$defaultStr = $default !== null ? " [{$default}]" : '';
echo color('?', 'cyan') . " {$prompt}{$defaultStr}: ";
$val = trim(fgets(STDIN) ?: '');
return $val !== '' ? $val : ($default ?? '');
}
function askRequired(string $prompt): string {
while (true) {
$val = ask($prompt);
if ($val !== '') break;
warn('Valore obbligatorio.');
}
return $val;
}
function confirm(string $prompt): bool {
$resp = strtolower(ask($prompt . ' [S/n]', 'S'));
return $resp === 's' || $resp === '';
}
function menu(string $prompt, array $options): string {
foreach ($options as $i => $opt) {
println(' ' . ($i + 1) . ') ' . $opt);
}
while (true) {
$val = ask($prompt, '1');
if (isset($options[(int)$val - 1])) return $options[(int)$val - 1];
warn('Scelta non valida.');
}
}
function checkCmd(string $cmd): bool {
$found = !(trim(shell_exec("command -v $cmd 2>/dev/null") ?: '') === '');
if (!$found) warn("Comando '$cmd' non trovato.");
return $found;
}
function run(string $cmd, ?string $label = null): bool {
if ($label) info($label);
println(" \$ {$cmd}");
passthru($cmd, $exitCode);
if ($exitCode !== 0) warn("Comando terminato con codice {$exitCode}");
return $exitCode === 0;
}
function runCapture(string $cmd): string {
return trim(shell_exec($cmd) ?: '');
}
/**
* Crea l'utente amministratore via PDO prepared statement.
* Nessun escaping PHP necessario (tutto via prepared statement).
*/
function createAdminUser(
string $dbHost, string $dbPort, string $dbName,
string $dbUser, string $dbPass,
string $adminName, string $adminEmail, string $adminPass
): bool {
try {
$pdo = new PDO(
"mysql:host={$dbHost};port={$dbPort};dbname={$dbName}",
$dbUser, $dbPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$hash = password_hash($adminPass, PASSWORD_BCRYPT);
$permissions = json_encode([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
]);
// Elimina eventuale utente con stessa email (seeder)
$stmt = $pdo->prepare('DELETE FROM users WHERE email = ?');
$stmt->execute([$adminEmail]);
// Inserisce admin con is_admin=1, status='active', permissions completi
$stmt = $pdo->prepare(
"INSERT INTO users (name, email, password, is_admin, status, permissions, role_preset_id, created_at, updated_at)
VALUES (?, ?, ?, 1, 'active', ?, 1, NOW(), NOW())"
);
$stmt->execute([$adminName, $adminEmail, $hash, $permissions]);
$userId = $pdo->lastInsertId();
// Assegna ruolo Spatie 'admin' (role_id = 1)
$stmt = $pdo->prepare(
"INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES (1, ?, ?)"
);
$stmt->execute(['App\\Models\\User', $userId]);
return true;
} catch (\PDOException $e) {
error("Errore creazione admin: " . $e->getMessage());
return false;
}
}
// ── Banner ───────────────────────────────────────────
println(color(' ____ _ _ _', 'white'));
println(color(' / ___| | __ _ ___| |_ ___ _ __ ___ | |_ ___', 'white'));
println(color('| | _| |/ _` / __| __/ _ \'__/ _ \ | __/ _ \\', 'white'));
println(color('| |_| | | (_| \__ \ || __/ | | __/ | || __/', 'white'));
println(color(' \____|_|\__,_|___/\__\___|_| \___| \__\___|', 'white'));
println(color(' Installer interattivo per sysadmin', 'cyan'));
println();
// ── Preflight ────────────────────────────────────────
$scriptDir = dirname(__FILE__);
chdir($scriptDir);
if (!file_exists('.env.example')) {
fail("File .env.example non trovato in {$scriptDir}. Sei nella cartella dell'applicazione?");
}
// ── Select mode ──────────────────────────────────────
println('Seleziona la modalità di installazione:');
$mode = menu('Scegli', [
'Fresh Install su Apache (LAMP tradizionale)',
'Fresh Install via Docker',
'Restore da Backup (Apache)',
]);
// ── Common params ────────────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PARAMETRI GENERALI', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$appName = askRequired('Nome del sito/app (es. Glastree MyChurch)');
$adminName = askRequired('Nome del primo utente amministratore');
$adminEmail = askRequired('Email dell\'amministratore');
$adminPass = askRequired('Password per l\'amministratore');
$appUrl = askRequired('URL pubblico del sito (es. https://glastree.esempio.it)');
// ── DB params (Apache / Restore) ─────────────────────
$dbType = 'mysql';
$dbHost = '127.0.0.1';
$dbPort = '3306';
$dbName = 'glastree';
$dbUser = 'glastree';
$dbPass = '';
if (in_array($mode, ['Fresh Install su Apache (LAMP tradizionale)', 'Restore da Backup (Apache)'])) {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' DATABASE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$dbType = menu('Tipo database', ['mysql', 'sqlite']);
if ($dbType === 'mysql') {
$dbHost = askRequired('Host database');
$dbPort = ask('Porta database', '3306');
$dbName = ask('Nome database (verrà creato se non esiste)', 'glastree');
$dbUser = askRequired('Utente database');
$dbPass = askRequired('Password database');
}
}
// ── Web server user detection ─────────────────────────
$wwwUser = 'www-data';
$sudo = '';
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
$detected = trim(shell_exec('ps aux | grep -E "apache|httpd" | grep -v grep | head -1 | awk \'{print $1}\'') ?: '');
if ($detected !== '' && $detected !== 'root') {
$wwwUser = $detected;
}
if (function_exists('posix_getuid') && posix_getuid() === 0) {
$sudo = "sudo -u {$wwwUser} ";
}
}
// ══════════════════════════════════════════════════════
// MODE 1: FRESH INSTALL APACHE
// ══════════════════════════════════════════════════════
if ($mode === 'Fresh Install su Apache (LAMP tradizionale)') {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PRE-FLIGHT CHECKS', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
// PHP version
$phpVer = PHP_VERSION;
info("PHP versione: {$phpVer}");
if (version_compare($phpVer, MIN_PHP, '<')) {
fail("PHP >= " . MIN_PHP . " richiesto (trovato {$phpVer})");
}
ok("PHP {$phpVer}");
// Extensions
foreach (REQUIREMENTS as $ext) {
if (!extension_loaded($ext)) {
warn("Estensione PHP '{$ext}' non trovata (alcune funzionalità potrebbero non funzionare)");
} else {
ok("Estensione PHP '{$ext}'");
}
}
// Composer
checkCmd('composer');
checkCmd('node');
checkCmd('npm');
// ── Step 1: Environment ─────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 1/7 — CONFIGURAZIONE AMBIENTE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if (!file_exists('.env')) {
copy('.env.example', '.env');
ok('.env creato da .env.example');
} else {
info('.env già esistente, lo aggiorno');
}
// APP_KEY
runCapture("{$sudo}php artisan key:generate --force");
ok('APP_KEY generata');
// Config
$dotenv = file_get_contents('.env');
$replacements = [
'/^APP_NAME=.*/m' => 'APP_NAME="' . addslashes($appName) . '"',
'/^APP_URL=.*/m' => "APP_URL={$appUrl}",
'/^APP_ENV=.*/m' => 'APP_ENV=production',
'/^APP_DEBUG=.*/m' => 'APP_DEBUG=false',
];
if ($dbType === 'mysql') {
$replacements['/^DB_CONNECTION=.*/m'] = 'DB_CONNECTION=mysql';
$replacements['/^DB_HOST=.*/m'] = "DB_HOST={$dbHost}";
$replacements['/^DB_PORT=.*/m'] = "DB_PORT={$dbPort}";
$replacements['/^DB_DATABASE=.*/m'] = "DB_DATABASE={$dbName}";
$replacements['/^DB_USERNAME=.*/m'] = "DB_USERNAME={$dbUser}";
$replacements['/^DB_PASSWORD=.*/m'] = "DB_PASSWORD={$dbPass}";
} else {
$replacements['/^DB_CONNECTION=.*/m'] = 'DB_CONNECTION=sqlite';
$replacements['/^DB_HOST=.*/m'] = '# DB_HOST=';
$replacements['/^DB_PORT=.*/m'] = '# DB_PORT=';
$replacements['/^DB_DATABASE=.*/m'] = 'DB_DATABASE=' . $scriptDir . '/database/database.sqlite';
$replacements['/^DB_USERNAME=.*/m'] = '# DB_USERNAME=';
$replacements['/^DB_PASSWORD=.*/m'] = '# DB_PASSWORD=';
}
foreach ($replacements as $pattern => $replacement) {
$dotenv = preg_replace($pattern, $replacement, $dotenv);
}
file_put_contents('.env', $dotenv);
ok('.env configurato');
// ── Step 2: Database ────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 2/7 — DATABASE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if ($dbType === 'mysql') {
info("Connessione a MySQL {$dbHost}:{$dbPort}, database '{$dbName}'...");
try {
new PDO(
"mysql:host={$dbHost};port={$dbPort};dbname={$dbName}",
$dbUser, $dbPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
ok("Accesso a '{$dbName}' come '{$dbUser}' riuscito");
} catch (\PDOException $e) {
// Diagnostica l'errore
$errMsg = $e->getMessage();
$diagnosi = match (true) {
(bool)preg_match('/1049|Unknown database/', $errMsg) => "Database '{$dbName}' non esiste",
(bool)preg_match('/1045|Access denied for user/', $errMsg) => "Utente '{$dbUser}' o password non validi",
(bool)preg_match('/1044/', $errMsg) => "Utente '{$dbUser}' non ha accesso al database '{$dbName}'",
default => $errMsg,
};
warn($diagnosi);
info("Connessione come root per creare/riparare utente e database...");
$rootPdo = null;
try {
$rootPdo = new PDO(
"mysql:host={$dbHost};port={$dbPort}",
'root', '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
} catch (\PDOException) {
$rootPass = askRequired('Password utente root MySQL (lascia vuoto se senza password)');
try {
$rootPdo = new PDO(
"mysql:host={$dbHost};port={$dbPort}",
'root', $rootPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
} catch (\PDOException $r) {
fail("Connessione come root fallita: " . $r->getMessage());
}
}
$safeUser = str_replace(["'", '"', '`', "\0", '\\'], '', $dbUser);
$safePass = str_replace(["'", '"', '`', "\0", '\\'], '', $dbPass);
$safeName = str_replace(["'", '"', '`', "\0", '\\'], '', $dbName);
$rootPdo->exec("CREATE DATABASE IF NOT EXISTS `{$safeName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
foreach (['localhost', '%'] as $host) {
$rootPdo->exec("CREATE USER IF NOT EXISTS '{$safeUser}'@'{$host}' IDENTIFIED BY '{$safePass}'");
$rootPdo->exec("GRANT ALL PRIVILEGES ON `{$safeName}`.* TO '{$safeUser}'@'{$host}'");
}
$rootPdo->exec("FLUSH PRIVILEGES");
ok("Database '{$dbName}' e utente '{$dbUser}' configurati con permessi completi");
}
} else {
$sqlitePath = $scriptDir . '/database/database.sqlite';
if (!file_exists($sqlitePath)) {
touch($sqlitePath);
chmod($sqlitePath, 0664);
}
ok('SQLite pronto: ' . $sqlitePath);
}
// ── Step 3: Composer ────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 3/7 — INSTALLAZIONE DIPENDENZE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
run("COMPOSER_ROOT_VERSION=dev-main composer install --no-dev --optimize-autoloader", 'Installazione dipendenze Composer...');
ok('Dipendenze PHP installate');
// ── Step 4: Package discovery ────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 4/7 — REGISTRAZIONE PACCHETTI', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
runCapture("{$sudo}php artisan package:discover --ansi 2>/dev/null");
ok('Pacchetti registrati');
// ── Step 5: Schema + Seed ────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 5/7 — SCHEMA DATABASE E DATI BASE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$sqlFile = $scriptDir . '/database/install.sql';
if (!file_exists($sqlFile)) {
fail("File database/install.sql non trovato in {$scriptDir}");
}
if ($dbType === 'mysql') {
$importCmd = "mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} -p{$dbPass} {$dbName} < {$sqlFile}";
if (!run($importCmd, 'Importazione struttura database e dati base...')) {
fail("Importazione SQL fallita. Verifica il file database/install.sql e le credenziali.");
}
} else {
// SQLite fallback: usa le migration (install.sql è solo per MySQL)
if (!run("{$sudo}php artisan migrate --seed --force", 'Esecuzione migration e seed...')) {
fail('Migration fallita. Verifica la configurazione SQLite.');
}
}
ok('Database popolato con struttura e dati base');
runCapture("{$sudo}php artisan storage:link --force 2>/dev/null");
// ── Step 6: Admin user (via PDO — no tinker) ─────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 6/7 — CREAZIONE AMMINISTRATORE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if ($dbType === 'mysql') {
if (createAdminUser($dbHost, $dbPort, $dbName, $dbUser, $dbPass, $adminName, $adminEmail, $adminPass)) {
ok("Amministratore {$adminEmail} creato con permessi completi");
} else {
fail("Creazione admin fallita. Verifica i dati inseriti.");
}
} else {
// SQLite: usa seeder (l'admin verrà creato da DatabaseSeeder)
// Poi aggiorna la password con quella scelta dall'utente
runCapture("{$sudo}php artisan tinker --execute=" . escapeshellarg(
"\$u = \App\Models\User::first(); " .
"if (\$u) { " .
"\$u->name = " . var_export($adminName, true) . "; " .
"\$u->email = " . var_export($adminEmail, true) . "; " .
"\$u->password = bcrypt(" . var_export($adminPass, true) . "); " .
"\$u->is_admin = true; " .
"\$u->status = 'active'; " .
"\$u->permissions = " . var_export([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
], true) . "; " .
"\$u->role_preset_id = 1; " .
"\$u->save(); " .
"echo 'OK'; " .
"}"
) . " 2>/dev/null");
ok("Amministratore {$adminEmail} configurato");
}
// Clear all caches after schema + admin creation
runCapture("{$sudo}php artisan optimize:clear 2>/dev/null");
ok('Cache ottimizzate');
// ── Step 7: Asset ────────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 7/7 — ASSET FRONTEND', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$hasNpm = checkCmd('npm');
if ($hasNpm && confirm('Compilare gli asset frontend con npm?')) {
run("{$sudo}npm install", 'Installazione dipendenze npm...');
run("{$sudo}npm run build", 'Compilazione asset...');
ok('Asset compilati');
} else {
if (!$hasNpm) warn('npm non disponibile — asset non compilati (AdminLTE via CDN)');
else info('Asset non compilati (AdminLTE via CDN)');
}
// ── Permissions ──────────────────────────────────
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
info('Impostazione permessi...');
runCapture("chown -R {$wwwUser}:{$wwwUser} vendor storage bootstrap/cache 2>/dev/null");
chmod("{$scriptDir}/storage", 0775);
chmod("{$scriptDir}/bootstrap/cache", 0775);
ok("Permessi impostati (utente: {$wwwUser})");
}
// ── Summary ──────────────────────────────────────
println();
println(color('══════════════════════════════════════════', 'green'));
println(color(' INSTALLAZIONE COMPLETATA!', 'green'));
println(color('══════════════════════════════════════════', 'green'));
println();
println(" URL: {$appUrl}");
println(" Admin: {$adminEmail}");
println(" Cartella: {$scriptDir}");
println();
warn('Configura Apache con il VirtualHost (vedi Guida → Installazione, Passo 4).');
if ($dbType === 'mysql') {
warn('Se hai appena creato il database, assicurati che l\'utente abbia privilegi completi.');
}
}
// ══════════════════════════════════════════════════════
// MODE 2: DOCKER
// ══════════════════════════════════════════════════════
if ($mode === 'Fresh Install via Docker') {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PRE-FLIGHT CHECKS (Docker)', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
checkCmd('docker');
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' CONFIGURAZIONE DOCKER', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$dockerDb = menu('Tipo database per Docker', ['sqlite', 'mysql']);
$dockerPort = ask('Porta host (es. 8080)', '8080');
$composeFile = $dockerDb === 'mysql' ? 'docker-compose.mysql.yml' : 'docker-compose.yml';
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' INSTALLAZIONE DOCKER', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if (!run("docker compose -f {$composeFile} build", 'Build immagine Docker...')) {
fail('Build fallita');
}
// Override compose con variabili d'ambiente
$override = [
'services:',
' glastree:',
' environment:',
" - APP_NAME={$appName}",
" - APP_URL={$appUrl}",
' - APP_ENV=production',
' - APP_DEBUG=false',
];
file_put_contents('docker-compose.override.yml', implode(PHP_EOL, $override) . PHP_EOL);
if (!run(
"docker compose -f {$composeFile} -f docker-compose.override.yml up -d",
'Avvio container...'
)) {
@unlink('docker-compose.override.yml');
fail('Avvio container fallito');
}
// Attendi container pronto
info('Attendo che il container sia pronto...');
$containerId = runCapture("docker compose -f {$composeFile} ps -q glastree 2>/dev/null");
$adminCreated = false;
if ($containerId !== '') {
for ($i = 0; $i < 30; $i++) {
$status = runCapture("docker inspect -f '{{.State.Status}}' {$containerId} 2>/dev/null");
if ($status === 'running') {
$http = runCapture("docker exec {$containerId} sh -c 'wget -q -O- http://localhost/ 2>/dev/null && echo OK'");
if (str_contains($http, 'OK')) {
info('Container pronto');
break;
}
}
sleep(2);
}
// Import schema SQL nel container
$sqlFile = $scriptDir . '/database/install.sql';
if (file_exists($sqlFile) && $dockerDb === 'mysql') {
info('Importazione schema nel container...');
runCapture("docker cp {$sqlFile} {$containerId}:/tmp/install.sql 2>/dev/null");
$mysqlHost = 'mysql';
$mysqlUser = 'glastree';
$mysqlPass = 'secret';
$mysqlDb = 'glastree';
$importResult = runCapture(
"docker exec {$containerId} sh -c 'mysql -h {$mysqlHost} -u {$mysqlUser} -p{$mysqlPass} {$mysqlDb} < /tmp/install.sql' 2>&1"
);
if ($importResult !== '') {
warn("Import SQL: {$importResult}");
} else {
ok('Schema database importato');
}
} elseif ($dockerDb === 'sqlite') {
run("docker exec {$containerId} php artisan migrate --seed --force", 'Migration e seed...');
}
// Crea admin
if ($dockerDb === 'mysql') {
// Script PHP temporaneo con var_export (safe)
$adminScriptPath = sys_get_temp_dir() . '/glastree-create-admin-' . uniqid() . '.php';
$hash = password_hash($adminPass, PASSWORD_BCRYPT);
$perm = json_encode([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
]);
$nameExp = var_export($adminName, true);
$emailExp = var_export($adminEmail, true);
$hashExp = var_export($hash, true);
$userExp = var_export($mysqlUser, true);
$passExp = var_export($mysqlPass, true);
$dbExp = var_export($mysqlDb, true);
$hostExp = var_export($mysqlHost, true);
$adminPhp = <<<PHP
<?php
\$pdo = new PDO("mysql:host=" . {$hostExp} . ";dbname=" . {$dbExp}, {$userExp}, {$passExp}, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
\$pdo->prepare("DELETE FROM users WHERE email = ?")->execute([{$emailExp}]);
\$pdo->prepare("INSERT INTO users (name, email, password, is_admin, status, permissions, role_preset_id, created_at, updated_at) VALUES (?, ?, ?, 1, 'active', ?, 1, NOW(), NOW())")->execute([{$nameExp}, {$emailExp}, {$hashExp}, '{$perm}']);
\$uid = \$pdo->lastInsertId();
\$pdo->prepare("INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES (1, ?, ?)")->execute(['App\\\\Models\\\\User', \$uid]);
echo "OK:" . \$uid;
PHP;
file_put_contents($adminScriptPath, $adminPhp);
runCapture("docker cp {$adminScriptPath} {$containerId}:/tmp/create_admin.php 2>/dev/null");
$adminResult = runCapture("docker exec {$containerId} php /tmp/create_admin.php 2>/dev/null");
@unlink($adminScriptPath);
} else {
// SQLite: usa tinker via docker exec
$adminResult = runCapture("docker exec {$containerId} php artisan tinker --execute=" . escapeshellarg(
"\$u = \App\Models\User::first(); " .
"if (\$u) { " .
"\$u->name = " . var_export($adminName, true) . "; " .
"\$u->email = " . var_export($adminEmail, true) . "; " .
"\$u->password = bcrypt(" . var_export($adminPass, true) . "); " .
"\$u->is_admin = true; " .
"\$u->status = 'active'; " .
"\$u->permissions = " . var_export([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
], true) . "; " .
"\$u->role_preset_id = 1; " .
"\$u->save(); " .
"echo 'OK'; " .
"}"
) . " 2>/dev/null");
}
if (str_contains($adminResult ?? '', 'OK')) {
ok("Amministratore {$adminEmail} creato nel container");
$adminCreated = true;
} else {
warn("Creazione admin fallita — output: " . (($adminResult ?? '') ?: 'nessun output'));
}
}
@unlink('docker-compose.override.yml');
println();
println(color('══════════════════════════════════════════', 'green'));
println(color(' INSTALLAZIONE DOCKER COMPLETATA!', 'green'));
println(color('══════════════════════════════════════════', 'green'));
println();
println(" URL: http://localhost:{$dockerPort}");
println(" Admin: {$adminEmail}");
println(" Container: {$containerId}");
println();
warn('Per URL pubblici, imposta APP_URL nelle environment del container.');
warn('Dati persistenti: volumi Docker (glastree_storage, glastree_db o mysql_data).');
}
// ══════════════════════════════════════════════════════
// MODE 3: RESTORE DA BACKUP
// ══════════════════════════════════════════════════════
if ($mode === 'Restore da Backup (Apache)') {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PRE-FLIGHT CHECKS (Restore)', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
checkCmd('php');
checkCmd('composer');
checkCmd('unzip');
if ($dbType === 'mysql') {
checkCmd('mysql');
}
$backupZip = askRequired('Percorso del file ZIP di backup');
if (!file_exists($backupZip)) {
fail("File non trovato: {$backupZip}");
}
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' RESTORE DA BACKUP', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
// Extract
$tmpDir = sys_get_temp_dir() . '/glastree-restore-' . uniqid();
mkdir($tmpDir, 0700, true);
info("Estrazione backup in {$tmpDir}...");
if (!run("unzip -qo {$backupZip} -d {$tmpDir}", null)) {
// pulisci tmp dir prima di fallire
runCapture("rm -rf {$tmpDir}");
fail('Estrazione backup fallita');
}
// Environment
if (!file_exists('.env')) {
copy('.env.example', '.env');
}
// Restore .env from backup preserving DB credentials
if (file_exists("{$tmpDir}/.env")) {
$currentEnv = file_get_contents('.env');
$backupEnv = file_get_contents("{$tmpDir}/.env");
// Save current DB_* values
$currentDb = [];
foreach (['DB_CONNECTION', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD'] as $key) {
if (preg_match("/^{$key}=(.*)$/m", $currentEnv, $m)) {
$currentDb[$key] = $m[1];
}
}
// Use backup .env as base
file_put_contents('.env', $backupEnv);
// Restore current DB credentials
foreach ($currentDb as $key => $val) {
$dotenv = file_get_contents('.env');
$dotenv = preg_replace("/^{$key}=.*/m", "{$key}={$val}", $dotenv);
file_put_contents('.env', $dotenv);
}
ok('.env ripristinato dal backup (credenziali DB preservate)');
}
// Update APP_* config
$dotenv = file_get_contents('.env');
$dotenv = preg_replace('/^APP_NAME=.*/m', 'APP_NAME="' . addslashes($appName) . '"', $dotenv);
$dotenv = preg_replace('/^APP_URL=.*/m', "APP_URL={$appUrl}", $dotenv);
$dotenv = preg_replace('/^APP_ENV=.*/m', 'APP_ENV=production', $dotenv);
$dotenv = preg_replace('/^APP_DEBUG=.*/m', 'APP_DEBUG=false', $dotenv);
file_put_contents('.env', $dotenv);
// Generate APP_KEY
runCapture("{$sudo}php artisan key:generate --force");
ok('APP_KEY rigenerata');
// Import database
if ($dbType === 'mysql') {
$sqlFile = "{$tmpDir}/database.sql";
if (file_exists($sqlFile)) {
info("Importazione database MySQL: {$sqlFile}");
$cmd = "mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} -p{$dbPass} {$dbName} < {$sqlFile}";
if (run($cmd, null)) {
ok('Database importato');
} else {
warn('Import database fallito — importa manualmente');
}
} else {
warn('database.sql non trovato nel backup');
}
} else {
warn('Restore automatico supportato solo con MySQL. Per SQLite ripristina manualmente i file.');
}
// Restore storage
$storageBackup = "{$tmpDir}/storage";
if (is_dir($storageBackup)) {
info('Ripristino file storage...');
runCapture("cp -r {$storageBackup}/* {$scriptDir}/storage/ 2>/dev/null");
ok('Storage ripristinato');
}
// Composer
run("COMPOSER_ROOT_VERSION=dev-main composer install --no-dev --optimize-autoloader", 'Installazione dipendenze Composer...');
runCapture("{$sudo}php artisan package:discover --ansi 2>/dev/null");
// Storage link + cache
runCapture("{$sudo}php artisan storage:link --force 2>/dev/null");
runCapture("{$sudo}php artisan optimize:clear");
// Permissions
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
runCapture("chown -R {$wwwUser}:{$wwwUser} vendor storage bootstrap/cache 2>/dev/null");
chmod("{$scriptDir}/storage", 0775);
chmod("{$scriptDir}/bootstrap/cache", 0775);
ok("Permessi impostati (utente: {$wwwUser})");
}
// Create admin if not exists (via PDO)
if ($dbType === 'mysql') {
$exists = false;
try {
$pdo = new PDO("mysql:host={$dbHost};port={$dbPort};dbname={$dbName}", $dbUser, $dbPass);
$stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE email = ?');
$stmt->execute([$adminEmail]);
$exists = (int) $stmt->fetchColumn() > 0;
} catch (\PDOException) {
// Ignora — tenteremo la creazione
}
if ($exists) {
ok("Amministratore {$adminEmail} già esistente");
} elseif (createAdminUser($dbHost, $dbPort, $dbName, $dbUser, $dbPass, $adminName, $adminEmail, $adminPass)) {
ok("Amministratore {$adminEmail} creato");
} else {
warn("Creazione admin fallita — crealo manualmente dal pannello admin");
}
} else {
// SQLite fallback: usa tinker
$result = runCapture("{$sudo}php artisan tinker --execute=" . escapeshellarg(
"\$e = \App\Models\User::where('email', " . var_export($adminEmail, true) . ")->exists(); " .
"if (!\$e) { " .
"\$u = new \App\Models\User; " .
"\$u->name = " . var_export($adminName, true) . "; " .
"\$u->email = " . var_export($adminEmail, true) . "; " .
"\$u->password = bcrypt(" . var_export($adminPass, true) . "); " .
"\$u->is_admin = true; " .
"\$u->status = 'active'; " .
"\$u->permissions = " . var_export([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
], true) . "; " .
"\$u->role_preset_id = 1; " .
"\$u->save(); " .
"echo 'OK'; " .
"} else { echo 'EXISTS'; }"
) . " 2>/dev/null");
if (str_contains($result, 'OK')) {
ok("Amministratore {$adminEmail} creato");
} elseif (str_contains($result, 'EXISTS')) {
ok("Amministratore {$adminEmail} già esistente");
} else {
warn("Creazione admin fallita (output: " . ($result ?: 'vuoto') . ")");
}
}
// Cleanup
runCapture("rm -rf {$tmpDir}");
println();
println(color('══════════════════════════════════════════', 'green'));
println(color(' RESTORE COMPLETATO!', 'green'));
println(color('══════════════════════════════════════════', 'green'));
println();
println(" URL: {$appUrl}");
println(" Admin: {$adminEmail}");
println(" Cartella: {$scriptDir}");
println();
warn('Verifica che APP_KEY non abbia invalidato dati criptati (token OAuth, password IMAP).');
warn('Se necessario, riconfigura SMTP e Google Drive dalle Impostazioni.');
}
println();
+384
View File
@@ -0,0 +1,384 @@
class ColumnManager {
constructor(tableId, options = {}) {
this.tableId = tableId;
this.table = document.getElementById(tableId);
if (!this.table) return;
this.options = Object.assign({
entityType: 'individui',
storageKey: null,
resizable: true,
reorderable: true,
persistWidths: true,
persistOrder: true,
}, options);
this.columns = [];
this.columnWidths = {};
this.columnOrder = [];
this.dragState = null;
this.resizeState = null;
this.init();
}
init() {
this.loadState();
this.initColumns();
if (this.options.resizable) this.initResize();
if (this.options.reorderable) this.initReorder();
this.applyState();
}
initColumns() {
const ths = this.table.querySelectorAll('thead th');
ths.forEach((th, idx) => {
const colKey = th.dataset.column || `col_${idx}`;
const col = {
index: idx,
key: colKey,
element: th,
width: th.style.width || '',
visible: th.style.display !== 'none',
label: th.textContent.trim(),
};
this.columns.push(col);
});
}
loadState() {
if (this.options.storageKey) {
try {
const saved = sessionStorage.getItem(this.options.storageKey);
if (saved) {
const data = JSON.parse(saved);
this.columnWidths = data.widths || {};
this.columnOrder = data.order || [];
return;
}
} catch (e) {}
}
const vistaEl = document.getElementById('vista-data');
if (vistaEl && vistaEl.textContent && vistaEl.textContent !== 'null') {
try {
const vista = JSON.parse(vistaEl.textContent);
this.columnWidths = vista.colonne_larghezze || {};
this.columnOrder = vista.colonne_ordine || [];
} catch (e) {}
}
}
persistState() {
if (!this.options.storageKey) return;
try {
sessionStorage.setItem(this.options.storageKey, JSON.stringify({
widths: this.columnWidths,
order: this.columnOrder,
}));
} catch (e) {}
}
applyState() {
if (this.columnOrder.length > 0) {
this.reorderColumns(this.columnOrder, false);
}
if (Object.keys(this.columnWidths).length > 0) {
this.applyWidths();
}
}
applyWidths() {
this.columns.forEach(col => {
const w = this.columnWidths[col.key];
if (w) {
col.element.style.width = w;
}
});
}
initResize() {
this.table.querySelectorAll('thead th').forEach(th => {
const handle = document.createElement('div');
handle.className = 'col-resize-handle';
handle.style.cssText = 'position:absolute;right:0;top:0;bottom:0;width:5px;cursor:col-resize;z-index:10;';
th.style.position = 'relative';
th.appendChild(handle);
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const thEl = th;
const startX = e.clientX;
const startWidth = thEl.offsetWidth;
const onMouseMove = (ev) => {
const diff = ev.clientX - startX;
const newWidth = Math.max(30, startWidth + diff);
thEl.style.width = newWidth + 'px';
const colKey = thEl.dataset.column;
if (colKey) {
this.columnWidths[colKey] = newWidth + 'px';
this.persistState();
}
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
});
}
initReorder() {
const thead = this.table.querySelector('thead');
if (!thead) return;
let dragCol = null;
let dragIndex = -1;
let placeholder = null;
thead.querySelectorAll('th').forEach(th => {
if (th.querySelector('.col-resize-handle')) return;
th.draggable = true;
th.addEventListener('dragstart', (e) => {
dragCol = th;
dragIndex = Array.from(thead.querySelectorAll('th')).indexOf(th);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
th.classList.add('dragging');
});
th.addEventListener('dragend', () => {
th.classList.remove('dragging');
if (placeholder && placeholder.parentNode) {
placeholder.parentNode.removeChild(placeholder);
}
dragCol = null;
dragIndex = -1;
placeholder = null;
});
th.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!dragCol || dragCol === th) return;
if (!placeholder) {
placeholder = document.createElement('th');
placeholder.style.cssText = 'border:2px dashed #007bff;background:rgba(0,123,255,0.1);width:0;padding:0;';
dragCol.parentNode.insertBefore(placeholder, dragCol);
}
const rect = th.getBoundingClientRect();
const midX = rect.left + rect.width / 2;
const parent = th.parentNode;
if (e.clientX < midX) {
parent.insertBefore(placeholder, th);
} else {
parent.insertBefore(placeholder, th.nextSibling);
}
});
th.addEventListener('drop', (e) => {
e.preventDefault();
if (!dragCol || dragCol === th) return;
const parent = th.parentNode;
const allTh = Array.from(parent.querySelectorAll('th')).filter(el => el !== placeholder);
const fromIdx = allTh.indexOf(dragCol);
let toIdx = allTh.indexOf(th);
if (placeholder) {
toIdx = Array.from(parent.children).indexOf(placeholder);
if (placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
placeholder = null;
}
if (fromIdx < 0) return;
const actualRows = this.table.querySelectorAll('tr');
actualRows.forEach(row => {
const cells = row.querySelectorAll('td, th');
if (cells[fromIdx] && cells[toIdx]) {
if (toIdx > fromIdx) {
row.insertBefore(cells[fromIdx], cells[toIdx].nextSibling);
} else {
row.insertBefore(cells[fromIdx], cells[toIdx]);
}
}
});
this.updateColumnOrder();
});
});
}
updateColumnOrder() {
const ths = this.table.querySelectorAll('thead th');
this.columnOrder = [];
const keysInOrder = [];
ths.forEach((th, idx) => {
const colKey = th.dataset.column || `col_${idx}`;
keysInOrder.push(colKey);
this.columnOrder.push(colKey);
});
this.columns.sort((a, b) => keysInOrder.indexOf(a.key) - keysInOrder.indexOf(b.key));
this.columns.forEach((col, idx) => col.index = idx);
this.persistState();
}
reorderColumns(keyOrder, persist = true) {
if (!keyOrder || keyOrder.length === 0) return;
const thead = this.table.querySelector('thead');
if (!thead) return;
const rows = this.table.querySelectorAll('tr');
const keyToThIndex = {};
const allTh = Array.from(thead.querySelectorAll('th'));
allTh.forEach((th, idx) => {
const key = th.dataset.column || `col_${idx}`;
keyToThIndex[key] = idx;
});
rows.forEach(row => {
const cells = row.querySelectorAll('td, th');
const reordered = [];
let lastRef = null;
keyOrder.forEach(key => {
const srcIdx = keyToThIndex[key];
if (srcIdx !== undefined && cells[srcIdx]) {
reordered.push(cells[srcIdx]);
}
});
reordered.forEach(cell => {
if (lastRef) {
row.insertBefore(cell, lastRef.nextSibling);
} else {
row.insertBefore(cell, row.firstChild);
}
lastRef = cell;
});
});
this.columnOrder = [...keyOrder];
this.columns.sort((a, b) => keyOrder.indexOf(a.key) - keyOrder.indexOf(b.key));
this.columns.forEach((col, idx) => col.index = idx);
if (persist) this.persistState();
}
setVisible(columnKey, visible) {
const th = this.table.querySelector(`th[data-column="${columnKey}"]`);
if (th) th.style.display = visible ? '' : 'none';
const colIdx = this.columns.findIndex(c => c.key === columnKey);
if (colIdx >= 0) {
this.columns[colIdx].visible = visible;
const rows = this.table.querySelectorAll('tbody tr, thead tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td, th');
if (cells[colIdx]) {
cells[colIdx].style.display = visible ? '' : 'none';
}
});
}
}
getColumnWidths() {
return { ...this.columnWidths };
}
getColumnOrder() {
return [...this.columnOrder];
}
getVisibleColumns() {
return this.columns.filter(c => c.visible).map(c => c.key);
}
getColumnMetadata() {
return {
larghezze: this.getColumnWidths(),
ordine: this.getColumnOrder(),
visibili: this.getVisibleColumns(),
};
}
printWithSettings() {
const printWindow = window.open('', '_blank');
if (!printWindow) return;
const styles = Array.from(document.styleSheets)
.filter(sheet => {
try { return sheet.cssRules; } catch (e) { return false; }
})
.map(sheet => Array.from(sheet.cssRules).map(rule => rule.cssText).join('\n'))
.join('\n');
const printStyles = `
@media print {
body { font-family: 'DejaVu Sans', Arial, sans-serif; font-size: 10pt; }
.no-print { display: none !important; }
.card { border: none !important; box-shadow: none !important; }
.table { border-collapse: collapse; width: 100%; }
.table th { background: #f5f5f5 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.table th, .table td { border: 1px solid #ddd; padding: 4px 6px; }
a { text-decoration: none; color: #000; }
.badge { border: 1px solid #999; padding: 1px 4px; font-size: 8pt; }
.pagination, .card-header .card-tools { display: none !important; }
.page-break { page-break-before: always; }
}
`;
const title = document.title || 'Stampa';
const pageTitle = document.querySelector('.page_title, .card-title h1, h1')?.textContent?.trim() || title;
let content = '';
const printArea = this.table?.cloneNode(true);
if (printArea) {
printArea.querySelectorAll('.col-resize-handle').forEach(h => h.remove());
content = printArea.outerHTML;
} else {
content = document.getElementById(this.tableId)?.outerHTML || '';
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${title}</title>
<style>${styles}${printStyles}</style>
</head>
<body>
<div class="no-print" style="text-align:right;margin-bottom:10px;">
<button onclick="window.print()" style="padding:6px 16px;font-size:12pt;">🖨️ Stampa</button>
<button onclick="window.close()" style="padding:6px 16px;font-size:12pt;">✖ Chiudi</button>
</div>
<h2 style="margin-bottom:16px;">${pageTitle}</h2>
${content}
<p style="text-align:center;margin-top:16px;color:#999;font-size:8pt;">
Stampato il ${new Date().toLocaleDateString('it-IT')}${document.location.href}
</p>
</body>
</html>
`);
printWindow.document.close();
}
}
window.ColumnManager = ColumnManager;
@@ -14,9 +14,14 @@
<div class="card">
<div class="card-header">
<h3 class="card-title">Attività di Sistema</h3>
<div class="card-tools">
<button type="button" class="btn btn-danger btn-sm" onclick="deleteLogs()">
<i class="fas fa-trash mr-1"></i> Cancella Log
</button>
</div>
</div>
<div class="card-body">
<form method="GET" class="mb-3">
<form method="GET" class="mb-3" id="filter-form">
<div class="row">
<div class="col-md-2">
<select name="user_id" class="form-control">
@@ -94,4 +99,59 @@
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
function deleteLogs() {
var filterForm = document.getElementById('filter-form');
var params = new URLSearchParams(new FormData(filterForm));
var filterDescription = [];
var userFilter = params.get('user_id');
var moduleFilter = params.get('module');
var actionFilter = params.get('action');
var fromFilter = params.get('from');
var toFilter = params.get('to');
if (userFilter) filterDescription.push('utente=' + userFilter);
if (moduleFilter) filterDescription.push('modulo=' + moduleFilter);
if (actionFilter) filterDescription.push('azione=' + actionFilter);
if (fromFilter) filterDescription.push('dal=' + fromFilter);
if (toFilter) filterDescription.push('al=' + toFilter);
var message = filterDescription.length > 0
? 'Eliminare i log filtrati? (' + filterDescription.join(', ') + ')'
: 'Eliminare TUTTI i log?';
if (!confirm(message)) return;
var form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/activity-logs';
form.style.display = 'none';
var csrf = document.createElement('input');
csrf.name = '_token';
csrf.value = '{{ csrf_token() }}';
form.appendChild(csrf);
var method = document.createElement('input');
method.name = '_method';
method.value = 'DELETE';
form.appendChild(method);
filterForm.querySelectorAll('select, input').forEach(function(el) {
if (el.name && el.value) {
var input = document.createElement('input');
input.name = el.name;
input.value = el.value;
form.appendChild(input);
}
});
document.body.appendChild(form);
form.submit();
}
</script>
@endsection
@@ -248,6 +248,7 @@
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" class="custom-control-input" id="is_active"
name="is_active" value="1" {{ ($settings->is_active ?? false) ? 'checked' : '' }}>
<label class="custom-control-label" for="is_active">Account Email Attivo</label>
@@ -265,6 +266,7 @@
<div class="card-body">
<div class="form-group">
<div class="custom-control custom-switch">
<input type="hidden" name="signature_enabled" value="0">
<input type="checkbox" class="custom-control-input" id="signature_enabled"
name="signature_enabled" value="1" {{ ($settings->signature_enabled ?? true) ? 'checked' : '' }}>
<label class="custom-control-label" for="signature_enabled">Attiva firma automatica</label>
@@ -377,7 +379,7 @@
</div>
</div>
<div class="card-footer">
<div class="card-footer d-flex flex-wrap align-items-center gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save mr-1"></i> Salva Impostazioni
</button>
@@ -392,6 +394,15 @@
</a>
</div>
</form>
<div class="d-flex justify-content-end mt-2">
<form action="{{ route('impostazioni.email.destroy') }}" method="POST" onsubmit="return confirm('Eliminare definitivamente la configurazione email? Tutti i dati di connessione verranno rimossi.')">
@csrf @method('DELETE')
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-trash mr-1"></i> Elimina Configurazione
</button>
</form>
</div>
</div>
</div>
@endsection
@@ -521,7 +532,7 @@ function editSender(id) {
const sender = senderAccounts.find(s => s.id === id);
if (!sender) return;
document.getElementById('senderForm').action = '{{ route('impostazioni.sender.update', '') }}/' + id;
document.getElementById('senderForm').action = '{{ route('impostazioni.sender.update', '__ID__') }}'.replace('__ID__', id);
document.getElementById('senderMethod').value = 'PUT';
document.getElementById('senderModalTitle').textContent = 'Modifica Mittente - ' + sender.email_address;
document.getElementById('senderId').value = sender.id;
@@ -551,7 +562,7 @@ function testSenderSmtp(id) {
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
$.ajax({
url: '{{ route('impostazioni.sender.test', '') }}/' + id,
url: '{{ route('impostazioni.sender.test', '__ID__') }}'.replace('__ID__', id),
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
data: { email: email },
@@ -628,9 +639,11 @@ function testSmtp() {
});
}
var quillEditorInstance = null;
function createEditor() {
var container = document.querySelector('#signature-editor');
if (!container || container.querySelector('.ql-toolbar')) return;
if (!container || quillEditorInstance) return;
var quill = new Quill('#signature-editor', {
theme: 'snow',
@@ -679,10 +692,12 @@ function createEditor() {
signatureInput.value = quill.root.innerHTML;
}
});
quillEditorInstance = quill;
}
function initSignatureEditor() {
if (document.querySelector('#signature-editor .ql-toolbar')) return;
if (quillEditorInstance) return;
if (typeof Quill === 'undefined') {
var script = document.createElement('script');
+1 -1
View File
@@ -59,7 +59,7 @@ $appOrgName = AppSetting::getOrgName() ?? '';
<div class="row">
<div class="col-8">
<div class="icheck-primary">
<input type="checkbox" id="remember">
<input type="checkbox" id="remember" name="remember">
<label for="remember">Ricordami</label>
</div>
</div>
+36
View File
@@ -103,6 +103,42 @@
@endif
</div>
</div>
@include('partials._tag-selector', ['selectedTags' => $selectedTags ?? [], 'label' => 'Etichette'])
</div>
<div class="card-body">
<table class="table table-sm table-borderless">
<tr>
<td style="width: 100px;"><strong>File:</strong></td>
<td>{{ $documento->file_path }}</td>
</tr>
<tr>
<td><strong>Tipo MIME:</strong></td>
<td>{{ $documento->mime_type ?? '-' }}</td>
</tr>
<tr>
<td><strong>Dimensione:</strong></td>
<td>{{ $documento->dimensione ? number_format($documento->dimensione / 1024, 1) . ' KB' : '-' }}</td>
</tr>
<tr>
<td><strong>Creato:</strong></td>
<td>{{ $documento->created_at->format('d/m/Y H:i') }}</td>
</tr>
@if($documento->user)
<tr>
<td><strong>Utente:</strong></td>
<td>{{ $documento->user->name }}</td>
</tr>
@endif
</table>
@if($documento->file_path)
<hr>
<a href="/documenti/{{ $documento->id }}/download" class="btn btn-primary btn-block">
<i class="fas fa-download mr-1"></i> Scarica
</a>
@endif
</div>
</div>
</div>
</div>
+102 -8
View File
@@ -5,6 +5,9 @@
@php
$canWriteDocumenti = Auth::user()->canManage('documenti');
$canDeleteDocumenti = Auth::user()->canDelete('documenti');
$vistaDefaultJson = isset($vista) ? $vista->toJson() : 'null';
$visibleColumnsJson = json_encode($visibleColumns ?? []);
$tableColumnsJson = json_encode($tableColumns ?? []);
@endphp
@section('css')
@@ -123,6 +126,8 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
</div>
</div>
@include('partials._tag-filter-bar', ['filterTags' => $allTags])
<div class="card-body p-0">
{{-- Toolbar azioni massive --}}
<form id="massForm" method="POST">
@@ -144,6 +149,9 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
<button type="button" class="btn btn-sm btn-secondary" onclick="$('#massMoveModal').modal('show')">
<i class="fas fa-folder-open mr-1"></i> Sposta selezionati in...
</button>
<button type="button" class="btn btn-sm btn-info" onclick="$('#massTagModal').modal('show')">
<i class="fas fa-tags mr-1"></i> Tag
</button>
<button type="button" class="btn btn-sm btn-success" onclick="massDownload()">
<i class="fas fa-download mr-1"></i> Scarica
</button>
@@ -226,6 +234,11 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
</span>
@endif
</div>
<div class="text-center mb-1" style="display:flex;flex-wrap:wrap;gap:2px;justify-content:center;">
@foreach($documento->tags as $tag)
<a href="{{ route('documenti.index', ['tag[]' => $tag->slug]) }}" class="badge" style="background-color: {{ $tag->color ?? '#6c757d' }}; color: #fff; font-size:0.7rem;">{{ $tag->name }}</a>
@endforeach
</div>
<div class="small text-muted text-center">
@if($documento->user)
{{ $documento->user->name }}
@@ -283,20 +296,21 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
{{-- View: Lista --}}
<div id="viewList" style="display:none;">
@if($sottoCartelle->count() > 0 || $documenti->count() > 0)
<table class="table table-bordered table-hover table-striped mb-0">
<table class="table table-bordered table-hover table-striped mb-0" id="documenti-table">
<thead class="thead-light">
<tr>
@if($canDeleteDocumenti)
<th style="width: 40px;">
<th style="width: 40px;" data-column="select">
<input type="checkbox" id="selectAll" onchange="toggleAll(this)">
</th>
@endif
<th>Nome</th>
<th style="width: 100px;">Tipologia</th>
<th style="width: 90px;">Dimensione</th>
<th style="width: 120px;">Contesto</th>
<th style="width: 110px;">Data</th>
<th style="width: 130px;">Azioni</th>
<th data-column="nome">Nome</th>
<th style="width: 100px;" data-column="tipologia">Tipologia</th>
<th style="width: 90px;" data-column="dimensione">Dimensione</th>
<th style="width: 120px;" data-column="visibilita">Contesto</th>
<th style="width: 100px;" data-column="tag">Tag</th>
<th style="width: 110px;" data-column="created_at">Data</th>
<th style="width: 130px;" data-column="azioni">Azioni</th>
</tr>
</thead>
<tbody>
@@ -314,6 +328,7 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
<td class="small text-muted">-</td>
<td class="small text-muted">-</td>
<td class="small text-muted">-</td>
<td class="small text-muted">-</td>
<td>
<a href="/documenti?folder_id={{ $cartella->id }}" class="btn btn-xs btn-warning" title="Apri cartella">
<i class="fas fa-folder-open"></i>
@@ -423,6 +438,11 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
<span class="text-muted small">-</span>
@endswitch
</td>
<td>
@foreach($documento->tags as $tag)
<a href="{{ route('documenti.index', ['tag[]' => $tag->slug]) }}" class="badge" style="background-color: {{ $tag->color ?? '#6c757d' }}; color: #fff;">{{ $tag->name }}</a>
@endforeach
</td>
<td class="small">{{ $documento->created_at->format('d/m/Y') }}</td>
<td>
@if($documento->file_path)
@@ -498,6 +518,10 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
</div>
</form>{{-- /massForm --}}
@include('partials.table-settings')
<div id="vista-data" style="display:none;">{{ $vistaDefaultJson }}</div>
{{-- MODAL: Nuova Cartella --}}
<div class="modal fade" id="newFolderModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-scrollable" role="document">
@@ -834,6 +858,50 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
</div>
</div>
{{-- MODAL: Gestione Tag massiva --}}
<div class="modal fade" id="massTagModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-tags mr-2"></i>Gestione Tag</h5>
<button type="button" class="close" onclick="$('#massTagModal').modal('hide')">
<span>&times;</span>
</button>
</div>
<form id="massTagForm" method="POST" action="/documenti/mass-tag">
@csrf
<div class="modal-body">
<p class="mb-2">Operazione su <strong id="massTagCount">0</strong> documenti selezionati.</p>
<div class="form-group mb-3">
<label class="font-weight-bold">Azione</label>
<div class="d-flex" style="gap:1.5rem;">
<div class="form-check">
<input class="form-check-input" type="radio" name="mode" id="mtModeAssign" value="assign" checked>
<label class="form-check-label" for="mtModeAssign">Assegna tag</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="mode" id="mtModeRemove" value="remove">
<label class="form-check-label" for="mtModeRemove">Rimuovi tag</label>
</div>
</div>
</div>
@include('partials._tag-selector', ['label' => 'Seleziona tag'])
<input type="hidden" name="ids" id="massTagIds" value="">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="$('#massTagModal').modal('hide')">Annulla</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-tags mr-1"></i> Applica
</button>
</div>
</form>
</div>
</div>
</div>
{{-- MODAL: Sposta documento --}}
<div class="modal fade" id="moveModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-scrollable" role="document">
@@ -877,6 +945,7 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
@endsection
@section('scripts')
<script src="{{ asset('js/column-manager.js') }}"></script>
<script>
const individui = @json($individui->map(fn($i) => ['id' => $i->id, 'label' => $i->cognome . ' ' . $i->nome]));
const gruppi = @json($gruppi->map(fn($g) => ['id' => $g->id, 'label' => $g->nome]));
@@ -1218,6 +1287,12 @@ document.getElementById('massMoveModal')?.addEventListener('show.bs.modal', func
document.getElementById('massMoveCount').textContent = ids.length;
});
document.getElementById('massTagModal')?.addEventListener('show.bs.modal', function() {
const ids = getSelectedIds();
document.getElementById('massTagIds').value = ids.join(',');
document.getElementById('massTagCount').textContent = ids.length;
});
document.getElementById('massMoveNewFolderBtn')?.addEventListener('click', function() {
Swal.fire({
title: 'Nuova cartella',
@@ -1874,5 +1949,24 @@ function deleteFolder(id, nome) {
}
});
}
document.addEventListener('DOMContentLoaded', function () {
const table = document.getElementById('documenti-table');
if (table) {
window.columnManager = new ColumnManager('documenti-table', {
entityType: 'documenti',
storageKey: 'cm_documenti',
resizable: true,
reorderable: false,
});
initTableSettings();
}
});
window.printWithCurrentSettings = function () {
if (window.columnManager) {
window.columnManager.printWithSettings();
}
};
</script>
@endsection
+110 -4
View File
@@ -87,10 +87,17 @@
<div class="form-group">
<label>Allegati</label>
<div class="border rounded p-3 bg-light">
<label class="btn btn-sm btn-success mb-0">
<i class="fas fa-upload mr-1"></i> Carica file
<input type="file" name="allegati[]" multiple class="d-none">
</label>
<div class="mb-3">
<button type="button" class="btn btn-sm btn-primary" data-toggle="modal" data-target="#documentiModal">
<i class="fas fa-folder-open mr-1"></i> Seleziona da Documenti
</button>
<label class="btn btn-sm btn-success mb-0">
<i class="fas fa-upload mr-1"></i> Carica file
<input type="file" name="allegati[]" id="allegati" multiple class="d-none" onchange="handleFileUpload(this)">
</label>
</div>
<div id="allegati-list"></div>
<input type="hidden" name="documenti_selezionati" id="documenti_selezionati" value="">
</div>
</div>
@@ -110,10 +117,62 @@
</div>
</div>
</div>
<div class="modal fade" id="documentiModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-folder-open mr-2"></i>Seleziona Documenti</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
@if($documenti->count() > 0)
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm">
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Nome File</th>
<th>Tipologia</th>
</tr>
</thead>
<tbody>
@foreach($documenti as $doc)
<tr>
<td>
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input doc-checkbox" id="doc-{{ $doc->id }}" value="{{ $doc->id }}" data-nome="{{ $doc->nome_file }}">
<label class="custom-control-label" for="doc-{{ $doc->id }}"></label>
</div>
</td>
<td>{{ $doc->nome_file }}</td>
<td><span class="badge badge-info">{{ $doc->tipologia ?: $doc->tipo }}</span></td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-muted">Nessun documento disponibile.</p>
@endif
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Annulla</button>
<button type="button" class="btn btn-primary" onclick="aggiungiDocumenti()">
<i class="fas fa-plus mr-1"></i> Aggiungi
</button>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
let allegatiSelezionati = [];
function toggleDestinatari() {
const tipo = document.getElementById('destinatario_tipo').value;
@@ -160,6 +219,53 @@ function loadGruppiContatti() {
});
}
function aggiungiDocumenti() {
const checkboxes = document.querySelectorAll('.doc-checkbox:checked');
checkboxes.forEach(function(cb) {
const id = parseInt(cb.value);
const nome = cb.dataset.nome;
if (!allegatiSelezionati.includes(id)) {
allegatiSelezionati.push(id);
const container = document.getElementById('allegati-list');
const div = document.createElement('div');
div.className = 'd-flex align-items-center justify-content-between bg-white p-2 mb-2 rounded';
div.id = 'allegato-' + id;
div.innerHTML = '<span><i class="fas fa-file mr-2"></i>' + nome + '</span>' +
'<button type="button" class="btn btn-xs btn-danger" onclick="removeAllegato(' + id + ')">' +
'<i class="fas fa-times"></i></button>';
container.appendChild(div);
}
cb.checked = false;
});
document.getElementById('documenti_selezionati').value = allegatiSelezionati.join(',');
$('#documentiModal').modal('hide');
}
function removeAllegato(id) {
allegatiSelezionati = allegatiSelezionati.filter(function(a) { return a !== id; });
const el = document.getElementById('allegato-' + id);
if (el) el.remove();
document.getElementById('documenti_selezionati').value = allegatiSelezionati.join(',');
}
function handleFileUpload(input) {
if (!input.files) return;
const container = document.getElementById('allegati-list');
for (const file of input.files) {
const div = document.createElement('div');
div.className = 'd-flex align-items-center justify-content-between bg-white p-2 mb-2 rounded';
div.innerHTML = '<span><i class="fas fa-file mr-2"></i>' + file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)</span>' +
'<button type="button" class="btn btn-xs btn-danger" onclick="this.parentElement.remove()">' +
'<i class="fas fa-times"></i></button>';
container.appendChild(div);
}
input.value = '';
}
function saveDraft() {
const form = document.getElementById('emailForm');
const formData = new FormData(form);

Some files were not shown because too many files have changed in this diff Show More